Systèmes de conception personnalisés dans Compose

Bien que Material soit notre système de conception recommandé et que Jetpack Compose comprenne une implémentation de Material, vous n'êtes pas obligé de l'utiliser. Material est entièrement basé sur des API publiques. Il est donc possible de créer votre propre système de conception de la même manière.

Plusieurs approches sont possibles :

Vous pouvez également continuer à utiliser les composants Material avec un système de conception personnalisé. Cela est possible, mais vous devez tenir compte de certains points selon l'approche que vous avez adoptée.

Pour en savoir plus sur les constructions de niveau inférieur et les API utilisées par MaterialTheme et les systèmes de conception personnalisés, consultez le guide Anatomie d'un thème dans Compose.

Extension de la thématisation Material

Compose Material modélise étroitement la thématisation Material afin que les consignes de Material soient simples et sûres à suivre. Cependant, il est possible d'étendre les ensembles de couleurs, de typographie et de formes avec des valeurs supplémentaires.

L'approche la plus simple consiste à ajouter des propriétés d'extension :

// Use with MaterialTheme.colors.snackbarAction
val Colors.snackbarAction: Color
    get() = if (isLight) 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)

Cela permet d'assurer la cohérence avec les API d'utilisation de MaterialTheme. primarySurface est un exemple défini par Compose, qui joue le rôle de proxy entre primary et surface en fonction de Colors.isLight.

Une autre approche consiste à définir un thème étendu qui "encapsule" MaterialTheme et ses valeurs.

Supposons que vous souhaitiez ajouter deux autres couleurs (tertiary et onTertiary) tout en conservant les couleurs de Material existantes :

@Immutable
data class ExtendedColors(
    val tertiary: Color,
    val onTertiary: Color
)

val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(
        tertiary = Color.Unspecified,
        onTertiary = Color.Unspecified
    )
}

@Composable
fun ExtendedTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val extendedColors = ExtendedColors(
        tertiary = Color(0xFFA8EFF0),
        onTertiary = Color(0xFF002021)
    )
    CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
        MaterialTheme(
            /* colors = ..., typography = ..., shapes = ... */
            content = content
        )
    }
}

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

Cela est semblable aux API d'utilisation de MaterialTheme. Plusieurs thèmes sont également pris en charge, car vous pouvez imbriquer des ExtendedTheme de la même manière que MaterialTheme.

Utilisation des composants Material

Lorsque vous étendez la thématisation Material, les valeurs MaterialTheme existantes sont conservées et les composants Material conservent des valeurs par défaut raisonnables.

Si vous souhaitez utiliser des valeurs étendues dans des composants, encapsulez-les dans vos propres fonctions modulables, en définissant directement les valeurs que vous souhaitez modifier et en exposant les autres en tant que paramètres du composable associé :

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

Vous devez ensuite remplacer les utilisations de Button par ExtendedButton, le cas échéant.

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

Remplacement des systèmes Material

Au lieu d'étendre la thématisation Material, vous pouvez remplacer un ou plusieurs systèmes (Colors, Typography ou Shapes) par une implémentation personnalisée, tout en conservant les autres.

Supposons que vous souhaitiez remplacer les systèmes de type et de forme tout en conservant le système de couleurs :

@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
}

Utilisation des composants Material

Lorsqu'un ou plusieurs systèmes de MaterialTheme ont été remplacés, l'utilisation de composants Material tels quels peut entraîner des valeurs indésirables de couleur, de type ou de forme Material.

Si vous souhaitez utiliser des valeurs de remplacement dans des composants, encapsulez-les dans vos propres fonctions modulables, en définissant directement les valeurs du système concerné et en exposant les autres en tant que paramètres du composable associé.

@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()
            }
        }
    )
}

Vous devez ensuite remplacer les utilisations de Button par ReplacementButton, le cas échéant.

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

Implémentation d'un système de conception entièrement personnalisé

Vous pouvez remplacer la thématisation Material par un système de conception entièrement personnalisé. Notez que MaterialTheme fournit les systèmes suivants :

  • Colors, Typography et Shapes : systèmes de thématisation Material
  • ContentAlpha : niveaux d'opacité pour mettre l'accent sur Text et Icon
  • TextSelectionColors : couleurs utilisées pour la sélection de texte par Text et TextField
  • Ripple et RippleTheme : implémentation de Material de Indication

Si vous souhaitez continuer à utiliser les composants Material, vous devrez remplacer certains de ces systèmes dans vos thèmes personnalisés ou gérer les systèmes dans vos composants pour éviter tout comportement indésirable.

Toutefois, les systèmes de conception ne sont pas limités aux concepts sur lesquels se base Material. Vous pouvez modifier des systèmes existants et en introduire de nouveaux, avec de nouvelles classes et de nouveaux types, afin de rendre d'autres concepts compatibles avec les thèmes.

Dans le code suivant, nous modélisons un système de couleurs personnalisé comprenant des dégradés (List<Color>), intégrons un système de types, ajoutons un nouveau système d'élévation et excluons les autres systèmes fournis par 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
}

Utilisation des composants Material

En l'absence de MaterialTheme, l'utilisation de composants Material tels quels entraîne des valeurs indésirables de couleur, de type et de forme Material, ainsi qu'un comportement d'indication.

Si vous souhaitez utiliser des valeurs personnalisées dans des composants, encapsulez-les dans vos propres fonctions modulables, en définissant directement les valeurs pour le système concerné et en exposant les autres en tant que paramètres du composable associé.

Nous vous recommandons d'accéder aux valeurs que vous avez définies à partir de votre thème personnalisé. Si votre thème ne fournit pas Color, TextStyle, Shape ni d'autres systèmes, vous pouvez également les coder en dur.

@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 = ContentAlpha.disabled)
        ),
        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)

Si vous avez introduit de nouveaux types de classes, tels que List<Color> pour représenter les dégradés, il peut être préférable d'implémenter les composants en repartant de zéro plutôt que de les encapsuler. Pour obtenir un exemple, examinez JetsnackButton dans l'exemple Jetsnack.