Swift

【swift入門】TableViewで無限スクロールを実装しよう

前回まででapiを叩いてTableViewに表示できるようになっています。
今までの実装では起動時にqiitaのapiで最新の20記事を取得しそれを表示するだけで一番下にすぐたどりついてしまいます。

今回は20記事移行も表示できるようにしていきたいと思います。
となったときに最初に取得する記事数を20記事だったのを100記事とか1000記事とかに増やせばいいのではないか、となりますがこれでは

  • 結局一番下にたどり着いてしまったらそこでおわり
  • 記事数を増やせば増やすほどapiの通信が時間がかかるようになる

ではどうすればいいのでしょうか。ということで今回はTableViewの無限スクロールを実装していこうと思います。

無限スクロールとは

とりあえず実物を見たほうがわかりやすいと思うのでこんなかんじです。(Xcodeをアップデートしたのでシミュレーターの雰囲気が前回と比べて変わっています。)

どういう実装になっているかというと、

  1. 最初は今まで通り20記事取得
  2. 70〜80%ぐらいスクロールしたところで次の20記事を取得して表示
  3. 一番下にたどり着く頃には次の記事が既に表示されている
  4. 2〜3の繰り返し

このようになっていて見た目的には一番下にたどり着かず無限にスクロールするように見えるけど裏では分割して20記事を取得しているというかんじです。
注目してほしいのはスクロールバーです。
下にスクロールしてる途中でスクロールバーの位置が突然上に移動する時が来ると思います。そのタイミングが追加で記事を取得して表示されている瞬間です。

ということでこの無限スクロールを実装していきます。

前回までのコード

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    private let viewModel = ViewModel()

    fileprivate var articles: [Article] {
        return viewModel.articles
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.fetchArticles()
        initViewModel()
        initTableView()
    }

    private func initViewModel() {
        viewModel.reloadHandler = { [weak self] in
            self?.tableView.reloadData()
        }
    }

    private func initTableView() {
        tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell")
        tableView.estimatedRowHeight = 150
        tableView.rowHeight = UITableViewAutomaticDimension
    }
}

extension ViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return articles.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell: TableViewCell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell") as! TableViewCell
        let article = articles[indexPath.row]
        cell.bindData(article: article)
        return cell
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableViewAutomaticDimension
    }
}

extension ViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let vc = UIStoryboard(name: "ArticleDetail", bundle: nil).instantiateInitialViewController()! as! ArticleDetailViewController
        vc.article = articles[indexPath.row]
        navigationController?.pushViewController(vc, animated: true)
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        return
    }
}

======================================

class ViewModel {

    var articles: [Article] = [] {
        didSet {
            reloadHandler()
        }
    }

    var reloadHandler: () -> Void = { _ in }

    func fetchArticles() {
        guard let url: URL = URL(string: "http://qiita.com/api/v2/items") else { return }
        let task: URLSessionTask  = URLSession.shared.dataTask(with: url, completionHandler: {data, response, error in
            do {
                guard let data = data else { return }
                guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments) as? [Any] else { return }
                let articlesJson = jsonArray.flatMap { (json: Any) -> [String: Any]? in
                    return json as? [String: Any]
                }
                let articles = articlesJson.map { (articleJson: [String: Any]) -> Article in
                    return Article(json: articleJson)
                }
                DispatchQueue.main.async() { () -> Void in
                    self.articles = articles
                }
            }
            catch {
                print(error)
            }
        })
        task.resume()
    }
}

=========================

struct Article {
    let title: String
    let body: String
    let renderedBody: String
    let urlString: String
    let userId: String
    let profileImageUrlString: String

    init(json: [String: Any]) {
        title = json["title"] as? String ?? ""
        body = json["body"] as? String ?? ""
        renderedBody = json["rendered_body"] as? String ?? ""
        urlString = json["url"] as? String ?? ""
        if let user = json["user"] as? [String: Any] {
            userId = user["id"] as? String ?? ""
            profileImageUrlString = user["profile_image_url"] as? String ?? ""
        } else {
            userId = ""
            profileImageUrlString = ""
        }

    }

    init() {
        title = ""
        body = ""
        renderedBody = ""
        urlString = ""
        userId = ""
        profileImageUrlString = ""
    }
}

=============================================

class TableViewCell: UITableViewCell {

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var userIdLabel: UILabel!
    @IBOutlet weak var userThumbImageView: UIImageView!

    func bindData(article: Article) {
        titleLabel.text = article.title
        userIdLabel.text = article.userId
        setUserThumbImageView(imageUrlString: article.profileImageUrlString)
    }

    private func setUserThumbImageView(imageUrlString: String) {
        guard let profileImageUrl = URL(string: imageUrlString) else { return }
        let session = URLSession(configuration: .default)
        let downloadImageTask = session.dataTask(with: profileImageUrl) { (data, response, error) in
            guard let imageData = data else { return }
            let imageimage = UIImage(data: imageData)
            DispatchQueue.main.async() { () -> Void in
                self.userThumbImageView.image = imageimage
            }
        }
        downloadImageTask.resume()
    }
}

