Systemy niestandardowego projektowania elementów w Compose

Zalecamy Material Design, a Jetpack Compose zawiera wdrożenie Material, ale nie musisz z niego korzystać. Materiał jest w całości oparty na publicznych interfejsach API, więc można w ten sam sposób stworzyć własny system do projektowania.

Oto kilka rzeczy, które możesz zastosować:

Możesz też nadal używać komponentów Material Design z niestandardowym systemem projektowania. Jest to możliwe, ale trzeba pamiętać o kilku kwestiach.

Więcej informacji o konstrukcjach niższego poziomu i interfejsach API używanych przez MaterialTheme oraz o niestandardowych systemach projektowania znajdziesz w przewodniku Anatomia motywu w tworzeniu.

Rozszerzanie tematyki materiałów

Interfejs Compose Material ściśle modeluje motywowanie Material Design, aby ułatwić i umożliwić przestrzeganie wytycznych dotyczących materiałów. Możesz jednak rozszerzyć zestawy kolorów, typografii i kształtów o dodatkowe wartości.

Najprostszym sposobem jest dodanie właściwości rozszerzenia:

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

Zapewnia to spójność z interfejsami API MaterialTheme. Przykładem zdefiniowanego przez samą funkcję tworzenia wiadomości jest primarySurface, która w zależności od Colors.isLight działa jako serwer proxy między primary a surface.

Innym sposobem jest zdefiniowanie rozszerzonego motywu, który „zawija” element MaterialTheme i jego wartości.

Załóżmy, że chcesz dodać dwa dodatkowe kolory – tertiary i onTertiary – zachowując przy tym dotychczasowe kolory materiału:

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

Przypomina to interfejs API MaterialTheme dotyczący wykorzystania. Obsługuje też wiele motywów, ponieważ obiekty ExtendedTheme możesz zagnieżdżać w taki sam sposób jak obiekt MaterialTheme.

Korzystanie z komponentów Material

W przypadku rozszerzania zakresu tematycznego materiału istniejące wartości MaterialTheme są zachowywane, a komponenty Material Design mają nadal uzasadnione wartości domyślne.

Jeśli chcesz używać rozszerzonych wartości w komponentach, umieść je we własnych funkcjach kompozycyjnych, bezpośrednio ustawiając wartości, które chcesz zmienić, i ujawnij innym jako parametry funkcji kompozycyjnej:

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

W razie potrzeby zastąpisz użycie słowa Button wartością ExtendedButton.

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

Zastępowanie systemów Material Design

Zamiast rozszerzać uporządkowanie tematyczne, możesz zastąpić jeden lub kilka systemów – Colors, Typography lub Shapes – wdrożeniem niestandardowej implementacji, zachowując pozostałe.

Załóżmy, że chcesz zmienić układ typu i kształtów przy zachowaniu systemu kolorów:

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

Korzystanie z komponentów Material

W przypadku zastąpienia co najmniej 1 systemu elementu MaterialTheme użycie komponentów Materiał w takiej postaci może spowodować przypisanie do niego niepożądanych wartości koloru, typu lub kształtu.

Jeśli chcesz użyć wartości zastępczej w komponentach, umieść je we własnych funkcjach kompozycyjnych, bezpośrednio ustawiając wartości dla odpowiedniego systemu, a innym ujawniając je jako parametry funkcji kompozycyjnej.

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

W razie potrzeby zastąpisz użycie słowa Button wartością ReplacementButton.

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

Wdrożenie systemu projektowania w pełni niestandardowego

Możesz zastąpić motyw Material Design systemem całkowicie niestandardowym. Pamiętaj, że MaterialTheme udostępnia te systemy:

  • Colors, Typography i Shapes: systemy doboru tematycznego materiałów
  • ContentAlpha: poziomy przezroczystości, które pozwalają podkreślić wyróżnienie w językach Text i Icon
  • TextSelectionColors: kolory używane do zaznaczania tekstu według właściwości Text i TextField
  • Ripple i RippleTheme: materialna implementacja Indication

Jeśli nadal chcesz korzystać z komponentów Material Design, musisz zastąpić niektóre z nich w swoich motywach niestandardowych lub motywach niestandardowych albo obsługiwać systemy w komponentach, aby uniknąć niepożądanych zachowań.

Systemy projektowania nie są jednak ograniczone do koncepcji, na których opiera się aplikacja Material. Możesz modyfikować istniejące systemy i wprowadzać zupełnie nowe (z nowymi klasami i typami), aby inne koncepcje były zgodne z motywami.

W poniższym kodzie modelujemy niestandardowy system kolorów, który zawiera gradienty (List<Color>), dodamy system typów, wprowadzimy nowy system wysokości i wykluczymy inne systemy dostarczane przez 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
}

Korzystanie z komponentów Material

Jeśli nie ma elementu MaterialTheme, użycie komponentów Materiał w takiej postaci spowoduje niepożądane ustawienia koloru, typu i kształtu materiału oraz zachowania innych wskaźników.

Jeśli chcesz używać w komponentach wartości niestandardowych, umieść je we własnych funkcjach kompozycyjnych, ustaw bezpośrednio wartości dla odpowiedniego systemu, a inne (udostępnij) jako parametry funkcji kompozycyjnej.

Zalecamy dostęp do wartości ustawionych z poziomu motywu niestandardowego. Jeśli Twój motyw nie obsługuje Color, TextStyle, Shape ani innych systemów, możesz je zakodować na stałe.

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

Po wprowadzeniu nowych typów klas, np. List<Color> do reprezentowania gradientów, lepiej zaimplementować komponenty od zera zamiast je pakować. Spójrzmy na przykład na wartość JetsnackButton z próbki Jetsnack.