iOSアプリにおける複数リリースに跨った機能改善の開発事例紹介

レシピ事業部のHaurta (@0x746572616e79 )です。グローバルサービスとの統合プロジェクト(One Experienceプロジェクト)に伴いiOSアプリケーションもグローバルと日本で別々のアプリケーションを開発していた体制から一変して、グローバルのアプリケーションベースの開発(グローバル版)へ移行を進めました。

グローバルと日本で異なるアプリケーションを開発してきたため、同じクックパッドでも細かな挙動の違いが見られます。気になる挙動がないかどうかチームで何度もウォークスルーを重ねた結果、レシピエディターやプロフィール設定画面で使われるフォトピッカーの挙動が問題として浮上しました。

フォトピッカーの改善を重要なタスクとして取り組むことにしましたが、フォトピッカーに限らずOne Experienceプロジェクトが始まってからはグローバル版のコードベースを読むところからのスタートになるため、このタスクの完了にどのくらい時間がかかるのか推測しづらい状態でした。

一般的に大きなタスクを小さく分割することは行われますが、具体的な分割方法や進行の手法については、慣れや経験だったり試行錯誤が必要です。

この記事では、フォトピッカーの改善を通じて、タスク分割とプロジェクトの進め方について幾つかの学びがあったため、どのようにタスクを分解し進めていったのかを紹介します。

フォトピッカー

クックパッドにおいてフォトピッカーは非常に重要な機能の一つで、旧日本版、グローバル版ともにフォトピッカーは標準のPHPickerViewControllerを使用せず、何種類か目的と体験にあった自前のフォトピッカーを実装していました。

旧日本版では、ユーザーがレシピに写真を追加する際、フォトピッカーが最初に起動し、そこからカメラスクリーンに遷移できるボタンが配置されていました。これに対し、グローバル版では、カメラスクリーンが最初に起動し、カメラスクリーンにフォトピッカーへの遷移ボタンが設置されていました。

手順写真を載せようとした時に表示される画面

グローバル版のカメラ機能はシンプルなため、多くのユーザーが外部のカメラアプリを利用するだろうと考えられます。この状況では、画像をアップロードするたびにカメラスクリーンが起動するのは不便です。また、X(旧Twitter)やInstagramといったSNSアプリでは、フォトピッカーを先に表示し、内部にカメラアクセスの導線を設けるのが一般的です。そこで、グローバル版もフォトピッカーを最初に起動する流れに改善することになりました。

問題解決への道筋を立てる

グローバル版は各機能がどのように実装されているか、画面の構造、実装を理解するところから改善を進めます。当初は画面の入れ替えだけで済むと考えていましたが、コードリーディングを進めるうちにかなりの工数が必要であることが判明しました。

グローバル版はCoordinator Patternを用いた画面遷移を採用しており、フォトピッカーにはカメラへの遷移を設置する必要がありました。この時、概算で10時間以上の工数がかかる可能性を感じつつも、どこから着手すれば良いのか具体的な見積もりができていません。

グローバル版のアーキテクチャ

レシピ事業部はスクラム開発を採用しており、タスクの優先順位や範囲を決定するためにも、工数の見積もりが必要です。非常に正確である必要はありませんが、大雑把過ぎる見積もりでは不透明なため優先順位判断が難しくなります。

改善タスクには破壊的な変更が含まれており、複数スプリントにわたって開発が必要な場合モバイル特有の問題を考える必要があります。モバイルアプリはスプリントごとにリリース*1をしているため、未完成の機能が露出しないようにしなければなりません。そのため、mainブランチへ細かく変更を加えていくのか、開発ブランチを事前に用意しまとめてmainブランチに取り込む方法を採用すべきかどうかを検討しました。

mainブランチに直接細かな変更を積み重ねる方法では、コミットの粒度をコントロールしやすく、各変更の影響範囲を小さく保つことができます。また、最新のmainに追加していくため、安定性を確保しやすいです。しかし、開発中の機能や未完成の変更がリリースで露出してしまわないよう厳重な管理が求められます。

