【SwiftUI】Listスクロールでデータ追加取得させる実装方法

# はじめに

APIで取得したデータをListで表示して、末尾までスクロールすると再取得する実装を考えてみました。

# 内容

このようなデータを取得してList表示する場合を考えてみようと思います

```swift: Fruits
struct Fruits: Identifiable, Hashable {
    let id: Int
    let name: String
    let price: Int
}
```


APIからのデータ取得ではなく、擬似的なデータ追加取得をさせて動きを確認します
ここではAPIから５件づつデータを取得できるとし、取得データが5件未満であれば、全取得済みという形となります

```swift:SampleViewModel
class SampleViewModel: ObservableObject {
    @Published var isLoading: Bool = false
    @Published var canLoadMore: Bool = false
    @Published var fruitsList: [Fruits] = []
    
    @MainActor private func toggleIsLoading(isLoading: Bool) {
        self.isLoading = isLoading
    }
    
    @MainActor private func setUpData(_ data: [Fruits]) {
        self.fruitsList = data
        self.canLoadMore = data.count == 5
    }
    
    @MainActor private func addData(_ data: [Fruits]) {
        self.fruitsList.append(contentsOf: data)
        self.canLoadMore = data.count == 5
    }
    
    func fetch() async {
        await toggleIsLoading(isLoading: true)
        sleep(1)
        let fetchedData = [
            Fruits(id: 1, name: "りんご", price: 200),
            Fruits(id: 2, name: "みかん", price: 200),
            Fruits(id: 3, name: "ぶどう", price: 300),
            Fruits(id: 4, name: "いちご", price: 400),
            Fruits(id: 5, name: "なし", price: 400)
        ]
        await setUpData(fetchedData)
        await toggleIsLoading(isLoading: false)
    }
    
    func fetchMore() async {
        guard !isLoading, fruitsList.count % 5 == 0 else { return }
        await toggleIsLoading(isLoading: true)
        sleep(1)
        let nextPage = fruitsList.count / 5 + 1
        switch nextPage {
        case 2:
            let fetchedData = [
                Fruits(id: 6, name: "スイカ", price: 800),
                Fruits(id: 7, name: "洋なし", price: 200),
                Fruits(id: 8, name: "ブルーベリー", price: 400),
                Fruits(id: 9, name: "メロン", price: 800),
                Fruits(id: 10, name: "びわ", price: 200)
            ]
            await addData(fetchedData)
        case 3:
            let fetchedData = [
                Fruits(id: 11, name: "パイナップル", price: 500),
                Fruits(id: 12, name: "オレンジ", price: 200),
                Fruits(id: 13, name: "バナナ", price: 200),
                Fruits(id: 14, name: "キウイ", price: 200),
                Fruits(id: 15, name: "レモン", price: 200)
            ]
            await addData(fetchedData)
        default:
            let fetchedData = [
                Fruits(id: 16, name: "もも", price: 400),
                Fruits(id: 17, name: "マスカット", price: 900)
                ]
            await addData(fetchedData)
        }
        await toggleIsLoading(isLoading: false)
    }
}
```

## 追加ローディングViewをリストの末尾に配置する

リストの最後に追加取得Viewを配置して、そのViewの`.onAppear`が呼ばれたタイミングで追加取得の処理をさせるようにしました

<img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/32f5c188-f226-f76a-a76e-5c2ac90b5e9b.png"><img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/5cfbac63-3e44-c350-eec5-28ec1bd91167.png"><img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/7f1da606-0303-d03a-cdf0-99eb5c765121.png"><img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d3f7b94e-053a-1644-a5fd-d6d8f17968ee.png">

```swift
struct ContentView: View {
    @StateObject private var viewModel: SampleViewModel = SampleViewModel()
    
    var body: some View {
        ZStack {
            List {
                ForEach(viewModel.fruitsList) { item in
                    VStack(alignment: .leading) {
                        Text("ID") + Text(item.id.description)
                        Text(item.name)
                        Text(item.price.description) + Text("円")
                    }
                    .font(.system(size: 16, weight: .bold))
                    .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading)
                }
                if viewModel.canLoadMore {
                    Text("追加読み込み")
                        .onAppear {
                            Task {
                                await viewModel.fetchMore()
                            }
                        }
                }
            }
            .task {
                await viewModel.fetch()
            }
            if viewModel.isLoading {
                ProgressView()
            }
        }
    }
}
```

