概要
SwiftUIにおいて、Listが1番下までスクロールされたことを検知するベストプラクティスを紹介します。
色々調べてみて、GeometryReader
やUIScrollView
を使用するやり方がありました。今回はList
の性質を利用したやり方でやってみたいと思います。
サンプルコードの概要
- Listに数値を表示して、1番下までスクロールした事を検知
- Listを更新する際に変なとこに?スクロールしないようにする
やり方
1. Listに数値を表示して、1番下までスクロールした事を検知
まず、ListとForEachで適当なViewを作成します。Listには数値を表示します。
List {
ForEach(viewModel.numbers, id: \.self) { number in
Text("Number \(number)")
.frame(height: 100)
}
}
また、今回データは以下のViewModelで定義します。loadMoreNumbers
によって数値を20個取得して、numbers
が更新されます。
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番下までスクロールしているか検知できます。
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を制御します。
.onChange(of: viewModel.numbers) {
if let lastNumber = viewModel.lastNumber {
proxy.scrollTo(lastNumber, anchor: .bottom)
}
}
コードの全貌
コメントや細かい解説を書いて見やすくしました。
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も遅延読み込みに対応しているので、今回と同じようなことができるはずです。