Уверен, что каждый, кто использовал цветовые схемы в Android приложениях для раскраски интерфейса, хоть раз задавался вопросом "А как можно поменять скучные цвета primary, secondary, tertiary и др. на свои?" В этой это статье речь пойдет как раз про использование своей цветовой схемы для работы с приложением.

Скучная дефолтная тема

Тема Android приложения, которая поставляется в проект Android из коробки, кажется мне довольно неудобной, потому что иногда начинается путаница и головная боль, которая приходит с необходимостью в процессе кодинга определять, в какой переменной темы лежат нужные тебе цвета. Статья 0+, поэтому начнем по порядку. Выглядит эта тема следующим образом:

  1. Цветовые палетки, определяющие цвета в темной и светлой теме

  2. Метод темы (Шрифт мелкий, чтобы уместить все на одном изображении)

  3. Странно, но раньше еще был объект темы, в котором можно было получить эти цвета для других компонентов.

Обращу внимание, что в этой теме присутствует динамическая цветовая схема. К сожалению или счастью, с кастомной цветовой схемой это работать не будет!!

Немного вики: С выходом Android 12 на устройствах появилась динамическая цветовая схема приложений, позволяющая раскрашивать их в зависимости от обоев. На это все пошла бурная реакция пользователей, вследствие которой все разделились на 3 группы: 1 - те, кому это зашло, 2 - те, кому как было плевать, так и осталось, 3 - хейтеры. Я отношусь к третьим, поскольку определение основных цветов обоев иногда происходит максимально забавно. У меня на телефоне стояли обои черного цвета - просто черный фон, с небольшой "пудрой" красных точек в углу, которые я бы даже не заметил, если б все мои гугловские приложения не перекрасились в розовый :) После я проводил еще несколько экспериментов с черными обоями, и одни из них перекрасили все приложения, почему-то, в болотный цвет, а потом уже я подобрал обои, которые вернули старый добрый светло-голубой цвет.

Итак, начнем!

Палетки

Палетки - это обычный data class, в котором можно определить любые понравившиеся цвета.

data class ColorPalette(
    val mainColor: Color,
    val singleTheme: Color,
    val oppositeTheme: Color,
    val buttonColor: Color,
)

Я нашел для себя удобными первые 3 цвета: мне нравится делать приложения не радужными, а с одним-двумя основными цветами, отличными от базовых (Черный, Белый, Серый) - речь про mainColor. Далее singleTheme и oppositeTheme - сюда я кидаю белый и черный в белой теме и наоборот - в темной теме. Ну и buttonColor для разнообразия палетки. Теперь определим палетки для обоих тем:

val baseLightPalette = ColorPalette(
    mainColor = mainLightColor, // просто цвет из Color.kt 
    singleTheme = Color.White,
    oppositeTheme = Color.Black,
    buttonColor = Color(0xFFEFEEEE)
)
val baseDarkPalette = baseLightPalette.copy(
    mainColor = mainDarkColor,// просто цвет из Color.kt 
    singleTheme = Color.Black,
    oppositeTheme = Color.White,
    buttonColor = Color(0xFF2D2D31)
)

Метод темы

Функция, описывающая изменения цветов, будет выглядеть следующим образом:

fun MainTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {

    val colors = if (!darkTheme) baseLightPalette
    else baseDarkPalette

    // Место для вызова MaterialTheme 
}

Я не добавил в код выше MaterialTheme, потому что Material использует только собственные цветовые схемы, и нам это не подойдет. Углубимся!

На картинке выше - код из либки MaterialTheme.kt. Под капотом этот метод вызывает CompositionLocalProvier -
(Скучно, можно не читать, дальше будет простыми словами) CompositionLocalProvider binds values to ProvidableCompositionLocal keys. Reading the CompositionLocal using CompositionLocal. current will return the value provided in CompositionLocalProvider's values parameter for all composable functions called directly or indirectly in the content lambda

Простыми словами: Это позволит всегда иметь правильно перекомпанованную переменную (в нашем случае) colors в зависимости от темы, потому что заметим, здесь нет использования remember, что позволяет перекомпоновать код при изменении отслеживаемого значения.

