26
21

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 5 years have passed since last update.

【Swift】SwiftUIのListでスクロール末尾で次のデータを読み込み表示する方法

Last updated at Posted at 2019-08-18

SwiftUIでListを使って一覧を表示するサンプルはよく見かけますが
画面表示時に全てのデータを取得して設定するものが多く
APIなどでページを分けて読み込む方法がなかなか見つけられませんでした。

そこで色々調べていて
一つ方法を見つけましたので書いてみました。

もし間違いやうまくいかないケースなどございましたら
ご指摘いただけますとうれしいです:bow_tone1:

今回必要となるものは

  • RandomAccessCollectionの拡張
  • onAppearメソッド

https://developer.apple.com/documentation/swift/randomaccesscollection
https://developer.apple.com/documentation/swiftui/text/3276931-onappear

です。

※ Bata版のためSnapshotは載せられないのでコードだけ示させていただきます🙇🏻‍♂️
※ 細かい処理などは省略しています🙇🏻‍♂️

実装

内容としてはQiitaのAPIから記事の一覧を取得します。

RandomAccessCollectionの拡張

こちらを参考にしています。


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

やっていることは
引数に渡されたitemの次のitemが
配列の一番最後のitemの場合に
trueを返すようにします。

データの取得

次にデータを取得します。


import Foundation

// QiitaのAPIから取得するデータモデル

struct QiitaItem: Decodable, Equatable, Identifiable {
    let id: String
    let likesCount: Int
    let reactionsCount: Int
    let commentsCount: Int
    let title: String
    let createdAt: String
    let updatedAt: String
    let url: URL
    let tags: [Tag]
    let user: User?
    
    var profileImageURL: URL? {
        guard let url = user?.profileImageUrl else {
            return nil
        }
        return URL(string: url)
    }
    
    struct Tag: Decodable, Equatable {
        let name: String
    }
    
    struct User: Decodable, Equatable {
        let githubLoginName: String?
        let profileImageUrl: String?
    }
}

import Combine
import Foundation

final class ListViewModel: ObservableObject {
    
    @Published var items: [QiitaItem] = []
    @Published var isLoading = false
    
    private var cancellables: Set<AnyCancellable> = []
    
    private let perPage = 20
    private var currentPage = 1
    
    func loadNext(item: QiitaItem) {
        if items.isLastItem(item) {
            self.currentPage += 1
            getQiitaList(page: currentPage, perPage: perPage) { [weak self] result in
                self?.handleResult(result)
            }
        }
    }
    
    func onAppear() {
        getQiitaList(page: currentPage, perPage: perPage) { [weak self] result in
             self?.handleResult(result)
        }
    }
    
    private func getQiitaList(page: Int, perPage: Int,
                              completion: @escaping (Result<[QiitaItem], Error>) -> Void) {
        
        let parameters: [String: Any] = [
            "page": currentPage,
            "per_page": perPage,
        ]
        
        guard let url = URL(string: "https://qiita.com/api/v2/items"),
            let request = makeGetRequest(url: url, parameters: parameters) else {
                return completion(.failure(APIError.requestError))
        }
        fetch(request: request) { result in
            completion(Result {
                let decoder = JSONDecoder()
                decoder.keyDecodingStrategy = .convertFromSnakeCase
                return try decoder.decode([QiitaItem].self, from: result.get())
            })
        }
    }

    private func fetch(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                return completion(.failure(APIError.responseError(error)))
            }
            guard let httpResponse = response as? HTTPURLResponse else {
                return completion(.failure(APIError.invalidResponse(response)))
            }
            guard (200 ..< 300) ~= httpResponse.statusCode else {
                return completion(.failure(APIError.invalidStatusCode(httpResponse.statusCode)))
            }
            guard let data = data else {
                return completion(.failure(APIError.noResponseData))
            }
            return completion(.success(data))
        }.resume()
    }
    
    private func makeGetRequest(url: URL, parameters: [String: Any]) -> URLRequest? {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return nil
        }
        components.queryItems = parameters.map { (arg) -> URLQueryItem in
            let (key, value) = arg
            return URLQueryItem(name: key, value: String(describing: value))
        }
        var request = URLRequest(url: components.url!)
        request.httpMethod = "GET"
        return request
    }
    
    private func handleResult(_ result: Result<[QiitaItem], Error>) {
        DispatchQueue.main.async { [weak self] in
            guard let self = self else {
                return
            }
            self.isLoading = false
            switch result {
            case .success(let items):
                self.currentPage += 1
                self.items.append(contentsOf: items)
            case .failure(let error):
                self.currentPage = 1
                print(error)
            }
        }
    }    
}

enum APIError: Error {
    case requestError
    case responseError(Error)
    case invalidResponse(URLResponse?)
    case invalidStatusCode(Int)
    case noResponseData
    case resultError
}

画面への表示


import SwiftUI

struct ContentView: View {
    
    @ObservedObject var viewModel: ListViewModel
    
    var body: some View {
        NavigationView {
            List(viewModel.items) { item in
                Text(item.title)
                    .onAppear {
                        self.viewModel.loadNext(item: item)
                }
            }.onAppear {
                self.viewModel.onAppear()
            }.navigationBarTitle("検索結果")
        }
    }
}

ここでのポイントは


Text(item.title)
    .onAppear {
        self.viewModel.loadNext(item: item)
}

でListの中のデータが画面に表示された時に
ListViewModelのloadNextを呼び出して
現在取得しているデータ配列の最後の一つ手前のデータだった場合に
次のデータを取得しています。

まとめ

スクロール末尾の検知(みたいなこと)
それに合わせた次のページの読み込み
ができるということは確認できました。

まだやってみたという段階なので
まだ見えていない問題などが出てくるかもしれませんので
今後も見つけたら更新します。

参考にしたRandomAccessCollectionの拡張には
offset使って読み込むタイミングを調整する方法もありますので
ぜひそちらも参考にしてみてください😀

26
21
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
26
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?