Compose のカスタム デザイン システム

マテリアルは Google が推奨するデザイン システムであり、Jetpack Compose にはマテリアルの実装が同梱されていますが、これを使用するように強制されているわけではありません。マテリアルは完全に公開 API に基づいて構築されているため、同じ手法で独自のデザイン システムを作成できます。

独自のデザイン システムは、以下の複数のアプローチで作成できます。

カスタム デザイン システムでも引き続きマテリアル コンポーネントを使用することもできます。この方法は可能ですが、採用したアプローチに合わせて留意すべき点があります。

MaterialTheme およびカスタム デザイン システムで使用される下位の構造と API の詳細については、Compose のテーマの構造ガイドをご覧ください。

マテリアル テーマを拡張する

Compose Material は、マテリアル テーマ設定を綿密にモデル化し、マテリアル ガイドラインに沿った、シンプルかつタイプセーフなものにします。ただし、色、タイポグラフィ、シェイプのセットに値を追加して拡張することは可能です。

最も簡単なアプローチは、拡張プロパティを追加する方法です。

// Use with MaterialTheme.colorScheme.snackbarAction
val ColorScheme.snackbarAction: Color
    @Composable
    get() = if (isSystemInDarkTheme()) Red300 else Red700

// Use with MaterialTheme.typography.textFieldInput
val Typography.textFieldInput: TextStyle
    get() = TextStyle(/* ... */)

// Use with MaterialTheme.shapes.card
val Shapes.card: Shape
    get() = RoundedCornerShape(size = 20.dp)

これにより、MaterialTheme を使用する API との整合性が確保されます。Compose 自体によって定義される拡張プロパティの例として、surfaceColorAtElevation があります。これは、エレベーションに応じて使用するサーフェス カラーを決定します。

もう一つのアプローチは、MaterialTheme とその値を「ラップ」する拡張テーマを定義することです。

既存のマテリアル カラーを維持しながら、cautiononCaution の 2 つのカラー(半危険なアクションに使用される黄色)を追加する場合は、以下のようになります。

@Immutable
data class ExtendedColors(
    val caution: Color,
    val onCaution: Color
)

val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(
        caution = Color.Unspecified,
        onCaution = Color.Unspecified
    )
}

@Composable
fun ExtendedTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val extendedColors = ExtendedColors(
        caution = Color(0xFFFFCC02),
        onCaution = Color(0xFF2C2D30)
    )
    CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
        MaterialTheme(
            /* colors = ..., typography = ..., shapes = ... */
            content = content
        )
    }
}

// Use with eg. ExtendedTheme.colors.caution
object ExtendedTheme {
    val colors: ExtendedColors
        @Composable
        get() = LocalExtendedColors.current
}

これは、MaterialTheme を使用する API に似ています。また、複数のテーマをサポートしており、MaterialTheme と同じ方法で ExtendedTheme をネストできます。

マテリアル コンポーネントを使用する

マテリアル テーマを拡張する際、既存の MaterialTheme 値は維持され、マテリアル コンポーネントには引き続き適切なデフォルトが適用されます。

拡張した値をコンポーネントで使用する場合は、それらを独自のコンポーズ可能な関数でラップし、変更する値を直接設定します。他の値はパラメータとして、それらを含むコンポーザブルに公開します。

@Composable
fun ExtendedButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = ExtendedTheme.colors.caution,
            contentColor = ExtendedTheme.colors.onCaution
            /* Other colors use values from MaterialTheme */
        ),
        onClick = onClick,
        modifier = modifier,
        content = content
    )
}

その後、必要に応じて Button の使用を ExtendedButton に置き換えます。

@Composable
fun ExtendedApp() {
    ExtendedTheme {
        /*...*/
        ExtendedButton(onClick = { /* ... */ }) {
            /* ... */
        }
    }
}

マテリアル サブシステムを置き換える

マテリアル テーマを拡張する代わりに、1 つ以上のシステム(ColorsTypographyShapes)をカスタム実装に置き換え、他のシステムはそのまま使用することもできます。

カラーシステムはそのまま使用し、タイプシステムやシェイプ システムを置き換えると以下のようになります。

@Immutable
data class ReplacementTypography(
    val body: TextStyle,
    val title: TextStyle
)

@Immutable
data class ReplacementShapes(
    val component: Shape,
    val surface: Shape
)

val LocalReplacementTypography = staticCompositionLocalOf {
    ReplacementTypography(
        body = TextStyle.Default,
        title = TextStyle.Default
    )
}
val LocalReplacementShapes = staticCompositionLocalOf {
    ReplacementShapes(
        component = RoundedCornerShape(ZeroCornerSize),
        surface = RoundedCornerShape(ZeroCornerSize)
    )
}

@Composable
fun ReplacementTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val replacementTypography = ReplacementTypography(
        body = TextStyle(fontSize = 16.sp),
        title = TextStyle(fontSize = 32.sp)
    )
    val replacementShapes = ReplacementShapes(
        component = RoundedCornerShape(percent = 50),
        surface = RoundedCornerShape(size = 40.dp)
    )
    CompositionLocalProvider(
        LocalReplacementTypography provides replacementTypography,
        LocalReplacementShapes provides replacementShapes
    ) {
        MaterialTheme(
            /* colors = ... */
            content = content
        )
    }
}

// Use with eg. ReplacementTheme.typography.body
object ReplacementTheme {
    val typography: ReplacementTypography
        @Composable
        get() = LocalReplacementTypography.current
    val shapes: ReplacementShapes
        @Composable
        get() = LocalReplacementShapes.current
}

