クックパッドアプリのLiquid Glass対応

こんにちは、レシピ事業部でiOSエンジニアをしている山田(@0x746572616e79)です。

iOS 26で導入されたLiquid Glassは、iOS 27で強制的に有効化される予定です。対応を先送りにすると、その間に進む機能開発やデザイン調整がすべてLiquid Glassへの考慮がされないまま積み上がり、後から手戻りが増えていきます。早期に有効化しておくことで、新しい画面や機能を追加する段階でLiquid Glassを前提としたデザイン議論やベストプラクティスの調査ができるようになります。

こうした背景からクックパッドiOSアプリでは早めの対応を進めてきました。この記事では、UINavigationBarとUITabBar周りで遭遇した破壊的変化と、Liquid Glassの新しい仕組みを活用した事例を共有します。

UINavigationBar

カスタムtitleViewのUITextFieldがRTLでクラッシュする

まずはUINavigationBarに関わる問題です。

クックパッドアプリではいくつかの画面でnavigationItem.titleViewにカスタムの検索バーを設定していました。Liquid Glassを有効化してデバッグしている中で、モーダル遷移かつRTL表示の組み合わせでクラッシュすることに気づきました。ナビゲーションバーの内部レイアウトが刷新された影響でUISearchBarTextFieldのレイアウト計算が破綻し、CALayerのpositionにNaNが入ることが原因でした。 このクラッシュはiOS 26.2では再現されますがiOS 26.4では修正された模様ですが、クックパッドアプリはiOS17以降をサポートするためそのまま利用することはできません。 titleViewベースの検索バーをやめて、UISearchControllerベースに移行することで解決しました。

navigationItem.searchControllernavigationItem.preferredSearchBarPlacement = .stackedを用いたレイアウトであればiOS 26.2でも問題なく動作します。

// Before
navigationItem.titleView = customSearchBar

// After
let searchController = UISearchController(searchResultsController: nil)
searchController.searchBar.delegate = self
navigationItem.searchController = searchController
navigationItem.preferredSearchBarPlacement = .stacked

UISearchControllerへ移行することでLiquid Glassのガラス質感や検索バーのアニメーションも自然に適用されます。

ナビゲーションバーのアイテムが省略されてしまう

Liquid Glassではナビゲーションバーのバーボタンアイテムの扱いが大きく変わりました。従来はleftBarButtonItemsとrightBarButtonItemsの描画サイズが自動調整され、コンテンツの要求するサイズが長い場合でも他要素の表示領域を確保した上でいっぱいに広げることができました。クックパッドのレシピ詳細画面ではこの仕様を利用してレシピタイトルを左寄せにしていましたが、Liquid Glass有効下では各アイテムに最低44ptのタップ領域と8pt程度のスペースが確保されるようになり、収まらないアイテムは標準のoverflowボタンにまとめられるようになりました。

これにより表示可能領域よりも長いタイトルで全ての要素が標準のOverflowボタンに省略されてしまいました。

短いレシピタイトル
長いレシピタイトル

タイトルは、そもそもBarButtonItemsに入れること自体が少々特殊な対応だったためレイアウトの調整を頑張るのではなく、中央寄せになる挙動を許容しnavigationItem.titleViewへ移行することにしました。

また、これまでrightBarButtonItemsに自前でUIMenuを表示するOverflowボタンを表示していましたが、UIKitが表示する標準のoverflowボタンの中に意図せず省略されてしまうといった問題が起きやすくなるため、標準の仕組みへ乗ることにしました。具体的にはnavigationItem.additionalOverflowItemsにメニュー項目を登録して置くことで、自動的に標準のOverflowボタンを利用することができます。

navigationItem.additionalOverflowItems = UIDeferredMenuElement.uncached { completion in
    completion([
        UIAction(title: "Delete", attributes: .destructive) { _ in /* ... */ }
    ])
}

