スクロールイベントにおける position:absolute 要素の位置補正

スクロールイベント内での position : absolute 要素の表示位置の補正処理について考えてみます。

具体的には以下のプラグインのような動きの動作原理について考えてみます。

jQuery Scroll Follow | demo

スクロールにより対象要素が隠れると、スクロールに追従するように absolute 要素の表示位置が補正されます。

以前のエントリ「IE6 向け position:fixed + スクロール追尾型の要素固定表示の方法を考えてみた」でも似たようなことを試しましたが、この時は IE6 で position:fixed を擬似的に実現する事を目的としてたので、absolute 要素をスクロールさせるコンテナが $(window) 限定でした。

今回は position:relative な DIV 要素をコンテナとした場合や、擬似フレームの対応も含めて考えてみます。

absolute 要素の親に static な要素しか存在しない場合

まずは一番単純な absolute 要素の親に position:static な要素しか存在しないケースから試してみます。

<body>
    <div class="static">
        <div class="absolute">absolute</div>
    </div>
</body>
$j.fn.syncroll_a = function(){
    return this.each(function(){
        var o=$j(this);

        //コンテナ要素からの相対位置を取得(初期位置)…(1)
        var c=o.position();

        var container=$j(window).scroll(function(){

            //スクロール量が初期位置を超えたら、スクロール量を表示位置にする…(2)
            o.css({
                top:Math.max(container.scrollTop(),c.top),
                left:Math.max(container.scrollLeft(),c.left)
            })      
        })
    })
}
$j('div.absolute').syncroll_a();

サンプルページ
(サンプルページの動作確認は、IE6,7、Safari3、Opera9.5、Firefox3 で行っております。)

まず、absolute 要素の $(window) からの相対位置を初期位置として position メソッドで取得しおきます。…(1)

次に $(window) の scroll イベント内で、absolute 要素の初期位置とスクロール量を比較し、スクロール量が初期位置を超えた場合は、対象要素が表示領域からはずれたと判断し、absolute 要素の表示位置をスクロール量と同じ値にします。…(2)

図解すると以下のような感じです。

jQuery Scroll Follow のように animate しながらの位置補正をさせる場合は、下記メソッドを使用します。

$j.fn.delayEfect = function(paramGetter,cfg){
    var o=this;
    var c=$j.extend((o.data('delayEfectCfg')||{
        delay:100,
        speed:300
    }),cfg);    
    o.data('delayEfectCfg',c);
    if(c.timer)clearTimeout(c.timer);
    c.timer=setTimeout(function(){
        o.queue([]).animate(paramGetter(),c.speed);
    },c.delay)
}

$j(window).scroll メソッド内で、以下のように呼び出します。

o.delayEfect(function(){
    return {
        top:Math.max(container.scrollTop(),c.top),
        left:Math.max(container.scrollLeft(),c.left)
    }   
})

サンプルページ

scroll イベントや hover イベントなどのように連続的に発生するイベントの中で、animate メソッドを実行させる場合、animate の実行が未完了の状態で次の animate の実行依頼がかかってしまう可能性が高いので、o.queue([]) で一旦キューをクリアした後、animate メソッドを実行するようにしています。

absolute 要素の親に relative な要素が存在する場合

<body>
    <div class="relative">
        <div class="absolute">absolute</div>
    </div>
</body>

サンプルページ

absolute 要素が隠れてない状態なのに位置補正が行われてしまいます。
absolute 要素の親に relative 要素が追加されたので、position メソッドで取得される absolute 要素の初期位置と、scrollTop メソッドで取得されるスクロール量の基準位置にずれが生じたためです。
ずれを補正してみます。

$j.fn.syncroll_b = function(){
    return this.each(function(){
        var o=$j(this);
        var c=o.position();

        //コンテナ要素の位置を取得…(1)
        var containerPos=o.offsetParent().offset();

        var scroller=$j(window).scroll(function(){
            o.delayEfect(function(){
                //スクロール量からコンテナ要素の位置を引く…(2)
                return {
                    top:Math.max(scroller.scrollTop()
                                    -containerPos.top,c.top),
                    left:Math.max(scroller.scrollLeft()
                                    -containerPos.left,c.left)
                }   
            })
        })
    })
}
$j('div.absolute').syncroll_b();