このようなかんじです。
これに手を加えていきます。

スクロール検知

先程の手順を振り返ると

  1. 最初は今まで通り20記事取得
  2. 70〜80%ぐらいスクロールしたところで次の20記事を取得して表示
  3. 一番下にたどり着く頃には次の記事が既に表示されている
  4. 2〜3の繰り返し

1に関しては既に実装されているので2をやっていきます。

今回は、
まず、ある程度スクロールしたら次のapiを叩くという処理を実装していきたいので、ある程度スクロールしたかの判定が出来るようにします。

ということでとりあえず、
「一番下にたどりつく500px前に次のapiを叩く」
という実装にしていきたいと思います。

500pxという数字はとりあえずテキトーに決めましたがこれは一番下に辿り着く前にはapiから値が返ってきて表示されるところまで終わっていてほしいので、
追加の記事読み込みに対してスクロールして一番下にたどり着くのが早いことがないように数値をあとから調整するようにしましょう。
より厳密にやるなら500pxという固定の数値を用いるのではなく端末によってサイズが異なるので使用端末の画面サイズかScrollViewの高さに応じて決まる数値に設定するのが良いかなと思います。

加えてですが、記事によっては、
(現在の座標) / (現状のTableViewの高さ) < (ある一定の割合)
というように条件設定してる場合がありますが、この条件だと記事数が多くなってTableViewの高さが大きくなると、追加読み込みをしてもすぐ条件を満たしてしまうようになってしまうので、先程のように割合を使わないように定義するようにしました。

ScrollViewDidScrollを使用する

では、「一番下にたどりつく500px前に次のapiを叩く」の部分を実装していきます。

必要になってくるのが

  • TableViewの高さ
  • 現在の位置の座標

です。

TableViewでは、スクロールしたときに呼ばれるScrollViewDidScrollメソッドがあるので今回はこれを使用します。
ScrollViewDidScrollメソッドについてわからない人は以下の記事でまとめてあるので参考にしてください。

【Swift入門】TableViewを作成しようこんばんは。 今回はアプリ作成に欠かせないTableViewを作成する手順をまとめていきます。 前回の記事でGitHubのリポジトリを...

scrollViewDidScrollでは引数にscrollViewが渡されるのでこれで先程の2つを取得することができます。

このように実装しました。

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let currentOffsetY = scrollView.contentOffset.y
    let maximumOffset = scrollView.contentSize.height - scrollView.frame.height
    let distanceToBottom = maximumOffset - currentOffsetY
    print("currentOffsetY: \(currentOffsetY)")
    print("maximumOffset: \(maximumOffset)")
    print("distanceToBottom: \(distanceToBottom)")
}

ビルドして実行してみるとこのようになりました。

このようにスクロールするたびに呼ばれています。

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let currentOffsetY = scrollView.contentOffset.y
    let maximumOffset = scrollView.contentSize.height - scrollView.frame.height
    let distanceToBottom = maximumOffset - currentOffsetY
    print("currentOffsetY: \(currentOffsetY)")
    print("maximumOffset: \(maximumOffset)")
    print("distanceToBottom: \(distanceToBottom)")
    if distanceToBottom < 500 {
        viewModel.fetchArticles()
    }
}

下にスクロールするにつれてdistanceToBottomが小さくなっていくので
このようにdistanceToBottomが500を下回ったときにfetchArticles()を呼ぶようにします。
(いままでviewModelはprivateで定義していましたがここで使用するためにfileprivateに書き換えました。)

クエリパラメータの調整

次にapiを叩く処理に手を加えていきます。先程distanceToBottomが500を下回ったらapiを呼ぶ処理を書きましたがこれではひたすら同じ記事を取得するapiを叩くことになるので取得してる記事の続きを取得するように書き換えます。

今まで使用してたものは

http://qiita.com/api/v2/items

でした。

qiitaのapiのドキュメントを見てみると、
今まで叩いていたapiのクエリパラメータの説明が書いてありました。

GET /api/v2/items
投稿の一覧を作成日時の降順で返します。
page
ページ番号 (1から100まで)
Example: 1
Type: string
Pattern: /^[0-9]+$/
per_page
1ページあたりに含まれる要素数 (1から100まで)
Example: 20
Type: string
Pattern: /^[0-9]+$/
query
検索クエリ
Example: "qiita user:yaotti"
Type: string

このように設定をすることができます。

クエリパラメータというのは詳細な設定を書くことができて、今回でいうと

http://qiita.com/api/v2/items?page=2&per_page=20

というように設定することができ、URLの末尾に?をつけて、○○=△△のような形式を&でつなげていくつでも設定することができます。

