3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-05-21

概要

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

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

サンプルコードの概要

  1. Listに数値を表示して、1番下までスクロールした事を検知
  2. Listを更新する際に変なとこに?スクロールしないようにする

やり方

1. Listに数値を表示して、1番下までスクロールした事を検知

まず、ListとForEachで適当なViewを作成します。Listには数値を表示します。

ContentView
List {
    ForEach(viewModel.numbers, id: \.self) { number in
        Text("Number \(number)")
            .frame(height: 100)
    }
}

また、今回データは以下のViewModelで定義します。loadMoreNumbersによって数値を20個取得して、numbersが更新されます。

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番下までスクロールしているか検知できます。

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を制御します。

ListをScrollViewReaderで囲ってから使ってください。
.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も遅延読み込みに対応しているので、今回と同じようなことができるはずです。

参考資料

3
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?