LoginSignup
0
2

More than 1 year has passed since last update.

復習として無限スクロールを実装した

Last updated at Posted at 2021-10-16

 記事投稿するの久しぶり \‪( ˙꒳​˙ \三/ ˙꒳​˙)/‬

しばらくflutterをずっと触っていたのですが、久しぶりにswiftをやりたくなったので復習がてら無限スクロールの実装をしてみました。

注文を多くつけないならばそのままプロジェクトに突っ込んでも問題ないのではって感じのやつなので悩んでいる方はぜひみていってください

だいたいの要件説明を先にしておく

  • 無限スクロールができます
  • pull to refreshができます
  • 時間経過で再リクエストのフラグが立ちます。画面から戻ってきた際に読んだり、フラグを購読するなどして利用するとよいです
  • 読み込んだとき、データがないなら次のページの読み込みはしません
  • Userクラスを取得してくる想定です
class User {
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }

    final let name: String
    final let age: Int
}

簡単な実装しているファイルの紹介

  • ViewController.swift
    おなじみViewControllerさんが定義されています。ファイルを分けるのがめんどくさかったという理由だけでその下にPresenterくんとUserのEntityがもいますが気にしないでください。Presenterくんは多少活躍してます。

  • PagyController.swift
    今回の主役。UIKitを読み込みたくも無いなとも思ったが、分けるよりかはpagingの機構と一緒に置いた方がシンプルでよいなという個人的な好みが垣間見えます。UIRefreshControllとUIActivityIndicatorViewが定義されています。

この二つだけです。
初心者にもわかりやすいですね。

ViewController.swift

こちらはこんな感じです

import UIKit


class ViewController: UIViewController {

    @IBOutlet var table:UITableView!

    // pagingを管理している
    final let pagyController = Presenter<User>()


    required init?(coder: NSCoder) {
        super.init(coder: coder)

        // presenterで書いた方がいいか?
        // 一定期間でリフレッシュしたい
        pagyController.lifetime = 6 * 60 * 60 // 6時間毎に自動でrefresh
    }


    override func viewDidLoad() {
        super.viewDidLoad()
        // paging周りの処理を委譲
        table.delegate = pagyController
        // loadingのUI設定
        table.tableFooterView = pagyController.spinner
        table.refreshControl = pagyController.refreshCtl

        pagyController.reloadData = table.reloadData
    }
}

ViewControllerはこれだけです。
とてもシンプルで他のページにも無限スクロールを追加しやすくていいですね。



まぁUITableViewDataSourceのextensionがあるので「これだけ」というのは嘘なんですが。

/// MARK - TableViewDataSource
extension ViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if let count = pagyController.listData?.count {
            return count
        }
        // throw してもいいと思ふ。
        return 0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = table.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

        let label = cell.viewWithTag(1) as! UILabel
        // 読み込んだ新しいデータがUIに反映されたことがわかりやすいようにpage番号をcellに表示する
        if let userData = pagyController.listData?[indexPath.row] {
            label.text = "\(userData.name) : \(userData.age)"
        }
        return cell;
    }

}



/// presenter的な立ち回り
/// viewmodelとかでも可
/// viewから切り離したいねというやつ
class Presenter<T>: PagyController<T>, UITableViewDelegate {

    override init() {
        super.init()

        // scroll下端まで行ったとき呼ばれる
        pagingCallBack = { [weak self] completion in
            guard let page = self?.page else { return }

            // data取得処理
            // 疑似apiとして1秒後にデータを取得 & merge
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {

                // 4ページ目以降はpage終了のため読み込まない
                let fetchedList = page > 3 ? [] : [User](repeating: User(name: "airy", age: 24), count: 25) as! [T]

                completion(fetchedList, false)
            }
        }

        // scroll上端でpullしたとき呼ばれる
        refreshCallback = { completion in
            // page refresh処理
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                let fetchedList = [User](repeating: User(name: "someone", age: 100), count: 25) as! [T]
                completion(fetchedList)
            }
        }

        // repositoryからとってきてる想定で読んで♡
        // api requestで外部からデータを取得してくる
        // callback内でinitialLoadCompletionを呼ぶ
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
            let fetchedList = [T](repeating: User(name: "initialize", age: 24) as! T, count: 25)
            self?.initialLoadCompletion(data: fetchedList)
        }
    }

    // 画面遷移とかするやつ
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print(lifetime)
        /// FOR TEST
        /// 1秒ごとに自動でreloadする
        lifetime = 1
    }
}

Presenterくんです。
genericsはpagingするときapiとかからとってくるデータの型です。
なので今回はapiからUserデータをとってくる想定ということですね。

