iOS 10時代の Self-sizing UICollectionViewCell

  • 44
    Like
  • 0
    Comment

はじめに

iOS 10からの UICollectionViewCell の Self-sizing Cells に関する情報をまとめたいと思います。はじめは簡単そうに思えたのですが意外と落とし穴が多くて苦労したため、その知見も残しておきます。

概要の把握には以下の WWDC のセッションビデオを参考にしました。

WWDC2016: What's New in UICollectionView in iOS 10(21:40 あたり〜)
https://developer.apple.com/videos/play/wwdc2016/219/

UICollectionViewFlowLayoutAutomaticSize

estimatedItemSize に UICollectionViewFlowLayoutAutomaticSize を指定するとUITableViewAutomaticDimension のような感覚で最適なセルのサイズになるよう振舞います。
(※ UITableViewAutomaticDimension は UITableView 版の Self-sizing Cells で利用する定数)

UICollectionViewFlowLayout
flowLayout.estimatedItemSize = UICollectionViewFlowLayoutAutomaticSize

これはレイアウトに UICollectionViewFlowLayout を利用している場合に有効です。

セルのサイズ計算に関するコードを記述するのはほぼこれだけです。サイズを返すための例のデリゲートメソッドは消してしまいましょう。

UICollectionViewDelegateFlowLayout
// このメソッドはもう使用しない
func collectionView(_ collectionView: UICollectionView,
                        layout collectionViewLayout: UICollectionViewLayout,
                        sizeForItemAt indexPath: IndexPath) -> CGSize {
    return CGSize(width: 100, height: 100)
}

Self-sizing cells において UICollectionViewCell の実際のサイズを計算する3つの方法

  • Auto Layout で自動計算
  • sizeThatFits() をオーバーライドして手動計算
  • preferredLayoutAttributesFittingAttributes() をオーバーライドして手動計算
    • estimatedItemSize に0ではないサイズを指定する

3番目の方法ではサイズ情報以外にもセルに関する様々な属性(Transform など)を参照することができるので、複雑なレイアウトを組むのに向いています。

ここでは Auto Layout で簡単に自動計算する方法を採用します。

Auto Layout の組み方

コンテンツの大きさに依存する制約を設置します。例として2種類のラベルと横線を配置した xib を用意しました。

ラベルはいずれも numberOfLines = 0 を指定して行数制限を入れていません。

セルの最下端に高さ1の細いビュー(横線)があります。このビューの横幅はセルの横幅と一致させていて、その制約はプレースホルダー的にとりあえず200にしてありますが、この値はコードから動的に変更できるようレイアウトには IBOutlet を張っておきます。

この横線がセルの最低サイズを決めるものとなります。つまり、セルは横幅を一定の幅に保ちつつ、コンテンツに合わせて縦方向に伸縮する形になります。Twitter のセルのイメージです。

セルのレイアウト

セルの実装はこのような具合です。

CollectionViewCell
class CollectionViewCell: UICollectionViewCell {

    @IBOutlet weak var titleLabel: UILabel! {
        didSet {
            titleLabel.text = nil
        }
    }
    @IBOutlet weak var textLabel: UILabel! {
        didSet {
            textLabel.text = nil
        }
    }
    @IBOutlet weak var separator: UIView!
    @IBOutlet weak var widthLayout: NSLayoutConstraint?

    override func awakeFromNib() {
        super.awakeFromNib()
        // 本当は CollectionView の幅とかを指定したほうが良い
        setCellWidth(UIScreen.main.bounds.width)
    }

    func setCellWidth(_ width: CGFloat) {
        widthLayout?.constant = width
    }

    class func nib() -> UINib {
        return UINib(nibName: "\(self)", bundle: nil)
    }

}

最低サイズを決める制約は必ず実装するようにしましょう。少しでも矛盾があると UICollectionViewFlowLayoutBreakForInvalidSizes エラーが出てクラッシュします。そしてそのエラーから原因を特定するのはとても困難です。

実行結果

静止画 gif

簡単ですね。Auto Layout により面倒なサイズ計算のプログラミングから解放されるのはとても良いことです。

原因を特定しづらい落とし穴が多い

Auto Layout + Self-sizing Cells の手法は、原因不明のエラーにはまってしまうことが多いです。吐かれるエラー情報はもちろん、日本語に限らずGoogleで探し出せる情報があまりにも少なすぎるのでつらいです。ここでは私が実際に遭遇した落とし穴とその対象法を記しておきます。

最低サイズを決める制約を実装すること

最低サイズとなるような制約を設定しないとこのような警告が吐かれて正しく描画されないか、クラッシュします。縦あるいは横幅の最低サイズとなる制約を設定する必要があります。

UICollectionViewFlowLayoutBreakForInvalidSizes が吐かれるのは大抵、想定されるサイズと制約で計算される実際のサイズとに差異がある場合です。つまり Auto Layout の組み方に問題があります。

