Twitter(ブラウザ版)のサイドカラムのような「下にも上にもくっつく」サイドバーの実装方法

PCブラウザ版Twitterのサイドカラムの挙動を実装します。

下方向にスクロールする時は、

上方向にスクロールする時は、

その間の場合は、

2020年3月時点のUIです。将来挙動は変わるかもしれません。

仕様はこうです。

  1. 下方スクロール時は、下端がFixedするまで下方スクロール
  2. 上方スクロール時は、上端にFixedするまで上方スクロール

デモ

専用デモは用意していませんが、このサイトの右カラムがこれで実装されています。これも気が向いたらまた変えるかもですが…。その時はちゃんとデモを用意します。

position: stickyじゃダメなん?

自分も最初そう思いました。しかし95%くらい出来たところで断念しました。

「上にくっつく」と「下にくっつく」までは簡単ですが、「上下のどちらにもFixedしていないの挙動」が思ったより面倒でした。position: sticky; の挙動自体に条件分岐が内包されているので、それを避けながら条件を書いてたらコードが冗長になってきたので、ゼロからJavaScriptでやった方がシンプルだと方針転換。

以下、作り方です。

HTML

例えばこのようにマークアップしときます。

構造としてのコードはこうなります。

<div class="container">
    <main>
        メインコンテンツ(記事本文など)
    </main>
    <div class="sub-column">
        <div class="fixed-items">
            サブカラムの中身
        </div>
    </div>
</div>

header や footer は省略しています。

CSS

.container {
    display: flex;
}

.sub-column {
    position: relative;
}

display:flexで横並びにする事で、サイドカラムの高さをメインカラム高さと揃えるのがポイントです。これがそのまま中身の可動領域になります。
なおこれも構造に必要な記述のみで、スタイリングは省略しています。

JavaScript

いよいよ本番です。このサイトは以下のコードで動いています。

$(function($) {

	var c_y = 0; //上下スクロール判定用の基準座標

	$(window).on('load scroll resize', function(){

		var y = $(window).scrollTop(); //スクロール量
		var w_h = $(window).innerHeight(); //ブラウザの高さ
		var c_p = $('.sub-column').offset().top; //サイドカラム上端のy座標
		var c_h = $('.sub-column').outerHeight(); //サイドカラム領域の高さ
		var i_p = $('.fixed-items').offset().top; //中身上端のy座標
		var i_h = $('.fixed-items').outerHeight(); //中身の高さ
		var mt_h = 96; //ブラウザ上端からの固定位置
		var mb_h = 48; // ブラウザ下端からの固定位置

		if ( i_h < c_h ) { //中身がサイドカラムより小さかったら
			if ( i_h <= w_h - mt_h - mb_h ) { //中身がブラウザより小さかったらStickyと同じ挙動
				$('.fixed-items').css({'position':'sticky', 'top':mt_h, 'bottom':'auto'});
			} else { //中身がブラウザより大きかったら
				if ( y > c_y ) { //下降中の処理
					if( y + w_h - i_p - i_h < mb_h - 1 ) { //中身がブラウザより下に突き抜けていたら位置を相対固定していっしょにスクロール *小数点上下の調整あり
						$('.fixed-items').css({'position':'absolute', 'top':i_p - c_p, 'bottom':'auto'});
					} else { //サイトバーがブラウザ下端まで来たらFixed
						$('.fixed-items').css({'position':'fixed', 'top':'auto', 'bottom':mb_h});
					}
					if( c_p + c_h <= i_p + i_h + 1 ) { //中身がサイドカラム下端まで来たらストップ *小数点上下の調整あり
						$('.fixed-items').css({'position':'absolute', 'top':'auto', 'bottom':'0'});
					}
				}
				if ( y < c_y ) { //上昇中の処理
					if( i_p - y < mt_h ) { //中身がブラウザより上に突き抜けていたら位置を相対固定していっしょにスクロール
						$('.fixed-items').css({'position':'absolute', 'top':i_p - c_p, 'bottom':'auto'});
					} else { //中身がブラウザ上端まで来たらFixed
						$('.fixed-items').css({'position':'fixed', 'top':mt_h, 'bottom':'auto'});
					}
					if(i_p - c_p <= 0) { //中身がサイドカラム上端まで来たらストップ
						$('.fixed-items').css({'position':'absolute', 'top':'0', 'bottom':'auto'});
					}
				}
				c_y = y; //上昇下降の基準座標をセット
			}
		}
	});
});

自分がチェックした範囲では、

  • ガタガタしないし
  • フッターを突き抜けないし

事が確認できているので及第点かと思っていますが、Windows系ではほぼ検証できていません。やらないと…。

