inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

Jetpack Composeにおける “Slot-based layouts” の柔軟性と制約

こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。

Jetpack Compose で UI を構築する際、 “Slot-based layout” は Composable の柔軟性を担保する強力なパターンです。以下に示す TopAppBar (Material Components) の title navigationIcon actions のように引数に Composable ラムダを受け取ることで、スタイルの自由度を高めつつ、関数のオーバーロードを削減することができます。

@Composable
fun TopAppBar(
    title: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    navigationIcon: @Composable () -> Unit = {},
    actions: @Composable RowScope.() -> Unit = {},
) { ... }

このエントリーでは Slot-based layout によってもたらされる柔軟性と制約を触れていきつつ、発展的なトピックとしてサービス独自のデザインシステムにおける向き合い方も考えていきます。

Slot-based layoutとその利点

前述の通り、 Jetpack Compose では Slot-based layout は、Composable の上にカスタマイズの層をもたらすために導入されたパターンです。

developer.android.com

その利点については冒頭でも軽く言及しましたが、改めて整理すると以下の2点が挙げられます。

  • カスタマイズ性の向上 : 子コンポーネントの構成に必要なパラメータを細かく受け取ることなく、子コンポーネントそのものを受け取ることでUIを構成できる。APIの利用者は自由度高く子コンポーネントを入れ込めるため、コンポーネントのカスタマイズ性も担保される。
  • APIの簡素化 : 子コンポーネントの構成用に細かくパラメータ受け取る必要がないため、Composable 関数の引数を簡素に留められる。その結果として、関数のオーバーロードを削減することができる。

公式で提供されている “API Guidelines for @Composable components in Jetpack Compose” のガイドラインではボタンの Composable を題材に、Slot-based layout を用いない場合に失われる柔軟性について詳しく記述しています。具体的に生じうる制約の具体例が記されていることから、 Slot-based layout の利点をより捉えやすいかもしれません。

/*
 * Slot-based layout を用いない場合
 */
@Composable
fun Button(
    onClick: () -> Unit,
    text: String? = null,
    icon: ImageBitmap? = null
) { ... }

// 💣 "オーバーロードの爆発"の例
// アイコンとして ImageBitmap と VectorPainter の両方を受け入れる場合は新たな関数が必要となる
@Composable
fun Button(
    onClick: () -> Unit,
    text: String? = null,
    icon: VectorPainter? = null
) { ... }
/*
 * Slot-based layout を用いる場合
 */
@Composable
fun Button(
    onClick: () -> Unit,
    text: @Composable () -> Unit,
    icon: @Composable () -> Unit
) { ... }

(「Slot parameters - Why slots」の節より抜粋し要約)

  • スタイリングの選択肢の制限 : String を引数に受け取ることから、 AnnotatedString や他のソースを使用することを禁止される。スタイリングを提供するために TextStyle パラメータも受け取ることとなれば、APIの肥大化へつながる。
  • コンポーネントの選択肢の制限 : ユーザーが独自の MyTextWithLogging コンポーネントを使用してログ計測などの追加ロジックを実行したいと思った場合も、 Button をフォークしない限り不可能となる。
  • オーバーロードの爆発 : アイコンとして ImageBitmap と VectorPainter の両方を受け入れるなど、柔軟性を持たせたい場合はそのためのオーバーロードを提供しなければならない。このようなパラメータの数だけオーバーロードを用意する必要が生じる。
  • レイアウト機能の制限 : ユーザーがアイコンの配置をカスタマイズ(例 : ボタンの垂直方向のアラインの変更 / パディングの追加)したい場合も行えない。

Slot-based layoutは使用法が少し冗長になる

“API Guidelines for @Composable components in Jetpack Compose” では Slot-based layout を用いない場合に「スタイリングの選択肢の制限」「コンポーネントの選択肢の制限」「オーバーロードの爆発」「レイアウト機能の制限」の4つの観点で柔軟性が失われることを記述していました。

一方でガイドライン上には “単純な使用法が少し冗長になる” というデメリットについても触れられています。

Slots come with the price of simple usages being a bit more verbose, but this downside disappears quickly as soon as a real-application usage begins.

スロットには単純な使用法が少し冗長になるという代償が伴いますが、実際のアプリケーションでの使用が始まるとすぐにこの欠点は見えなくなります。