[77545:14477829] The behavior of the UICollectionViewFlowLayout is not defined because:
[77545:14477829] the item width must be less than the width of the UICollectionView minus the section insets left and right values, minus the content insets left and right values.
[77545:14477829] Please check the values returned by the delegate.
[77545:14477829] The relevant UICollectionViewFlowLayout instance is <UICollectionViewFlowLayout: 0x7fbe3bd198a0>, and it is attached to <UICollectionView: 0x7fbe3c82da00; frame = (0 20; 375 647); clipsToBounds = YES; autoresize = RM+BM; gestureRecognizers = <NSArray: 0x60000044a1d0>; layer = <CALayer: 0x600000034280>; contentOffset: {0, 0}; contentSize: {375, 50}> collection view layout: <UICollectionViewFlowLayout: 0x7fbe3bd198a0>.
[77545:14477829] Make a symbolic breakpoint at UICollectionViewFlowLayoutBreakForInvalidSizes to catch this in the debugger.

セルで prepareForReuse() を実装するなら必ず super を呼ぶこと

セルで prepareForReuse() をオーバーライドする際には必ず super を呼び出すようにしてください。これをやらないとセルのレイアウトがうまくいかず、50×50サイズ(デフォルトサイズと思われる)に縮小されてしまうことがあります。

CollectionViewCell.swift
override func prepareForReuse() {
    super.prepareForReuse() // Don't forget!

}

UICollectionReusableView のドキュメントにもこうあります。

https://developer.apple.com/reference/uikit/uicollectionreusableview/1620141-prepareforreuse

prepareForReuse()

Discussion

The default implementation of this method does nothing. However, when overriding this method, it is recommended that you call super anyway. Subclasses such as UICollectionViewCell override this method and use it to perform relevant actions. So if your subclass descends from UICollectionViewCell or some other intermediate class, calling super ensures that your class gets the parent’s behavior.

When a view is dequeued for use, this method is called before the corresponding dequeue method returns the view to your code. Subclasses can override this method and use it to reset properties to their default values and generally make the view ready to use again. You should not use this method to assign any new data to the view. That is the responsibility of your data source object.


このメソッドのデフォルト実装では何も行いません。 ただし、このメソッドをオーバーライドする場合は、とにかくsuperを呼び出すことをお勧めします。 UICollectionViewCellなどのサブクラスはこのメソッドをオーバーライドし、関連するアクションを実行するために使用します。 したがって、あなたのサブクラスがUICollectionViewCellまたはその他の中間クラスを継承している場合、superを呼び出すと、クラスが親の動作を取得することが保証されます。

ビューがデキューされて使用されると、対応するデキュー・メソッドがコードにビューを戻す前に、このメソッドがコールされます。 サブクラスはこのメソッドをオーバーライドしてプロパティをデフォルト値にリセットし、通常はビューを再び使用できるようにします。 ビューに新しいデータを割り当てるには、このメソッドを使用しないでください。 それはデータソースオブジェクトの責任です。

つまり、UICollectionView を含む UICollectionReusableView 系全般は、作法として、prepareForReuse() をオーバーライドする場合には必ず super を呼び出すようにしましょう。

UICollectionViewFlowLayout internal error に遭遇したら 1

UICollectionViewFlowLayout internal error という不親切にもほどがある内部エラーに悩まされることが多々ありました。しかもシミュレーターでは動くのに実機ではクラッシュするという有様です。具体的な原因はよくわからりませんが、少なくともこれが出たらレイアウトに由来するエラーだと疑ってください。

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UICollectionViewFlowLayout internal error'

internal error とかまったく意味不明なのでやめて欲しいですね。

ググるとコイツへの対処方法はいろいろ検討されていますが、私はこのようなコードで対処することができました。

UICollectionViewFlowLayout_internal_error対策第1案
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
collectionView.collectionViewLayout.invalidateLayout() // viewWillLayoutSubviews() でコレ
}

あるいは reloadData() の後に書いても良さそうです。

UICollectionViewFlowLayout_internal_error対策第2案
collectionView.reloadData()
collectionView.collectionViewLayout.invalidateLayout() // reloadData() の直後にコレ

参照元:http://stackoverflow.com/questions/18339030/uicollectionview-assertion-error-on-stale-data

UICollectionViewFlowLayout internal error に遭遇したら 2 – データソースはゼロセクションが成り立つように実装した方が良い

UICollectionViewDataSource を実装しますが、ゼロセクションが成り立つように実装しないと UICollectionViewFlowLayout internal error によりクラッシュすることがあります。

[74181:8590640] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UICollectionViewFlowLayout internal error'

つまり、次のようにセクション数0が成り立つように実装すると防げることがあります。

UICollectionViewDataSource
var items = [String]()

func numberOfSections(in collectionView: UICollectionView) -> Int {
    if items.count == 0 {
        return 0
    }

    return 1
}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIndex: Int) -> Int {
    return items.count
}

不可解な現象:初期表示の際にヘッダー/フッターの位置がずれる

UICollectionViewFlowLayoutAutomaticSize を使ったコレクションビューのレイアウトで、ヘッダー/フッター(UICollectionReusableView)の位置がずれるというiOSのバグがあるようです。

