WebDesign Dackel

jQueryでプラグインを使わずにそれなりにちゃんとしたスムーススクロールを実装する

jQueryでプラグインを使わずにそれなりにちゃんとしたスムーススクロールを実装する

Hatena0
Google+0
Pocket0
Feedly0

はじめに

いきなりですがjQueryでスムーススクロールを実装した事があるでしょうか? きっと沢山の人が実装したこと、使ったことのある機能だと思います。
ちょっと調べて出てきたサンプルのコードをコピペすれば、たった数行のコードでサイトに動きが出せるし、アニメーションが入ることでページ内で移動していることが分かりやすくて良いですよね!

僕がHTMLjQueryなどを触り始めた頃、コピペで簡単に動かせて感動したのを覚えています。
そこから色々なサイトを見たり触っているうちに、こんな感じで動かすのがいいかも!というのが出てきたので、これまで実装はコピペで済ませてきたり、プラグインを使って動かしていた方が、自力で”それなりに”ちゃんとしたスムーススクロールを実装できるように。と思ったので記事にしてみました。

ちなみにこれは、ちょっと前に書いたスムーススクロール用のプラグインjquery-sweet-scrollを作った時の覚書みたいな内容です。

よくあるサンプルコード

コピペで簡単設置!みたいなサンプルコードです。(jquery.easing.jsを別途読み込んでいる前提で進めていきます)

$(function(){
  $("a[href^=#]").click(function() {
    var targetY = $(this.hash).offset().top;
    $("html,body").animate({scrollTop: targetY}, 1000, "easeOutQuint");
    return false;
  });
});

一見なんの変哲も無いスムーススクロールのコードです。それほどこだわりが無いのであれば、上記のコードで多くの場合問題ない気がします。

ただ、実はこのコードにはちょっとした罠があります。。

サンプルコードの問題点

では一体どんなところが問題になってくるのでしょうか? 具体的には以下のような点です。

  1. hrefに指定した要素(ターゲット)が存在しない場合にエラーが出る
  2. ページの下の方へスクロールした時にピタッと止まる
  3. アニメーション終了のコールバックが2回呼ばれる
  4. アニメーション中にマウスホイールを動かすとチラつく
  5. 動的に追加した<a>に対して有効じゃ無い

デモページ

実際に上記の問題点が確認できるデモページを用意しました。

ダメなパターンのデモ

左上の「+」マークのボタンをクリックすると動的にブロックを追加するようになっていて、5の問題点を確認できる様になっています。

また、「存在しないブロック!」をクリックした場合、Chromeの場合、下記のようなエラーが出るかと思います。

Uncaught TypeError: Cannot read property 'top' of undefined

これは$.fn.offset()が、存在しない要素に対して実行した場合undefinedを返すのに、続けてtopプロパティを読もうとしているために起きています。

var targetY = $(this.hash).offset().top;

他にも、先ほど挙げた問題点がもろもろ確認できるようになっています。

改善したコード

じゃあどうすればいいんだー!ということで、問題となっていた箇所を改善するコードは下記のような感じです。

$(function(){

  // htmlとbody、どちらかスクロール可能な要素を取得
  function getFirstScrollable(selector){
    var $scrollable;

    $(selector).each(function(){
      var $this = $(this);
      if( $this.scrollTop() > 0 ){
        $scrollable = $this;
        return false;
      }else{
        $this.scrollTop(1);
        if( $this.scrollTop() > 0 ){
          $scrollable = $this;
          return false;
        }
        $this.scrollTop(0);
      }
    });

    return $scrollable;
  }

  // スクロールに使用する要素・イベントを設定
  var $win = $(window),
      $doc = $(document),
      $scrollElement = getFirstScrollable("html,body"),
      mousewheel = "onwheel" in document ? "wheel" : "onmousewheel" in document ? "mousewheel" : "DOMMouseScroll";

  // aタグのクリック
  $doc.on("click", "a[href^=#]", function(e){
    var $target = $(this.hash),
        top;

    // 指定した要素が存在しない場合は未処理とする
    if( $target.size() < 1 ) return false;

    // スクロール先の座標を調整する
    top = $target.offset().top;
    top = Math.min(top, $doc.height() - $win.height());

    // ウィールイベントをキャンセルしておく
    $doc.on(mousewheel, function(e){ e.preventDefault(); });

    // アニメーションの実行
    $scrollElement.stop().animate({scrollTop: top}, 1000, "easeOutQuint", function(){
      $doc.off(mousewheel);
    });

    return false;
  });
});