Slot-based layout を用いない場合 / 用いる場合でAPIの使用者側のコードは以下のようになります。開発時においてその欠点がどれほど表層化するかは一旦置いておいて、柔軟性が担保された分だけAPIの使用者側のコード量が増えているのは確かです。

// Slot-based layout を用いない場合
Button(
  onClick = onClick,
  text = "Label",
  icon = iconImageBitmap,
)

// Slot-based layout を用いる場合
Button(
  onClick = onClick,
  text = {
    Text(text = "Label")
  },
  icon = {
    Image(
      bitmap = iconImageBitmap,
      contentDescription = "Description",
    )
  },
)

デザインシステムとSlot-based layouts

サービス独自に構成されたデザインシステムが存在する場合、それに基づくUIコンポーネント群を実装して各画面で用いることが多いでしょう。B/43 においてもトークンレベルからシステマチックに整備されたデザインシステムがあり、それに倣う形でUIコンポーネント群を実装しています。

blog.smartbank.co.jp

ここからは少し話を転換して、サービス独自のUIコンポーネント群における Slot-based layouts の活用についても考えていきます。

前提として、このようなクローズドなデザインシステムを構成するUIコンポーネントは、広く多様なプロダクトに適用される Material Components のようなライブラリとは要求される柔軟性のレベルが異なります。子コンポーネントになりうる要素の種類やサイズ、テキストに施されうる装飾などなど… “サービス” という狭いスコープで構成されたデザインシステムだからこそ、より強い制約が多く存在しうることでしょう。

Slot-based layouts をリストアイテムに適用する

一例としてB/43のデザインシステムに存在するリストアイテムのUIコンポーネントを用いて考えていきます。これを Slot-based layouts のパターンに倣って実装すると以下のようになるでしょう。

@Composable
fun ListItem(
  body: @Composable () -> Unit,
  modifiler: Modifier = Modifier,
  leading: @Composable (() -> Unit)? = null,
  trailing: @Composable (() -> Unit)? = null,
) { ... }

オーバーロードした関数を別途用意しない場合、 Composable の使用者側の実装は以下のようになります。 “API Guidelines for @Composable components in Jetpack Compose” で触れられている通り、柔軟性が担保された分だけ単純な使用法でも少し冗長になっています。

ListItem(
  body = {
    Column {
      Text(
          text = "Title",
          // `B43Theme` はサービス独自のデザイントークンなどの定義を集約したオブジェクト
          style = B43Theme.typography.bodyMedium,
          color = B43Theme.color.textPrimary,
      )
      Text(
          text = "Description",
          style = B43Theme.typography.bodySmall,
          color = B43Theme.color.textSecondary,
      )
    }
  },
  leading = {
    // 背景色付きのアイコンに用いるサービス独自のUIコンポーネント
    IconThumbnail(
      icon = B43Icons.B43Card,
      contentDescription = null,
      size = ThumbnailSize.Small,
      background = IconThumbnailBackground.Accent,
    )
  },
)

極端な例ではありますが、このような Composable はコード量だけではなく、使用者に要求されるデザインシステムへの知識量を増大させます。例えば 「リストアイテムで IconThumbnail コンポーネントを用いる場合、必ずサイズは ThumbnailSize.Small (40dp四方) になる」といったような暗黙的な規則を使用者側が理解していなければなりません。

デザインシステムの制約を Slot-based layouts にもたらすアプローチ

使用者に要求されるデザインシステムへの知識量を最小限にするには、以下のようにいくつかのアプローチが考えられます。柔軟性と制約のトレードオフとそれぞれのアプローチ固有のデメリットを評価した上で用いることが望ましいでしょう。

① オーバーロードしたComposable関数を適切な粒度で用意する

“オーバーロードの爆発を防げる” という Slot-based layouts の利点を打ち消すこととなりますが、設計を模索する初手のプロセスとしては有用です。リストアイテムのように多様な子コンポーネントが存在すると難しいですが、比較的シンプルなコンポーネントにおいては十分な対応策となることもあります。

@Composable
fun ListItem(
  icon: Icon,
  title: String,
  description: String,
  modifier: Modifier = Modifier,
  trailing: @Composable (() -> Unit)? = null,
) {
  ListItem(
    body = { ... },
    modifier = modifier,
    leading = { ... },
    trailing = { ... }
  )
}

