日頃、なんとなく雰囲気で使ってた UITableView を正しく使えるように初心に返って調べ直しました。
UITableViewを使うときに考えること
UITableViewを使うとき、主に次の3つの要件を考慮して開発すると思います。
- セルの高さは同じなのか
- セルのレイアウトは全てAutoLayoutで組まれているのか
- ページングするのか
これらの要件の組み合わせで rowHeight
や estimatedRowHeight
の値は決まり、tableView(_:heightForRowAt:)
や tableView(_:estimatedHeightForRowAt:)
のデリゲートメソッドを実装すべきかどうか決まると思います。じゃあ、実際にどのような場合にどのような値やデリゲートメソッドを実装するのが良いんでしょうか。
3つの要件の組み合わせで生じる8つのパターン |
---|
rowHeight
と estimatedRowHeight
について
ドキュメントから要点を箇条書きにします。
- デフォルト値はどちらも
UITableViewAutomaticDimension
-
tableView(_:heightForRowAt:)
が実装されていない場合、rowHeight
がセルの高さになる -
tableView(_:estimatedHeightForRowAt:)
が実装されていない場合、estimatedRowHeight
がセルの見積もりの高さになる -
estimatedRowHeight
を使うとテーブルを表示するときに見積もりの高さを先に計算するので、実際のセルの高さの計算を遅らせることができる -
estimatedRowHeight
を0にすると見積もりの高さの計算を無効にできる - Xcode9(iOS11)はSelf-Sizingがデフォルトになり、Interface Builderからも
estimatedRowHeight
が設定可能になった
Interface Builderから設定 |
---|
iOS10まではコードから estimatedRowHeight
を設定する必要がありました。**しかし、iOS11の仕様変更でiOS10以前とiOS11ではコードからUITableViewを使ったとき、estimated系のデフォルト値が違います。**表のコードの値はそれぞれをprintしたときの値です。
コード( iOS10まで / iOS11 ) | Interface Builder | |
---|---|---|
estimatedRowHeight | ( 0 / -1 ) | UITableViewAutomaticDimension |
estimatedSectionHeaderHeight | ( 0 / -1 ) | 0 |
estimatedSectionFooterHeight | ( 0 / -1 ) | 0 |
コードからUITableViewを使うときは明示的に各estimatedに適切な値を設定しないと、予期せぬ動作をすることがあります。
要点はおさえたので、先の3つの要件が rowHeight
と estimatedRowHeight
にどう影響するのか考えます。
セルの高さは同じなのか
セルの高さが同じ場合、indexPathごとの高さの計算はしないのでデリゲートメソッドは不要です。rowHeight
と estimatedRowHeight
に値を設定すればいいだけです。
ただし、セルの高さが違う場合、デリゲートメソッドを実装する必要があるかもしれません。
セルのレイアウトは全てAutoLayoutで組まれているのか
レイアウトが全てAutoLayoutで組まれていれば、セルの高さが違ったとしてもデリゲートメソッドは不要です。UITableViewAutomaticDimension
が最適な高さを算出して返してくれます。もちろん、制約が矛盾していないことを前提とします。
しかし、場合によってはセルのcontentViewから直接Viewを追加したり削除したりすることもあります。そのときAutoLayoutで組まれていたとしても UITableViewAutomaticDimension
では正確に高さを算出できないことがあります。その場合、デリゲートメソッドを実装し systemLayoutSizeFitting(UILayoutFittingCompressedSize)
を使ってindexPathごとのセルの高さを算出して返す必要があります。
ページングするのか
最後にページングです。ページングの有無によって、見積もりの高さをキャッシュする必要があるのか決めます。
大量のセルを一度に表示するとパフォーマンスに影響があるため、ページングしてデータを小分けにします。そのとき、次のページングのタイミングで tableView.reloadData()
を呼ぶと、これまでに表示したセルの高さを全て再計算しなければなりません。 図にすると下記のような感じです。
ページングのデリゲート処理の流れ |
---|
したがって、表示済みのセル(例の場合、0~19のセル)の高さを再び算出するコストを下げるためにセルの高さをキャッシュします。そして、キャッシュした高さを tableView(_:estimatedHeightForRowAt:)
で返します。
なぜ、表示済みのセルの高さを再び参照する必要があるのかは、下記のコメントが参考になりました。
UITableViewはモバイル環境向けに非常に効率良く設計されたコンポーネントで、たいていのメソッドはおっしゃるとおり、表示されている部分+αしか呼ばれないため、パフォーマンスに優れていますが、高さのメソッドだけは別です。
なぜなら、表示する内容の全体の高さが決まらないと、スクロールバーの長さが決まらないとか、いろいろと不都合があるため、高さのデリゲートだけは全件数分しっかりと呼ばれます。
(そうしないと全体の高さが決まらないため)
https://qiita.com/glayash/items/92863bedc734eaa8e7ed#comment-3bbf41e903bbf6449c13
ここまでの流れをコードにします。このコードは**8つのパターンだと「⑤ 高さが可変で、かつAutoLayoutで組んだセルをページングするとき」**になります。
// キャッシュ
private var cellHeightList: [IndexPath: CGFloat] = [:]
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableViewAutomaticDimension
}
// MARK: - Transition
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
// 回転で高さが変わるのでキャッシュをクリア
self.cellHeightList = [:]
}
// MARK: - UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return users.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell", for: indexPath)
cell.configure(with: users[indexPath.row])
return cell
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
guard let height = self.cellHeightList[indexPath] else {
return UITableViewAutomaticDimension
}
return height
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
if !self.cellHeightList.keys.contains(indexPath) {
// キャッシュする
self.cellHeightList[indexPath] = cell.frame.height
}
if indexPath.row == users.count - 1 {
// 次の分をページング
fetch(isPaging: true, completion: {})
}
}
8つのパターンについて
これまでの内容を8つのパターンに当てはめて下記の表にしました。
8つのパターン | Interface Builderの場合 | コードの場合 |
---|---|---|
大きな表になってしまったけど、要するに**「AutoLayoutでレイアウトすればセルの高さが同じでも変わっても UITableViewAutomaticDimension
で高さの計算はすべて対応でき、ページングする際はキャッシュすれば良い」ということでした。**
コードからUITableViewをaddSubViewするときの注意点
上記の表を、Interface Buildeとコードで分けたのは訳があります。
Interface BuilderではXcode9からUITableViewの estimatedRowHeight
に Automatic のチェックが可能になったので、OSのバージョンに関係なくセルの高さが自動で計算されます。
一方、コードからUITableViewを使う場合、iOS10以前のバージョンで estimatedRowHeight
に UITableViewAutomaticDimension
を設定しても、セルの高さは自動で計算されません。
iOS11のみサポートするアプリは、Interface Builderと同じ実装で正常に表示できますが、iOS10以前のバージョンもサポートする場合は、適当な高さを estimatedRowHeight
に設定するか、表のようにデリゲートメソッドで対応する必要があります。
しかし、適当な高さを見積もりの高さに設定したとき実際の高さと大きく乖離すると、逆に表示が遅くなったりスクロールがカクついたりするので、可能な限り正確な高さを設定する必要があります。
まとめると、iOS10以前もサポートするアプリは、コードからUITableViewを使ってAutoLayoutでセルのレイアウトが組まれているとき、tableView(_:estimatedHeightForRowAt:)
で UITableViewAutomaticDimension
を返すとOSのバージョンに関係なく高さを自動で計算できます。
このとき本来は estimatedRowHeight
で良いところをデリゲートメソッドを呼ぶのでパフォーマンスに影響があるかもしれませんが、相当な数のセルを表示しないかぎり影響は限定的だと思います。
検証で使ったサンプルアプリ
これらのパターンを検証するために、QiitaのAPIでサンプルを作ってUITableViewの動作を確認しました。
フォローしているユーザーの一覧を取得して、ユーザーの投稿をSFSafariViewControllerで閲覧するサンプルアプリです。サンプルアプリはGitHubに公開しました。
ユーザーIDを切り替えてページングに必要な件数を調整したり、セルの高さが同じだったり異なったりするレイアウトのセルを作っています。UITableViewの動きをいろいろ試せる作りだと思うので、いろいろ試してみて下さい。
最後に
元も子もないですが、結局は仕様によるので必ず表の通りになるとは限らないと思います。
また今回はパフォーマンスの話をほとんどしていません。iOS10から使用できる UITableViewDataSourcePrefetching
であったり、Viewのレンダリングコストであったり、UITableViewには複数の要件が複雑に絡みます。この投稿でその複雑さが少しでも和らぐと良いです。
でも間違ってたら、それこそ元も子もないので、間違いの指摘やアドバイスお待ちしてます。