Собственные системы проектирования в Compose

Хотя Material – это рекомендуемая нами система проектирования, а Jetpack Compose поставляется с реализацией Material, вы не обязаны ее использовать. Material полностью построен на общедоступных API, поэтому вы можете создать свою собственную систему проектирования таким же образом.

Есть несколько способов сделать это:

Вы также можете продолжить использовать компоненты Material Design с собственной системой проектирования. Это возможно, но нужно учитывать некоторые особенности.

Чтобы узнать больше о конструкциях и API более низкого уровня, используемых MaterialTheme и пользовательскими системами проектирования, ознакомьтесь с руководством Anatomy of a theme in Compose.

Расширение тематического оформления Material Design

Compose Material точно моделирует тематическое оформление Material Design, чтобы упростить соблюдение рекомендаций Material Design и обеспечить типобезопасность. Однако вы можете расширить наборы цветов, шрифтов и форм, добавив дополнительные значения. Проще всего добавить свойства расширения:

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

Это обеспечивает согласованность с API использования MaterialTheme. Примером такого значения, определенного в Compose, является surfaceColorAtElevation, которое определяет цвет поверхности в зависимости от высоты.

Другой подход – определить расширенную тему, которая "оборачивает" MaterialTheme и его значения.

Предположим, вы хотите добавить два дополнительных цвета – caution и onCaution, желтый цвет, используемый для действий, которые являются полу-опасными, – сохраняя при этом существующие цвета Material:

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

Это похоже на API использования MaterialTheme. Она также поддерживает несколько тем, поскольку вы можете вкладывать ExtendedTheme так же, как и MaterialTheme.

Используйте компоненты Material Design

При расширении тематического оформления Material Design существующие значения MaterialTheme сохраняются, а компоненты Material Design по-прежнему имеют разумные значения по умолчанию.

Если вы хотите использовать расширенные значения в компонентах, оберните их в свои собственные composable-функции, напрямую задав нужные значения и предоставив другие в качестве параметров для содержащей composable-функции:

@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 = { /* ... */ }) {
            /* ... */
        }
    }
}

Замена подсистем Material

Вместо расширения тематического оформления Material Design вы можете заменить одну или несколько систем (ColorScheme, Typography или Shapes) на собственную реализацию, сохранив остальные.

Предположим, вы хотите заменить системы типов и форм, сохранив при этом систему цветов:

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

Используйте компоненты Material Design

Если вы заменили одну или несколько систем MaterialTheme, использование компонентов Material в исходном виде может привести к нежелательным значениям цвета, типа или формы.

Если вы хотите использовать заменяющие значения в компонентах, оберните их в собственные composable-функции, напрямую задав значения для соответствующей системы и предоставив другие в качестве параметров для содержащей composable-функции.

@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 = { /* ... */ }) {
            /* ... */
        }
    }
}

Как реализовать полностью настраиваемую систему проектирования

Вы можете заменить тематическое оформление Material Design полностью настраиваемой системой проектирования. Предположим, что MaterialTheme предоставляет следующие системы:

  • ColorScheme, Typography и Shapes: системы тематического оформления Material Design
  • TextSelectionColors: цвета, используемые для выделения текста в Text и TextField.
  • ripple, предоставляется как LocalIndication: реализация Material для Indication

Если вы хотите продолжить использовать компоненты Material, вам необходимо заменить некоторые из этих систем в своих пользовательских темах или обработать системы в своих компонентах, чтобы избежать нежелательного поведения.

Однако системы проектирования не ограничиваются концепциями, на которые опирается Material. Вы можете изменить существующие системы и ввести совершенно новые — с новыми классами и типами — чтобы сделать другие концепции совместимыми с темами.

В коде ниже мы моделируем пользовательскую систему цветов, которая включает градиенты (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
}

Используйте компоненты Material Design

Если MaterialTheme не задана, то при использовании компонентов Material Design будут применяться нежелательные значения цвета, шрифта и формы, а также поведение индикации.

Если вы хотите использовать в компонентах собственные значения, оберните их в свои composable-функции, напрямую задав значения для нужной системы и предоставив другие в качестве параметров для содержащей composable-функции.

Рекомендуем использовать значения, заданные в пользовательской теме. Если в вашей теме нет Color, TextStyle, Shape или других систем, вы можете задать их в коде.

@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> для представления градиентов, то лучше реализовать компоненты с нуля, а не упаковывать их. Пример можно посмотреть в JetsnackButton из примера Jetsnack.