Sistemi di progettazione personalizzati in Compose

Anche se Material è il nostro sistema di progettazione consigliato e Jetpack Compose invia un'implementazione di Material, non sei obbligato a utilizzarlo. Material è basato interamente su API pubbliche, quindi è possibile creare il tuo sistema di design nello stesso modo.

Puoi adottare diversi approcci:

Potresti anche voler continuare a utilizzare i componenti Material con un sistema di design personalizzato. È possibile farlo, ma occorre tenere presente alcuni aspetti adeguati all'approccio che hai adottato.

Per scoprire di più sui costrutti e sulle API di livello inferiore utilizzati da MaterialTheme e dai sistemi di progettazione personalizzati, consulta la guida Anatomia di un tema in Compose.

Estensione del tema Material

Compose Material rispecchia molto da vicino Material Theming per semplificare e garantire la sicurezza del tipo di utilizzo delle linee guida di Material. Tuttavia, è possibile estendere i set di colori, caratteri e forme con valori aggiuntivi.

L'approccio più semplice consiste nell'aggiungere proprietà delle estensioni:

// 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)

Ciò garantisce la coerenza con le API di utilizzo di MaterialTheme. Un esempio di questo valore definito da Compose stesso è surfaceColorAtElevation, che determina il colore della superficie da utilizzare in base all'elevazione.

Un altro approccio è definire un tema esteso che "avvolge" MaterialTheme e i suoi valori.

Supponiamo che tu voglia aggiungere altri due colori, caution e onCaution, un colore giallo utilizzato per azioni semipericolose, mantenendo i colori di Material esistenti:

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

Questo è simile alle API di utilizzo di MaterialTheme. Supporta anche più temi, poiché puoi nidificare i ExtendedTheme nello stesso modo in cui li nidifichi con MaterialTheme.

Utilizzare i componenti Material

Quando estendi i temi Material, i valori MaterialTheme esistenti vengono mantenuti e i componenti Material hanno ancora valori predefiniti ragionevoli.

Se vuoi utilizzare valori estesi nei componenti, inseriscili nelle tue funzioni composable, impostando direttamente i valori che vuoi modificare ed esponendo gli altri come parametri al composable contenente:

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

Dovresti quindi sostituire gli utilizzi di Button con ExtendedButton, ove opportuno.

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

Sostituire i sottosistemi di Material

Invece di estendere i temi materiali, potresti voler sostituire uno o più sistemi (Colors, Typography o Shapes) con un'implementazione personalizzata, mantenendo gli altri.

Supponiamo che tu voglia sostituire i sistemi di tipo e forma mantenendo il sistema di colori:

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

Utilizzare i componenti Material

Quando uno o più sistemi di MaterialTheme sono stati sostituiti, l'utilizzo dei componenti Material così come sono potrebbe generare valori di colore, tipo o forma del materiale indesiderati.

Se vuoi utilizzare valori sostitutivi nei componenti, aggregali nelle tue funzioni componibili, impostando direttamente i valori per il sistema pertinente ed esponendo altri come parametri al componibile contenitore.

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

Dovresti quindi sostituire gli utilizzi di Button con ReplacementButton, ove opportuno.

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

Implementare un sistema di design completamente personalizzato

Ti consigliamo di sostituire Material Theming con un sistema di design completamente personalizzato. Supponiamo che MaterialTheme fornisca i seguenti sistemi:

  • Colors, Typography e Shapes: sistemi di temi Material
  • TextSelectionColors: colori utilizzati per la selezione del testo tramite Text e TextField
  • Ripple e RippleTheme: implementazione del materiale di Indication

Se vuoi continuare a utilizzare i componenti Material, devi sostituire alcuni di questi sistemi nel tema o nei temi personalizzati oppure gestire i sistemi nei componenti per evitare comportamenti indesiderati.

Tuttavia, i sistemi di design non sono limitati ai concetti su cui si basa Material. Puoi modificare i sistemi esistenti e introdurne di completamente nuovi, con nuovi classi e tipi, per rendere altri concetti compatibili con i temi.

Nel codice seguente, modelliamo un sistema di colori personalizzato che include gradienti (List<Color>), un sistema di tipi, un nuovo sistema di elevazione ed escludi altri sistemi forniti da 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
}

Utilizzare i componenti Material

Se non è presente MaterialTheme, l'utilizzo dei componenti Material così come sono comporterà valori e comportamenti indesiderati per colore, tipo e forma del materiale.

Se vuoi utilizzare valori personalizzati nei componenti, inseriscili nelle tue funzioni composable, impostando direttamente i valori per il sistema pertinente ed esponendo gli altri come parametri al composable contenente.

Ti consigliamo di accedere ai valori impostati dal tema personalizzato. In alternativa, se il tuo tema non fornisce Color, TextStyle, Shape o altri sistemi, puoi impostarli in modo hardcoded.

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

Se hai introdotto nuovi tipi di classi, ad esempio List<Color> per rappresentare i gradienti, potrebbe essere meglio implementare i componenti da zero anziché aggregarli. Ad esempio, dai un'occhiata a JetsnackButton dell'esempio di Jetsnack.