Edited at
iOSDay 9

UITableViewのrowHeightやestimatedRowHeightに何を設定すると良いのか

More than 1 year has passed since last update.

 日頃、なんとなく雰囲気で使ってた UITableView を正しく使えるように初心に返って調べ直しました。

 


UITableViewを使うときに考えること

 UITableViewを使うとき、主に次の3つの要件を考慮して開発すると思います。


  • セルの高さは同じなのか

  • セルのレイアウトは全てAutoLayoutで組まれているのか

  • ページングするのか

 これらの要件の組み合わせで rowHeightestimatedRowHeight の値は決まり、tableView(_:heightForRowAt:)tableView(_:estimatedHeightForRowAt:) のデリゲートメソッドを実装すべきかどうか決まると思います。じゃあ、実際にどのような場合にどのような値やデリゲートメソッドを実装するのが良いんでしょうか。 :thinking:

3つの要件の組み合わせで生じる8つのパターン

image.001.png


rowHeightestimatedRowHeight について

 ドキュメントから要点を箇条書きにします。


  • デフォルト値はどちらも UITableViewAutomaticDimension


  • tableView(_:heightForRowAt:) が実装されていない場合、rowHeightがセルの高さになる


  • tableView(_:estimatedHeightForRowAt:) が実装されていない場合、estimatedRowHeightがセルの見積もりの高さになる


  • estimatedRowHeight を使うとテーブルを表示するときに見積もりの高さを先に計算するので、実際のセルの高さの計算を遅らせることができる


  • estimatedRowHeight を0にすると見積もりの高さの計算を無効にできる

  • Xcode9(iOS11)はSelf-Sizingがデフォルトになり、Interface Builderからも estimatedRowHeight が設定可能になった

Interface Builderから設定

ib01.jpg

 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つの要件が rowHeightestimatedRowHeight にどう影響するのか考えます。


セルの高さは同じなのか

 セルの高さが同じ場合、indexPathごとの高さの計算はしないのでデリゲートメソッドは不要です。rowHeightestimatedRowHeight に値を設定すればいいだけです。

 ただし、セルの高さが違う場合、デリゲートメソッドを実装する必要があるかもしれません。


セルのレイアウトは全てAutoLayoutで組まれているのか

 

 レイアウトが全てAutoLayoutで組まれていれば、セルの高さが違ったとしてもデリゲートメソッドは不要です。UITableViewAutomaticDimension が最適な高さを算出して返してくれます。もちろん、制約が矛盾していないことを前提とします。

 しかし、場合によってはセルのcontentViewから直接Viewを追加したり削除したりすることもあります。そのときAutoLayoutで組まれていたとしても UITableViewAutomaticDimension では正確に高さを算出できないことがあります。その場合、デリゲートメソッドを実装し systemLayoutSizeFitting(UILayoutFittingCompressedSize)

を使ってindexPathごとのセルの高さを算出して返す必要があります。


ページングするのか

 

 最後にページングです。ページングの有無によって、見積もりの高さをキャッシュする必要があるのか決めます。

 大量のセルを一度に表示するとパフォーマンスに影響があるため、ページングしてデータを小分けにします。そのとき、次のページングのタイミングで tableView.reloadData() を呼ぶと、これまでに表示したセルの高さを全て再計算しなければなりません。 図にすると下記のような感じです。

ページングのデリゲート処理の流れ

image.002.png

 したがって、表示済みのセル(例の場合、0~19のセル)の高さを再び算出するコストを下げるためにセルの高さをキャッシュします。そして、キャッシュした高さを tableView(_:estimatedHeightForRowAt:) で返します。

 なぜ、表示済みのセルの高さを再び参照する必要があるのかは、下記のコメントが参考になりました。


UITableViewはモバイル環境向けに非常に効率良く設計されたコンポーネントで、たいていのメソッドはおっしゃるとおり、表示されている部分+αしか呼ばれないため、パフォーマンスに優れていますが、高さのメソッドだけは別です。

なぜなら、表示する内容の全体の高さが決まらないと、スクロールバーの長さが決まらないとか、いろいろと不都合があるため、高さのデリゲートだけは全件数分しっかりと呼ばれます。

(そうしないと全体の高さが決まらないため)


https://qiita.com/glayash/items/92863bedc734eaa8e7ed#comment-3bbf41e903bbf6449c13



 ここまでの流れをコードにします。このコードは8つのパターンだと「⑤ 高さが可変で、かつAutoLayoutで組んだセルをページングするとき」になります。


PagingViewController.swift


// キャッシュ
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の場合
コードの場合

image.001.png
image.003.png
image.004.png

 大きな表になってしまったけど、要するに「AutoLayoutでレイアウトすればセルの高さが同じでも変わっても UITableViewAutomaticDimension で高さの計算はすべて対応でき、ページングする際はキャッシュすれば良い」ということでした。


コードからUITableViewをaddSubViewするときの注意点

 上記の表を、Interface Buildeとコードで分けたのは訳があります。

 Interface BuilderではXcode9からUITableViewの estimatedRowHeightに Automatic のチェックが可能になったので、OSのバージョンに関係なくセルの高さが自動で計算されます。

 一方、コードからUITableViewを使う場合、iOS10以前のバージョンで estimatedRowHeightUITableViewAutomaticDimension を設定しても、セルの高さは自動で計算されません。

 iOS11のみサポートするアプリは、Interface Builderと同じ実装で正常に表示できますが、iOS10以前のバージョンもサポートする場合は、適当な高さを estimatedRowHeight に設定するか、表のようにデリゲートメソッドで対応する必要があります。

 しかし、適当な高さを見積もりの高さに設定したとき実際の高さと大きく乖離すると、逆に表示が遅くなったりスクロールがカクついたりするので、可能な限り正確な高さを設定する必要があります。

 まとめると、iOS10以前もサポートするアプリは、コードからUITableViewを使ってAutoLayoutでセルのレイアウトが組まれているとき、tableView(_:estimatedHeightForRowAt:)UITableViewAutomaticDimension を返すとOSのバージョンに関係なく高さを自動で計算できます。

 このとき本来は estimatedRowHeight で良いところをデリゲートメソッドを呼ぶのでパフォーマンスに影響があるかもしれませんが、相当な数のセルを表示しないかぎり影響は限定的だと思います。


検証で使ったサンプルアプリ

 これらのパターンを検証するために、QiitaのAPIでサンプルを作ってUITableViewの動作を確認しました。

 フォローしているユーザーの一覧を取得して、ユーザーの投稿をSFSafariViewControllerで閲覧するサンプルアプリです。サンプルアプリはGitHubに公開しました。

 ユーザーIDを切り替えてページングに必要な件数を調整したり、セルの高さが同じだったり異なったりするレイアウトのセルを作っています。UITableViewの動きをいろいろ試せる作りだと思うので、いろいろ試してみて下さい。


最後に

 元も子もないですが、結局は仕様によるので必ず表の通りになるとは限らないと思います。

 また今回はパフォーマンスの話をほとんどしていません。iOS10から使用できる UITableViewDataSourcePrefetching であったり、Viewのレンダリングコストであったり、UITableViewには複数の要件が複雑に絡みます。この投稿でその複雑さが少しでも和らぐと良いです。

 でも間違ってたら、それこそ元も子もないので、間違いの指摘やアドバイスお待ちしてます。 :bow: