UIKitで巨大かつ複雑なStickyヘッダーを実装する

こんにちは、レシピ事業部でiOSエンジニアをしている山田(@0x746572616e79)です。つい先日リリースしたプロフィール画面のリニューアルで、SNSアプリでよく見かけるプロフィールコンポーネントとStickyなタブを組み合わせたUIを実装し、いくつか学びがあったので実装方法と合わせてご紹介します。

Stickyヘッダーの設計、実装方針

プロフィール画面は元々、プロフィールコンポーネントの下にレシピ一覧、その下に「つくれぽ」のカルーセルを表示するシンプルな縦スクロール構成でした。

リニューアル後は、プロフィールコンポーネントの下に2つのタブ(レシピ一覧・つくれぽ一覧)を配置し、それぞれが独立したスクロール領域を持つ構成に変更します。各タブの内容は既存の検索結果画面と同じUIコンポーネントを流用できたため、UIPageViewControllerで各一覧画面のViewControllerをホスティング(内包・管理)する設計を採用しました。

class ProfileViewController: UIViewController {
    private lazy var pageViewController: UIPageViewController = {
        let viewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
        viewController.dataSource = self
        viewController.delegate = self
        return viewController
    }()
    
    private let recipeListViewController: RecipeListViewController // レシピ一覧画面
    private let cooksnapListViewController: CooksnapListViewController // つくれぽ一覧画面
    
    // プロフィールヘッダー(ユーザー情報やタブを含む)
    private let headerProfileView = ProfileView()
    
    // ヘッダーの位置を制御する制約
    private lazy var headerTopAnchorConstraint = headerProfileView.topAnchor.constraint(
        equalTo: view.safeAreaLayoutGuide.topAnchor
    )

    override func viewDidLoad() {
        super.viewDidLoad()

        setupLayout()

        pageViewController.setViewControllers([recipeListViewController], direction: .forward, animated: false)
    }
    
    private func setupLayout() {
        // PageViewControllerを全画面に配置
        addChild(pageViewController)
        view.addSubview(pageViewController.view)
        pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            pageViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
            pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            pageViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        pageViewController.didMove(toParent: self)
        
        // ヘッダーをPageViewControllerの上に重ねて配置
        view.addSubview(headerProfileView)
        NSLayoutConstraint.activate([
            headerTopAnchorConstraint, // この制約のconstantを変更してヘッダーを上下に移動
            headerProfileView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            headerProfileView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            headerProfileView.heightAnchor.constraint(equalToConstant: 200)
        ])
    }
}

この構成に至るまでに、以下の技術的、体験的な課題がありました。

  • 複数のスクロールビューとヘッダーの連動
  • ヘッダーの位置に応じた各タブのオフセット調整
  • ヘッダー領域でスクロールジェスチャーが効かない

順番に見ていきましょう

課題1. 複数のスクロールビューとヘッダーの連動

固定ヘッダーをタブのスクロールに合わせて追従させるだけなら、実装はそれほど複雑ではありません。UIPageViewControllerの上にヘッダーコンポーネントを配置し、各タブのViewControllerにヘッダーと同じ高さのadditionalSafeAreaInsets.topを設定するだけで基本的な動作は実現できます。

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let headerHeight = headerProfileView.frame.height
    if recipeListViewController.additionalSafeAreaInsets.top != headerHeight ||
        cooksnapListViewController.additionalSafeAreaInsets.top != headerHeight {
        recipeListViewController.additionalSafeAreaInsets.top = headerHeight
        cooksnapListViewController.additionalSafeAreaInsets.top = headerHeight
    }
}

あとはヘッダーをスクロール量に合わせて上下に移動させることで、スクロール時にヘッダーが自然に隠れるような動作が実現できます。 しかし今回の実装では、各タブ内にも上部に固定表示したいコンポーネント(検索バー)が存在していました。これが実装を複雑にする要因となりました。

スクロールしても検索バーがその場に止まってしまう

単純にadditionalSafeAreaInsetsを設定するだけでは、スクロールに合わせてタブ内の検索バーの位置を動的に調整することができません。プロフィールヘッダーが隠れた分だけ検索バーを上に移動させる必要があり、これには各タブのViewControllerと親のProfileViewController間でのスクロール状態の連携が不可欠でした。

そこで、additionalSafeAreaInsets.topは基本的なレイアウト調整として設定しつつ、スクロール位置を同期するための専用の仕組みを用意しました。

まず、各タブのViewControllerが共通のインターフェースでスクロール情報を提供できるよう、以下のプロトコルを定義します。

protocol ScrollMovementDelegate: AnyObject {
    func scrollMovementDidScroll(_ scrollView: UIScrollView, viewController: UIViewController)
}

protocol ScrollMovementProvidingViewController: UIViewController {
    var scrollMovementDelegate: ScrollMovementDelegate? { get set }
    func adjustContentTopInset(_ topInset: CGFloat)
}

各タブのViewControllerはこのプロトコルに準拠し、スクロール時に親のProfileViewControllerに変更を通知します。

// レシピ一覧のViewControllerで実装
extension RecipeListViewController: UITableViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        scrollMovementDelegate?.scrollMovementDidScroll(scrollView, viewController: self)
    }
}