#### ちなみに`.onAppear`の部分を`.task`にしてみると

```swift
                if viewModel.canLoadMore {
                    Text("追加読み込み")
                        .task {
                            await viewModel.fetchMore()
                        }
                }
```

以前`.onAppear`と`.task`についてまとめましたことがありましたが、`.task`に書き換えても今回の実装では挙動が変わらずでした

https://qiita.com/yoshitaka/items/19e22f90a4b6b18a0d76

## Listのセルに `.onAppear`を追加してリストの最後かどうか判定する

<img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/62b51616-6a93-879a-f3f7-79f9cd3bea77.png"><img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/b4d11778-7dc8-7982-b769-7476e823ae2e.png"><img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/b79776bf-01f8-7239-cbee-7c5ccfd50e5b.png"><img width="150" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/0ee1bf0a-70d9-5697-d86d-b3a83e47168b.png">

`ObservableObject`の追加取得ロジックを少し修正して、fetchMoreにitemを渡し、そのitemがリストの最後なら追加取得させるようにする

```swift:SampleViewModel
   func fetchMore(_ item: Fruits) async {
        guard item.id == fruitsList.last?.id else { return }
        guard !isLoading, fruitsList.count % 5 == 0 else { return }
        await toggleIsLoading(isLoading: true)
```

そして`List`のセルに`.onAppear`を追加することで末尾までスクロールすると追加取得できるようになる

```swift
struct ContentView: View {
    @StateObject private var viewModel: SampleViewModel = SampleViewModel()
    
    var body: some View {
        ZStack {
            List {
                ForEach(viewModel.fruitsList) { item in
                    VStack(alignment: .leading) {
                        Text("ID") + Text(item.id.description)
                        Text(item.name)
                        Text(item.price.description) + Text("円")
                    }
                    .font(.system(size: 16, weight: .bold))
                    .frame(maxWidth: .infinity, minHeight: 200, alignment: .topLeading)
                    .onAppear {
                        Task {
                            await viewModel.fetchMore(item)
                        }
                    }
                }
            }
            .task {
                await viewModel.fetch()
            }
            if viewModel.isLoading {
                ProgressView()
            }
        }
    }
}
```

##### リストの最後かどうかを判定するロジックを追加してみる

こちらの記事を参考にすると

https://qiita.com/shiz/items/f0f663f8fb926d914990

```swift
extension RandomAccessCollection where Self.Element: Identifiable {
    public func isLastItem<Item: Identifiable>(_ item: Item) -> 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
    }    
}
```

こんな形にもできるようです

```swift:SampleViewModel
func fetchMore(_ item: Fruits) async {
        guard fruitsList.isLastItem(item) else { return }
        guard !isLoading, fruitsList.count % 5 == 0 else { return }
        await toggleIsLoading(isLoading: true)
```

# さいごに

たくさん調べましたが、やはり`.onAppear`を使う方法が一般的なのかなと思いました。
他にもあればぜひ教えて頂きたいところです。

# 参考

https://qiita.com/shiz/items/f0f663f8fb926d914990

https://qiita.com/kazuooooo/items/9f93cbc49a304156ea8f

https://www.yururiwork.net/archives/1527

https://medium.com/whatnot-engineering/the-next-page-8950875d927a

https://github.com/globulus/swiftui-infinite-list

https://betterprogramming.pub/build-an-endless-scrolling-list-with-swiftui-combine-and-urlsession-8a697a8318cb

https://liman.io/blog/infinite-scrolling-list-pagination-graphql-swiftui

https://www.vadimbulavin.com/infinite-list-scroll-swiftui-combine/

https://github.com/V8tr/InfiniteListSwiftUI

https://kompitimes.com/search/infinite-scrolling-in-swiftui