absolute 要素にとってのコンテナ要素の位置を offset メソッドで求め、…(1)

スクロール量との比較の際、スクロール量からコンテナ要素の位置を引いて比較します。…(2)

サンプルページ

Safari3 と Firefox3 の場合、微妙に位置がずれて表示されます。
原因がはっきりしないのですが、DOM 構築処理の遅延が関係してるように見受けられます。
以下のように window の load イベントで実行するとずれはなくなります。

$j(window).load(function(){
    $j('div.absolute').syncroll_b()            
})

サンプルページ

図解すると以下のような感じです。


absolute 要素の親に 擬似フレーム が存在する場合

<body>
    <div class="scroller relative"><!-- overflow:scroll -->
        <div class="relative">
            <div class="absolute">absolute</div>
        </div>
    </div>
</body>

基本ロジックはそのままで、スクロールバーを持つコンテナ(以降スクロールコンテナ)を引数で指定できるように修正し、擬似フレーム要素を渡すかたちで試してみます。

$j.fn.syncroll_c = function(scroller){
    scroller = scroller||$(window);
    (省略)
}
$j('div.absolute').syncroll_c($j('div.scroller'))            

サンプルページ
擬似フレームの配置位置分のずれが生じます。
ロジックを修正してみます。

$j.fn.syncroll_c = function(scroller){
    return this.each(function(){
        var o=$j(this);
        var c=o.position();
        var containerPos=o.offsetParent().offset();

        scroller = scroller||$(window);

        //擬似フレームの位置を取得…(1)
        var scrollerPos=scroller.offset();

        scroller.scroll(function(){
            o.delayEfect(function(){
                //コンテナ位置から擬似フレームの位置を差し引く…(2)
                return {
                    top:Math.max(
                        scroller.scrollTop()
                            -(containerPos.top-scrollerPos.top),
                        c.top),
                    left:Math.max(scroller.scrollLeft()
                            -(containerPos.left-scrollerPos.left),
                        c.left)
                }   
            })
        })
    })
}
$j('div.absolute').syncroll_c($j('div.scroller'))            

擬似フレームの配置位置を offset メソッドで取得し、…(1)
位置比較の際、コンテナ位置から擬似フレーム位置を引いた値で比較します。…(2)

サンプルページ

図解すると以下のような感じです。


absolute 要素の親に border 付きな relative 要素が存在する場合

親(先祖)要素に border 付き relative 要素があると、その border の幅分、補正位置にずれが生じます。

サンプルページ

absolute 要素の配置上の原点位置は、コンテナ要素の内寸の配置位置となりますが、offset や position といった位置取得メソッドは外寸の配置位置を返すため、採寸対象の要素に border が付いてると内寸と外寸の配置位置に差が生じます。

外寸の配置位置に border の幅を加算して、内寸位置を求めるようにします。

css メソッドで取得する border 幅は数値変換の必要があるので、以下のような数値変換付き css メソッドを定義しておきます。

$j.fn.cssInt = function(prop){
	try{
		return parseInt(this.css(prop))||0;
	}
	catch(e){
		return 0;
	}
}
$j.fn.syncroll_d = function(scroller){
    return this.each(function(){
        var o=$j(this);
        var c=o.position();
        var container=o.offsetParent();
        var containerPos=container.offset();
        scroller=scroller||$j(window);
        var scrollerPos=scroller.offset();

        //コンテナの内寸配置位置を求める
        containerPos.top += container.cssInt('border-top-width');
        containerPos.left += container.cssInt('border-left-width');
        scrollerPos.top += scroller.cssInt('border-top-width');
        scrollerPos.left += scroller.cssInt('border-left-width');

        scroller.scroll(function(){
            o.delayEfect(function(){
                return {
                    top:Math.max(
                            scroller.scrollTop()
                                -(containerPos.top-scrollerPos.top),
                            c.top),
                    left:Math.max(
                            scroller.scrollLeft()
                                -(containerPos.left-scrollerPos.left),
                            c.left)
                }   
            })
        })
    })
}
$j('div.absolute').syncroll_d($j('div.scroller'))            