マテリアル コンポーネントを使用する

MaterialTheme の 1 つ以上のシステムが置き換えられたときにマテリアル コンポーネントをそのまま使用すると、不要なマテリアルの色、タイプ、またはシェイプの値が返されることがあります。

置換した値をコンポーネントで使用する場合は、それらを独自のコンポーズ可能な関数でラップし、関連するシステムの値を直接設定します。他の値はパラメータとして、それらを含むコンポーザブルに公開します。

@Composable
fun ReplacementButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        shape = ReplacementTheme.shapes.component,
        onClick = onClick,
        modifier = modifier,
        content = {
            ProvideTextStyle(
                value = ReplacementTheme.typography.body
            ) {
                content()
            }
        }
    )
}

その後、必要に応じて Button の使用を ReplacementButton に置き換えます。

@Composable
fun ReplacementApp() {
    ReplacementTheme {
        /*...*/
        ReplacementButton(onClick = { /* ... */ }) {
            /* ... */
        }
    }
}

フルカスタム デザイン システムを実装する

マテリアル テーマをフルカスタム デザイン システムに置き換えることもできます。MaterialTheme が次のシステムを提供しているとします。

  • ColorsTypographyShapes: マテリアル テーマ設定システム
  • TextSelectionColors: TextTextField によるテキスト選択に使用される色
  • RippleRippleTheme: Indication のマテリアル実装

マテリアル コンポーネントを引き続き使用するには、これらのシステムの一部をカスタムテーマまたはテーマで置き換えるか、コンポーネント内でこれらのシステムを処理して、望ましくない動作が発生しないようにする必要があります。

ただし、デザイン システムはマテリアルが依存するコンセプトに限定されません。既存のシステムを変更して、新しいクラスと型を備えたまったく新しいシステムを導入し、他のコンセプトをテーマに対応させることができます。

次のコードでは、グラデーション(List<Color>)を含むカスタム カラーシステムをモデル化し、タイプシステムを含め、新しいエレベーション システムを導入して、MaterialTheme で提供される他のシステムを除外しています。

@Immutable
data class CustomColors(
    val content: Color,
    val component: Color,
    val background: List<Color>
)

@Immutable
data class CustomTypography(
    val body: TextStyle,
    val title: TextStyle
)

@Immutable
data class CustomElevation(
    val default: Dp,
    val pressed: Dp
)

val LocalCustomColors = staticCompositionLocalOf {
    CustomColors(
        content = Color.Unspecified,
        component = Color.Unspecified,
        background = emptyList()
    )
}
val LocalCustomTypography = staticCompositionLocalOf {
    CustomTypography(
        body = TextStyle.Default,
        title = TextStyle.Default
    )
}
val LocalCustomElevation = staticCompositionLocalOf {
    CustomElevation(
        default = Dp.Unspecified,
        pressed = Dp.Unspecified
    )
}

@Composable
fun CustomTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val customColors = CustomColors(
        content = Color(0xFFDD0D3C),
        component = Color(0xFFC20029),
        background = listOf(Color.White, Color(0xFFF8BBD0))
    )
    val customTypography = CustomTypography(
        body = TextStyle(fontSize = 16.sp),
        title = TextStyle(fontSize = 32.sp)
    )
    val customElevation = CustomElevation(
        default = 4.dp,
        pressed = 8.dp
    )
    CompositionLocalProvider(
        LocalCustomColors provides customColors,
        LocalCustomTypography provides customTypography,
        LocalCustomElevation provides customElevation,
        content = content
    )
}

// Use with eg. CustomTheme.elevation.small
object CustomTheme {
    val colors: CustomColors
        @Composable
        get() = LocalCustomColors.current
    val typography: CustomTypography
        @Composable
        get() = LocalCustomTypography.current
    val elevation: CustomElevation
        @Composable
        get() = LocalCustomElevation.current
}

マテリアル コンポーネントを使用する

MaterialTheme が存在しない場合、マテリアル コンポーネントをそのまま使用すると、不要なマテリアルの色、タイプ、シェイプの値および表示動作が発生します。

カスタム値をコンポーネントで使用する場合は、それらを独自のコンポーズ可能な関数でラップし、関連するシステムの値を直接設定します。他の値はパラメータとして、それらを含むコンポーザブルに公開します。

設定した値にはカスタムテーマからアクセスすることをおすすめします。また、テーマが ColorTextStyleShape などのシステムを提供していない場合は、それらをハードコードできます。

@Composable
fun CustomButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = CustomTheme.colors.component,
            contentColor = CustomTheme.colors.content,
            disabledContainerColor = CustomTheme.colors.content
                .copy(alpha = 0.12f)
                .compositeOver(CustomTheme.colors.component),
            disabledContentColor = CustomTheme.colors.content
                .copy(alpha = 0.38f)

        ),
        shape = ButtonShape,
        elevation = ButtonDefaults.elevatedButtonElevation(
            defaultElevation = CustomTheme.elevation.default,
            pressedElevation = CustomTheme.elevation.pressed
            /* disabledElevation = 0.dp */
        ),
        onClick = onClick,
        modifier = modifier,
        content = {
            ProvideTextStyle(
                value = CustomTheme.typography.body
            ) {
                content()
            }
        }
    )
}

val ButtonShape = RoundedCornerShape(percent = 50)

グラデーションを表す新しいクラスタイプ(List<Color> など)を導入した場合は、コンポーネントをラップするのではなく、ゼロから実装することをおすすめします。例として、Jetsnack サンプルの JetsnackButton をご覧ください。