@Composable
private fun ListItem(
  body: @Composable () -> Unit,
  modifier: Modifier = Modifier,
  leading: @Composable (() -> Unit)? = null,
  trailing: @Composable (() -> Unit)? = null,
) { ... }

② CompositionLocal を用いてデフォルト値を指定する

CompositionLocal を用いて子コンポーネントに対する制約やスタイリングを隠蔽することもできます。しかし、多様な子コンポーネントが存在する場合に CompositionLocal の定義が増大しうることは大きなデメリットです。

CompositionLocal の多用はデータフローの追跡を困難とさせるので、UIコンポーネントの使用者にとって意図しない振る舞いを招く可能性もあります。LocalTextStyle など、フレームワークや Material Components であらかじめ用意されている定義を最小限で用いる程度に留めておくのがよい印象を持っています。

@Composable
fun ListItem(
  body: @Composable () -> Unit,
  modifier: Modifier = Modifier,
  leading: @Composable (() -> Unit)? = null,
  trailing: @Composable (() -> Unit)? = null,
) {
  Row(
    modifier = modifier.padding(horizontal = 16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp),
  ) {
    CompositionLocalProvider(LocalThumbnailSize provides ThumbnailSize.Small) {
      leading?()
    }

    ...
  }
}

③ 子コンポーネントとしての使用を意図した Composable を別途用意する

子コンポーネントとしての使用を意図した Composable を用意するアプローチも存在します。詳細なコンポーネントの仕様を知る必要性が薄まるのは利点ですが、別途用意した Composable の存在を使用者が認知する必要が生じるのはデメリットです。

以下の例においては、ListItem のスロット内において IconThumbnail の使用を禁止し、 ListItemLeadingIconThumbnail の使用を強制することはできません。逆に ListItemLeadingIconThumbnail の ListItem 以外での使用を防ぐこともできません。

/**
 * ListItem コンポーネントの `leading` のスロットでの使用を意図した `IconThumbnail` をラップする実装
 */
@Composable
fun ListItemLeadingIconThumbnail(
  icon: B43Icon,
  contentDescription: String?,
) {
  IconThumbnail(
    icon = icon,
    contentDescription = contentDescription,
    size = ThumbnailSize.Small,
    background = IconThumbnailBackground.Accent,
  )
}

④ DSL-based slots

ここまで、UIコンポーネントに制約をもたらすアプローチについて述べてきましたが、最後に “DSL-based slots” と称される手法についても触れておきます。これはスコープを用いて定義することで、子コンポーネントが意図しない箇所で使用されることを防ぐアプローチです。

@Stable
object ListItemLeadingScope {
  @Composable
  fun LeadingIconThumbnail(
    icon: B43Icon,
    contentDescription: String? = null,
  ) {
    IconThumbnail(
      icon = icon,
      contentDescription = contentDescription,
      size = ThumbnailSize.Small,
      background = IconThumbnailBackground.Accent,
    )
  }
}

@Composable
fun ListItem(
  body: @Composable () -> Unit,
  modifier: Modifier = Modifier,
  leading: @Composable (ListItemLeadingScope.() -> Unit)? = null,
  trailing: (ListItemTrailingScope.() -> Unit)? = null,
) { ... }
ListItem(
  body = { ... },
  leading = {
      LeadingIconThumbnail(
        icon = B43Icons.B43Card,
        contentDescription = null,
      )
  },
)

この手法についてもスコープを区切って用意した Composable の存在を使用者が認知している必要が生じます。また、スロット内において他の Composable の使用を禁止できないのは ③ のアプローチと同様です。

“API Guidelines for @Composable components in Jetpack Compose” においては可能な限りDSLベースのスロットやAPIを避け、単純な Composableラムダを優先することが望ましい旨が述べられています。

Avoid DSL based slots and APIs where possible and prefer simple slot @Composable lambdas. While giving the developers control over what the user might place in the particular slot, DSL API still restricts the choice of component and layout capabilities. Moreover, the DSL introduces the new API overhead for users to learn and for developers to support.