サンプルページ

正しく補正されるようになりました。

再度 absolute 要素の親に static な要素しか存在しない場合を試す

<body>
    <div class="static">
        <div class="absolute">absolute</div>
    </div>
</body>

修正してきたソースで再度試してみます。

サンプルページ

IE 以外の場合、補正処理が行われません。

これはコンテナ要素が $(window) になったため、offset メソッドが実行時エラーになってしまったためです。

(詳細は前回のエントリ「jQuery でブラウザの表示領域に対するサイズや位置情報を取得してみる」をご覧ください)

エラーにならないようにラッパーメソッドを定義します。

$j.fn.exOffset = function(){
    return this.attr('tagName')=='HTML'||this[0]==window||this[0]==document ?
        {top:0,left:0} : this.offset();
}

また、IE 以外のブラウザで offsetParent() を実行すると、body 要素の position が static にもかかわらず何故か body 要素が返ってきてしまいます。
body が position:static の時は、html 要素を返すラッパーメソッドを定義し、offsetParent メソッドの替わりに呼ぶようにします。(標準モードのみを考慮)

$j.fn.exOffsetParent = function(){
    var op=this.offsetParent();
    return op.attr('tagName')=='BODY'&& op.css('position')=='static'?$j('html'):op;
}

サンプルページ(staticのみ)
サンプルページ(relativeあり)
サンプルページ(擬似フレームあり)

スクロールされてる状態で実行した場合

以下のように事前にスクロールされてる状態にして、実行してみます。

$j('div.absolute').syncroll_e(
	$j('div.scroller').scrollTop(50)
)            

サンプルページ

スクロール量分ずれた位置に補正されてしまいます。

原因は、コンテナ位置取得処理で offset メソッドを使っているためです。

ここで欲しいコンテナ位置とは、ドキュメントの左上を原点とした時の配置位置(物理的な位置)なのですが、offset メソッドの場合はスクロール量分のずれを含んだ位置(見た目の位置)を返してきてしまいます。

2009/2/23 図解修正

スクロール量分のずれを含むのは、擬似フレームの場合のみで、ブラウザのスクロールバーのスクロール量のずれは含まないようです。

ブラウザのスクロールバーをスクロールさせた場合

擬似フレームをスクロールさせた場合

スクロール状態に影響されない物理的な offset 位置を取得するメソッドを定義し、これを offset メソッドの替わりに呼ぶようにしてみます。

$j.fn.offsetPosition = function(){
	var o=this;
	var pos=o.offset();
	o.parents().each(function(){
		var el=$j(this);
		if(el.attr('tagName')!='html' && el.css('position')!='static'){
			pos.top+=el.scrollTop();
			pos.left+=el.scrollLeft();
		}
	})
	return pos;
}
$j.fn.syncroll_e = function(scroller){
    return this.each(function(){
        var o=$j(this);
        var c=o.position();
        var container=o.exOffsetParent();
        scroller=scroller||$j(window);

        //物理的な offset 位置を取得
        var containerPos=container.offsetPosition();
        var scrollerPos=scroller.offsetPosition();

        containerPos.top += container.cssInt('border-top-width');
        containerPos.left += container.cssInt('border-left-width');
        scrollerPos.top += scroller.cssInt('border-top-width');
        scrollerPos.left += scroller.cssInt('border-left-width');

        scroller.scroll(function(){
            o.delayEfect(function(){
                return {
                    top:Math.max(
                            scroller.scrollTop()
                                -(containerPos.top-scrollerPos.top),
                            c.top),
                    left:Math.max(
                            scroller.scrollLeft()
                                -(containerPos.left-scrollerPos.left),
                            c.left)
                }   
            })
        })
    })
}
$j('div.absolute').syncroll_e(
	$j('div.scroller').scrollTop(50)
)            

サンプルページ(擬似フレームあり)
サンプルページ

次回につづきます

スクロールコンテナの表示原点位置を取得する処理のみを抽出し、汎用的に活用できるメソッドを定義できないか考えてみたいと思います。