【後編】Backbone.jsでつくるMVPなUIパターン【スクロールスパイ】

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

※この記事はBackbone.jsでつくるMVPなUIパターン【スクロールスパイ】前編からの続きの後編です。

ContentView

スクリーンショット 2013-03-28 18.54.48

継承

ContentViewは文章を表示し、自身の管轄しているスクロール範囲を知っているコンポーネントです。Backbone.Viewを継承して実装します。

var ContentView = Backbone.View.extend({});

テンプレート

UI表示用のテンプレート関数をNavViewと同じように定義しておきます。

<script type="text/template" id="ui-scroll-spy-content-template">
  <div class="scroll-spy-content">
    <%= content %>
  </div>
</script>
var ContentView = Backbone.View.extend({
  contentTemplate: _.template($('#ui-scroll-spy-content-template').html())
});

コンストラクタ

新しいContentViewの生成時に自身のUIをjQueryオブジェクトで $el プロパティに保持しておきます。また自分が何番目の文章なのか知っておく必要があるので、インデックス番号を index プロパティに保持しておきます。

  initialize: function(options) {
    this.$el = $(this.contentTemplate(this.options.data));
    this.index = this.options.data.i;
  }

描画

UIを描画する render 関数も実装しておきます。

  render: function() {
    this.options.$content_container.append(this.$el);
  }

表示領域の高さを計算

次にScrollSpyを実装する上でとても重要になる"範囲"を計算する関数を実装します。
計算した値は el_height というプロパティに保持しておきます。このプロパティが空の場合のみ計算を行うようにします。(この実装だと内容が書き換わった場合に再計算を行えませんので強制的に計算をおこなうフラグを引数に受け付けるなどで対応する必要があります)

  elementHeight: function() {
    if (this.el_height) {
      this.el_height;
    } else {
      var height = this.$el.height();
      var margin_top = parseInt(this.$el.css('margin-top').replace(/px/, ''));
      var margin_btm = parseInt(this.$el.css('margin-bottom').replace(/px/, ''));
      this.el_height = height + margin_top + margin_btm;
    }
    return this.el_height;
  }

余白を考慮するとhtml要素の高さを単純に返せばいいというわけにもいきません。要素の外側の余白 margin を加えた値が実際に表示されている領域となります。

範囲の格納と判別

計算した結果を元に、管轄する範囲を与えられるように setRenge 関数を実装します。範囲の始まりと終わりをそれぞれ renge_start renge_end プロパティに保持しておきます。

  setRenge: function(start, end) {
    this.renge_start = start;
    this.renge_end = end;
  }

setRengeした値の範囲に引数でもらった値が収まっているか判別する isInRenge 関数も実装します。

  isInRenge: function(val) {
    return val >= this.renge_start && val < this.renge_end;
  }
[/javascript]

<p>最後に自身が現在表示されていることを通知するための <tt>toCurrent</tt> 関数を実装します。これは <tt>current</tt> イベントを発火することで他のコンポーネントへ通知できるようにします。発火の際には自身のインデックス番号をイベントハンドラへ渡スようにしておきます。</p>




<h2 id="toc-contentlistview">ContentListView</h2>

<p><a href="https://dev.classmethod.jp/ria/backbone-js-mvp-ui-scroll-spy-1/attachment/%e3%82%b9%e3%82%af%e3%83%aa%e3%83%bc%e3%83%b3%e3%82%b7%e3%83%a7%e3%83%83%e3%83%88-2013-03-28-18-53-02/" rel="attachment wp-att-52568"><img src="https://cdn-ssl-devio-img.classmethod.jp/wp-content/uploads/2013/03/4b731f082186445a8fbd06c6dac63e2d-640x521.png" alt="スクリーンショット 2013-03-28 18.53.02" width="640" height="521" class="alignnone size-medium wp-image-52568" /></a></p>

<h3>継承</h3>
<p>ContentViewの集合を扱うContentListViewを実装します。<tt>Backbone.View</tt> を継承します。</p>


var ContentListView = Backbone.View.extend({});

イベントハンドリング

ContentListViewはスクロールイベントをハンドリングします。Backbone.View を継承すると 'イベント名': 'イベントハンドラ名 (セレクタ)' という形式でつくったオブジェクトを events プロパティに定義することで自身の持つ $el プロパティ(jQueryオブジェクト)で発生するイベントを扱えるようになります。(セレクタはjQueryで使われるものと同じです。用途に応じて省略できます)
今回は scroll イベントに対して watchScrollTop という関数を紐付けたいので以下のようにします。

  events: {
    'scroll': 'watchScrollTop'
  }

コンストラクタ