一点注意が必要なのが、UIBarButtonItem(customView:)で初期化したアイテムの扱いです。titleやimageで初期化した場合はUIKitが自動的にmenuRepresentationを設定してくれますが、customViewで初期化した場合はmenuRepresentationnilになります。menuRepresentationがnilの場合、表示領域が足りなくなるとOverflowメニューにもナビゲーションバーにも表示されなくなります。

let item2 = UIButton()
item2.setTitle("Item2", for: .normal)
navigationItem.rightBarButtonItems = [
    UIBarButtonItem(title: "Item1", style: .plain, target: nil, action: nil),
    // Item2だけcustomView
    UIBarButtonItem(customView: item2),
    UIBarButtonItem(title: "Item3", style: .plain, target: nil, action: nil),
    UIBarButtonItem(title: "Item4", style: .plain, target: nil, action: nil),
    UIBarButtonItem(title: "Item5", style: .plain, target: nil, action: nil),
    UIBarButtonItem(title: "Item6", style: .plain, target: nil, action: nil)
]

Item1, Item3の間にItem2が入ってほしいが表示されない

customViewベースのアイテムを使う場合はmenuRepresentationを明示的に設定する必要があります。

let barButtonItem = UIBarButtonItem(customView: button)
barButtonItem.menuRepresentation = UIAction(title: "Item2") { _ in /* ... */ }

Item1, Item3の間にItem2が表示される

UITabBar

FABと「今日作る」ボタンをUITabBarに統合する

従来のクックパッドアプリでは、レシピ作成用のFAB(Floating Action Button)と今日の献立を開く「今日作る」ボタンをUITabBarの上にオーバーレイとして配置していました。タブバーだけLiquid Glassのデザインに切り替わった状態で、その上に従来のオーバーレイが乗っているとデザインの統一性がなく違和感があったため、Liquid Glassで追加された新しい仕組みを使ってタブバーに統合することにしました。

FABはUITabBarItem(tabBarSystemItem: .search)を使ってタブバー右端にピン留めし、「今日作る」ボタンはUITabAccessoryとしてタブバー下部に配置することで、オーバーレイを廃止してコンテンツの表示領域を広げました。

Before
After

Liquid GlassのUITabBarでは、tabBarSystemItem: .searchで作成したアイテムが他のタブとは独立してタブバーの右端に固定配置されます。本来は検索タブ用途のシステムアイテムですが、この固定配置の仕様を利用して、FABのようなアクションボタンをタブバーに統合しています。

献立アクセサリはUITabAccessoryで実現しています。Apple Musicのミニプレーヤーが代表例ですが、クックパッドでは今日の献立に登録されたレシピのサムネイルとカレンダーボタンを常時表示し、どの画面からでも献立にアクセスできるようにしています。

tabBarController.tabBarMinimizeBehavior = .onScrollDown
tabBarController.setBottomAccessory(
    UITabAccessory(contentView: accessoryContentView),
    animated: false
)

従来のオーバーレイボタンでは自前で表示領域を確保する都合上、コンテンツの邪魔にならないようラベルとアイコンのみの表示に留めていましたがUITabAccessoryではUIKit側が表示領域を管理してくれるため、今日作るレシピのサムネイルを表示するといった表示内容の自由度が高まりました。

コンテンツビューはtraitCollection.tabAccessoryEnvironment.inline.regularが切り替わるので、それぞれに合わせてラベルの表示有無やサムネイルの表示枚数を調整しています。

override func updateProperties() {
    super.updateProperties()

    switch traitCollection.tabAccessoryEnvironment {
    case .inline:
        // コンパクト表示に切り替え
    case .regular, .unspecified:
        // 通常表示に切り替え
    }
}
Inline
Regular

hidesBottomBarWhenPushedとの連携

デバッグ中に、hidesBottomBarWhenPushed = trueの画面に遷移した際、タブバーは隠れるのにアクセサリが残り続ける問題に気づきました。これはUINavigationControllerDelegatewillShowでアクセサリの保存・復元を手動で行うことで解決しました。

