Bootstrap 3 の Affix を今さら勉強する

Bootstrap 3 には Affix という機能が同梱されている。Bootstrap の JS (jQuery プラグイン) を併用して、画面のスクロールに合わせて動的に position: sticky 的な動作を切り替えるような機能だ。

よくウェブサイトのサイドメニューなどで見かける UI だが、自分で作ったことがなかったので、今回試してみた。

目次

環境

これらを適用した単一の HTML を作っていく。

data 属性を使って指定する基本パターン

まずは data-spy="affix" という data 属性を使用しての基本パターンを実装してみる。

<header style="height: 250px; margin-bottom: 30px; color: #fff; background: #08f;">HEADER</header>

<div class="affix-target-wrapper">
  <!--
    offset-top    : 280 (px) = header 要素の height (250px) + margin-bottom (30px)
    offset-bottom : 今回は適当に、画面最下部から 1000px のところで解除する
  -->
  <div class="affix-target btn-group btn-group-justified" data-spy="affix" data-offset-top="280" data-offset-bottom="1000">
    <a href="#" class="btn btn-default">Menu 1</a>
    <a href="#" class="btn btn-default">Menu 2</a>
    <a href="#" class="btn btn-default">Menu 3</a>
  </div>
</div>

<div class="dummy-contents" style="max-width: 800px;">
  <!-- ページの高さを稼ぐために適当なダミーテキストを書き連ねておく -->
  <p>Lorem ipsum……</p>
</div>

<footer style="height: 400px; color: #fff; background: #444;">FOOTER</footer>

今回は適当に高さのあるヘッダ・フッタと、テキストコンテンツを用意してある。Affix を適用したい要素は .affix-target で、コレに data-spy="affix" という属性を振ることで Affix の効果を適用させている。

Affix を使用する際、対象となる要素に次のような CSS を指定するのが必須となる。ココがドキュメントにちゃんと書かれていなくて、使い方が分かりにくかった。HTML だけ書いても上手く行かないのだ。

/* 子要素 .affix-target が position: fixed になった時、その下にあるコンテンツの表示位置がズレないよう指定しておく */
.affix-target-wrapper {
  height: 60px;
}

/* Affix を適用する対象の要素 */
.affix-target {
  width: 500px;    /* position: fixed になった時、幅が狂わないよう指定しておく */
  z-index: 99999;  /* position: fixed になった時、他の要素の下に重ならないようにしておく */
}

/* Affix を適用する対象の要素が position: fixed になった時の指定 */
.affix-target.affix {
  position: fixed !important;  /* .affix 自体に position: fixed 指定はあるものの、.affix-bottom が style 属性に position: relative を埋め込むので、それに勝るよう !important を指定しておく */
  top: 0;  /* 画面上部からの位置を指定する。data-offset-top との辻褄が合う数値にしておくこと */
}

ついでに、効果を確認するためのデバッグ用に以下を仕込んでおく。

/* Affix の適用状態に応じて文字色を変える */
.affix-top    * { color: blue;  }  /* Affix 開始よりスクロール位置が上の場合 */
.affix        * { color: red;   }  /* position: fixed になった時 */
.affix-bottom * { color: green; }  /* Affix を解除するスクロール位置より下の場合 */

/* ページの高さを稼ぐためのコード */
.dummy-contents p {
  line-height: 3;
  margin-bottom: 3rem;
}

このように実装したサンプルは以下。

コレでとりあえず思った動きはできた。

基礎コードのイマイチポイント

上のコードで「それっぽい動き」自体はできたが、イマイチなポイントがある。

position: fixed を使用するために、可変幅レイアウトとの相性が良くない。ただ Affix を適用しただけではダメで、かなり細かく CSS 側の調整が必要である。

問題点を理解したところで、次のやり方を試してみる

JavaScript (jQuery) で Affix を制御する

続いて jQuery プラグインで制御してみる。

<header style="height: 250px; margin-bottom: 30px; color: #fff; background: #08f;">HEADER</header>

<div class="affix-target-wrapper">
  <div class="affix-target btn-group btn-group-justified">
    <a href="#" class="btn btn-default">Menu 1</a>
    <a href="#" class="btn btn-default">Menu 2</a>
    <a href="#" class="btn btn-default">Menu 3</a>
  </div>
</div>

<div class="dummy-contents" style="max-width: 400px;">
  <!-- ページの高さを稼ぐために適当なダミーテキストを書き連ねておく -->
  <p>Lorem ipsum……</p>
</div>

<footer style="height: 500px; color: #fff; background: #444;">FOOTER</footer>

HTML 側は、.affix-target に指定していた data 属性がなくなっている。

CSS 側は変更なし。JavaScript を次のように追加する。

$(() => {
  $('.affix-target').affix({
    offset: {
      top: $('header').outerHeight(true),  // true にすると margin を含めた値を得られる
      bottom: 600
    }
  })
    // Affix の適用状態に応じて文字色やフォント設定を変える
    .on('affixed-top.bs.affix'   , () => { console.log('affixed-top.bs.affix'   ); $('.affix-target *').css({ 'color': 'blue'  }); })
    .on('affixed.bs.affix'       , () => { console.log('affixed.bs.affix'       ); $('.affix-target *').css({ 'color': 'red'   }); })
    .on('affixed-bottom.bs.affix', () => { console.log('affixed-bottom.bs.affix'); $('.affix-target *').css({ 'color': 'green' }); })
    .on('affix-top.bs.affix'     , () => { console.log('affix-top.bs.affix'     ); $('.affix-target *').css({ 'font-weight': 'bold'  , 'font-style': 'normal' }); })
    .on('affix.bs.affix'         , () => { console.log('affix.bs.affix'         ); $('.affix-target *').css({ 'font-weight': 'normal', 'font-style': 'italic' }); })
    .on('affix-bottom.bs.affix'  , () => { console.log('affix-bottom.bs.affix'  ); $('.affix-target *').css({ 'font-weight': 'bold'  , 'font-style': 'italic' }); });
});

