【Swift】UITableViewを使って無限スクロールする方法
この記事ではUITableViewを使って無限スクロールする方法を書いていきます。
以下、完成イメージです。
無限スクロールを実装するにあたって方法はいくつかあると思いますが、調べてた時によく見かけたのが以下の2つの方法です。
- scrollViewDidScroll / scrollViewDidEndDraggingのdelegate methodを使った方法
- tableView will Display cellのdelegate methodを使った方法
※理由は後述します。
1.準備
今回は例として、メディアアプリのように記事一覧を表示する形にしていきます。
1-1. Viewの準備
まずはViewの準備からやっていきます。ViewControllerの中にUITableViewが置いてあるだけのシンプルな構成です。
ViewControllerにはUITableViewのoutletを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 |
import UIKit class ViewController: UIViewController { @IBOutlet weak var tableView: UITableView! override func viewDidLoad() { super.viewDidLoad() } } |
次に、tableViewのcellを作ります。
cellの中には記事のタイトルを表示するためのUILabelをcellの中央に配置しています。
TableCell classにはUILabelのoutletを追加します。
1 2 3 4 5 6 7 8 |
import Foundation import UIKit class TableCell: UITableViewCell { @IBOutlet weak var titleLabel: UILabel! } |
1-2. ViewModelの準備
無限スクロールするにはどこかのデータベースから少しずつ情報を取得する必要があります。今回はCloud Firestoreを使います。
この記事ではFirestoreの設定については触れませんが、気になる方は公式ページがご覧ください。
以下2つのクラスを追加します。
- Article.swift (Model)
- ArticleCollection.swift (ViewModel)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import Foundation import FirebaseFirestore class Article { var order: Int = 0 var title: String = "" init(queryDocumentSnapshot object: QueryDocumentSnapshot) { if let order = object.get("order") as? Int { self.order = order } if let title = object.get("title") as? String { self.title = title } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
import Foundation import FirebaseFirestore class ArticleCollection { var articles: [Article] = [] //取得した記事を格納する配列 var lastDocument: DocumentSnapshot? //分割して取得していくので、最後に取得したドキュメントのスナップショットを保持する //============================================================= //@collectionName: コレクション名 //@limit: 最大取得数 //============================================================= //returns -> 取得したQueryDocumentSnapshotをarticlesの中に追加する //============================================================= func fetchArticles(_ collectionName: String, limit: Int, completed: @escaping() -> Void) { let articlesRef = Firestore.firestore().collection(collectionName) articlesRef .order(by: "order") .limit(to: limit) .getDocuments(){ (querySnapshot, err) in if let _ = err { completed() return } self.articles.removeAll() guard let snapshot = querySnapshot else { completed() return } self.lastDocument = snapshot.documents.last //最後のドキュメントスナップショットを変数に格納 for document in snapshot.documents { let article = Article(queryDocumentSnapshot: document) //aritcleのイニシャライズ self.articles.append(article) //配列に追加 } completed() } } //============================================================= //@collectionName: コレクション名 //@limit: 最大取得数 //============================================================= //returns -> 取得したQueryDocumentSnapshotをarticlesの中に追加する //============================================================= func fetchMoreArticles(_ collectionName: String, limit: Int, completed: @escaping() -> Void) { guard let lastDocument = lastDocument else { //全ての記事を取得し終わったら、lastDocumentはnilになります。 completed() return } let articlesRef = Firestore.firestore().collection(collectionName) articlesRef .order(by: "order") .start(afterDocument: lastDocument) //最後に取得したドキュメントの後からクエリを始める .limit(to: limit) .getDocuments() { (querySnapshot, err) in if let _ = err { completed() return } guard let snapshot = querySnapshot else { completed() return } self.lastDocument = snapshot.documents.last //もう取得するドキュメントがない場合、このタイミングでlastDocumentがnilになります。 for document in snapshot.documents { let article = Article(queryDocumentSnapshot: document) self.articles.append(article) } completed() } } } |
ArticleCollectionの中には2つのメソッドがあります。それぞれ名前の通りですが、fetchArticlesメソッドが最初だけ呼ばれるメソッドで、決めた数だけのドキュメントを取得します。それ以降はfetchMoreArticlesメソッドから決まった数の記事を取得していきます。
Firestoreには便利なクエリが用意されていて、「start(afterDocument: DocumentSnapshot)」を使えば、同じクエリ内容で開始地点を変えることが出来ます。
それでは準備が出来たので、2通りの方法を見ていきます。
2.scrollViewDidScrollを使った方法
scrollViewのdelegate methodを使った方法を見ていきます。UITableViewのdelegateをViewControllerに渡していれば、scrollViewのdelegate methodも使えるので、ViewControllerに以下を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
import UIKit class ViewController: UIViewController { @IBOutlet weak var tableView: UITableView! fileprivate var articleCollection: ArticleCollection = ArticleCollection() override func viewDidLoad() { super.viewDidLoad() self.tableView.register(UINib(nibName: "TableCell", bundle: nil), forCellReuseIdentifier: "TableCell") self.tableView.delegate = self self.tableView.dataSource = self prefetchArticles() } fileprivate func prefetchArticles() { self.articleCollection.fetchArticles("articles", limit: 20) { [unowned self] in self.tableView.reloadData() } } } extension ViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.articleCollection.articles.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "TableCell") as? TableCell else { return UITableViewCell() } let article = articleCollection.articles[indexPath.row] cell.titleLabel.text = article.title return cell } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 50 } func scrollViewDidScroll(_ scrollView: UIScrollView) { let scrollPosY = scrollView.contentOffset.y //スクロール位置 let maxOffsetY = scrollView.contentSize.height - scrollView.frame.size.height //スクロール領域の高さからスクロール画面の高さを引いた値 let distanceToBottom = maxOffsetY - scrollPosY //スクロール領域下部までの距離 //スクロール領域下部に近づいたら追加で記事を取得する if distanceToBottom < 200 { self.articleCollection.fetchMoreArticles("articles", limit: 20) { [unowned self] in self.tableView.reloadData() } } } } |
ただscrollViewDidScrollですと、スクロールする度に記事を追加で取得するかの判定を行うので、処理が重くなりがちです。さらには、判定領域に入った後もスクロールし続けるので繰り返し記事を取得してしまい、一覧の中に重複する値が入ることがあります。
以下が実際のgif です。
scrollViewDidScrollですと、スクロールする度に呼び出されてしまうので、scrollViewDidEndDraggingを使ったほうが良いと言う人もいます。scrollViewDidEndDraggingはユーザーがスクロール後に指を画面から離したら呼び出されるメソッドです。
確かにscrollViewDidScrollと比較するとscrollViewDidEndDraggingの方が安定した挙動になりますが、touch upに反応するので指を離さなかったら追加で記事を取得しないのと、下までスクロールする度にカクつくので、ユーザーに取ってスムーズな体験にはならないかもしれません。
以下がscrollViewDidEndDragging版のgif です。
どうしてもscrollViewのdelegate methodで無限スクロールを実装したい場合scrollViewDidEndDraggingを使った方がマシだと思いますが、以下3つの理由から推奨はしません。
スクロール判定がダメだと思う理由:
- スクロールする度に判定メソッドが呼ばれ、判定領域に入ったらtableをreload。これが複数回呼ばれる可能性があるため、重複することがある上にtableViewがカクつく
- 無駄に判定メソッドが何回も呼ばれることが非効率的だと思う。
- 上にスクロールした際にも呼ばれる可能性がある。(判定領域内に入っていれば良いので、方向までは判断軸に含まれていない)
3.tableView will Display cellを使った方法
次に、tableView will Display cellのdelegate methodを使った方法を書いていきます。
ViewControllerを以下のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
extension ViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return self.articleCollection.articles.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "TableCell") as? TableCell else { return UITableViewCell() } let article = articleCollection.articles[indexPath.row] cell.titleLabel.text = article.title return cell } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 50 } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { if self.articleCollection.articles.count >= 20 && indexPath.row == ( self.articleCollection.articles.count - 10) { self.articleCollection.fetchMoreArticles("articles", limit: 20) { [unowned self] in self.tableView.reloadData() } } } } |
willDisplay cellは〇〇番目のcellが表示される時に呼びだされるメソッドです。この記事では、まず記事を格納してる配列の総数が20個以上あり、後ろから10番目の記事が表示される時に追加で記事を取得するようになっています。
以下が実際のgif です。
このパターンですと、indexPath.rowが重複することがないので、無駄にfetchMoreArticlesが走る恐れもありませんし、上にスクロールしたとしてもcellを表示したあとにwillDisplay cellが呼ばれることもないので意図しないタイミングでfetchMoreArticlesが呼ばれる心配もありません。
ですので、UITableViewを使って無限スクロールを実装する場合はtableViewのwillDisplay cellの delegate methodをオススメします。
以上、UITableViewを使って無限スクロールする方法でした。
おまけ:CloudFirestoreクエリ:offset vs startAfter
CloudFirestoreでページネーションを行う場合、以下のクエリ方法があります。
- offset()
- startAt() / startAfter()
結論から言いますと、何か理由がない限りはstartAt() / startAfter()を使いましょう。
なぜかと言うと、offsetメソッドはスキップしたドキュメントまでも読み込むらしく、課金されるので注意が必要だからです。
以下のようなクエリがあったとします:
.where( ‘city’ == ‘Tokyo’ )
.limit(to: 20)
.offset( 40 )
↑は60ドキュメント読み込まれ、課金されます。
startAt / startAfterメソッド
startAt / startAfterメソッドは実際の開始地点からドキュメントが読み込まれるので飛ばしたドキュメントまでは読み込まれません。(この記事でもstartAfterを使っています)