親ViewControllerでは、受け取ったスクロール情報を元にヘッダーのトップアンカー制約のconstantを調整してヘッダーを上下に移動させつつ、各タブのadjustContentTopInsetメソッドを呼び出してタブ内の固定コンポーネント(検索バー)の位置を連動させます。これにより、プロフィールヘッダーの移動量に合わせて、各タブ内の検索バーも同じ距離だけ上に移動し、一体感のあるスクロール体験を実現できました。

// プロフィールViewControllerでスクロール情報を受け取り、ヘッダー位置を調整
extension ProfileViewController: ScrollMovementDelegate {
    func scrollMovementDidScroll(_ scrollView: UIScrollView, viewController: UIViewController) {
        guard let tab = currentTab(from: viewController), selectedTab == tab else { return }
        let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
        headerTopAnchorConstraint.constant = max(-offsetY, -headerProfileView.frame.height)
        updateTabContentsTopInset()
    }
    
    private func updateTabContentsTopInset() {
        let inset = headerTopAnchorConstraint.constant
        recipeListViewController.adjustContentTopInset(inset)
        cooksnapListViewController.adjustContentTopInset(inset)
    }
}

extension RecipeListViewController: ScrollMovementProvidingViewController {
    func adjustContentTopInset(_ topInset: CGFloat) {
        searchBarTopAnchorConstraint.constant = topInset // ヘッダーが移動した分、検索バーを移動させる
        // スクロールインジケーターの位置も合わせて調整
        tableView.verticalScrollIndicatorInsets.top = topInset + tableView.contentInset.top
    }
}

課題2. ヘッダーの位置に応じたオフセットの調整

この仕組みだけでは新たな問題が発生します。ヘッダーが上下に移動した状態で別のタブに切り替えると、そのタブのスクロール位置が意図しない状態になってしまうのです。

別のタブ切り替え時に意図しない余白が開いてしまう

例えば、以下のようなケースです:

  1. タブAでスクロールしてヘッダーが半分隠れる(headerTopAnchorConstraint.constant = -100)
  2. タブBに切り替え
  3. タブBのコンテンツとヘッダーの間に意図しない余白が開く、または重なって表示される

この問題に対して、複雑な制御でタブごとのヘッダー位置を記憶し、切り替え時に適切な位置に調整することも考えられました。しかし、メンテナンス性と実装の複雑さを考慮して、よりシンプルなアプローチを採用しました。 ヘッダーが移動した状態でタブ切り替えが発生した場合、非表示タブのスクロール位置をヘッダーのトップに合わせる形でリセットする処理を挟みます。

// 各タブのViewControllerで実装
func adjustContentTopInset(_ topInset: CGFloat, isVisible: Bool) {
    let previousTopInset = searchBarTopAnchorConstraint.constant

    searchBarTopAnchorConstraint.constant = topInset
    tableView.verticalScrollIndicatorInsets.top = topInset + tableView.contentInset.top
    
    // 非表示タブ(現在表示されていないタブ)でヘッダー位置が変わった場合は、
    // 次回表示時にスクロール位置を調整するためのフラグを設定
    if !isVisible && topInset != previousTopInset {
        pendingTopInsetAdjustment = topInset
    }
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    // pendingTopInsetAdjustmentがある場合、スクロール位置をリセット
    if let pendingTopInsetAdjustment {
        let minimumTopOffset = -pendingTopInsetAdjustment - tableView.adjustedContentInset.top
        tableView.contentOffset.y = minimumTopOffset
        self.pendingTopInsetAdjustment = nil
    }
}

このpendingTopInsetAdjustmentは、次回そのタブが表示される際にviewDidLayoutSubviewsで処理され、自然な位置にスクロール位置がリセットされます。 片方のタブがトップにスクロールするともう片方のタブもトップに戻ってしまうデメリットはありますが、ユーザーにとって違和感のないタブ切り替え体験を実現することができました。

実は、当初は別のアプローチも検討していました。スクロール位置に応じてadditionalSafeAreaInsetsを動的に変更する方法です。

この方法では、ヘッダーが隠れるにつれてadditionalSafeAreaInsets.topを小さくし、完全に隠れた時点で0にすることで、各タブのコンテンツ領域を自然に拡張させることができます。SafeAreaを変形させる方法のため課題1の問題も自動的に解決します。

// 没になったアプローチの例
func scrollMovementDidScroll(_ scrollView: UIScrollView, viewController: UIViewController) {
    let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
    let headerHeight = headerProfileView.frame.height
    let newSafeAreaTop = max(0, headerHeight - offsetY)
    
    // これが問題を引き起こした
    recipeListViewController.additionalSafeAreaInsets.top = newSafeAreaTop
    cooksnapListViewController.additionalSafeAreaInsets.top = newSafeAreaTop
}