На примере картинки, что такое colorScheme, мы убедились, а с LocalColorScheme, которое вызывает provides, сейчас разберемся.

Здесь используется staticCompositionLocalOf. Сейчас будет также много текста, который можно скипать:
Create a CompositionLocal key that can be provided using CompositionLocalProvider.
Unlike compositionLocalOf, reads of a staticCompositionLocalOf are not tracked by the composer and changing the value provided in the CompositionLocalProvider call will cause the entirety of the content to be recomposed instead of just the places where in the composition the local value is used. This lack of tracking, however, makes a staticCompositionLocalOf more efficient when the value provided is highly unlikely to or will never change. For example, the android context, font loaders, or similar shared values, are unlikely to change for the components in the content of a the CompositionLocalProvider and should consider using a staticCompositionLocalOf. A color, or other theme like value, might change or even be animated therefore a compositionLocalOf should be used.

Простыми словами: если что-то изменяется часто - compositionLocalOf, редко - staticCompositionLocalOf. staticCompositionLocalOf вызывает перекомпановку всего содержимого, а compositionLocalOf - только тех мест, где это содержится.

Итог: staticCompositionLocalOf позволит избежать лишних перкомпозиций, т.к. цветовая схема используется по всему приложению.

val LocalColors = staticCompositionLocalOf<ColorPalette> {
    error("Colors composition error")
}

Здесь в качестве типа указывается цветовая схема, а в лямбде - параметр defaultFactory - a value factory to supply a value when a value is not provided. This factory is called when no value is provided through a CompositionLocalProvider of the caller of the component using CompositionLocal.current. If no reasonable default can be provided then consider throwing an exception.

Простыми словами: лямбда будет вызываться, если параметр LocalColors не будет обрабатываться через CompositionLocalProvider. Я этого не делаю, поскольку далее мы явно пропишем это в CompositionLocalProvider.

Закончим метод темы:

@Composable
fun MainTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {

    val colors = if (!darkTheme) baseLightPalette
    else baseDarkPalette

    CompositionLocalProvider(
        LocalColors provides colors,
        content = content
    )
}

Аналогичным образом в CompositionLocalProvider можно поместить абсолютно любые переменные.

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

Объект темы

С помощью синглтона мы сможем получать цвета из любой части приложения, и вот как мы это сделаем:

object MainTheme {
    val colors: ColorPalette
        @Composable @ReadOnlyComposable
        get() = LocalColors.current
}

Теперь все же стоит вернуться и прочитать подробнее про фабрику staticCompositionLocalOf, вот цитата:

This factory is called when no value is provided through a CompositionLocalProvider of the caller of the component using CompositionLocal.current.

Здесь говорится о том, что актуальное значение можно получить с помощью свойства current, поэтому мы просто в объекте делаем Composable и ReadOnlyComposable геттер и возвращаем там это значение.

Что такое ReadOnlyComposable? - This annotation can be applied to Composable functions so that no group will be generated around the body of the function it annotates. This is not safe unless the body of the function and any functions that it calls only executes "read" operations on the passed in composer. This will result in slightly more efficient code.
A common use case for this are for functions that only need to be composable in order to read CompositionLocal values, but don't call any other composables.
Caution: Use of this annotation means that the annotated declaration MUST comply with this contract, or else the resulting code's behavior will be undefined.

Простыми словами: функции, помеченные этой аннотацией, не будут отслеживаться компилятором на изменение состояния для их перерисовки, что приведет к более эффективному коду, но они не должны вызывать внутри себя других Composable-функций.

И теперь получать актуальную палетку темы можно с помощью MainTheme.colors.

Проверим? Один из моих пет-проектов с расписанием.

Заключение

В этой статье был рассмотрен процесс создания кастомной цветовой схемы и темы Android приложения.

Более того, в статье фигурировали data-классы с расчётом на то, что читатель имеет минимальные представления о том, чем они отличаются от обычных классов и почему удобны в этом случае.

No errors, no warnings, gentlemen and ladies!

Комментарии (2)


  1. Spinoza0
    11.08.2024 06:36

    В пример геттера добавить бы ReadOnlyComposable


    1. vafeen Автор
      11.08.2024 06:36

      Благодарю за замечание, обновил!