ページネーションを壊さずにInterleavingをする

こんにちは、レシピ事業部検索チームの薄羽です。

検索チームでは日々レシピ検索機能の改善を行っています。

最近行った検索結果改善の過程で、2つの検索結果を交互に配置するinterleavingを実装する機会がありました。今回は、この機能の実装の際の課題となる点や、それをどう解決したのかを紹介します。

Interleaving

Interleavingは2つのランキングを評価する手法であり、2つのランキングの一番上から文書を交互に取っていくことで、ランキングを生成します。

interleaving機能を実装する際、ページネーションを壊さずにinterleavingを実装するには少し工夫が必要です。2つの検索結果から1つの検索結果を作るため、2つのランキングで重複した文書があると、生成される検索結果には同じ文書が2回表示されることになってしまいます。



どうやるか

すでにビズリーチさんがElasticsearchでのinterleavingの取り組みを記事にされています (https://engineering.visional.inc/blog/615/implement-interleaving-for-search-evaluation/)。ビズリーチさんもinterleavingの実装にあたりページネーションの問題にぶつかっていて、その解決のためにinterleavingするページを固定し、すでに表示した文書をキャッシュしておくことで、問題を解決しています。

我々も同様にページネーションの問題にぶつかり、同様にキャッシュを用いましたが、少し別のアプローチを取りました。1ページ目のときだけinterleavingするというアイデアも最初はありましたが、我々のアプリケーションでは検索結果は無限スクロールとなっており、ユーザがページを捲りやすいようになっています。またper_pageも基本20~30と小さいです。2ページ目以降もinterleavingするとなると、2ページ目のinterleavingした結果も保存する必要が出てきてしまいますが、検索結果全てのページをキャッシュするというのはあまり良い方法ではありません。加えて、保守性の観点で1ページ目だけに特別な意味を持たせることをしたくないという理由でページを限定してinterleavingする方法はやめました。

以上を考慮して、我々はページの代わりに「interleavingする片方のランキング」を固定することでページを渡るinterleavingを実装しました。

実装

我々は、片方の検索結果を全て取得してキャッシュに保存するという方法を取りました。 片方の検索結果を全件取得することで、どのページであってももう片方の検索結果から重複する結果を除外した上で検索することが可能になります。

我々のケースでは、interleavingしたい片方のランキングの長さは短く、対象の文書やクエリは限定的で、事前計算されています。また、返却する文書情報も多くないため、検索結果全体をキャッシュしても問題ないと判断しました。

この方法では、最初の検索では2回直列でESへの問い合わせをする必要がありますが、それ以降は通常の検索と並列にキャッシュへのアクセスをすることで、遅延の増加を抑えることができます。

今回、評価のためだけでなくランキングの仕組みの一つとしてinterleavingできるようにしたいため、interleavingを始める位置や長さ・文書の数を柔軟にコントロールできるような機能にしました。そのため、2ページ以降もinterleavingするときの課題として、ページ・per_page、今回はさらにinterleavingを始めるoffsetによって「どっちのランキングの文書から始まるか」が変わり複雑になるということがあります。ここで、単純にページとper_pageのみを考えるのであれば、per_pageとページが共に奇数のときのみ、検索結果の一番上がどっちから始まるかが変わるということがわかります。あとはここにoffsetを考慮して、愚直にどっちから始まるかを計算してあげればよさそうです。

キャッシュを用いた手法では、ページを切り替えるタイミングでキャッシュが切れると検索結果がおかしくなる可能性がありますが、今回のケースではキャッシュのttlを長めにとることができるため、問題にならないと判断しました。

この手法により、ページネーションの整合を保ったままページを渡ったinterleavingを実装することができました。我々のケースではキャッシュの肥大化も問題にならず、特別latencyの増加も認められませんでした。

まとめ

  • Elasticsearchをサーチエンジンとしたinterleavingを実装しました。
  • interleavingをサーバ側でやるとページネーションが壊れてしまうという問題がありますが、片方のランキングを全てキャッシュしておくことで解決しました。

We are hiring!

クックパッドでは現在絶賛採用活動中です。毎日の料理を楽しみにしたい皆様からのご応募を熱烈歓迎しております。まずはいま開いている枠を眺めてみてくださいませ。 https://cookpad.careers/

クックパッドはDroidKaigi 2025とiOSDC Japan 2025に協賛します!

こんにちは、iOSエンジニアの山田(0x746572616e79)です。

このたび、9月10日から開催される DroidKaigi 2025 と 9月19日から開催される iOSDC Japan 2025 に、それぞれゴールドスポンサーとして協賛いたします。また両イベントともブース出展をいたします。クックパッドではAndroid/iOSエンジニアを積極募集中です。当日は私を含むモバイルエンジニアがブースにおりますので、展示内容はもちろん、普段のプロダクト開発についてや採用に関するお話をカジュアルにできればと思います。

iOSDC Japan 2024のブースの様子です。

今年のスポンサーブースでは、グローバル展開中のクックパッドアプリ開発事例を昨年に引き続きご紹介します。また、最近社内で活発に取り組んでいるAIの利活用事例も併せて展示予定です。特にAI事例は現在試行錯誤の段階ですので、ぜひ皆さまと知見を共有できれば嬉しいです。また、昨年ご好評いただいた「フードクリップ」をノベルティとして配布を予定しています。ぜひお気軽にお立ち寄りください!

皆さまにお会いできるのを楽しみにしています!

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ですが実装例を見ることがあまりないので、一つの例として公開しました。