適当にスクロールとかして UICollectionReusableView を dequeue させることでなんとか対処することができますが、根本的な解決策が見つかったら続報を追記したいと思います。

iOS 10 UICollectionViewFlowLayout using UICollectionViewFlowLayoutAutomaticSize results in footer supplementary view misaligned

とりあえずこのようにして対処しました。(すごくキモいけど他に方法が見つからない)

これはイケてないボツ
let milliseconds = 350 // 目安: Simulator: 350ms, iPhone 6s Plus: 150ms
let contentOffset = collectionView.contentOffset

// 適当に遠くまで飛ぶことで、UICollectionReusableView を無理やり dequeue させる
collectionView.contentOffset = CGPoint.init(x: 0, y: collectionView.height * 10)

// 数秒後に元に戻す。フェードイン効果などで誤魔化す必要があるかもしれない
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(milliseconds)) {
    collectionView.contentOffset = contentOffset
}

ヘッダー/フッターのレイアウトを一旦失効させる

この現象は、UICollectionViewFlowLayoutInvalidationContext を活用することで対処することができます。新たに UICollectionViewFlowLayoutInvalidationContext インスタンスを用意し、失効させる対象のヘッダーおよびフッターを指示します。そして コンテクストを用いて reloadData() の直後にレイアウト失効を実行します。

そのためにはまず前述の UICollectionViewFlowLayout internal error の件で入れたこの対応は外しておきます。今回の処理が効かなくなる恐れがあるからです。

collectionView.collectionViewLayout.invalidateLayout()

そして、reloadData() の直後に collectionView.collectionViewLayout.invalidateLayout(with: context) を実施し、ヘッダー/フッターのレイアウトを失効させます。こうすることで初回表示の際にヘッダー/フッターの位置がずれてしまう現象を修正することができます。invalidateLayout() のみでも効果がありそうに思えますが試したところ何も効果がなかったため、このような面倒な指示が必要となりました。

DataSource
/// 画面内の Supplementary View を取得
private func viewedSupplementaryElements(for kinds: [String]) -> [UICollectionViewLayoutAttributes] {
    var viewRect = CGRect()
    viewRect.origin = collectionView.contentOffset
    viewRect.size = UIScreen.main.bounds.size

    var result = [UICollectionViewLayoutAttributes]()

    if let layoutAttributes = collectionView.collectionViewLayout.layoutAttributesForElements(in: viewRect) {
        for attribute in layoutAttributes {
            for kind in kinds where attribute.representedElementKind == kind {
                result.append(attribute)
            }
        }
    }

    return result
}

func invalidateLayout() {
    // ここで UICollectionViewFlowLayoutInvalidationContext を利用して二つの対策を施す
    // 1. UICollectionViewFlowLayout internal error 対策
    // 2. Self Sizing Cells (UICollectionViewFlowLayoutAutomaticSize) を利用しているとき、最初の reloadData() でヘッダー/フッターの座標がずれてしまうので、レイアウトを失効させる。

    let viewedSupplementaries = viewedSupplementaryElements(for: [UICollectionElementKindSectionHeader, UICollectionElementKindSectionFooter])
    let context = UICollectionViewFlowLayoutInvalidationContext()

    for attribute in viewedSupplementaries {
        context.invalidateSupplementaryElements(ofKind: attribute.representedElementKind!, at: [attribute.indexPath])
    }

    collectionView.collectionViewLayout.invalidateLayout(with: context)
}

func reloadData() {
    collectionView.reloadData()
    invalidateLayout()
}

描画とパフォーマンスについて

WWDC2016: What's New in UICollectionView in iOS 10(26:10 あたり〜) でデモされていますが、iOS 10の方法だと最初のセルの計算が走った時に全てのセルの estimatedItemSize にもそれが適用されるので、特に同一の高さを持つセルが格子状に並べられる場合にはまず y 座標が確定するのでパフォーマンスが良さそうです。

初期状態
まず、仮サイズでセルが並べられているとする
iOS 9: (1) iOS 9: (2)
スクリーンショット 2016-12-22 13.58.10.png
一つ目のセルに実際のサイズが適用されるとその分左に詰まる 一行分の計算が終わると、全体が上に詰まる
iOS 10: (1) iOS 10: (2)
一つ目のセルに実際のサイズが適用された瞬間に、他の全てのセルにも同じサイズが適用される セルの計算が進むごとに estimatedItemSize が変わってゆく

むすび

UICollectionView における、UICollectionViewFlowLayoutAutomaticSize を利用した Self-sizing cells についてご紹介しました。

iOS 10 以降であればセルのサイズ計算がより簡単に行えるので本来なら積極的に使っていきたいところですが、何やら落とし穴も多いので注意が必要です。ここは思った以上に茨の道でしたので、覚悟を決めてから踏み込むことをおすすめいたします。

今回制作したプロジェクト
https://github.com/usagimaru/SelfSizingCollectionViewCells