ここではUIの振る舞いを記述しています。
この一言で説明できていると思う。

ちょっと気に入っていないのは初回ロードは別途で記述しているところ。
まぁこれは仕方ないのかな。

PagyController.swift

これはやってることは大したことないんですが、テキストで説明してもわかりづらいのでコードを読んだ方がずっといいです。

大事なのはこの辺かな。


enum PagyStatus {
    case refreshing     // pull to refreshで表示しているデータを刷新する
    case pageLoading    // 画面下端まで行って次のpageを読み込む
    case available      // 何も読み込み中でなく、load可能
    case pageEnd        // 読み込めるpageが無くなったのでrefreshのみ可能
}

コードに何度もloadと表記がありますが、pull to refreshとpaging loadの二つを指しています。


    // manage loading status
    internal var loadStatus: PagyStatus = .available {
        didSet {
            print(self.loadStatus)
            switch loadStatus {
            case .pageLoading:
                page += 1
                spinner.startAnimating()
            case .available:
                spinner.stopAnimating()
                refreshCtl.endRefreshing()
            case .refreshing:
                page = 1
                refreshCtl.beginRefreshing()
            case .pageEnd:
                spinner.stopAnimating()
                refreshCtl.endRefreshing()
            }
        }
    }

そのPagyStatusですが、読み込みのステータスに応じてindicatorのステータスを連動させています
refreshするとpageのデータは全部捨てちゃうのでpage=1, 新しいページを読み込むときはpageを+1しています。

これで管理するべきはこのstatusだけになるので実装がだいぶ楽になりますね!



    // 下端に行って次のページをとってくる処理
    public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if (!isPagiable || pagingCallBack == nil) {
            return
        }

        if (hasInitialLoad == false) {
            reloadData?()
            return
        }

        let currentOffsetY = scrollView.contentOffset.y
        let maximumOffset = scrollView.contentSize.height - scrollView.frame.height
        let distanceToBottom = maximumOffset - currentOffsetY
        if(distanceToBottom < 200) {
            self.loadStatus = .pageLoading
            pagingCallBack?() { [weak self] data, hadError in
                if (hadError) {
                    self?.loadStatus = .available
                    return
                }

                if (data.isEmpty) {
                    self?.loadStatus = .pageEnd
                } else {
                    self?.loadStatus = .available
                    self?.listData?.append(contentsOf: data)
                    self?.reloadData?()
                }
            }
        }
    }

pagingの肝となるのはこの関数だと思います。

流れとしては、
スクロールされた際に、tableviewの全体のheightから、tableviewのフレームのheightを引いて、あとどれくらいスクロールに余裕があるか確認しています。

あとはPagyStatusに応じてこねくり回していくだけですね!



     timer = Timer.scheduledTimer(timeInterval: TimeInterval(lifetime), target: self, selector: #selector(updateLifetimeStatus), userInfo: nil, repeats: true)

////////////////離れたとこ/////////////////////////////

    // set need lifetime refresh
    @objc func updateLifetimeStatus() {
        needLifetimeRefresh = true
    }

    // 画面遷移から戻ってきたときに呼んで、古い情報を更新する
    func lifetimeRefresh() {
        if (!needLifetimeRefresh || loadStatus != .available) {
            return;
        }
        refresh()
    }

lifetimeRefreshです。(はい)

timerを設定しており、一定期間ごとにneedLifetimeRefreshというフラグを立てています。

githubにあげているコードではlifetimeRefreshは呼んでいませんが、最初に書いてあるとおり、別の画面から戻ってきた際に呼んだり、購読するなどして
使ってみてください。

ざっとした解説はこんな感じです。

お読みいただきありがとうございます。

余談

  • 唐突にやりたくなってこのpagingを作成しました。githubにあげるつもりも、ましてや記事にするつもりも全くなかったので完成品のmasterコミットひとつだけで読みづらいかもですがご容赦を。
  • 昔、一瞬使ったきりStoryBoardを使ってなかったんですが今回いい機会だと思って使ってみました。感想としては、「やはりだるい。」笑 色々文句はあるが何より、マウス動かすのが圧倒的にだるいんじゃ!笑
  • notionとかに慣れているとqiitaのmarkdownはheadlineの#の後ろにスペースいらないのが逆に困りますね。
  • 久しぶりにqiitaでちゃんと文字を入力したけどまだmarkdownのデコーダはアレなままなんですね。

見直して思ったけどなんでViewControllerのinitにcallback書いてるんやろ。。
Presenterでええやん。。
ってことで投稿して10分程度で書き換えた。

以上

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