func navigationController(_ nc: UINavigationController, willShow vc: UIViewController, animated: Bool) {
    if vc.hidesBottomBarWhenPushed {
        storedAccessory = tabBarController?.bottomAccessory
        tabBarController?.setBottomAccessory(nil, animated: false)
    } else if let stored = storedAccessory {
        tabBarController?.setBottomAccessory(stored, animated: false)
        storedAccessory = nil
    }
}

インタラクティブなpopジェスチャーがキャンセルされた場合の復元もtransitionCoordinatorのキャンセルハンドラで行っています。

contentScrollViewのコンテナVC転送

Liquid GlassのタブバーはtabBarMinimizeBehavior = .onScrollDownを設定することで、コンテンツのスクロールに連動してbottomAccessoryがタブバーの領域に収まるよう折りたたまれます。UIKitはcontentScrollView(for:)で返されるUIScrollViewを監視してこの挙動を実現しており、UIViewControllerを直接タブに配置している場合は自動で解決されます。

ただし、ViewControllerを入れ子にしていたりUIPageViewControllerを間に挟んでいる場合は注意が必要です。tabBarMinimizeBehaviorを設定しても反応する画面としない画面があり、特定のページでしかスクロール連動が機能しないなど意図しない挙動になることがあります。クックパッドでもカスタムのコンテナVCが子VCへ適切に転送できていない問題に遭遇し、contentScrollView(for:)のoverrideで対応しました。

override func contentScrollView(for edge: NSDirectionalRectEdge) -> UIScrollView? {
    children.first?.contentScrollView(for: edge)
}

まとめ

Liquid Glassはカスタム実装をOS標準パターンに置き換えること、追加された新しい仕組みを活用することの2軸を柱に対応を進めました。

標準APIに準拠するほど対応コストは下がります。UISearchControllerやoverflowメニューへの移行はその好例です。また、UITabAccessorytabBarSystemItem: .searchの固定配置といった新しい仕組みを活用することで、FABや献立ボタンをタブバーに統合しコンテンツの表示領域を広げることもできました。

一方ですべてを標準UIに寄せればいいわけではなく、サービスらしさを表現するためにカスタムが必要な箇所もあります。どこを標準に乗せてどこをこだわるか、そのバランスを意識しながら対応を進めていくことが大切です。特にUITabAccessoryはミニプレーヤー以外の用途での情報がまだ少なく、この記事が参考になれば幸いです。

RubyKaigi 2026 のクックパッドブースはこんな感じです

RubyKaigi 2026 のロゴ画像です。

こんにちは。レシピ事業部の石川です。

来週 4 月 22 日から 3 日間、RubyKaigi 2026 が開催されます。クックパッドは今年も Platinum スポンサーとして RubyKaigi に協賛いたします。また、スポンサーブースをご用意いたします。

今年のスポンサーブースでは、ここ 1 年のクックパッドでの開発の様子をあっちからこっちまでご紹介いたします。社内で Claude Code の話題が出たのは去年の 3 月でした。皆さまご存知のとおりそこからの 1 年でがらりと変わった開発環境の話は積もるものがございます。またこの 1 年の間もいくつかの新機能がリリースされました。その裏で動いている技術の話も、対面なら細かいニュアンスまでお伝えできます。その他、Rails アプリで pull request を出してからデプロイされるまでの時間を短くするための取り組みや多言語での検索システムの話など、技術の話のタネをいろいろとご用意いたします。

cookpad.com を提供している Rails アプリは現在 Ruby 4.0 & Bundler 4.0 で動いています。次の更新に向けてどんなことができそうか、トークを聞いて回りながら考えようと個人的に思っています。皆さまの考えもぜひお聞かせください。

それでは、現地で雑談できるのを楽しみにしております。また来週!

なんだか最近 RuboCop のキャッシュファイルが git diff に入ってくる方へ

こんにちは、レシピ事業部バックエンド基盤部の石川です。これは RuboCop のバージョンを上げましょうという記事です。

なんだか最近 RuboCop を使っていたら .rubocop-29343e612b03ba2227a3c3390a755e8d.yml のような名前のファイルが .gitignore を貫通してきませんか?

