無限スクロール by Josh & Eliza, 2011 WWDC
UIScrollView
を使った無限スクロールはかなり昔から知られていて、僕が一番はじめに聞いたのは 2011 年 WWDC の Josh & Eliza の Advanced ScrollView Techniquesでした。
通常、スクロールビュー上で例えば左スワイプをすると、画面が左へ流れるイメージを持ちますが、実際はビューポートが右へ動くことによってそう見せています。次図で言うと、最初黄緑部分だけが画面に映っていたのが、左スワイプによってビューポートが右へ移動し、今度は黄色部分が画面に映ります。
この通常のスクロールだと、当然 Content View の端っこで、これ以上スクロールできなくなります。
Josh & Eliza が 2011 に紹介した無限スクロールテクニックの肝は、UIScrollViewDelegate.viewDidScroll(...)
で
-
UIScrollView
のcontentOffset
を常にアジャストして、ビューポートを常に Content View の中心に維持する - ビューポートを動かさない代わりに、Content View を、ビューポートの通常の動きとは反対に動かす
- ContentView の空いた部分に、__新しいコンテンツを追加__する
ということです。
ビューポートは常に ContentView の中心なのでスクロールを止めることはなく、プログラムが ContentView にちゃんと新しいコンテンツを供給している限り、延々とスクロールし続けられます。
UIScrollViewDelegate.viewDidScroll(...)
はビューポートの移動ごとに呼ばれ、かつレイアウト前の段階で呼ばれるので、スクロールに伴ったこのような動きを記述するのに最適です。
ページング付きスクロール
問題点
Josh & Eliza の方法はとても簡単に実装できて、UIScrollView
のページング機能を使わない場合は、今でも非常に有効です。ただ、例えばスクロールビューをを画像ビューアーとして使いたい場合など、各ページでスナップしてくれるページング機能を使いたいです。
本稿で扱う例は、動物版「いないないばあ」アプリ(!?)で、画像を左右にスクロールすると、ランダムに新しい動物キャラクターが表示されると言うものです。
UIScrollView
には isPagingEnabled
というフラグがあって、これを true
にするだけで、スクロールした時に各動物の絵のところでピチッと止まってくれます。ところが、Josh & Eliza の無限スクロールテクニックを使うと、ビューポートは常に ContentView の中心にあるので、iOS からすると全くページ移動をしていないと勘違いしてしまい、ページングが利かなくなります。
解決方法
基本的な考え方は Josh & Eliza のものを使うとして、変更の肝となるアイデアは「そんなにしょっちゅうビューポートをアジャストする必要はない」ということです。Josh & Eliza は、scrollViewDidScroll(...)
で毎回アジャストを行なっていました。実際にアジャストが必要となるのは次の二つのタイミングのみです。
- 右方向にスクロールして、次ページのコンテントがビューポートのマジョリティを占めるようになった時
- 左方向にスクロールして、前ページのコンテントがビューポートのマジョリティを占めるようになった時
右スクロールして n+1 ページ目がマジョリティになった場合:
左スクロールして n-1 ページ目がマジョリティになった場合:
__各々のタイミングで 1 ページ分、ビューポートをずらして次のページを補填__してやれば良いわけです。ページ境界では、通常通りのスクロールが動いているのでページングも有効です。
コード例は以下の通り。
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetX = scrollView.contentOffset.x
if (offsetX > scrollView.frame.size.width * 1.5) {
// 1. モデルをアップデート。n-1 ページ目を削除して, n+2 ページ目を追加
let newImage = fetcher.fetchRandomImage()
images.remove(at: 0)
images.append(newImage)
// 2. 後述。n ページ目から n+2 ページのフレーム設定
layoutImages()
// 3. ビューポートの調整
scrollView.contentOffset.x -= scrollViewSize.width
}
if (offsetX < scrollView.frame.size.width * 0.5) {
// 1. モデルをアップデート。n+1 ページ目を削除して, n-2 ページ目を追加
let newImage = fetcher.fetchRandomImage()
images.removeLast()
images.insert(newImage, at: 0)
// 2. 後述。n-2 ページ目から n ページのフレーム設定
layoutImages()
// 3. ビューポートの調整
scrollView.contentOffset.x += scrollViewSize.width
}
}
各ページのフレーム設定をするコード例は以下。
private func layoutImages() {
imageViews.enumerated().forEach { (index: Int, imageView: UIImageView) in
imageView.image = images[index]
imageView.frame = CGRect(x: scrollViewSize.width * CGFloat(index),
y: 0,
width: scrollViewSize.width,
height: scrollViewSize.height)
}
}
まとめ
UIScrollView
で無限スクロールを実現したい場合、ページングが必要ない場合は、UIScrollViewDelegate.scrollViewDidScroll(...)
で毎回 contentOffset
と ContentView のフレームを調整することで実現できます。
しかし、ページングが必要な場合も調整するタイミングを絞ることで同じ方法を用いることができます。
おそらく UIPageViewController
なんかを使ってもできるでしょうが、僕は UIView
の世界だけで実現できるこの方法が好きです。リソースとしても現在のページ + 左右のページを用意するだけですみ、最適にエコですw
あと、デバイスの回転をサポートするようになるとちょっとややこしくはなってくるんですが、その辺も含めた コードがここ にあります。
今度アンドロイドで似たようなことをするステップを調査してみたいと思っています。