一方、開発ブランチを用いた方法では、mainブランチに影響を与えることなく機能を追加、変更することができます。ただ、開発ブランチでの作業が進む中でmainブランチにも並行して別の変更が加えられることがほとんどで、コンフリクトのリスクが高くなります。また、mainマージをするタイミングで全ての差分をチェックする必要があり、レビューコストの増加につながります。アルファリリースでは、開発ブランチを用いて変更を積み重ねていましたが、mainブランチと大きな差分が発生してしまい、コミットの統合時に多大な工数がかかることがありました。

最終的にはmainブランチへ変更を積み上げていく方法で進めることを決めました。

リリースを跨ぐ機能開発

mainブランチに変更を積み上げていくと決めましたが、ではどう破壊的な変更を閉じておくべきでしょうか?

Feature Toggle

グローバル版にはFeature Toggle *2 が用意されています。A/Bテストなどで広く使われており、代表的なサービスとしてFirebase Remote Config *3 が有名です。詳しくは取り上げませんが、One Experience向けの機能をグローバル版へ実装、展開する際にもFeature Toggleが利用されていました。遷移先の切り替えや、UITableViewで表示するコンテンツの出し分けなど色々なユースケースでの利用ができます。

class Coordinator: InteractorDelegate {
  ...
  func interactorWantsToInsertStepAttachments() {
    if appContext.featureToggle.supports(.フォトピッカーを先に起動する) {
      startPhotoPickerFlow()
    } else {
      startCameraFlow()
    }
  }
}

今回のフォトピッカー改善タスクではCoordinatorの画面遷移処理にてFeature Toggleを利用し出し分けを行う方法を採用しました。別の方法としてFeature Toggleがオンのときにはフォトピッカーを、オフのときにはカメラスクリーンを初期表示する単一のCoordinatorを定義する方法も検討しましたが、実装の簡素化と管理のしやすさを考えると独立したCoordinatorを用意する方が適していると今回は判断しました。

デバッグメニューからFeatureToggleの切り替えができる

開発中は端末内のFeatureToggleを強制的に有効化しフォトピッカーを立ち上げ、本番環境では以前のカメラを立ち上げることで意図せず開発中の実装がユーザーの方に見えてしまう事故を防ぐことができます。

タスクと仕様を整理する

Feature Toggleによって全ての問題が解決するかというと、そうではありません。Feature Toggleで分岐先を変えるのが良さそうだというのはわかりましたが、依然としてどれくらいの工数がかかるかわからないためタスクと仕様を整理する必要があります。遷移先の画面をFeature Toggleで分岐できるようになったので全く新しい画面を定義して表示することもできますが、また0からフォトピッカーやカメラスクリーンを実装するのは二度手間でその分工数がかかってしまいます。手間を省くためにも可能な限り、既存のコンポーネントや実装は使い回しをしたいです。

(左)改善前のエラー表示、(右)期待するエラー画面

ただ、既存のフォトピッカーは使い回すには少々難しい実装になっていました。たとえばデバイス内の写真にアクセスするための権限をリクエストするPHPhotoLibrary.requestAuthorizationはカメラスクリーンを表示しているタイミングで実行していたり、権限がない時は専用のエラーコンポーネントをフォトピッカーで表示するのではなく、UIAlertControllerを利用したエラー表示を行っていました。このまま画面を入れ替えてしまうと、フォトピッカーのアクセスを拒否したときカメラスクリーンへの導線がなくなってしまい体験が悪いです。エラー画面の追加、アクセス権限をリクエストするタイミング調整など、新しいCoordinatorを定義する前にフォトピッカー自体の機能追加が必要だということがわかりました。

少しずつ取り組むべきタスクが明確になり、優先順位もまた見えてきました。新しいCoordinatorを追加する前にフォトピッカーの機能追加を進める必要がありますが、フォトピッカーにカメラセルを追加する、エラー画面を追加するといった変更はそれぞれ独立したタスクとして進めることができます。また、新しいCoordinatorを追加するのもFeatureToggleを利用することでユーザーの目に触れることなく開発を進められることがわかりました。

ここまで調査時間含めて3h程度で整理、設計をし、やっとある程度正確に工数を見積もることができました。これで他Epicイシューとの優先順位を決めタスクとして進めることができるようになります。

まとめ