一気に長くなりましたね…。

デモページ

良いパターンのデモ

改善のポイント

先ほどのコードのポイントを抑えると、

  1. htmlbodyからスクロール可能な要素をどちらか取得しておく
  2. 動的に追加した要素にも対応する形式で、href属性が#で始まる<a>に対してクリックイベントを設定
  3. クリック時に指定したIDを持つ要素が存在するかチェックする
  4. スクロール先の座標を調整
  5. スクロールアニメーション中にマウスホイールを動かしても不自然な動きにならないように、mousewheelイベントをキャンセルしておく
  6. $.fn.animate()を使って調整済みの座標へアニメーションさせる
  7. アニメーション完了時に、キャンセルしていたmousewheelイベントを元に戻す

こんな感じです。

その他にもこんな例外対策

大体の場合、先ほどの改善コードでいけるかなと思ったのですが、設置するサイトによっては次のような場合に対応する必要がありそうです。

ヘッダーが固定の場合

ブログなどでも採用しているサイトが多くありますね。この時問題になってくるのは、スクロール完了後にヘッダの高さの分だけコンテンツに被ってしまうという点です。

固定ヘッダーが重なってしまっている

対策のポイントは、被ってしまうヘッダの高さ分だけスクロール位置をずらすだけです。

ヘッダーのサイズが予め分かっている場合

<a>のクリック後に座標を調整する箇所を下記のようにちょっと書き足します。
例では、固定ヘッダのサイズが50pxだとします。

// ... 省略

// スクロール先の座標を調整する
top = $target.offset().top;
top = Math.min(top, $doc.height() - $win.height());
top -= 50;

単純に調整後の座標から、50px引いているだけです。

ヘッダーのサイズが分からない場合

予め高さが分かっている場合と大差はなく、直接数値で指定していた箇所をjQuery側で高さを取得するだけです。

// ... 省略

// スクロール先の座標を調整する
top = $target.offset().top;
top = Math.min(top, $doc.height() - $win.height());
top -= $(ヘッダー).outerHeight();

特に理由が無い場合はこちらの方がよいかなぁ〜と思います。

ページ読み込み時にもスクロールしたい

あまり使う機会があるようには思えなかったのですが、一応。

$(function(){

  // ... 省略

  // 読み込み時にスクロールを行う
  var location = window.location;

  if( !/[;:]/.test(location.hash) && $(location.hash).size() > 0 ){
    var $target = $(location.hash),
        top;

    location.hash = "";

    top = $target.offset().top;
    top = Math.min(top, $doc.height() - $win.height());

    $doc.on(mousewheel, function(e){ e.preventDefault(); });

    $scrollElement.stop().animate({scrollTop: top}, 1000, "easeOutQuint", function(){
      $doc.off(mousewheel);
      $("#counter").text( parseInt($("#counter").text()) + 1 );
    });
  }

});

現在のURLからハッシュを取得して、クリック後と同様にアニメーションを行っています。

まとめ

ちゃんと作ろうと思うと意外と考える事が多くて面倒くさいですね。
「たかだかスムースルクロールで…」という声も聞こえてきますが、よくある機能だからこそ細かい点に気を配って、自分なりにこだわりを持っていたいなと思います。

他にもこういう場合に対応した方が良いよ!という箇所などありましたら、優しく教えて頂けたら嬉しいです。