SwiftUI x Firestoreで無限スクロールをしてみた

## はじめに

SwiftUI製のアプリをこれまで[15個](https://apps.apple.com/us/developer/ryo-tsudukihashi/id1320583602?ign-itscg=30200&ign-itsct=apps_box_link&see-all=i-phonei-pad-apps)ほどリリースしている[@tsuzuki817](https://twitter.com/tsuzuki817)です🐥
よろしくお願い致します！

まずSwiftUI Advent Calendar 2022を作ってくださった[@treastrain](https://qiita.com/treastrain)さんありがとうございます🙇

最高のアドベントカレンダーです🎄

最高のアドベントカレンダーですが、初っ端の一日目ということで軽く見ていただければと思います。
よろしくお願い致します🙇

## いきなり宣伝

Full SwiftUI（カメラ、アルバム周りは除く）で作ったアプリ[「TicketMania」](https://apps.apple.com/jp/app/ticketmania/id6444087177?platform=iphone)をこの度リリースしました！

TicketManiaは美術館・博物館・乗り物・イベントなどいろんなところで使うチケットを思い出として保存・共有することができるアプリです！（BEはFirestoreを使ってます）

ぜひインストールしてお出かけしてチケットを手に入れた際には投稿してみてください🎟

![TicketMania.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171702/d35144bb-df47-746e-6f3e-958d0a48371a.jpeg)

## 閑話休題

さて今回のアプリではSwiftUI x Firestoreの構成でアプリを作りました。
その際に無限スクロールの実装に癖があり少し苦労してしまいましたので、ご紹介したいと思います！
また**リアルタイムリスナーは使用しておらず、適宜getしている実装**になりますのでご注意ください🚨

※他に良い実装方法があればぜひコメントしていただけると助かります🙇

## 確認環境

- Xcode 14, iOS 16
- Firebase 10.0.0

## 画面構成

画面構成は以下のようになっています。

- NavigationStack
    - GeometryReader
        - ZStack
            - ScrollView
                - LazyVGrid

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171702/8aca2ece-8714-481c-d0ee-6d3324ec82bb.png" width=320>

## 表示するデータ

対象画面のViewModelに@Publishedで表示するデータを保持しています。

```swift
@Published var tickets: [Ticket] = []
```

## RandomAccessCollectionを拡張する

表示されているViewが配列の最後であるかどうかを判断する必要があります。
そこでRandomAccessCollectionのExtensionを作ります。

```swift
extension RandomAccessCollection where Self.Element: Identifiable {
    public func isLastItem<T: Identifiable>(_ item: T) -> Bool {
        guard !isEmpty else { return false }

        guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
            return false
        }

        let distance = self.distance(from: itemIndex, to: endIndex)
        return distance == 1
    }
}
```

## onAppearで毎回チェック

LazyVGrid内のViewを生成しているForEach内でonAppearメソッドを使い保持しているデータの最後のデータが表示されたら追加フェッチ処理を行うような関数を書いていきます。

（Route.ticketはアプリ内で定義していて、この値がNavigationStackの最前面に積まれる感じです。あまり気にしなくて良いです🐸）

```swift
NavigationLink(value: Route.ticket(id: ticket.id)) {
    ListTicketView(ticket: ticket, size: size)
        .onAppear {
            Task {
                await viewModel.loadNextIfNeeded(ticket: ticket)
            }
        }
}
```

## Firestoreからデータを取得する

**初回読み込み**

対処とするデータは作成した時間のフィールドを持たせます。
この `createdAt`フィールドのorderを指定してあげるのがキモです🐟

```swift
try await firestore
    .collection("tickets")
    .order(by: "createdAt", descending: true)
    .limit(to: 30)
    .getDocuments()
    .documents
    .compactMap { try $0.data(as: Ticket.self) }
```

**追加読み込み**

初回読み込みとの違いは`start` メソッドの違いです。
`start(after:)`を使うことで、指定したフィールドを含まないデータを返してくれます。

今回では指定した時間以降のデータが欲しいためアプリ側で持っている最後のデータの時間から-1した時間を与えてリクエストしていますね🥸

```swift
try await firestore
    .collection("tickets")
    .order(by: "createdAt", descending: true)
    .start(after: [createdAt.addingTimeInterval(-1)])
    .limit(to: 30)
    .getDocuments()
    .documents
    .compactMap { try $0.data(as: Ticket.self) }
```

## その他注意事項

SwiftUI x Firestoreの実装に限った話ではないのですが、スクロールに合わせて追加読み込みを行う処理を書く際にはデータをフェッチ中であるかどうかと、これ以上読み込むデータがあるのかないのか判断する必要があります。

これをしないと、連続でリクエストしてしまったり、もうデータがないのにリクエストし続けるといったサーバーに優しくない作りになってしまいますね🥸 ~~SDGs~~

リクエスト中かどうかは単純にリクエスト中かどうかを判定したい場所で定義してあげると良いと思います。
リクエスト中はリクエスト中であるローディングインジケータなど表示したい場合が多いと思いますので👋
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171702/b1e30383-7501-7c1a-8328-7ba99c4043f1.gif" width=320>


最後であるかどうかのフラグも同じ場所に実装してあげる必要があります。
これは取得する際に投げるlimit数以下ならtrueにしてあげることで判定できます。

これらを考慮すると以下のようになります。（一部簡略化のため省略しています）

```swift
func loadNextIfNeeded(ticket: Ticket) async {
    guard !isFetching, !isLast else { return }
    if tickets.isLastItem(ticket) {
        isFetching = true
        await nextFetch(last: ticket)
    }
}

func nextFetch(last: Ticket) async {
    defer {
        isFetching = false
    }

    do {
        let value = try await ticketService.fetchTickets(
            type: .new,
            createdAt: last.createdAt
        )

        await updateTickets(newValue: value)
    } catch {
        print(error.localizedDescription)
    }
}
```

## まとめ

- SwiftUIxFirestoreで無限スクロールするためにすること
    - RandomAccessCollectionを拡張してonAppearのタイミングで最後のデータか確認する！
    - フェッチするときにorderメソッドでソートしstart(after:)で順番に取得する！
    - リクエスト中、これ以上データがあるかどうか判断し無駄にリクエスト叩かないようにする！

以上になります！

明日は@hyuga_amaziaさんの `SwiftUIの広告系について何か`についての記事です！
爆笑必至の記事になること間違いありません！楽しみですね🥺

最後まで閲覧いただきありがとうございました！

Twitter
[https://twitter.com/tsuzuki817](https://twitter.com/tsuzuki817)

個人開発したアプリ
[https://apps.apple.com/us/developer/ryo-tsudukihashi/id1320583602?ign-itscg=30200&ign-itsct=apps_box_link&see-all=i-phonei-pad-apps](https://apps.apple.com/us/developer/ryo-tsudukihashi/id1320583602?ign-itscg=30200&ign-itsct=apps_box_link&see-all=i-phonei-pad-apps)