フォトピッカー改善タスクを例にタスクの分解とリリースを跨いだ機能開発の事例についてご紹介しました。特に、大きなタスクを細かく分解するためにモバイル特有のリリース制約を考慮し、対応が必要な細かいタスクをリストアップする進め方は他のタスクにも応用できると思います。

また、今回は遷移先のコントロールにFeature Toggleを活用し、比較的綺麗な設計を実現しましたが、Feature Toggleの運用には注意点も存在します。実際に、Feature Toggleを使ったアプローチがうまくいかなかったケースもいくつか経験しています。機会があれば、Feature Toggle単体のお話もできればと思います。

この記事で紹介した方法や考え方が、皆さんの今後のアプリケーション開発において何かしらの参考になれば幸いです。

*1:レシピ事業部では1スプリントを一週間で回しているため週次リリースを行っています

*2:一般的にはFeature Flagとも呼ばれている(https://martinfowler.com/articles/feature-toggles.html

*3:https://firebase.google.com/products/remote-config?hl=ja

ちょっと複雑なサイドバーをHotwireで簡単に作りたい

こんにちは、レシピ事業部プロダクト開発グループの渡邉(@taso0096)です。 クックパッドは最近、One Experienceというプロジェクトによって日本版とグローバル版のシステムが統合されました。 どちらのシステムもRailsで実装されているという点は同じですが、統合先となったグローバル版ではHotwireが使われていました*1。そのため、One Experience関連の開発ではHotwireが積極的に活用されています。本記事ではそんなHotwireの多くの機能が使われたデスクトップ版のサイドバーについてご紹介します。

デスクトップ版で表示されるサイドバー

ちょっと複雑なサイドバー

One Experienceに伴い、グローバル版にもともと存在したUIのまま移行するのではなく、いくつか画面構成の変更を入れる事になりました。特にデスクトップ版においては、自分のコンテンツにより素早くアクセスできるようにするためにサイドバーの導入が決まりました。

このサイドバーでは、一般的なナビゲーションメニューのほかに「きろく」と呼ばれるコンテンツを表示しています。「きろく」とはユーザの保存レシピや投稿レシピなどを整理するための機能です。また、「きろく」に保存したレシピはユーザーさんの手で、フォルダを作成して分類できます。このとき、レシピやフォルダの数が多いユーザーさんでも充分素早く表示させたいです。更に、サイドバーの外でレシピが保存されたとき、リロードせずともサイドバーの中の表示も追従する必要があります。以上を整理すると、作りたいものは以下のようなものということになります。

  • コンテンツが多く読み込みに時間がかかる場合を考慮して非同期で読み込む
  • フォルダが多い場合でも全てのフォルダを読み込める
  • レシピの保存やフォルダの作成などの操作時に表示を同期する

これらの要件はHotwireを使えば簡単に実装することができます。ここではHotwireの各機能を軽く説明しつつ、それぞれどういった実装をしたのかご紹介します。

Turbo Frames

Turbo Framesとはページ全体をリロードせずに部分的な更新を可能にするための機能です。部分更新したい箇所をTurbo Framesのタグで囲うことで、そのタグ内の部分更新が行われます。また、iframeのようにURLを指定することで全く別のページを埋め込むことも可能です。この場合はタグの中身が最初にレンダリングされ、その後に非同期で別のページが読み込まれます。

今回実装したサイドバーでは、非同期でコンテンツを読み込むためにTurbo Framesを利用しました。そのためにTurbo Framesで表示される専用ページを新規作成し、図の赤枠内で読み込むようにしました。この赤枠の部分ではローディングが最初に表示され、ページの読み込み後に非同期でコンテンツが読み込まれます。

Turbo Framesの範囲

多くの場合、Rails側で実装してある既存のページのロジックなどをそのまま流用して簡単に埋め込めるというのがTurbo Framesのメリットかと思います。一方、この例では新規ページをわざわざ作成しました。これはフォルダのページネーション実装をシンプルにするためで、Stimulus で実装しています。詳細は後ほど解説します。

なお、Turbo Framesはlazy loadingも可能であり、今回も設定しています。サイドバーはデスクトップ版ではスクロール位置などに関係なく常に表示されるものですが、スマホ版だとそうではないためです。

ページ遷移に伴うリセット

Turbo Framesによって非同期でのコンテンツの取得が実現できました。しかし、このままではページ遷移するたびにコンテンツの再取得が行われてしまいます。場合によってはそれでも問題ないですが、今回はページネーションによって読み込まれたフォルダの情報がリセットされることを避けたいと考えました。そうでなくともページ遷移する度にサイドバーがローディングによって一瞬使えなくなることはかなり不便だと思います。

そこでdata-turbo-permanent属性というものを使用しました*2。以下のようにこの属性が付与されたDOMはページ遷移時にもDOMが維持されます。

<div data-turbo-permanent>sidebar</div>

これにより一度読み込んだTurbo Framesのコンテンツは通常の画面遷移ではリセットされなくなります。これを再度読み込むにはブラウザのリロードやJavaScriptによる再読み込みの処理が必要になります。

レスポンス

Turbo Framesが取得するHTMLはページに埋め込まれる部分だけではありません。layoutテンプレートこそ使用されませんがActionViewでレンダリングされたページ全体がレスポンスとして返されます。そのため、ActionViewの中の一要素のみを埋め込みたいといった場合はそれ以外のレスポンスは破棄されてしまいます。おおよそのレスポンスは以下のようになっており、ブラウザ側のHotwireランタイムで解釈されて画面に埋め込まれます。

<html>
  <head></head>
  <body>
    <div>破棄される要素</div>
    <turbo-frame id="dom_id">埋め込みたい要素</turbo-frame>
  </body>
</html>

多少の無駄は生じてしまいますが、上でも書いたように既存のページをほぼそのまま流用可能であるというメリットの方が大きいと考えます。なお、今回は新規ページを作成しActionView全体が埋め込まれる形にしたため、そもそも破棄される要素は存在しません。

Turbo Streams

Turbo Streamsとはリアルタイムでのデータ更新を簡単にするための機能です。ページに対してDOMの追加・変更・削除などが可能であり、複数箇所を同時に更新することもできます。今回はレシピを新規で保存した際のレシピ数の更新や、フォルダ自体の編集を反映するために使用しました。

レスポンス

Turbo Framesと違い、Turbo Streamsではページ全体ではなく、差分のみをサーバーからレスポンスします。DOMをどのように扱うかについてはTurbo Streamsのアクションによって指示されます。例えばユーザが新規でレシピをフォルダに追加した場合を考えます。この場合は画面の赤枠部分が全て更新されます。

Turbo Streamsによって更新される要素

この時に返されるレスポンスはおおよそ以下のようになります。

<turbo-stream action="replace" target="dom_id">保存ボタンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">ドロップダウンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「すべて」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「保存済み」のHTML</turbo-stream>
<turbo-stream action="prepend" target="dom_id">「きろく」の「新規フォルダ」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">通知のHTML</turbo-stream>

このレスポンスをHotwireランタイムが解釈して画面の更新が行われます。

リダイレクト

サイドバーの同期を実装するにあたって、これまでは画面の更新が不要だったいくつかの既存のリクエストのformatをHTMLからTurbo Streamsに置き換える必要がありました。基本的に問題なく置き換えることが可能でしたが、リダイレクトが必要な場合は少し工夫する必要がありました。

例えばユーザがフォルダのページからそのフォルダを削除した場合を考えます。この場合はサイドバーの「きろく」から対象のフォルダのDOMを削除した上で「きろく」のトップにリダイレクトするという仕様になっています。これはデフォルトのアクションでは対応できないため、以下のようなリダイレクトのためのカスタムアクションを追加することで対応しました。

Turbo.StreamActions.redirect = function () {
  Turbo.visit(this.target)
}

View側では通常のアクションとほとんど同じように呼び出すことが可能です。

<%= turbo_stream.action :redirect, path %>

実際のレスポンスは以下のようになり、Hotwireランタイムで解釈されてリダイレクトが実行されます。

<turbo-stream action="redirect" target="path"></turbo-stream>

カスタムアクションは任意のJavaScriptを簡単に実行できるためかなり便利な機能です。しかし、だからと言ってデフォルトアクションで対処可能な機能に対してカスタムアクションを作成してしまうとコードの一貫性が失われてしまうため注意が必要です。

Stimulus

StimulusとはHTMLとJavaScriptを適切に切り離して書くための枠組みです。これによってHTMLだけを見た時にどんな挙動をするのかわかりやすくしたり、コード自体の再利用性を高めるメリットがあります。CSSがHTMLのclass属性を介して紐づいているように、StimulusではHTMLのデータ属性を介して任意のJavaScriptによる操作を可能にします。このJavaScriptはcontrollerという単位で分けられており、controllerのメソッドをDOMのライフサイクルやイベントをフックに実行するというのが主な機能です。これによって再利用しやすいJavaScriptになるような仕組みになっています。今回はページネーションのための無限スクロールとアクティブ状態の更新のために使われました。

無限スクロール

Turbo Framesのセクションでフォルダのページネーションについて解説しましたが、より使いやすくするために無限スクロールに対応します。グローバル版には元々無限スクロール用のcontrollerが実装されていたためこれをそのまま使用しました。実装としてはシンプルで、スクロールイベントを監視し閾値を超えたら次のページを非同期通信で読み込むというものです。Stimulusのtargetsとvaluesの機能を使ってリストの中身はどのDOMなのか、次のページのURLは何なのかといった情報を管理しています。

なお、Hotwireにおける無限スクロールの実装としてTurbo Framesの遅延読み込みを活用したものもあります。この場合は自分ではJavaScriptを一切書かずに無限スクロールの実装が可能です。ただし、単純なページネーションにcontrollerを登録だけすれば済むStimulusと比較すると、Viewに少し手を入れる必要がある点には注意が必要です。今回は便利に使える既存実装があったのでTurbo Framesによる無限スクロールをあえて採用するようなことはしませんでした。

アクティブ状態の更新

Turbo Framesのセクションで説明したように、サイドバーは画面遷移によるリセットを回避するためにdata-turbo-permanent属性が指定されています。しかし、これによって現在のページに応じて「きろく」のリンクのアクティブ状態を更新する機能が壊れてしまいました。これに対処するにはJavaScriptによってページ遷移を検出してアクティブ状態を更新する必要があります。作成したcontrollerの中身自体は単純なものですが、メソッドを呼び出すためのイベントの指定だけ少し特殊になっています。Stimulusではdata-action属性を使ってどのイベントにフックしてメソッドを実行するか指定することができます。このとき、基本的には属性が指定されたDOMに対するイベントを参照しますが、ページ遷移のようなグローバルのイベントフックしたい場合は@documentのようなsuffixを指定することで対応できます*3

<div data-action="turbo:visit@document->controller#method"></div>

まとめ

Hotwireを活用したちょっと複雑なサイドバーの実装についてご紹介しました。Hotwireの仕組みを利用することでインタラクティブなUIのためのJavaScriptをほとんど書かずに主要な機能の実装ができたかと思います。個人的には元々はNext.jsを書いていたこともありJavaScriptは好きですが、Railsを書く上でHotwireはかなり良くできた仕組みだと感じています。Hotwireはまだ使い始めたばかりの技術ですので、新しい知見が溜まったらまた共有したいと思います。

Simple Custom Compose Layout

こんにちは、「ウィリアム」です。クックパッドのAndroidエンジニアです。 私の日本語はまだ上手ではないので、これから英語で書きます!

Self-introduction (自己紹介)

Hi, my name is William, I'm an Android Engineer from the Cookpad's recipe team. I was originally in the global recipe team in Bristol, Uk, but I'm rehired to join the recipe team in Japan instead last year 2023. It is my first time ever in my life to write a techblog, please forgive me for the messy and unorganised structures, よろしくお願いします!

Statement

If you have been reading our techblog recently, you must be aware of what is currently going on with Cookpad! If you haven't, then I'll summarise it in a paragraph.

In the past, Cookpad recipe app operated separately in Japan, and rest of the world, Global. But now, we’ve merged into one, and we call it 'One experience.'

It was a long, challenging project, but also an exciting one! Looking back, what followed was the massive backlog of tasks created by the merger. There were so many things we wanted to accomplish, but we were often unable to due to time constraints, shifting priorities, or the limitations of the existing legacy architecture.

One day, if I still remember what it was all about, I’ll try to write about them. For now, I'll just write about something we face on a daily basis instead!

Recent Challenges We Faced in Android Development

Have you ever received a UI/UX requirement that isn't natively supported out of the box? One such requirement we received recently is:

Hide the ingredients section if there's not enough vertical space in the recipe card to prevent the cards in the list from expanding due to a long title or smaller screen sizes, which could result in an inconsistent UI.

Recipe Card

Above is an existing Compose component that we have currently in Your Collection tab screen (also known as きろく tab screen when you switch to JP region in the app). We have a different version of this component depending on the recipe type but ultimately they all have the same content:

Recipe card components

  1. Recipe title
  2. Recipe ingredient list
  3. Recipe author information
  4. Recipe actionable buttons
  5. Recipe image with「公開済み」label, this label appears when it is a published recipe belonging to the user.

The Problem

When there's not enough space

As you can see, the ingredient list section overlaps with the author information section when the recipe title spans two lines. This happens for a few reasons.

One of them is that the recipe image needs to be in a 3:4 aspect ratio; another is that the actionable buttons need to be positioned at the bottom of the card; and, additionally, the recipe height is now set to 160.dp.

So, there’s a Modifier.weight(1f) and a Modifier.height(160.dp) applied somewhere...

This will be more likely to happen when the user enlarges their device's default font size or screen size, which will cause the ingredient list (2) to overlap with other components if there isn’t enough space for it.

How to fix it

Now we need to make the ingredient list (2) disappear if there isn’t enough space for it, let's dissect the component!

Normal recipe card component

From the figure above, we identify the three components of the recipe card: the top, bottom, and optional center (ignoring the recipe image).

Logically, after the top and bottom are drawn, we need to calculate the remaining vertical space and then determine the required height to display the ingredient list.

After researching it for a while, it was actually easier than I originally thought. It's quite straight forward after going through Android documentation here developer.android.com

It's time for the classic hello world testing

I decided to try using the Layout Compose component to solve the problem above. Below is the composable preview of hello world test I used to experiment with the composable.

Layout(
    modifier = modifier,
    content = {
        Text("hello world")
    },
) { measurables, constraints ->
    val placables = measurables.map { it.measure(constraints) }
    val text = placables[0]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        text.placeRelative(0, 0)
    }
}