解説

偉そうに言うと解説ですが、実態は自分が何やってるか分からなくなるのを防ぐためのメモです。

条件定義

$(window).on('load scroll resize', function(){
	略
});

load時, scroll時, resize時の処理をまとめて記述。

変数

var y = $(window).scrollTop(); //スクロール量
var w_h = $(window).innerHeight(); //ブラウザの高さ
var c_p = $('.sub-column').offset().top; //サイドカラム上端のy座標
var c_h = $('.sub-column').outerHeight(); //サイドカラム領域の高さ
var i_p = $('.fixed-items').offset().top; //中身上端のy座標
var i_h = $('.fixed-items').outerHeight(); //中身の高さ
var mt_h = 96; //ブラウザ上端からの固定位置
var mb_h = 48; // ブラウザ下端からの固定位置

図解しますとこうなります。

変数名を極力短くしたい人間なので暗号みたいになっていますが、一応こういうルールで設定しています。

  • c_p : "column" の "position"
  • i_h : "internal" の "height"

サイドカラムのy座標(c_p)と高さ(c_h)は原則変化しないはずですが、他のエリアに動的に読み込まれる要素があった場合、位置や高さが変化する可能性がありますので、scroll, resize時にいちいち取得するようにしています。

条件1. 中身がサイドカラムより小さかったら

if ( i_h < c_h ) { //中身がサイドカラムより小さかったら
	略
});

日本語だと何を言っているのか分かりづらいですが、ここでやりたいのは「中身がサイドカラムと同じ高さだったら何もしない」という事です。


図解するとこういう時の事です。メインカラムのコンテンツが少ない場合に起こりえます。

条件2. 中身がブラウザより小さかったら

if ( i_h <= w_h - mt_h - mb_h ) { //中身がブラウザより小さかったらStickyと同じ挙動
	略
});

これは図解するとこういう状態です。

正確には、「ブラウザ縦幅から上下マージンを省いた値より小さければ」ですが。
この場合は複雑な処理は必要なく、ただ一般的なposition: sticky; の挙動だけで済みます。

ブラウザをリサイズした時用に、この処理も risize 条件下に書いておく必要があるかと思います。

条件3. 中身がブラウザより大きかったら

先の条件2のelse条件です。ここからが大変です。

自分の場合は「下降中の処理」と「上昇中の処理」から分けて書いた方が頭が整理しやすかったのですが、人によって整理しやすい条件の書き方は違う気がします。

やってる事は、以下3種類の状態を切り替えているだけです。

  1. ブラウザ下端にくっつく = positon: fixed; bottom: 指定;
  2. ブラウザ上端にくっつく = positon: fixed; top: 指定;
  3. メインカラムと一緒にスクロールする = position: absolute; かつ、top か bottom に値を動的に渡す

3つ目の「メインカラムと一緒にスクロールする」挙動を分解して記述したのが最大のポイントである、というか、この挙動をわざわざJavaScriptで書いた唯一の理由です。

中身がブラウザの上か下かに突き抜けていたらposition: absolute; にしてスクロールするという設定なのですが、冒頭で述べた通り position: sticky; でこれをやろうとすると、sticky自身が内包している分岐条件と綺麗に背反条件を特定するのが途中でめんどくさくなったので自分で書いた、というのが趣旨でした。

小数点の処理

2箇所、if条件の中に "-1" とか "+1" があります。

// 1つ目
if( y + w_h - i_p - i_h < mb_h - 1 ) { //中身がブラウザより下に突き抜けていたら位置を相対固定していっしょにスクロール *小数点上下の調整あり

//2つ目
if( c_p + c_h <= i_p + i_h + 1 ) { //中身がサイドカラム下端まで来たらストップ *小数点上下の調整あり

各要素のoffset値やheight値さが必ずしも整数値をとらないため、ちゃんと意図が条件にハマるようの調整です。

本当は整数化してから条件式に打ち込んだ方がいいのでしょうが、まだそういう処理が不慣れのため妥協しました。

実数を整数に丸める4パターン(JavaScript おれおれ Advent Calendar 2011 – 7日目) | Ginpen.com
いつかちゃんとやる時用の参考に。

position: stickyの余談

「上にくっつく」のは簡単に実装できるのですが、「下にくっつく」はちょっと特殊で、 align-self: flex-end; と組み合わせる必要があります。最初はこれにも手こずりました。

あとposition:stickyは

  • ブラウザ対応状況がよく分からない(ただの怠慢)
  • CSSなのに動的にheightを変化させる仕様がちょっと扱いづらい

という点でちょっと苦手意識があったりします。

以上です

おかしなところがあったら教えて頂けると嬉しいです。