しかし、この実装では上方向のスクロール時にスクロールの慣性が不自然に早く止まってしまい、ユーザーが期待するスムーズなスクロール体験を提供できませんでした。具体的には、通常であれば慣性でしばらく続くはずのスクロールが、突然停止してしまう現象が発生しました。 この問題はおそらく、additionalSafeAreaInsetsの変更がスクロールビューのadjustedContentInsetに影響を与え、スクロール中にこれらの値が動的に変わることでUIScrollViewの内部的な慣性計算が狂ってしまうことが原因だと考えています。 ユーザー体験を最優先に考え、技術的には実現可能でも、スクロールの自然さを犠牲にしてまで採用する価値はないと考え、この手法は没にしました。 結果として、制約のconstantを調整する現在のアプローチに落ち着き、スムーズなスクロール体験を保ちながら縦方向にスクロールできるタブUIを実現することができました。

課題3. ヘッダー領域でスクロールジェスチャーが効かない

ここまでで見た目と実装上では意図した通りの配置になりましたが、親のViewControllerで直接表示しているヘッダーコンポーネントとUIPageViewController内の子ViewControllerで表示しているUIScrollViewは全く別の階層構造にあるためジェスチャーの共有は行われず、ヘッダーコンポーネントを縦にスワイプしてもジェスチャーは伝搬しません。

プロフィール画面の階層構造

クックパッドでは画像のようにプロフィールコンポーネントとタブコントロールに加えて、プロフィールの入力を促すカルーセルをヘッダー部分で表示します。もしヘッダー部分をスワイプできないとなると画面下部の極端に小さいエリアをスワイプしなければならず、ユーザー体験は最悪なものになります。どのように対応するべきでしょうか?

カルーセルを含むプロフィール画面

この階層構造の違いを解決するため、UIScrollViewのpanGestureRecognizerを活用します。

Getterとして公開されているため新たにUIPanGestureRecognizerを作成してUIScrollViewに適用することはできませんが、このpanGestureRecognizerを別のViewに付け替えることはできます。今回だと親のViewControllerにあるrootViewに対してUIScrollViewのpanGestureRecognizeraddGestureRecognizerで登録してあげると、UIPageViewControllerのスワイプ操作やヘッダーコンポーネントにあるボタンのタップ操作などはそのままにヘッダーコンポーネントを縦にスワイプ操作してもコンポーネント裏側にあるUIScrollViewへジェスチャーを伝搬させることができます。

ただし、2つのタブのpanGestureRecognizerを同時に紐づけてしまうと片方のジェスチャーのみ有効化されてしまうため、UIPageViewControllerDelegateを用いて現在表示中のタブを管理しつつ、タブが切り替わったタイミングでpanGestureRecognizerを付け替える必要があります。

この実装を可能にするために、まずpanGestureRecognizerをProfileViewControllerへブリッジするために、ScrollMovementProvidingViewControllerプロトコルへpanGestureRecognizerプロパティを追加します。

protocol ScrollMovementProvidingViewController: UIViewController {
    var scrollMovementDelegate: ScrollMovementDelegate? { get set }
    var panGestureRecognizer: UIPanGestureRecognizer { get } // New
    func adjustContentTopInset(_ topInset: CGFloat, isVisible: Bool)
}

class RecipeListViewController: ScrollMovementProvidingViewController {
    ...
    var panGestureRecognizer: UIPanGestureRecognizer {
        tableView.panGestureRecognizer // UICollectionView or UITableViewのpanGestureRecognizerを公開
    }
}

各ViewControllerから公開されたpanGestureRecognizerをタブが切り替わるタイミングでProfileViewControllerのviewに付け替えます。

// ProfileViewControllerでの実装
private func updatePanGestureRecognizer(for tab: Tab) {
    switch tab {
    case .recipes:
        view.removeGestureRecognizer(cooksnapListViewController.panGestureRecognizer)
        view.addGestureRecognizer(recipeListViewController.panGestureRecognizer)
    case .cooksnaps:
        view.removeGestureRecognizer(recipeListViewController.panGestureRecognizer)
        view.addGestureRecognizer(cooksnapListViewController.panGestureRecognizer)
    }
}

// タブ切り替え時に呼び出し
private func updateSelectedTab(_ tab: Tab) {
    selectedTab = tab
    updatePanGestureRecognizer(for: tab)
}

1番上のviewに対してジェスチャーを紐づけることにより、ヘッダーコンポーネントやSegmentControl、UIPageViewControllerのスワイプ、タップジェスチャーを優先しつつ、UIScrollView外の縦スワイプを各UIScrollViewに伝搬させることができるようになりました。

まとめ

以下3つの技術的、体験的課題を解決し、ユーザー体験を損なうことなく折りたたみ可能なStickyヘッダーパターンを実現できました。

  1. 複数スクロールビューとヘッダーの連動: ScrollMovementDelegateプロトコルによるスクロール状態の同期
  2. タブ切り替え時のオフセット調整: pendingTopInsetAdjustmentによる自然なスクロール位置リセット
  3. ヘッダー領域でのジェスチャー伝搬: panGestureRecognizerの動的な付け替えによる解決

完成系

技術的に可能でもユーザー体験を犠牲にするアプローチは採用せず、常にスムーズな操作感を最優先に実装することができました。割とありがちなUIですが実装例を見ることがあまりないので、一つの例として公開しました。