val placables is a list of components that need to be placed in the layout, determined by what we pass into the content parameter above. Another example will be:

Layout(
    modifier = modifier,
    content = {
        Text("hello world")
        Text("goodbye world")
    },
) { measurables, constraints ->
    val placables = measurables.map { it.measure(constraints) }
    val helloworld = placables[0]
    val goodbyeworld = placables[1]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        helloworld.placeRelative(0, 0)
        goodbyeworld.placeRelative(0, helloworld.height)
    }
}

Now, val placables is a list containing the Text("hello world") node and the Text("goodbye world") node.

When using .placeRelative(x, y) to position the node in the layout, the coordinates are relative to the current layout's (0, 0) point, which starts at the top-start corner on LTR devices and the top-end corner on RTL devices.

Alternatively, you can use .place(x, y) to position the node, but note that .place(x, y) ignores the RTL context, so it will always position the node relative to the top-left corner, regardless of whether the configuration is RTL or not.

Implementation

So, after familiarising ourselves with Layout, we can now start implementing the custom composable. Since we identified that there are three parts in the composable, we call them top, center, and bottom:

Layout(
    modifier = modifier,
    content = {
        Column {
            top() // The top part will consist of the recipe title.
        }

        Column {
            center() The center will contain the ingredient list.
        }

        Column {
            bottom() // The bottom part will include the author information and the actionable buttons.
        }
    },
) { measurables, constraints -> {
   ...
}

I wrapped them in Column as it's a column design and I can define the custom view composable parameter as ColumnScope.() -> Unit.

The next step is to place them in the layout

Layout(
    modifier = modifier,
    content = {...},
) { measurables, constraints -> {
    val placables = measurables.map { it.measure(constraints) }
    val topPlacable = placables[0]
    val centerPlacable = placables[1]
    val bottomPlacable = placables[2]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        topPlacable.place(0, 0)
        bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height)
    }
}