ContentListViewの生成時には自身の受け持つhtml要素をjQueryで取得し $el プロパティに格納し、ContentViewオブジェクトを格納するための配列を content_views プロパティに持っておきます。ContentViewの生成は長くなってしまったので setViews 関数にまとめて分けておきました。

  initialize: function(options) {
    this.$el = this.options.$container.children('.scroll-spy-contents');
    this.content_views = [];
    this.setViews();
  }

子クラス(ContentView)の生成とイベントのエスカレーション

setViews はインデックス番号とContentViewのコンテナをデータとともに渡し生成したオブジェクトを保持しておきますが、同時に各ContentViewから発せられる current イベントを監視して受け取ったインデックス番号を使って自身から change_current イベントを発火するようにしておきます。
こうすることで ContentView > ContentListView > ScrollSpyApp という流れでイベントをエスカレーションするようになります。

  setViews: function() {
    var self = this;
    _.each(this.options.data, function(_data, i) {
      var _options = {
        data: _.extend({ i: i }, _data),
        $content_container: self.$el
      };

      var contentView = new ContentView(_options);
      self.content_views.push(contentView);

      self.listenTo(contentView, 'current', function(index) {
        self.trigger('change_current', index);
      });
    });
  }

子クラス(ContentView)の描画

全てのContentViewを一気に描画できるように renderAll 関数を実装します。

  renderAll: function() {
    _.each(this.content_views, function(contentView) {
      contentView.render();
    });
  }

子クラスの範囲を計算してセット

ContentViewを全て描画できたら全ての範囲を計算する必要があります。コンテナの内側の上部にある余白を考慮して、各ContentViewの高さを受け取り一つ前までの高さに積み上げる形で範囲を決定していきます。算出された値はContentViewの setRenge 関数でセットしておきます。

  setContentRenges: function() {
    var padding_top = parseInt(this.$el.css('padding-top').replace(/px/, ''));
    var stack_height = 0;

    _.each(this.content_views, function(contentView) {
      var renge_end = stack_height + padding_top + contentView.elementHeight();
      contentView.setRenge(stack_height, renge_end);
      stack_height += contentView.elementHeight();
    });
  }

scrollイベントをハンドリングする watchScrollTop 関数は現在のスクロール位置を jQuery.fn.scrollTop 関数で取得し、各ContentViewに範囲内かどうかを問い合わせて範囲内の場合のみ toCurrent 関数を実行するようにします。
しかしこの関数はscrollイベントが起きる度に実行されるとあまりにも頻繁なためUnderscore.jsの _.throttle 関数を使ってイベントの発生を250msecで間引くようにしています。

  watchScrollTop: _.throttle(function() {
    var scroll_top = this.$el.scrollTop();

    _.each(this.content_views, function(contentView) {
      if (contentView.isInRenge(scroll_top)) {
        contentView.toCurrent();
      }
    });
  }, 250)

実はこんな書き方があるなんてちょっと驚きました。

スクロール位置の操作

最後に受け取ったインデックス番号から、文章を表示する位置までスクロールさせる関数を実装しておきます。

  changeCurrentTo: function(index) {
    var scroll_top = this.content_views[index].renge_start;
    this.$el.scrollTop(scroll_top);
  }

コード全体

以上でContentListViewの実装は完了です。まとめたコードが以下になります。

