【SwiftUI】Listが1番下までスクロールした事を検知する

# 概要
SwiftUIにおいて、Listが1番下までスクロールされたことを検知するベストプラクティスを紹介します。

色々調べてみて、`GeometryReader`や`UIScrollView`を使用するやり方がありました。今回は`List`の性質を利用したやり方でやってみたいと思います。

# サンプルコードの概要
1. Listに数値を表示して、1番下までスクロールした事を検知
2. Listを更新する際に変なとこに？スクロールしないようにする

# やり方
### 1. Listに数値を表示して、1番下までスクロールした事を検知
まず、ListとForEachで適当なViewを作成します。Listには数値を表示します。

```swift: ContentView
List {
    ForEach(viewModel.numbers, id: \.self) { number in
        Text("Number \(number)")
            .frame(height: 100)
    }
}
```
また、今回データは以下のViewModelで定義します。`loadMoreNumbers`によって数値を20個取得して、`numbers`が更新されます。

```swift: NumberListViewModel
class NumberListViewModel: ObservableObject {
    @Published var numbers: [Int] = Array(1...10)
    @Published var isLoading = false
    @Published var lastNumber: Int?
    
    func loadMoreNumbers() {
        // 更新前のラストのデータを保持
        lastNumber = self.numbers.last
        guard !isLoading else { return }
        isLoading = true
        
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            let moreNumbers = (self.numbers.count + 1)...(self.numbers.count + 20)
            DispatchQueue.main.async {
                self.numbers.append(contentsOf: moreNumbers)
                self.isLoading = false
            }
        }
    }
}
```

Listは画面に表示されているViewをレンダリングする（遅延読み込み）ので、`onAppear`を使用することで、子Viewが画面に表示しているかを判定できます。

このことを利用して、レンダリングされた子View（今回は数値）がviewModelの数値データのラストと一致するか判定することで、1番下までスクロールしているか検知できます。

```swift: ContentView
List {
    ForEach(viewModel.numbers, id: \.self) { number in
        Text("Number \(number)")
            .frame(height: 100)
            .id(number)
            .onAppear {
                print("\(number)のViewがレンダリングされたで！")
                // 最後の数値が表示されているか判定
                if number == viewModel.numbers.last {
                    viewModel.loadMoreNumbers()
                }
            }
    }
}
```

### 2. Listを更新する際に変なとこに？スクロールしないようにする
しかし、これだけだとデータ更新によってListが再レンダリングされてしまうため、Listの1番上までスクロールしたりしてしまいます。。。。
そこで、`ScrollViewReader`を使用して、Scrollを制御します。

```swift: ListをScrollViewReaderで囲ってから使ってください。
.onChange(of: viewModel.numbers) {
    if let lastNumber = viewModel.lastNumber {
        proxy.scrollTo(lastNumber, anchor: .bottom)
    }
}
```
# コードの全貌

コメントや細かい解説を書いて見やすくしました。
```swift: コードの全貌
import SwiftUI

struct ContentView: View {
    @StateObject private var viewModel = NumberListViewModel()
    var body: some View {
        ScrollViewReader { proxy in
            List {
                ForEach(viewModel.numbers, id: \.self) { number in
                    Text("Number \(number)")
                        .frame(height: 100)
                        .id(number)
                        .onAppear {
                            print("\(number)のViewがレンダリングされたで！")
                            // 1番下のデータが表示されているか判定
                            if number == viewModel.numbers.last {
                                // データをさらに読み込む
                                viewModel.loadMoreNumbers()
                            }
                        }
                }
            }
            .onChange(of: viewModel.numbers) {
                // 更新する前までのデータのラストのViewを1番下に合わせる。→勝手にスクロールさせないため。
                if let lastNumber = viewModel.lastNumber {
                    proxy.scrollTo(lastNumber, anchor: .bottom)
                }
            }
        }
    }
}

#Preview {
    ContentView()
}

class NumberListViewModel: ObservableObject {
    // 表示するデータ
    @Published var numbers: [Int] = Array(1...10)
    // ロード可能かの判定: 本当はLoadState的なenumを作るのがいいかも
    @Published var isLoading = false
    // 更新する前までのデータのラストデータ: データを画面に追加したときに"scrollTo"で自動スクロールするために使用
    @Published var lastNumber: Int?
    
    // データをさらに読み込むメソッド
    func loadMoreNumbers() {
        // 更新前のラストのデータを保持
        lastNumber = self.numbers.last
        guard !isLoading else { return }
        isLoading = true
        
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            let moreNumbers = (self.numbers.count + 1)...(self.numbers.count + 20)
            DispatchQueue.main.async {
                self.numbers.append(contentsOf: moreNumbers)
                self.isLoading = false
            }
        }
    }
}
```

# 終わりに
今回はListが`遅延読み込み`に対応していることを利用して、Listが1番下までスクロールしたことを検知するコードを書きました。また、LazyVStackも遅延読み込みに対応しているので、今回と同じようなことができるはずです。

# 参考資料
- [[SwiftUI]ListとScrollViewはどうやって使い分ける？二つの特徴と違い](https://blog.code-candy.com/list_scrollview_vary/#:~:text=%E3%81%A6%E3%81%84%E3%81%8D%E3%81%BE%E3%81%99%E3%80%82-,2%2D2%20List%E3%81%AE%E3%83%A1%E3%83%A2%E3%83%AA%E7%94%9F%E6%88%90,%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%80%8D%E3%81%A8%E3%82%82%E5%91%BC%E3%81%B0%E3%82%8C%E3%81%BE%E3%81%99%E3%80%82&text=%E3%81%A7%E3%81%AF%E3%80%81%E4%B8%8A%E8%A8%98%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92,%E3%81%8C%E7%A2%BA%E8%AA%8D%E3%81%A7%E3%81%8D%E3%81%BE%E3%81%99%E3%81%AD%E3%80%82)

- [SwiftUI の List も、LazyVStack のように iOS 14 でコンテンツを遅延読み込みしますか?](https://developer.apple.com/forums/thread/651256)

- [【SwiftUI】ScrollViewが1番下までスクロールした事を検知する](https://qiita.com/SNQ-2001/items/d0a160d7aa3608d6736c)

- [【SwiftUI】List の一番下までスクロールしたかチェックする方法（追加読み込み）](https://www.yururiwork.net/archives/1527)