The code above will position the top component at the top-start and the bottom component at the bottom-start. Once the top and bottom components are in place, we will need to measure the available vertical space to determine if there is enough room to place the center component, which is the ingredient list.

Layout(
    modifier = modifier,
    content = {...},
) { measurables, constraints -> {
    val placables = measurables.map { it.measure(constraints) }
    val topPlacable = placables[0]
    val centerPlacable = placables[1]
    val bottomPlacable = placables[2]

    layout(width = constraints.maxWidth, height = constraints.maxHeight) {
        topPlacable.place(0, 0)
        bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height)

        val availableVerticalHeight = constraints.maxHeight - topPlacable.measuredHeight - bottomPlacable.measuredHeight
        if (centerPlacable.measuredHeight <= availableVerticalHeight) {
            centerPlacable.place(0, topPlacable.measuredHeight)
        }
    }
}

The steps to calculate the available height are as follows:

  1. First, we get the maximum height of the parent layout.
  2. Using the parent layout's height, we subtract the measured height of the top component and the measured height of the bottom component.
  3. With the remaining vertical height, we compare it to the center component's measured height and determine if there's enough space for it.
  4. If there is enough space for the center component, we place it below the top component. Otherwise, we do nothing.

With that in place, we can now use it in the recipe card composable and check if it works! The results are as follows as we tweak the device's font size and screen size:

