【MVC実践編】画面スクロールでコンテンツ自動ロード「loadMore」の実装について

コンテンツを画面に表示するとき、下にスクロールすれば自動的に次のコンテンツをロードしてくれる、という「loadMore」機能を考える
例えば、クラウド上に保存してある写真を画面に表示するような Webアプリを作るとする。

# 第1案
MVC を少しかじると、次のような実装が頭に浮かぶ。

```coffeescript
# apis/image.coffee
angular.module("app").factory "getImageAPI", ->
  getImageAPI = (userId, offset, limit) ->
    # call API and return promise
  return getImageAPI

# models/image.coffee
angular.module("app").factory "ImageModel", ["getImageAPI", (getImageAPI) ->
  ImageModel = (userId) ->
    _pageNo = 0
    _pageSize = 10
    _images = []
    _isComplete
    self =
      loadMore: ->
        promise = getImageAPI userId, (_pageNo++) * _pageSize, _pageSize
          .then (response) ->
            data = response.data
            Array.prototype.push.apply _images, data.images
            _isComplete = data.isComplete
            return response
        return promise
      getUserId: -> userId
      getImages: -> _images
      isComplete: -> _isComplete
    return self

  return ImageModel
]

# controllers/image.coffee
angular.module("app").controller "ImageController", ["$scope", "$stateParams", "ImageModel", ($scope, $stateParams, ImageModel) ->
  userId = queryParams.userId
  imageModel = ImageModel userId
  $scope.images = imageModel.getImages()
  $scope.isComplete = -> imageModel.isComplete()
  $scope.loadMore = ->
    imageModel.loadMore().then (data) ->
      $scope.$broadcast 'scroll.infiniteScrollComplete'
]
```

```haml
-# templates/image.haml
%ion-list
  %ion-item(ng-repeat="image in images")
    %img(ng-src="{{ image.src }}")
%ion-infinite-scroll(on-infinite="loadMore()" ng-if="!isComplete()")

```

angularJS と ionic を使っているが他のライブラリでもこんな構成になるはずだ。

- M
    - getImageAPI
        - API 通信を行う
    - ImageModel
        - ユーザーの写真を管理するモデルを提供する
        - loadMore メソッドを呼ぶと、内部で API 通信を行い、画像を追加で読み込む
- C
    - ImageController
        - モデルである ImageModel と、ビューである templates/image.haml をつなぎとめるコントローラ
        - $scope に渡した値が、view にバインドされる。
        - $stateParams で、queryString を受け取ることができる
- V
    - templates/image.haml
        - ion-list
            - コンテンツをリスト表示する
        - ion-item
            - 1つのコンテンツを表す
        - ion-infinite-scroll
            - このタグが画面上に現れ、かつ `ng-if` が `true` を返す限り、`on-infinite` を実行する
            - on-infinite には、loadMore の実行が指示されている

# 第1案の問題点
第1案は、実は完全な MVC 構成とは言えない。何がおかしいのだろう。
一言で言えば、「loadMore は view の事情であって、 Model が管理するべきではない」ということである。

- クラウド上に保存されている画像をどう表示するかは、view が決める
- view 側の実装は、例えば次のようなものが考えられる
    - スクロールすれば自動的につぎの10件がロードされる
    - ページャーを用意し、次へボタンを押せば次の10件がロードされる
    - 画像が1枚だけ表示され、3秒ごとに次の画像に切り替わる
- loadMore は、その中の1つの選択肢に過ぎない。
- つまり、loadMore 関数が ImageModel の中にあるのはおかしい。

ImageModel は、あくまで offset, limit を受け取り、その分のデータを表示するべきだろう。

# 第2案
そこで、次のような util 関数を用意して、この切り分けを実現する。

```coffeescript
# apis/image.coffee
angular.module("app").factory "getImageAPI", ->
  getImageAPI = (userId, offset, limit) ->
    # call API and return promise
  return getImageAPI

# models/image.coffee
angular.module("app").factory "ImageModel", ["getImageAPI", (getImageAPI) ->
  ImageModel = (userId) ->
    self =
      getImages: (offset, limit) ->
        promise = getImageAPI userId, offset, limit
          .then (response) ->
            return {
              images: response.data.images
              isComplete: response.data.isComplete
            }
        return promise
      getUserId: -> userId
    return self

  return ImageModel
]

# views/loadMore.coffee
angular.module("app").factory "view.loadMoreTask", ->
  return (startPage, loader) ->
    self =
      pageNo: startPage
      items: []
      isComplete: false
      loadMore: ->
        loader(self.pageNo++).then (response) ->
          Array.prototype.push.apply(self.items, response.items)
          self.isComplete = response.isComplete
    return self

# controllers/image.coffee
angular.module("app").controller "ImageController", ["$scope", "$stateParams", "ImageModel", "view.loadMoreTask", ($scope, $stateParams, ImageModel, loadMoreTask) ->
  userId = queryParams.userId
  imageModel = ImageModel userId
  pageSize = 10
  $scope.loadMoreTask = loadMoreTask 0, (pageNo) ->
    imageModel.getImages(pageNo * pageSize, pageSize).then (data) ->
      $scope.$broadcast 'scroll.infiniteScrollComplete'
      return {
        items: data.images
        isComplete: data.isComplete
      }
]
```

```haml
-# templates/image.haml
%ion-list
  %ion-item(ng-repeat="item in loadMoreTask.items")
    %img(ng-src="{{ item.src }}")
%ion-infinite-scroll(on-infinite="loadMoreTask.loadMore()" ng-if="!loadMoreTask.isComplete")
```

- M は、V がどのように表示しているかは知らない
    - offset, limit を受け取って 画像を返すだけである
- V は、M から受け取ったデータを表示するためのプレゼンテーションロジックを提供する。
    - このロジックは、データの中身が画像なのか、ツイートなのかなどは知らない。
- C は、M と V とを結びつけ、M から受け取ったデータを V が提供するプレゼンテーションロジックに渡す。