BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

UITableViewを用いた実装で意識したほうがいいポイント

はじめに

こんにちは、ネイティブアプリチームの筧です。 自分はモバイルアプリの開発は今まで Android でしか経験がなかったのですが、最近は iOS アプリ開発にコンバートしました。 はじめは Storyboard の扱いに慣れなかったり、AutoLayout の設定に色々と苦戦していたのですが少しずつ慣れていきました。

今回 UITableView を用いた新機能の開発を経験し、iOS エンジニアの先輩にレビューで課題点をあぶり出してもらい勉強になったので、いくつかポイントをピックアップして紹介します。

開発のお題

まずは簡単に今回のお題となった機能を実現するにあたって、求められる挙動をいくつか紹介します。 主に 2 種類の UITableViewCell を実装します

  • 1 つは文字を入力できる UITableViewCell。
    • UITableViewCell の中に UITextView が埋め込まれている。
    • UITextView に入力するテキストを増やしたら、テキストに応じて UITextView ならびに UITextView の親となる UITableViewCell の高さを変更する。
    • データによって UITableViewCell の高さは異なる。
  • もう 1 つは選択形式の UITableViewCell。
    • 各 Section の中から必ず 1 つの Cell が選択状態になっていること。
    • 選択された Cell はチェックマークが表示される。

UITableViewCellの高さの計算について

UITableViewCellの高さの見積もりをとる

UITableView(UICollectionView)ではestimatedRowHeightによる UITableViewCell の高さの見積もりを指定することで、実際の高さの計算処理のパフォーマンスの向上が狙えます。ただ適当な高さを指定すればいいわけでなく、できるだけ実際の高さとのズレが小さく済むように指定しないといけません。ズレが大きいと結局スクロールがカクついたりする原因になることもあります。

すべての Cell において高さが等しいのであればestimatedRowHeightを指定すればいいのですが、今回は Cell によって高さが異なります。そこでtableView(_:estimatedHeightForRowAt:)を用いて各 Cell ごとに見積もりの高さを指定しました。

    func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
        if isHigh(at: indexPath) {
            return 300
        }
        return tableView.estimatedRowHeight
    }

    func isHigh(for at: IndexPath) -> Bool {
        // 高いheightを指定するべきか判定する
        // ...
    }

(実際のコードではありません。

高さのキャッシュを設定して再利用する

Estimating the Height of a Table's Scrolling Area

The table view asks for estimates for every item in your table, so do not perform long-running operations in your delegate methods. Instead, generate estimates that are close enough to be useful for scrolling.

公式ドキュメントにこう書いてあるように UITableView は逐一見積もりの高さを要求するので、できるだけ省エネで各 Cell の見積もりの高さを求めて返す必要があります。そこでtableView(_:willDisplay:forRowAt:)で実際の Cell の高さを取得してキャッシュを生成します。

あとは tableView(_:estimatedHeightForRowAt:)にてキャッシュされた height があればそれを見積もりの高さとして返すような処理を追加します。

    private var cacheCellHeights: [IndexPath: CGFloat] = [:]

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        cacheCellHeights[indexPath] = ceil(cell.bounds.height)
    }

    func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
        if let height = cacheCellHeights[indexPath] {
            return height
        }

        if isHigh(at: indexPath) {
            return 300
        }
        return tableView.estimatedRowHeight
    }

AutoLayoutの制約はできるだけシンプルに

はじめは UILabel の height の制約を指定し、UILabel の width と表示するテキストに応じて必要な高さを計算し、制約の値を上書きするようなやり方で動的に高さを変更できる UILable を実装していました。以下のコードのような感じです。

@IBOutlet weak var label: UILabel!
@IBOutlet weak var labelHeight: NSLayoutConstraint!

// Viewの初期化をする
func apply(text: String) {
    // heightの制約の値を上書き
    labelHeight.constant = text.height(withConstrainedWidth: label.frame.width, font: UIFont.systemFont(ofSize: 12))
}

extension String {
    public func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil)
        return ceil(boundingBox.height)
    }
}

しかし今回は以下のような要素が絡むことで、制約のコンフリクトが起こりやすい設定となってしまっていました。 - 端末の画面サイズによって、諸々の View のサイズは異なる。 - UILabel の親 View となる UITableViewCell の高さが不変ではない。

なので高さの制約は削除し、親 View との上下左右の制約に留めることで、制約の不整合が起こりにくくなるように修正しました。

特に端末の画面サイズが異なることはモバイルアプリ開発においては常あることなので、できるだけ AutoLayout の制約をシンプルに保たねばと改めて自分を戒めました。

UITableViewCellのデータ更新について

reloadRows(at:with:)は極力使用しないこと