var ContentListView = Backbone.View.extend({
  /**
   * $elプロパティに格納してあるjQueryオブジェクトに
   * 紐付けるイベント名をキーとし、ハンドラ関数の名前を値とする
   *
   */
  events: {
    'scroll': 'watchScrollTop'
  },

  /**
   * 新しいContentListViewオブジェクトを作成した時に呼ばれる
   *
   * @class ContentViewオブジェクトの集合を扱う
   * @param {Obejct} options
   * @property {Obejct} options
   * @property {Array} content_views
   * @property {jQuery Object} $el (selector: .scroll-spy-contents)
   * @return undefined
   *
   */
  initialize: function(options) {
    this.$el = this.options.$container.children('.scroll-spy-contents');
    this.content_views = [];
    this.setViews();
  },

  /**
   * データを元にContentViewオブジェクトを生成しcontent_views格納
   * 各ContentViewオブジェクトのcurrentイベントを監視して
   * change_currentイベントをキックする
   *
   * @return undefined
   *
   */
  setViews: function() {
    var self = this;
    _.each(this.options.data, function(_data, i) {
      var _options = {
        data: _.extend({ i: i }, _data),
        $content_container: self.$el
      };

      var contentView = new ContentView(_options);
      self.content_views.push(contentView);

      self.listenTo(contentView, 'current', function(index) {
        self.trigger('change_current', index);
      });
    });
  },

  /**
   * 各ContentViewオブジェクトのrender関数を呼ぶ
   *
   * @return undefined
   *
   */
  renderAll: function() {
    _.each(this.content_views, function(contentView) {
      contentView.render();
    });
  },

  /**
   * 各ContentViewエレメントの範囲を計算しセットする
   *
   * @return undefined
   *
   */
  setContentRenges: function() {
    var padding_top = parseInt(this.$el.css('padding-top').replace(/px/, ''));
    var stack_height = 0;

    _.each(this.content_views, function(contentView) {
      var renge_end = stack_height + padding_top + contentView.elementHeight();
      contentView.setRenge(stack_height, renge_end);
      stack_height += contentView.elementHeight();
    });
  },

  /**
   * $elエレメントが発火するscrollイベントのハンドラ。
   * 250msecで間引きして定義。
   * コンテナの縦スクロール位置を取得して
   * 範囲に該当するContentViewのtoCurrent関数を呼び出す
   *
   * @return undefined
   *
   */
  watchScrollTop: _.throttle(function() {
    var scroll_top = this.$el.scrollTop();

    _.each(this.content_views, function(contentView) {
      if (contentView.isInRenge(scroll_top)) {
        contentView.toCurrent();
      }
    });
  }, 250),

  /**
   * 受け取ったindexに該当するContentViewオブジェクトの
   * 範囲開始位置までコンテナをスクロールさせる
   *
   * @param {Number} index
   * @return undefined
   *
   */
  changeCurrentTo: function(index) {
    var scroll_top = this.content_views[index].renge_start;
    this.$el.scrollTop(scroll_top);
  }
});

ScrollSpyApp

スクリーンショット 2013-03-28 18.48.26

さぁここまでくればあと一息!ScrollSpyの振る舞いをコントロールする ScrollSpyApp を実装していきましょう。

継承

直接htmlを扱う必要は無いので Backbone.Events を継承します。継承は _.extend で行なっていますがこれはBackbone.Eventsはprototypeに関数を定義しているBackbone.Viewクラス等とは違い単純なJavaScriptオブジェクトにイベント系の関数が入っているだけのモジュールとして定義されているためです。

var ScrollSpyApp = _.extend({}, Backbone.Events);

コンストラクタ

ページ表示時に呼び出される初期化メソッドを実装します。Backbone.Eventsを継承した場合は new HogeView のようにインスタンスの生成はしないので、初期化関数はinitialize という名前でなくてもよいのですが、敢えてBackboneのAPIに合わせることでコードに統一性をもたせようと思います。

  initialize: function(data, router) {
    this.data = data;
    this.router = router;
    this.$container = $('#ui-scroll-spy');

    this.setViews();
    this.bindChangeCurrent();

    return this;
  }

初期化時にはデータとScrollSpyRouterを受け取り自身のプロパティに格納して、UI全体を囲んでいる要素 #ui-scroll-spy を取得しておきます。
またビュー達の準備は別関数にしておきました。その後にイベントのハンドリングも定義しておきます。
関数の最後には ScrollSpyRouter がこの初期化済みのコンポーネントを受け取れるように自分自身を返すようにしています。

ビュー達を準備する

NavViewやContentViewはここから直接操作はしないので、それぞれの集合を扱っている NavListViewContentListView を生成しておきます。

  setViews: function() {
    var options = {
      data: this.data,
      $container: this.$container
    };

    this.navListView = new NavListView(options);
    this.contentListView = new ContentListView(options);
  }

ContentListViewのイベントをハンドリング

ContentView の集合である ContentListView から現在表示中の文章が変わったことを示すイベントが発生したら、ScrollSpyRouterNavListViewを操作するようにしています。
この時 Backbone.Routernavigate 関数を実行していますが注意スべき点が2つあります。
一つ目は引数に文字列しか渡せないということ、二つ目はオプションを { trigger: false }(デフォルト)にしておくことです。 こうしておかないとスクロールする度に文章範囲の始まりの位置まで戻されてしまうので、スクロールイベントからはハッシュフラグメントの変更とナビゲーションの見た目の変更のみ行うようにしておきます。

  bindChangeCurrent: function() {
    var self = this;
    this.listenTo(this.contentListView, 'change_current', function(index) {
      self.router.navigate(String(index));
      self.navListView.changeCurrentTo(index);
    });
  }

ナビゲーションと文章の表示切替

スクロールイベントをきっかけとするナビゲーションと文章の表示切替の他に、ハッシュフラグメントの変更からも表示が切り替わるようにしたいと思います。これはナビボタンをクリックしてハッシュフラグメントが変わった際と、ページの表示時に初期値のハッシュフラグメントがついていた場合にも動作するようになります。

  changeTo: function(index) {
    this.navListView.changeCurrentTo(index);
    this.contentListView.changeCurrentTo(index);
  }