今回は、per_pageは20で固定にして、pageが1つずつ増えていくようにしていきましょう

ということで、まずこのように書き換えました。

class ViewModel {

    private var page: Int = 1 //追加
  
    var articles: [Article] = [] {
        didSet {
            reloadHandler()
        }
    }

    var reloadHandler: () -> Void = { _ in }

    func fetchArticles() {
        guard let url: URL = URL(string: "http://qiita.com/api/v2/items?page=\(page)&per_page=20") else { return } //pageに応じてクエリパラメータを変化させる
        let task: URLSessionTask  = URLSession.shared.dataTask(with: url, completionHandler: {data, response, error in
            do {
                guard let data = data else { return }
                guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments) as? [Any] else { return }
                let articlesJson = jsonArray.flatMap { (json: Any) -> [String: Any]? in
                    return json as? [String: Any]
                }
                let articles = articlesJson.map { (articleJson: [String: Any]) -> Article in
                    return Article(json: articleJson)
                }
                DispatchQueue.main.async() { () -> Void in
                    self.articles = self.articles + articles //現状のarticlesに追加してくように書き換え
                    self.page += 1 //pageを+1する処理
                }
            }
            catch {
                print(error)
            }
        })
        task.resume()
    }
}

まず叩くリクエストのURLを現状の取得記事数に応じて変えるようにしなければいけません。
なのでpageというプロパティを作成し、pageに応じてクエリパラメータが決まるように書き換えます。

追加で記事取得するのでapiで値が返ってきたあとにself.articlesにセットする際に現状取得している記事に足すようにするように書き換えます。
そして取得し終わったらpageを+1するようにします。

これで大丈夫そうではないかなと思われますが、1つ問題点があります。
実行してみればわかるかと思いますが、先程の

if distanceToBottom < 500 {
    viewModel.fetchArticles()
}

この実装では下にスクロールして条件をみたすようになってから大量にapiが叩かれることになってしまいます。(その結果apiのアクセス制限をくらう可能性もあります)

なのでapiを叩いて結果を待ってる間はapiを叩かないようにする処理を書かなければいけません。

このように変更を加えました。

class ViewModel {
    
    var articles: [Article] = [] {
        didSet {
            reloadHandler()
        }
    }

    private var page: Int = 1
    private var loadStatus: String = "initial" //追加

    var reloadHandler: () -> Void = { _ in }

    func fetchArticles() {
        guard loadStatus != "fetching" && loadStatus != "full" else { return } //読み込み中またはもう次に記事がない場合にはapiを叩かないようんいする
        loadStatus = "fetching" //loadStatusを読み込み中に変更
        guard let url: URL = URL(string: "http://qiita.com/api/v2/items?page=\(page)&per_page=20") else { return }
        let task: URLSessionTask  = URLSession.shared.dataTask(with: url, completionHandler: {data, response, error in
            do {
                guard let data = data else {
                    self.loadStatus = "error"
                    return
                }
                guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments) as? [Any] else {
                    self.loadStatus = "error"
                    return
                }
                let articlesJson = jsonArray.flatMap { (json: Any) -> [String: Any]? in
                    return json as? [String: Any]
                }
                let articles = articlesJson.map { (articleJson: [String: Any]) -> Article in
                    return Article(json: articleJson)
                }
                if articles.count == 0 {
                    self.loadStatus = "full" //もう続きの記事がないときにはloadStatusをfullにする
                    return
                }
                DispatchQueue.main.async() { () -> Void in
                    self.articles = self.articles + articles
                    self.loadStatus = "loadmore" //記事取得が終わった段階でloadStatusをloadmoreにする
                }
                if self.page == 100 {
                    self.loadStatus = "full" //100ページまで来たらloadStatusをfullにする
                    return
                }
                self.page += 1
            }
            catch {
                print(error)
                self.loadStatus = "error" //loadStatusをエラーにする
            }
        })
        task.resume()
    }
}

このようになりました。
loadStatusを導入して現在の状況を管理できるようにしました。

loadStatusの内容はこのようにしています。

  • initial → 初期状態
  • fetching → apiを叩いて結果が返ってきて表示されるまでの状態
  • loadMore → 次のページがまだある状態
  • full → 次のページにはもう記事がない状態
  • error → エラー

これでfetching,fullの状態ではController側からviewModel.fetchArticles()が呼ばれたとしてもapiを叩かないようにしています。

ビルドして実行してみると正常に実行できるようになりました。

さて、他の記事では読み込み中的なステータスをviewControllerで管理しているものもあると思います。
今回の場合ではViewModelで管理するようにしています。これはMVVMで実装しているのでその場合で言うとデータの管理はViewModelが持つべき役割なのでViewControllerは現在のapiの状態がどうなっているかは知らないけど現在のViewの状態から追加読み込みをすべきかどうかを判断してその場合にViewModelに命令を送るという設計にしています。