reloadRows(at:with:)は指定された Cell をリロードするメソッドです。リロードする際のアニメーションを指定できるので、ユーザーにわかりやすくリロードしている Cell を示すのには有効です。しかしこれは Cell の高さの再計算などの初期化処理を促すのでパフォーマンス観点で多用することは好ましくありません。これもまたスクロールなどがカクつく原因になりえます。

自分が今回やらかしたのは、ユーザーが Cell を選択するたびに Cell の表示を更新するために reloadRows(at:with:)を実行していました。これでは選択するたびに reloadRows(at:with:)が走るので、選択時に TableView の表示が崩れるかもしれません。

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // indexPathに応じたデータを更新
        ... 

        // 更新したデータをUITableViewCellに反映するために、該当するCellのリロードを促す
        tableView.reloadRows(at: [indexPath], with: .none)    
    }

reloadRows(at:with:)を使わずにどう解決したかは次の章で紹介します。

UITableViewの各Cellの選択の制御について

前の章で述べたとおり、はじめは選択されるたびにデータの更新及び reloadRows(at:with:)による Cell の更新をする実装にしていました。

しかしこれでは以下の課題があります。

  • 選択するたびに reloadRows(at:with:)することで、Cell の高さ計算などの初期化が走りパフォーマンスがよくない。
  • 今どの Cell が選択状態なのか Model のデータを参照しないとわからない。UITableView のどの Cell が選択されているについての選択状態の管理が Model 側に侵入し、ロジックの境界が曖昧になっている。

これらは先輩のレビューによって以下のように改修されました。 方針としては、各 Section ごとにどの Cell が選択されているかについて選択状態を自前で管理していたところを、UITableView で管理するようにしたという感じです。

    override func viewDidLoad() {
        super.viewDidLoad()
        // 複数のrowを選択状態として扱うことを許可する
        tableView.allowsMultipleSelection = true
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 画面初期化時は、各sectionのはじめのrowを選択状態にする
        if presenter.numberOfSections() > 0 {
            for i in stride(from: 0, to: presenter.numberOfSections(), by: 1) {
                let index = IndexPath(row: 0, section: i)
                tableView.selectRow(at: index, animated: false, scrollPosition: .none)
            }
        }
    }

    func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        if let selectedRows = tableView.indexPathsForSelectedRows {
            let selectedInSection = selectedRows.filter { $0.section == indexPath.section }
            for deselectingIndexPath in selectedInSection {
                tableView.deselectRow(at: deselectingIndexPath, animated: false)
            }
        }
        return indexPath
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // 選択に反応してなにか実行したいロジックがあればここに書く
    }


@IBDesignable
class CustomCell: UITableViewCell {

    @IBOutlet weak var selectIcon: UIImageView!

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
        selectIcon.isHidden = !selected
    }
}

まず tableView.allowsMultipleSelection = true を実行して、一度に複数の Row が選択状態として扱えるようにします。特に今回は「各 Section の中で、必ず 1 つだけが選択されている状態にする」という仕様があったため、選択状態を UITableView で管理する上で必須の設定となります。

そしてここが変更のポイントなのですが、選択されるたびにwillSelectRowAt 内で tableView.indexPathsForSelectedRows から選択状態になってる indexpath を割り出し、さらにその中から Select の対象になってる IndexPath と section が一致する IndexPath を特定し、その IndexPath を deselect します。 willSelectRowAt 後は didSelectRowAt が走るので、didSelectRowAt 内で選択後に実行したい処理があれば実行します。このとき didSelectRowAt 内で対象になった Row を deselect するようなことはしません。選択状態は保持したままにします

こうすることによって、選択中の Cell はデータを参照せずともindexPathsForSelectedRowsによって判別可能となりました。 そして 選択状態の管理は willSelectRowAt 、選択によって実行したい処理は didSelectRowAtと処理の分離ができて見通しが良くなりました。

また UITableViewCell 側の表示の変更は、UITableViewCell のsetSelected(_:animated:)内に書くことで、UITableViewCell 内で完結できます。選択状態の Cell のスタイルについては、selectionStyleを用いて以下のように設定しておけば問題ありません。

cell.selectionStyle = .none

おわりに

他にもレビューでもらったアドバイスは色々あるのですが、今回は UITableView に焦点を当てて紹介しました。今回の内容はもちろん同じくコレクション系の View を実装する UICollectionView でも有効です。

今回紹介したようなパフォーマンス観点での実装の工夫は、iOS アプリ開発に入門した方にとってははじめは仕様を実現するので手一杯で、なかなか意識をするのは難しいのではないでしょうか。ですがスクロールのカクつきや View の表示などのパフォーマンスが悪いと、ユーザー体験にモロに響くので、実際のプロダクト開発においては必須のテクニックだと思います。