これは他リポジトリなどのリモートにある設定ファイルを inherit_from で参照したときに作られるキャッシュファイルです。ちょっと前までは .rubocop-https---raw-githubusercontent-com-cookpad-styleguide-master--rubocop-yml のような名前だったのが、2025 年末あたりに .rubocop-remote-23b9c67aff31e0f9d6c4a89d5eb660cb.yml のような名前になり *1、そのあと現在の名前になりました *2。現在の名前は $original_name-$hash.yml という規則になっています。

そのような感じでキャッシュファイルの名前が変わり、そしてよくある .gitignore では .rubocop-https?--* のみが ignore されていることが多く、新しいファイル名がこのルールに引っかからなくなったため git diff に現れるようになったという次第です。

さて、実は先日リリースされた RuboCop v1.84.2 からは、デフォルトでこれらのキャッシュファイルがプロジェクトの一番上のディレクトリではなく ~/.cache/rubocop_cache/ のような共通のディレクトリへ保存されるようになりました *3

したがって、お使いの RuboCop のバージョンを上げていただければこれらのキャッシュファイルは git diff を邪魔しなくなります。プロジェクトのディレクトリの外に保存されるためです。お試しください。

Include/Exclude に関する注意点

ところで、この変更に伴い、inherit_from で参照される設定ファイルの側で IncludeExclude を設定している方には注意点があります。IncludeExclude では相対パスを使って RuboCop の対象となるファイルを制御できますが、実はこの相対パスの起点が設定ファイルの名前によって変わります。

https://docs.rubocop.org/rubocop/configuration.html#path-relativity

In .rubocop.yml and any other configuration file beginning with .rubocop, files, and directories are specified relative to the directory where the configuration file is. In configuration files that don’t begin with .rubocop, e.g. our_company_defaults.yml, paths are relative to the directory where rubocop is run.

上記のドキュメントに書かれているように、名前が .rubocop から始まる設定ファイルではその設定ファイルの場所からの相対パスになり、それ以外の場合は rubocop コマンドが実行された場所からの相対パスになります。

今のところ、これは inherit_from で参照されている先の設定ファイルの名前についても同様に判断される挙動になっています。したがって RuboCop のバージョンを上げると意図しない挙動になる可能性があります。

RuboCop v1.84 では、元の設定ファイル名の先頭がキャッシュファイル名の先頭に残る上、キャッシュファイルの保存先がプロジェクトの一番上ではなくなります。このためリモートにある .rubocop.ymlinherit_from で参照している場合、IncludeExclude の相対パスの起点がプロジェクトの一番上でなくなってしまいます。

実際にファイル名によって挙動が変わる様子を以下に示します。ふたつ実行していて、最初はピリオドありの .rubocop.yml、次はピリオドなしの rubocop.yml を使っており、両方とも中身は Exclude です。両者について RuboCop v1.84.2 を動かすと、前者は spec/app_spec.rb が除外されず検査対象に残ってしまっています。なお RuboCop v1.84.1 のデフォルト挙動では前者でも後者と同じく spec/app_spec.rb が対象になっていませんでした。

% rubocop -v
1.84.2
% tree -a .
.
├── .rubocop.yml
├── app.rb
└── spec
    └── app_spec.rb

2 directories, 3 files
% cat .rubocop.yml
inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml
# inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/rubocop.yml

AllCops:
  DisabledByDefault: true
% curl -fsSL https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml
AllCops:
  Exclude:
    - 'spec/**/*'
% rubocop --list-target-files
app.rb
spec/app_spec.rb
% nano .rubocop.yml
% cat .rubocop.yml
# inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml
inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/rubocop.yml

AllCops:
  DisabledByDefault: true
% rubocop --list-target-files
app.rb

これを避けるため、inherit_from している先のファイル名が適切かチェックしておくのが良いでしょう。弊社では mv .rubocop.yml rubocop.yml としたあと、互換性のために .rubocop.yml では inherit_from: rubocop.yml で参照しておいて徐々に移行していくという手順を試しています。

以上、最近の RuboCop についての情報共有でした。まずはぜひバージョンを上げてみてください。

cookpad.careers