Sistemi di progettazione personalizzati in Compose

Sebbene Material sia il nostro sistema di progettazione consigliato e Jetpack Compose includa un'implementazione di Material, non sei obbligato a utilizzarlo. Material è basato interamente su API pubbliche, quindi è possibile creare il proprio sistema di progettazione nello stesso modo.

Puoi adottare diversi approcci:

Potresti anche voler continuare a utilizzare i componenti Material con un sistema di progettazione personalizzato. È possibile farlo, ma ci sono alcuni aspetti da tenere presenti per adattarsi 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 riproduce fedelmente Material Theming per semplificare e rendere sicuro il rispetto delle linee guida di Material. Tuttavia, è possibile estendere i set di colori, tipografia e forme con valori aggiuntivi.

L'approccio più semplice consiste nell'aggiungere le proprietà dell'estensione:

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

In questo modo si garantisce la coerenza con le API di utilizzo di MaterialTheme. Un esempio di questo definito da Compose stesso è surfaceColorAtElevation, che determina il colore della superficie da utilizzare a seconda dell'elevazione.

Un altro approccio consiste nel definire un tema esteso che "contenga" MaterialTheme e i relativi valori.

Supponiamo di voler aggiungere due colori aggiuntivi, caution e onCaution, un colore giallo utilizzato per le azioni semi-pericolose, mantenendo i colori 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 scenario è simile alle API di utilizzo di MaterialTheme. Supporta anche più temi, in quanto puoi nidificare i ExtendedTheme nello stesso modo dei MaterialTheme.

Utilizzare i componenti Material

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

Se vuoi utilizzare valori estesi nei componenti, racchiudili nelle tue funzioni componibili, impostando direttamente i valori che vuoi modificare ed esponendo gli altri come parametri alla funzione componibile contenitore:

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

Quindi, sostituisci gli utilizzi di Button con ExtendedButton, se opportuno.

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

Sostituzione dei sottosistemi Material

Anziché estendere i temi Material, 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 comportare valori indesiderati per colore, tipo o forma di Material.

Se vuoi utilizzare i valori di sostituzione nei componenti, racchiudili nelle tue funzioni componibili, impostando direttamente i valori per il sistema pertinente ed esponendo gli altri come parametri del componente 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()
            }
        }
    )
}

Quindi, sostituisci gli utilizzi di Button con ReplacementButton, se opportuno.

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

Implementare un sistema di progettazione completamente personalizzato

Potresti voler sostituire Material Theming con un sistema di progettazione completamente personalizzato. Tieni presente che MaterialTheme fornisce i seguenti sistemi:

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

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

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

Nel seguente codice, modelliamo un sistema di colori personalizzato che include gradienti (List<Color>), includiamo un sistema di tipi, introduciamo un nuovo sistema di elevazione ed escludiamo 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

Quando non è presente alcun MaterialTheme, l'utilizzo dei componenti Material così come sono comporterà valori e comportamenti di indicazione indesiderati per colore, tipo e forma di Material.

Se vuoi utilizzare valori personalizzati nei componenti, inseriscili nelle tue funzioni componibili, impostando direttamente i valori per il sistema pertinente ed esponendo gli altri come parametri del componente componibile contenitore.

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 codificarli in modo permanente.

@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é eseguire il wrapping. Per un esempio, dai un'occhiata a JetsnackButton dell'esempio Jetsnack.