AdventCalendar
Swift
tvOS
UICollectionViewLayout
tvOSDay 6

フォーカスに合わせて上下に動くStickyなセクションヘッダーを実装する

Apple謹製アプリを見てみると、コレクションビューのヘッダーがこういう動きをしていました。

popping-sticky-header.gif

これ実装したいですよね。しかもどうせなら複数セクションある場合にはStickyなヘッダーを実現したいですよね。
ライブラリあるかなーと思ったら意外と横方向のはなかったので、UICollectionViewのカスタムLayoutとして作りました。
iOSにも対応してます。

https://github.com/toshi0383/HorizontalStickyHeaderLayout

toshi0383/HorizontalStickyHeaderLayout

まずは提供されている以下の6つのdelegateを実装します。それぞれのセルとヘッダーのサイズやマージンを指定します。

@objc
public protocol HorizontalStickyHeaderLayoutDelegate: class {
    func collectionView(_ collectionView: UICollectionView, hshlSizeForItemAtIndexPath indexPath: IndexPath) -> CGSize
    func collectionView(_ collectionView: UICollectionView, hshlSectionInsetsAtSection section: Int) -> UIEdgeInsets
    func collectionView(_ collectionView: UICollectionView, hshlMinSpacingForCellsAtSection section: Int) -> CGFloat
    func collectionView(_ collectionView: UICollectionView, hshlSizeForHeaderAtSection section: Int) -> CGSize
    func collectionView(_ collectionView: UICollectionView, hshlHeaderInsetsAtSection section: Int) -> UIEdgeInsets
    @objc optional func collectionView(_ collectionView: UICollectionView, hshlDidUpdatePoppingHeaderIndexPaths indexPaths: [IndexPath])
}

フォーカスで上下に動くアニメーションは、自分で実装します。
なぜかというと、カスタムレイアウト内でレイアウト処理をしているビューの座標を変えると怒られるからです。つまり以下のようにヘッダービューのsubviewをアニメーションさせる必要があります。
アニメーションさせるタイミングについてはhshlDidUpdatePoppingHeaderIndexPathsのdelegateで通知されますが、レイアウト側はフォーカスの更新を検知するすべがないので、didUpdateFocusのタイミングでも再計算させてアニメーションさせると良いです。

    // Popping Header
    func collectionView(_ collectionView: UICollectionView, hshlDidUpdatePoppingHeaderIndexPaths indexPaths: [IndexPath]) {
        let (pop, unpop) = self.getHeaders(poppingHeadersIndexPaths: self.layout.poppingHeaderIndexPaths)
        UIView.animate(withDuration: Const.unpopDuration, delay: 0, options: [.curveEaseOut], animations: {
            unpop.forEach { $0.unpopHeader() }
            pop.forEach { $0.popHeader() }
        }, completion: nil)
    }

    override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
        layout.updatePoppingHeaderIndexPaths()
        let (pop, unpop) = self.getHeaders(poppingHeadersIndexPaths: self.layout.poppingHeaderIndexPaths)
        UIView.animate(withDuration: Const.unpopDuration, delay: 0, options: [.curveEaseOut], animations: {
            unpop.forEach { $0.unpopHeader() }
        }, completion: nil)
        coordinator.addCoordinatedAnimations({
            pop.forEach { $0.popHeader() }
        }, completion: nil)
        super.didUpdateFocus(in: context, with: coordinator)
    }

    private func getHeaders(poppingHeadersIndexPaths indexPaths: [IndexPath]) -> (pop: [HeaderView], unpop: [HeaderView]) {
        var visible = collectionView.visibleSupplementaryViews(ofKind: UICollectionElementKindSectionHeader)
        var pop: [HeaderView] = []
        for indexPath in indexPaths {
            guard let view = collectionView.supplementaryView(forElementKind: UICollectionElementKindSectionHeader, at: indexPath) else {
                continue
            }
            if let index = visible.index(of: view) {
                visible.remove(at: index)
            }
            if let header = view as? HeaderView {
                pop.append(header)
            }
        }
        return (pop: pop, unpop: visible.flatMap { $0 as? HeaderView })
    }

popHeader() unpopHeader()の中ではHeaderViewのsubviewの座標を変えています。

これで、フォーカスに合わせて上下に動くセクションヘッダーを実現することができました。複数セクションの場合のStickyな挙動は何もしなくても実現できています。

sticky-animated-header-for-tvos.gif

まとめ

フォーカスに合わせて上下に動くStickyなセクションヘッダーを実装してみました。
https://github.com/toshi0383/HorizontalStickyHeaderLayout

最初はレイアウト側でヘッダーの座標を変えてcollectionView.layoutIfNeeded()をアニメーションブロックに入れるというアプローチだったのですが、セルごと表示されなくなる不具合があったりしたため上記のような実装に落ち着いています。

tvOSアプリを開発する機会があればほぼ必ずこういうレイアウトはすると思うので、是非取り入れてみてください。