Yay! It works, and now it's in production! It seems to be fine, as there haven't been any crash logs related to it, so that's good news... so far.

Below is the actual display of the custom view in my device after changing the default system font size from x1.0 to x1.3.

How it looks like in my Samsung S23 Ultra device with system font size enlarged by x1.3

If the top component or the bottom component takes up more vertical space than is available, they will overlap. However, this depends on the specific feature using the layout. In our current use case, this is acceptable, since the top component is simply a recipe title with a maximum of two lines, and the bottom component is just author information (with a maximum of one line) and actionable buttons with fixed sizes. Therefore, we don't need to handle this edge case at this time, according to the YAGNI (You Aren't Gonna Need It) principle.

The final code

@Composable
fun ResponsiveColumn(
    top: @Composable ColumnScope.() -> Unit,
    center: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier,
    bottom: @Composable ColumnScope.() -> Unit
) {
    Layout(
        modifier = modifier,
        content = {
            Column {
                top()
            }

            Column {
                center()
            }

            Column {
                bottom()
            }
        },
    ) { measurables, constraints ->
        val placables = measurables.map { it.measure(constraints) }
        val topPlacable = placables[0]
        val centerPlacable = placables[1]
        val bottomPlacable = placables[2]

        layout(width = constraints.maxWidth, height = constraints.maxHeight) {
            topPlacable.placeRelative(0, 0)
            bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height)

            val availableVerticalHeight = constraints.maxHeight - topPlacable.measuredHeight - bottomPlacable.measuredHeight
            if (centerPlacable.measuredHeight <= availableVerticalHeight) {
                centerPlacable.placeRelative(0, topPlacable.measuredHeight)
            }
        }
    }
}

Closing statement

Building your custom Composable layout is actually quite straightforward and fun, although I deliberately left out some explanation in the post such as the layout's measurables and constraints.. please do read the Android documentation for their explanation! You can also check out in the code too if you prefers that.

There's also another use case for a similar technique inside Modifier.layout {} when implementing a horizontally-scrolling, full-width carousel in a lazy grid layout with content padding. This ensures that the carousel can scroll past the content padding. I’ll write about that in the future if I have the opportunity to do so.

If you're interested in the recipe I used in this post, feel free to take a look at the link provided below! Although it’s in English, it’s quite simple and straightforward. It took me a lot of trial and error to get the taste as close as possible to the one I had in Sweden when I was working there.

cookpad.com

* Clicking the link should open your Cookpad app if it's installed, it should work even regardless of which Play Store / App Store you installed it from ^^

I hope to improve my writing and provide more interesting use cases in the future. Thank you very much for reading!