【SwiftUI × Combine】さくっと無限スクロールを実装する

## やること
SwiftUIとCombineを用いて無限スクロールを実装します。

このような仕様のアプリを作ってみました。
1. TextFieldで何かしら検索を行なって、結果をリストに格納し、表示する
2. スクロールが終わりそうなときに追加のコンテンツを読み込み、リストに追加し、表示する

2の部分について書いていきます。
実際にはAPIを呼ぶことを想定していますが
今回は無限スクロールをどうやって実現するのかという点に絞ります。

## 環境
macOS 11.4
xcode13.1
swift5

## できたもの
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">無限スクロールの記事を書きました。<br>スクロールバーをよくみないとわからないかも。。。<br><br>【SwiftUI × Combine】さくっと無限スクロールを実装する <a href="https://twitter.com/hashtag/Qiita?src=hash&amp;ref_src=twsrc%5Etfw">#Qiita</a><a href="https://t.co/SsedGsNrcZ">https://t.co/SsedGsNrcZ</a> <a href="https://t.co/feDFyHzl9S">pic.twitter.com/feDFyHzl9S</a></p>&mdash; Yusuke Miyata (@yuskey38) <a href="https://twitter.com/yuskey38/status/1468645205373222914?ref_src=twsrc%5Etfw">December 8, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 実装
作成するファイルはこの3つです。

- SearchView.swift
- SearchViewCell.swift
- SearchViewModel.swift

大まかな処理はこんな感じです
1. 取得したコンテンツのデータObjectのIDをIntにする。
2. LazyVGridの各Itemが生成されるタイミングでそのItemのIDをViewModelに送る
3. 受け取ったIDが最後からn番目のIDであれば読み込みを行う 

### 1. 取得したコンテンツのObjectのIDをIntにする

```swift
struct Symbol: Identifiable {
    var id = 0
    var image: String
}
```

### 2. LazyVGridの各Itemが生成されるタイミングでIDをViewModelに送る

```swift
// SearchView.swift
VStack {
    TextField("Search", text: $viewModel.searchText)
        .textFieldStyle(.roundedBorder)
        .autocapitalization(.none)
        .padding(.horizontal)
    ScrollView {
        LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 0)), count: 2), spacing: 8) {
            ForEach(viewModel.cellDataList) { symbol in
                // ここが重要
                SearchViewCell(symbol: symbol)
                    .onAppear { viewModel.loadNext(symbol.id) }
            }
        }
    }
}
.ignoresSafeArea(.keyboard, edges: .bottom)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)) { _ in
    viewModel.search.send()
}
```


スクロールが終わりそうなことの検知するために
~~各Cellに`PassthroughSubject`を渡して, Itemが生成されたタイミングで、IDをViewModelに渡しています。~~
SearchViewCellが表示されたタイミングで`PassthroughSubject`でIDをViewModelに渡しています

### 3. 受け取ったIDが最後からn番目のIDであれば読み込みを行う 

```swift
// SearchViewModel.swift

// これが重要
private var loadingId: Int {
    perPage * page - 10
}

init() {
    loadNext
        .sink(receiveValue: { [weak self] id in
            guard let self = self,
                  self.hasNextPage,
                  self.loadingId == id,
                  !self.loadedIdList.contains(id) else {
                      return
                  }
            self.loadedIdList.append(id)
            self.page += 1
            self.cellDataList = self.cellDataList + self.makeSymbols(page: self.page)
            if self.page == 5 { self.hasNextPage = false }
        })
        .store(in: &cancellables)

}
```

最後から10番目のIDのときに追加のコンテンツを読み込むため
loadingIdを用意しました。

そして下記3つがすべてtrueになる場合にのみ、追加のコンテンツを読み込むようにすることで無駄なリクエストが発生しないようになっています。

- 読み込むコンテンツが存在する
- `loadNext`で渡されたIDが最後から10番目のID
- 一度読み込みを行なったIDではない

## 最後に
100件読み込んだら最初のn件を削除して再度読み込むようにするなど
キャッシュしている件数は固定にしておかないとメモリをどんどん消費することになりそうなので
次回はその辺も考慮した記事を書きたいと思います。

何か懸念点や問題点等あればぜひコメントお願いします！