コード全体

以上でScrollSpyAppの実装は完了です。以下がまとめたコードになります。

var ScrollSpyApp = _.extend({
  /**
   * ScrollSpyを初期化する
   *
   * @param {Object} data
   * @param {Backbone.Router Object} router
   * @property {Object} data
   * @property {jQuery Object} $container
   * @property {Backbone.Router} router
   * @return ScrollSpy.App
   *
   */
  initialize: function(data, router) {
    this.data = data;
    this.router = router;
    this.$container = $('#ui-scroll-spy');

    this.setViews();
    this.bindChangeCurrent();

    return this;
  },

  /**
   * 新しいNavListViewオブジェクトとContentListViewオブジェクトを作成
   *
   * @return undefined
   *
   */
  setViews: function() {
    var options = {
      data: this.data,
      $container: this.$container
    };

    this.navListView = new NavListView(options);
    this.contentListView = new ContentListView(options);
  },

  /**
   * ContentListViewが発火するchange_currentイベントを監視して
   * ScrollSpyRouterとNavListViewオブジェクトを操作する。
   *
   * @return undefined
   *
   */
  bindChangeCurrent: function() {
    var self = this;
    this.listenTo(this.contentListView, 'change_current', function(index) {
      self.router.navigate(String(index));
      self.navListView.changeCurrentTo(index);
    });
  },

  /**
   * NavListViewオブジェクトとContentListViewオブジェクトのrenderAll関数を呼び出す。
   * ContentListViewのsetContentRenges関数を呼び出す。
   *
   * @return undeifned
   *
   */
  renderAll: function() {
    this.navListView.renderAll();
    this.contentListView.renderAll();
    this.contentListView.setContentRenges();
  },

  /**
   * NavListViewオブジェクトとContentListViewオブジェクトの表示をを
   * 指定のindexに切り替える
   *
   * @param {Number} index
   * @return undefined
   *
   */
  changeTo: function(index) {
    this.navListView.changeCurrentTo(index);
    this.contentListView.changeCurrentTo(index);
  }
}, Backbone.Events);

ScrollSpyRouter

スクリーンショット 2013-03-28 18.46.52

継承

このクラスは Backbone.Router を継承して実装します。Backbone.history と組み合わせて使用することでハッシュフラグメントの切り替わりを検知してクライアントサイドのURLルーティングを実現出来ます。(HTML5のpushStateを利用することもできます)

var ScrollSpyRouter = Backbone.Router.extend({});

コンストラクタ

ScrollSpyRouterの初期化時にはデータを受け取り、ScrollSpyAppへ渡して自身のプロパティにセットしておきます。セットしたらすぐに描画を実行しておきます。

  initialize: function(options) {
    this.options = options;
    this.app = ScrollSpyApp.initialize(this.options.data, this);
    this.app.renderAll();
  }

ルーティング処理

Backbone.Router を継承したクラスでは routes というプロパティに 'ルーティング処理するパス': 'アクション関数名' といった形のオブジェクトを入れておくことでルーティングを定義します。
基本的にはどのハッシュフラグメントでも同じ動作で問題ないので *action といったワイルドカード指定を使用します。

  routes: {
    '*action': 'defaultAction'
  }

defaultAction 関数のなかではハッシュフラグメントの # がついていない文字列を受け取れるので、ScrollSpyAppの changeTo 関数に渡して表示を切り替えるように指示しておきます。

  defaultAction: function(action) {
    this.app.changeTo(action);
  }

コード全体

以上で実装は完了です。まとめたコードが以下になります。

var ScrollSpyRouter = Backbone.Router.extend({
  /**
   * 新しいScrollSpyRouterを生成した時に呼ばれる
   *
   * @param {Object} options dataキーをもったobject
   * @property {Object} options
   * @property {ScrollSpy.App} app
   * @return undefined
   *
   */
  initialize: function(options) {
    this.options = options;
    this.app = ScrollSpyApp.initialize(this.options.data, this);
    this.app.renderAll();
  },

  /**
   * 処理の対象にするハッシュフラグメントと
   * 対応する関数名を指定するオブジェクト
   *
   */
  routes: {
    '*action': 'defaultAction'
  },

  /**
   * 全てのハッシュフラグメントを処理するアクション
   *
   * @param {String} action URLの#で指定された文字列(#を含まない)
   * @return undefined
   *
   */
  defaultAction: function(action) {
    this.app.changeTo(action);
  }
});

サンプル

以上で全ての実装が完了しました!完成したものを用意してありますので以下からお試しください。