可能な限り DSL ベースのスロットと API を避け、単純な @Composable ラムダを優先してください。特定のスロットに何を配置すべきかを制御する一方で、DSL API はコンポーネントとレイアウト機能の選択を制限します。さらに、DSL では開発者が新しいAPIに対する学習やサポートが必要となるというオーバーヘッドが存在します。

Slot-based layouts をどこまで活用すべきか

「サービス独自のUIコンポーネント群において Slot-based layouts を活用すべきか?」という問いに対して絶対的な答えは存在しないでしょう。上述した制約をもたらすアプローチについても、どれを選択すべきかはUIコンポーネントごとの都合によっても大きく変わりえます。

しかし、 Slot-based layouts の "柔軟性" と "制約の強さ" のトレードオフにおいて、サービス独自のデザインシステムの存在が後者の重要性を相対的に高めることは確かです。

Slot-based layouts 制約をもたらす上述のアプローチ
(関数のオーバーロード / DSL-based slots など)
👍 UIコンポーネントの実装量を削減できる ❌ UIコンポーネントの実装量が膨らむ
👍 実装都合に応じて子コンポーネントの使用やスタイリングの柔軟な適用が可能になる ❌ 実装都合に応じて子コンポーネントの使用やスタイリングの柔軟な適用が難しい
❌ 使用者がデザインシステムによって規定される制約を十分に理解する必要がある 👍 使用者がデザインシステムによって規定される制約を理解する必要性が薄まる

私自身はUIコンポーネントの実装の際には、以下の流れで実装を試行する場面が多いです。Slotベースの Composable をそのまま各画面の実装で直接用いることを最後まで避けていますが、これはデザインシステムの制約を実装にもたらすことに重きを置いたことによる思考です。

💭 Off Topic : Figma と Slot-based layouts

UIコンポーネント群のデザインをFigmaで行っている例も多いことと思いますが、Figmaでは Slot-based layouts と同等の柔軟性を担保したコンポーネントを作成できません。以下の動画では “Scoped slot components” と称して柔軟性を持ったコンポーネントの作成方法について触れていますが、あくまでもスロットに入りうる子コンポーネントは網羅的に定義する必要があります。

www.youtube.com

B/43のデザインシステムのリストアイテムにおいても、スロットに入りうる子コンポーネントは網羅的に定義されています。例えば、リストアイテムの右端のスロットに入りうる要素は以下の図で示す9種類のコンポーネントに限定されています。

💭 Off Topic : iOS版B/43におけるリストアイテムの実装

iOS版B/43のリストアイテムは、Figma上でのコンポーネントの設計をかなり愚直に落とし込んだ実装になっています。SwiftUIにおいては enum をUIコンポーネント(= SwiftUI.View に準拠)として扱うことができるため、スロットに入りうる要素をすべて列挙した定義を用意しています。

public struct ListItem: View {
  private let leading: ListItemLeading
  private let body: ListItemBody
  private let trailing: ListItemTrailing?
  
  public var body: some View {
    ...
  }
}

...

/// リストアイテム右端に表示される子コンポーネントを列挙した定義
public enum ListItemTrailing: View {
  case label(String)
  case amountLabel(Int)
  case activityIndicator
  ...

  public var body: some View {
    switch self {
      ...
    }    
  }
}

UIコンポーネント側の実装の煩雑さは増しますが、使用者側のコード量や知識量を最小限で留めることができます。例えば、簡単なアイコンと金額のラベルを左右端に持つリストアイテムの実装は以下のようになります。

ListItem(
  leading: .iconThumbnail(.b43Card),
  body: .init(title: "Title", description: "Description"),
  trailing: .amountlabel(1288)
)

まとめ

このエントリーでは Jetpack Compose における Slot-based layout の柔軟性と制約について触れてきました。また、サービス独自のデザインシステムを踏まえた向き合い方をリストアイテムでの一例を示しつつ紹介してきました。

Slot-based layout は Composable の柔軟性を担保する強力なパターンですが、使用法が冗長になるというデメリットも持っています。サービス独自のデザインシステムが持つ制約の強さによっては、使用者に要求される知識量が増大しうる可能性も示しました。

その時々において、 Composable の柔軟性あるいは制約がいかほど重視されるのか、トレードオフを適切に評価して Slot-based layout の活用を考えていきたいです。


スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください 🙌

smartbank.co.jp

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.