✍️

個人開発のCompose MultiplatformのUIをMaterial3 から Unstyledに移行した話

に公開

はじめに

個人開発しているKotlin Multiplatformで作成したアプリで、Material3からCompose Unstyledへの移行を行いました。
約15画面のアプリをClaude Codeを活用しながら約12時間(3日間)で移行を完了させました。

Material3で感じていた課題

Material3で独自のデザインを実現しようとすると、以下の課題がありました。

デザインの自由度の制限

Material3は「Material Design」という明確なデザイン言語に基づいているため、それ以外のデザインを実現しようとするとカスタマイズが困難でした。特に、独自のカラーパレットやデザイントークンを適用する際に制約を感じていました。

カラー・テーマの制約

Material3のcolorSchemeは事前定義されたロールベースの色指定(primarysecondaryなど)に縛られます。独自のセマンティックカラー(例:successwarninginfo)を追加したい場合、Material3の仕組みに乗せるのが難しく、結局別の方法で管理する必要がありました。

Compose Unstyledとは

Compose Unstyledは、スタイルを持たない「headless」なUIコンポーネントを提供するライブラリです。

主な特徴:

  • スタイルなしのコンポーネント: アクセシビリティとインタラクションのロジックのみを提供
  • トークンベースのテーマシステム: 独自のデザイントークンを定義して使用可能
  • ミニマル: 不要なスタイルやデフォルト値を含まない

Web開発で言うところのRadix UIやHeadless UIに近い考え方です。

移行で必要だった対応

自前で用意が必要なコンポーネント

Compose Unstyledには含まれていないコンポーネントがあり、自前で実装する必要がありました。

  • Scaffold: レイアウトの骨格を自分で組む
  • DatePicker / TimePicker: Material3にあった日付・時間選択UIは自前で実装

これらは「大変」というよりは「工夫が必要」という程度でした。例えば、Scaffoldは自前で実装する必要がありますが、基本的なレイアウトコンポーネントを組み合わせることで実現できます。
あるいは、DatePickerやTimePickerについてはMaterial3と併用するのも選択肢です。

暗黙的な設定の再設定

Material3のコンポーネントには暗黙的にデフォルトのcolor、paddingなどが設定されていました。Compose Unstyledでは全て明示的に指定する必要があります。

暗黙的な設定がなくなる分、何が適用されているのか明確になるので、個人的には好都合でした。

AIエージェントの活用

Material3からCompose Unstyledへの移行は、Claude CodeなどのAIエージェントを活用することで効率化できます。

今回の移行では、以下のような作業をClaude Codeに任せました:

  • コンポーネントの書き換え: Material3のコンポーネントをCompose Unstyledのコンポーネントに置き換え
  • デザイントークンの適用: ハードコードされた値をトークンベースに変換
  • スタイルの明示化: 暗黙的な設定を明示的なModifierに書き換え
  • 色の設定漏れの修正: 色が設定されていないTextコンポーネントを探し出して適切な色を当てる

特に、「色が設定されていないTextを全て探して修正する」のような、パターンが決まっているが手作業では見落としやすい作業で活躍してくれました。約15画面のアプリを約12時間で移行できたのは、Claude Codeのおかげです。

デザイントークンの活用

Compose Unstyledのテーマシステムでは、ThemePropertyThemeTokenを使って独自のデザイントークンを定義できます。

トークンの定義

// テーマプロパティの定義
val colors = ThemeProperty<Color>("colors")
val spacing = ThemeProperty<Dp>("spacing")
val radius = ThemeProperty<Dp>("radius")
val textStyle = ThemeProperty<TextStyle>("textStyle")

// トークンの例
val primary = ThemeToken<Color>("primary")
val success = ThemeToken<Color>("success")  // Material3にはないセマンティックカラー
val spacingMedium = ThemeToken<Dp>("medium")
val radiusMedium = ThemeToken<Dp>("medium")
val textStyleBody = ThemeToken<TextStyle>("body")
// ... 他のトークンも同様に定義

テーマの構築

val MyLightTheme = buildTheme {
    name = "MyLight"

    properties[colors] = mapOf(
        primary to Color(0xFF7C9A92),
        surface to Color(0xFFFFFBFE),
        success to Color(0xFF4CAF50),  // Material3にないカラーも定義可能
        // ...
    )

    properties[spacing] = mapOf(
        spacingMedium to 16.dp,
        // ...
    )

    properties[radius] = mapOf(
        radiusMedium to 8.dp,
        // ...
    )

    properties[textStyle] = mapOf(
        textStyleBody to TextStyle(fontSize = 14.sp, lineHeight = 20.sp),
        // ...
    )
}

使用方法

@Composable
fun ProfileCard() {
    Column(
        modifier = Modifier
            .background(
                color = Theme[colors][surface],
                shape = RoundedCornerShape(Theme[radius][radiusMedium])
            )
            .padding(Theme[spacing][spacingMedium])
    ) {
        Text(
            text = "User Name",
            style = Theme[textStyle][textStyleHeadline],
            color = Theme[colors][onSurface]
        )
        Spacer(modifier = Modifier.height(Theme[spacing][spacingSmall]))
        Text(
            text = "user@example.com",
            style = Theme[textStyle][textStyleBody],
            color = Theme[colors][onSurfaceVariant]
        )
    }
}

Before / After

Before(Material3)

Button(
    onClick = { /* ... */ },
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.primary
    )
) {
    Text("Click me")
}

// セマンティックカラー(success等)は別途管理が必要
val successColor = Color(0xFF4CAF50)

After(Compose Unstyled)

// 全て明示的に指定
Button(
    onClick = { /* ... */ },
    background = Theme[colors][primary],
    contentColor = Theme[colors][onPrimary],
    shape = RoundedCornerShape(Theme[radius][radiusMedium])
) {
    Text("Click me")
}

// セマンティックカラーもトークンとして一元管理
Icon(tint = Theme[colors][success])

移行後のメリット

1. カスタマイズの自由度

Material Designの制約から解放され、独自のデザインを実現できるようになりました。「Material Design風だけどちょっと違う」という中途半端な状態から脱却できます。

2. デザイン意図の明確化

独自のデザイントークンを定義することで、「なぜこの値なのか」が明確になります。

// Before: マジックナンバー
Modifier.padding(16.dp)

// After: 意図が明確
Modifier.padding(Theme[spacing][spacingMedium])

チームでの開発や、将来自分がコードを見返したときにも理解しやすくなります。

3. 一貫性の担保

トークンベースで値を管理することで、アプリ全体でデザインの一貫性を保ちやすくなりました。値を変更したい場合も、トークンの定義を変えるだけで全体に反映されます。

まとめ

以下のような方におすすめです:

  • 独自のデザインシステムを持っている / 構築したい方
  • Material Design以外のデザイン言語でアプリを作りたい方
  • デザイントークンを使ってデザインと実装の一貫性を保ちたい方

逆に、Material Designに沿ったアプリを作る場合は、Material3をそのまま使う方が効率的です。

Material3のデザイン制約に悩んでいる方は、Compose Unstyledを試してみてください。

さいごに

私が勤めている株式会社スタジアムのFANTSというプロダクトでもここ1年くらいでデザインシステムが新たに構築されて、独自のデザイントークンが活用されるようになってきました。
今回の個人開発で試してみて、実務でもMaterial3からCompose Unstyledへの移行を検討する価値はありそうだと感じています。

参考リンク

株式会社スタジアム

Discussion