久々に書いたぜ jQuery。

このようにすると、data 属性で指定した時と同じ状態が再現できる。.on() イベントは Affix の状態変化に応じて発生するが、ページ読み込み時は発生しない様子。ちょっと使いづらい感じ。

とりあえず以下のようになる。

頑張って可変幅レイアウトに対応させてみる

JavaScript でも制御できることが分かったら、昔ながらのやり方で、可変幅レイアウトに無理やり対応させることはできそうだ。

まずは HTML。

<header style="height: 250px; margin-bottom: 30px; color: #fff; background: #08f;">HEADER</header>

<div class="container-fluid">
  <div class="row">
    <div class="col-xs-8">
      <div class="dummy-contents">
        <!-- ページの高さを稼ぐために適当なダミーテキストを書き連ねておく -->
        <p>Lorem ipsum……</p>
      </div>
    </div>
    <div class="col-xs-4">
      <ul class="affix-target nav nav-pills nav-stacked">
        <li class="active"><a href="#">Menu 1</a></li>
        <li><a href="#">Menu 2</a></li>
        <li><a href="#">Menu 3</a></li>
        <li><a href="#">Menu 4</a></li>
      </ul>
    </div>
  </div>
</div>

<footer style="height: 500px; color: #fff; background: #444;">FOOTER</footer>

.container-fluid を使用して、可変幅にしている。Affix したい .affix-target は、Grid Layout 用の .col-xs-4 の直下にいる。.affix-target-wrapper はなくなった。

続いて CSS。

/* Affix を適用する対象の要素 */
.affix-target {
  z-index: 99999;  /* position: fixed になった時、他の要素の下に重ならないようにしておく */
  /* width を指定していない */
}

/* Affix を適用する対象の要素が position: fixed になった時の指定 */
.affix-target.affix {
  position: fixed !important;  /* .affix 自体に position: fixed 指定はあるものの、.affix-bottom が style 属性に position: relative を埋め込むので、それに勝るよう !important を指定しておく */
  top: 30px;  /* 画面上部からの位置を指定する。data-offset-top との辻褄が合う数値にしておくこと */
}

/* Affix の適用状態に応じて文字色を変える */
.affix-top    * { color: blue;  }  /* Affix 開始よりスクロール位置が上の場合 */
.affix        * { color: red;   }  /* position: fixed になった時 */
.affix-bottom * { color: green; }  /* Affix を解除するスクロール位置より下の場合 */

/* ページの高さを稼ぐためのコード */
.dummy-contents p {
  line-height: 3;
  margin-bottom: 3rem;
}

.affix-target に書いていた width 指定がなくなった。あとレイアウト調整のために affix-target.affix で指定する top 値を調整した。

そして JavaScript。

$(() => {
  $('.affix-target').affix({
    offset: {
      top   : $('header').outerHeight(false),
      bottom: $('footer').outerHeight(true) + 400
    }
  })
    .on('affix.bs.affix', () => {
      // .affix-target の親要素の、padding の内側の幅を取得して、それを .affix-target の幅とする
      $('.affix-target').width($('.affix-target').parent().width());
    })
    .on('affix-top.bs.affix affix-bottom.bs.affix', () => {
      // .affix-top・.affix-bottom 時は上で指定した幅指定を解除し、親要素の幅に従わせる
      $('.affix-target').width('auto');
    });
  
  // ウィンドウリサイズに対応する
  $(window).resize(() => {
    // リサイズによる文章の折り返しなどでスクロール量が変わった場合に向けて、Affix の状況を再計算させる
    $('.affix-target').affix('checkPosition');
    // Affix 適用中の .affix-target のみ指定し、横幅を動的に設定する
    $('.affix-target.affix').width($('.affix-target').parent().width());
  });
});

.affix('checkPosition') という関数を使い、Affix の再計算をさせたりして、.affix-target の親要素の内側の幅に合わせるよう、動的に width を変更している。

コレで以下のようになる。

多分よくあるデザインだと、これくらいのことはしないといけないのかなと思われる。

以上

最近は Angular や Vue などの SPA で開発することが多いので、このように jQuery にベッタリな実装だとつらいモノがある。当時はまだ普及していなかった position: sticky という「標準」ももう整備されたし、今後はあまり使うことはないのかな。

ただ、特定のスクロール位置で position: sticky を解除する動きは、今も CSS オンリーでは困難な動きなので、こうした動きを再現したい場合は position: relativetop 値の動的変更が必要になってくる (もしくは position: absolute に切り替えてスクロール量に合わせた top 値を指定するとか・いずれもリサイズに対応できないと微妙になる)。

Affix の挙動を理解し、ノウハウを吸収できたので、あとは必要に応じて自分で実装してみるとしよう。

参考文献