SwiftUIで無限スクロールを実装する


はじめに

みなさんSwiftUI使っていますか?
自分も最近触り始めましたが、

  • ひたすらAutoLayoutのエラーと戯れたり
  • 都度ビルドして数秒放心状態になって時間を無駄にしたり

もなくなり開発効率が爆上がりしました

さて今回はそんなSwiftUIを使って、↓のような無限スクロールを実装する方法を紹介します。

Vertical Horizontal

ただし先に結論を言っておくと、SwiftUI側で機能的に足りないところがあり、思ったように実装できなかった箇所も多いです。そこについても詳しく説明していきます。

ライブラリとしても公開したのでとりあえず動かしてみたいという方はこちらをご覧ください。
最近デザインも勉強してるので、テンション上げるためにロゴも作ってみました

対象読者

  • SwiftUIで無限スクロールを実装したい方
  • ウォンバットが好きな方

実装の仕組み

無限に要素を生成する仕組み

無限スクロールを実装する方法…一度自分の頭でも考えてみて欲しいですが、どんな方法が思いつきますか?
自分も3つくらいアイデアを思いついたのですが、最終的に採用したのが以下の図のようなものです。

  1. N個の要素を用意しておき画面に配置します。ユーザーは画面をスクロールしていきます
  2. N - 1番目のViewがonAppearしたタイミングでN+1個目を生成します。
  3. 要素が1つ増えるので今見ているViewはN - 2番目になります。
  4. 2に戻る。無限に続く...

実装としては@ObservedObjectを使って、ViewModelを作成して状態管理を行なっています。
以下のようなものになっています。(※重要な部分だけ抜粋しています。)

viewmodel
open class InfinityScrollViewData: ObservableObject {
    @Published var items: [G.Item]

    func onAppear(page: Int){
        // 初期化
        if(isOnInitialize(appearedPage: page)) {
            items.append(generator.generateItem(page: 1))
            items.append(generator.generateItem(page: 2))
        }

        // N - 1個目の要素が表示されたら要素を追加する
        if(needToAppendItem(appearedPage: page)) {
            items.append(generator.generateItem(page: lastPage + 1))
        }
    }

    private func isOnInitialize(appearedPage page: Int) -> Bool { page == 0 && items.count == 1 }
    private func needToAppendItem(appearedPage page: Int) -> Bool {
        page == lastPage - 1
    }
}
view
public struct InfinityScrollView: View {
    // 状態管理
    @ObservedObject var scrollViewData: InfinityScrollViewData<G>

    public init(generator: G, direction: InfinityScrollDirection) {
        self.scrollViewData = InfinityScrollViewData<G>(generator: generator)
        self.scrollDirection = direction
    }

    public var body: some View {
        GeometryReader { geometry in
            VStack(alignment: .center) {
                List(self.scrollViewData.items) { item in
                    item
                    .onAppear(){
                        // Viewが画面上に表示されたらonAppearをコール
                        self.scrollViewData.onAppear(page: item.page)
                    }
                }
            }
       }
    }
}

横方向のスクロール

ListViewには横方向がないので、横方向のスクロールに対応するために、なんとLIstを90度に回転させてさらに中の要素を
-90度回転させるという暴挙に出ています笑

...
    var viewRotation: Double {
        switch(scrollDirection){
        case(.horizontal):
            return 90
        case(.vertical):
            return 0
        }
    }
...

    public var body: some View {
        GeometryReader { geometry in
            VStack(alignment: .center) {
                List(self.scrollViewData.items) { item in
                    item
                        // 回転させた分中身を逆方向に回転
                        .rotationEffect(.degrees(self.viewRotation))
                }
                // Listを横に回転
                .rotationEffect(.degrees(-self.viewRotation))
            }
       }
    }

ここで

「いやそもそもお前Horizontal View使えばええやんwww知らんのかいなwww てかそもそもなんでScrollView使わんの? 」

と思った関西の方。
はい、自分もそう思いました。
ここからはScrollViewで実装しようとしておきた問題点について書いていきます。

ScrollViewの問題点

ScrollViewを使えば以下のようなコードで横方向のスクロールは比較的容易に実装することができます。

ScrollView (.horizontal, showsIndicators: false) {
     HStack {
         //contents
     }
}.frame(height: 100)

しかしながらこれを無限スクロールにしようとすると色々問題が起きてきます。

OnAppearが画面上になくても発火する

まず、ScrollViewに要素を追加すると、画面上に表示されている / いないに関わらずOnAppearが発火します。
なのでList Viewで使った上記の無限に要素を生成する仕組みを使うことができません。

スクロール位置を取れない

じゃあ別にScroll位置を取得して、その位置に応じて要素生成すれば良いとなりますが、それも自分が探した限りは見当たりませんでした。
なので、もしやるとするならGeometryReaderをつけた見えない要素をScrollViewの端とかに配置して、Scroll位置を取得してそこから現在の表示領域を計算するという方法がありますが、なんか強引な気がしますしロジックが複雑になりそうな気がして、一旦見送りました。

ListView & ScrollView共通の問題

手前のViewを消すと位置がずれる

パフォーマンスを考えて、見えなくなったViewを消す処理を入れてみたところ、手前のViewを消すと表示位置がずれてしまいました。そのため、表示されなくなったViewも特に何もせずにSwiftUIに処理を任せています。
ある程度最適化されているのか、現状そこまでパフォーマンスの問題は起きていませんが結構気持ち悪い状態です。

描画位置をコントロールできない

上記の位置がずれる問題をずれた分描画位置を逆方向にずらせば、動作させられることも考えられますが、その機能も現状のSwiftUIにはありません。
また、

  • フリックしたときに1ページずつぴったりのところに表示する機能
  • 逆方向へのスクロール

も入れたかったのですが、同様の機能がなく今回は実装できていません。
UIKitにはcontentOffsetがあるので、この機能がSwiftUIにも入ることを期待しています。

まとめ

いかがだったでしょうか?
ちょっと今回作成したライブラリをそのままプロダクトに使えるほどではないので、それを期待した方はごめんなさい 🙇‍♂️
自分のプロダクトでも使いたいのでSwiftUIのバージョンアップを待ちつつももう少しクオリティ上げていくつもりです。
(Contributeも歓迎です )

感想としては
「SwiftUiまだまだ痒いところに手が届かないなー泣
という感じです。
とはいえ
「開発しやすくて好きだよSwiftUI
という気持ちもかなりあるのでこれからも使っていく所存です。

その他 〜SwiftUIおすすめリンク〜

日本語

SwiftUI実践入門
とても薄い本にしっかりとSwiftUIの重要な部分がまとまっています。
日本語 & わかりやすいのでシュッと入門するなら英語のチュートリアルやるよりいいかもしれません。

英語

Swift公式チュートリアル
言わずもがな。Appleがかなり力を入れて作ったと思われる。むしろこっちのWebの実装がどうなってるのか気になる。

HackingWithSwift
これはどうやるんだろう?というのを調べるときに短いサンプルコードがあるので、とても良い。
またState周りが若干SwiftUIの鬼門だがこの動画が非常にわかりやすく説明されている。
(※若干バージョンが古い。ただ概念は同じなので参考になる)

SwiftUI使ったことない方は是非試してみてください