0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

はじめに

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

内容

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

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

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

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が呼ばれたタイミングで追加取得の処理をさせるようにしました

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にしてみると

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

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

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

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

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を追加することで末尾までスクロールすると追加取得できるようになる

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()
            }
        }
    }
}
リストの最後かどうかを判定するロジックを追加してみる

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

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
    }    
}

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

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を使う方法が一般的なのかなと思いました。
他にもあればぜひ教えて頂きたいところです。

参考

0
1
0

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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?