Material 3: тема, которую вам уже сгенерировали
- Зачем это нужно
- Откройте
ui/theme - Цвет как роль, а не как имя
- Как тема попадает в компонент
- Разбор
Theme.kt - Типографика с макета
- Переход с рукодельной темы
- Динамический цвет и почему его выключают
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
В самой первой главе Android Studio создала проект, а в нём — папку ui/theme с тремя файлами. Мы её открыли, увидели непонятное и пошли дальше. С тех пор прошло одиннадцать глав: мы верстали экраны, собирали UI Kit, писали списки — и всё это время делали цвета и шрифты руками, объектами UiKitColors и UiKitType.
Пора вернуться. Эти три файла — не мусор шаблона, а готовая система оформления, которая умеет то, чего наши объекты не умеют совсем: подставлять другой набор цветов, когда телефон переключается в тёмную тему. Без единой правки в разметке.
Ещё важнее другое. Все готовые компоненты Material — кнопки, поля ввода, карточки, панели — берут цвета и шрифты оттуда. Пока вы красите свои компоненты вручную, а рядом ставите готовую кнопку, экран разъезжается: половина элементов вашего оттенка, половина — стандартного фиолетового.
Эта глава закрывает разрыв между «я крашу руками» и «в проекте есть тема».
Откройте ui/theme
Три файла, которые лежат там с первого дня:
Color.kt — просто значения цветов, объявленные как val. Ровно то же, что мы делали в UiKitColors, только сгенерированное заранее.
Type.kt — набор стилей текста: какой размер и начертание у заголовка, у основного текста, у подписи.
Theme.kt — самое интересное. Он собирает первые два в одно целое и раздаёт всему приложению.
Наши собственные объекты из главы про UI Kit не были ошибкой: чтобы понять, что делает тема, полезно было сначала собрать её вручную. Но дальше рукодельный вариант проигрывает по одной причине — он ничего не знает про тёмную тему и про готовые компоненты.
Цвет как роль, а не как имя
Главная идея Material, которую нужно принять до всего остального: цвет называется по своей работе, а не по своему виду.
В нашем UI Kit было UiKitColors.CardBackground — «фон карточки». В Material это surface — «поверхность». Разница кажется словесной, пока не появляется вторая тема. Смоделируем:
data class Scheme(val surface: String, val onSurface: String, val primary: String, val onPrimary: String)
val light = Scheme(surface = "#FFFFFF", onSurface = "#1B1B1B", primary = "#4B2FD6", onPrimary = "#FFFFFF")
val dark = Scheme(surface = "#141218", onSurface = "#E6E0E9", primary = "#CFBCFF", onPrimary = "#2A1A6B")
fun drawByHex(s: Scheme) = println(" фон #FFFFFF, текст #1B1B1B (жёсткий хардкод — тема не влияет)")
fun drawByRole(s: Scheme) = println(" фон ${s.surface}, текст ${s.onSurface} (роли — тема учтена)")
fun main() {
println("СВЕТЛАЯ ТЕМА"); drawByHex(light); drawByRole(light)
println("ТЁМНАЯ ТЕМА"); drawByHex(dark); drawByRole(dark)
println()
println("пара primary/onPrimary всегда контрастна: ${light.primary} + ${light.onPrimary}")
println("и в тёмной тоже: ${dark.primary} + ${dark.onPrimary}")
}
Живой вывод:
СВЕТЛАЯ ТЕМА
фон #FFFFFF, текст #1B1B1B (жёсткий хардкод — тема не влияет)
фон #FFFFFF, текст #1B1B1B (роли — тема учтена)
ТЁМНАЯ ТЕМА
фон #FFFFFF, текст #1B1B1B (жёсткий хардкод — тема не влияет)
фон #141218, текст #E6E0E9 (роли — тема учтена)
пара primary/onPrimary всегда контрастна: #4B2FD6 + #FFFFFF
и в тёмной тоже: #CFBCFF + #2A1A6B
Разметка в обоих случаях одна и та же. Но в тёмной теме жёстко заданный белый фон так и остался белым — и выбился из экрана, а текст на нём стал нечитаемым. Роль подставила нужное значение сама.
Второе, что видно в выводе: роли ходят парами. primary и onPrimary, surface и onSurface. Приставка on означает «то, что лежит на этом»: onPrimary — цвет текста и значков поверх primary. Значения в паре подобраны так, чтобы контраста хватало, и это избавляет от целого класса ошибок вроде серого текста на светло-сером фоне.
| Роль | Для чего |
|---|---|
primary / onPrimary | главное действие: основная кнопка, активный переключатель |
secondary / onSecondary | второстепенные акценты, чипы, фильтры |
error / onError | ошибки: рамка поля, текст под ним |
surface / onSurface | поверхность и текст на ней: карточки, диалоги, панели |
surfaceVariant / onSurfaceVariant | приглушённая поверхность: подписи, второстепенный текст |
background / onBackground | фон экрана целиком |
outline | границы, разделители, рамка неактивного поля |
Как тема попадает в компонент
Второй вопрос, который возникает сразу: если ProductCard берёт цвет из темы, кто ей эту тему передал? Ответ — никто. Смоделируем механизм:
class Theme(val bg: String, val text: String)
val stack = ArrayDeque<Theme>()
fun current(): Theme = stack.last()
fun provide(theme: Theme, body: () -> Unit) {
stack.addLast(theme); body(); stack.removeLast()
}
fun card(name: String) = println(" $name рисуется на фоне ${current().bg}, текст ${current().text}")
fun main() {
provide(Theme("белый", "чёрный")) {
println("светлая тема:")
card("Карточка"); card("Кнопка")
provide(Theme("чёрный", "белый")) {
println("вложенный блок с тёмной темой:")
card("Карточка")
}
println("вышли из блока — тема вернулась:")
card("Кнопка")
}
}
светлая тема:
Карточка рисуется на фоне белый, текст чёрный
Кнопка рисуется на фоне белый, текст чёрный
вложенный блок с тёмной темой:
Карточка рисуется на фоне чёрный, текст белый
вышли из блока — тема вернулась:
Кнопка рисуется на фоне белый, текст чёрный
Функция card не принимает тему параметром — она достаёт её из окружения в момент вызова. Вложенный provide переопределяет тему только внутри своего блока, а после выхода всё возвращается.
Именно так устроен MaterialTheme. Значение раздаётся вниз по дереву, и любая функция на любой глубине берёт его сама:
@Composable
fun ProductCard(title: String) {
Surface(color = MaterialTheme.colorScheme.surface) {
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
)
}
}
Механизм называется CompositionLocal. Кроме темы через него раздаются плотность экрана, локаль, контекст и другие вещи, которые нужны всем и которые бессмысленно тащить параметром через двадцать уровней. Своими такими значениями пользуются редко и осторожно: то, что берётся «из воздуха», труднее отследить, чем то, что передано явно.
Разбор Theme.kt
Теперь файл из шаблона читается целиком:
private val LightColors = lightColorScheme(
primary = Purple40,
onPrimary = Color.White,
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
)
private val DarkColors = darkColorScheme(
primary = Purple80,
onPrimary = Color(0xFF381E72),
surface = Color(0xFF1C1B1F),
onSurface = Color(0xFFE6E1E5),
)
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = Typography,
content = content,
)
}
Обратите внимание на content. Именно поэтому в MainActivity тема оборачивает экран фигурными скобками — это trailing lambda, последний параметр-функция:
setContent {
AppTheme {
ProductListScreen()
}
}
Тёмная тема после этого работает сама. Ничего дописывать не надо — при условии, что в разметке нет жёстко заданных цветов.
Типографика с макета
MaterialTheme.typography — набор готовых стилей. Их пятнадцать, и запоминать все не нужно: на практике встречаются пять-шесть.
Text("Заголовок экрана", style = MaterialTheme.typography.headlineSmall)
Text("Название товара", style = MaterialTheme.typography.titleMedium)
Text("Описание", style = MaterialTheme.typography.bodyMedium)
Text("2 490 ₽", style = MaterialTheme.typography.labelLarge)
Имена устроены системно: display — самые крупные, headline — заголовки экранов, title — заголовки блоков, body — основной текст, label — подписи и текст на кнопках. У каждого три размера: Large, Medium, Small.
Когда в макете написано «16sp, Medium, межстрочный 24», ищите ближайший подходящий стиль и при необходимости правьте его в Type.kt — один раз для всего приложения:
val Typography = Typography(
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
),
)
Не создавайте новый стиль под каждую строку макета. В хорошем макете стилей всё равно немного — дизайнер сам работает системой. Если кажется, что нужен шестнадцатый стиль, скорее всего подойдёт существующий с изменённым цветом: style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.
Переход с рукодельной темы
В главе про UI Kit мы завели свои объекты. Перевод на MaterialTheme делается механически:
// было
Text(text = title, fontSize = UiKitType.TitleFontSize)
Box(modifier = Modifier.background(UiKitColors.CardBackground))
// стало
Text(text = title, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface))
Порядок работы: сначала перенесите значения из своих объектов в Color.kt и Type.kt, потом замените обращения в компонентах, и только потом удалите старые объекты. Если удалить сразу, компилятор завалит вас ошибками во всех файлах разом, и станет непонятно, что уже перенесено, а что нет.
Динамический цвет и почему его выключают
Начиная с Android 12 система умеет собирать палитру из обоев пользователя — это называется динамическим цветом. В шаблоне проекта эта возможность обычно включена.
Для личного приложения приём приятный. Для работы, которую оценивают по макету, — вредный: цвета на экране перестают совпадать с макетом, потому что зависят от обоев конкретного телефона.
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // на чемпионате — false
content: @Composable () -> Unit,
) { /* ... */ }
Проверьте это в первый же день: если в шаблоне стоит dynamicColor: Boolean = true, поменяйте на false, иначе эксперт увидит не тот экран, что вы рисовали.
Типичные ошибки
Жёстко заданный цвет вместо роли. Color(0xFFFFFFFF) в разметке. В светлой теме всё хорошо, в тёмной элемент светится белым пятном. Самая частая причина «сломанной тёмной темы».
Своя палитра рядом со стандартной. Собственные компоненты покрашены вручную, готовые кнопки Material берут цвет из темы — экран из двух половин разного оттенка. Лечится переносом значений в Color.kt.
Тема объявлена не на самом верху. Часть экрана оказалась вне AppTheme и рисуется значениями по умолчанию — обычно фиолетовыми. Оборачивать надо в setContent, а не отдельные куски.
fontSize вместо style. Размер поменялся, а начертание и межстрочный интервал остались от стиля по умолчанию, и текст не сходится с макетом при измерении.
Забыли выключить dynamicColor. На телефоне разработчика цвета одни, на телефоне эксперта — другие.
Слова главы
MaterialTheme— обёртка, раздающая цвета, типографику и формы всему, что внутри.- Роль цвета — назначение вместо значения:
primary,surface,error. - Пара
on— цвет содержимого поверх основного:onPrimaryнаprimary. lightColorScheme/darkColorScheme— построители наборов ролей.isSystemInDarkTheme()— что выбрано в настройках телефона.typography— набор стилей текста:headline,title,body,label.CompositionLocal— механизм раздачи значений вниз по дереву без параметров.- Динамический цвет — палитра из обоев пользователя, Android 12+; на чемпионате выключают.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
color = MaterialTheme.colorScheme.onSurface
На чемпионате
Первым заданием модуля вёрстки обычно идёт «Создайте цвета, как на макете» и «Создайте тему с типографикой, как на макете» — то есть тема оценивается отдельно, до всех экранов. Стоит она немного, но без неё не сходится всё остальное: цвета проверяют на каждом экране.
Порядок первого часа, который экономит время потом:
- Выключить
dynamicColor. - Перенести цвета макета в
Color.ktпод ролями Material, а не под своими именами. - Собрать
Typographyпо стилям макета. - Проверить оба режима — светлый и тёмный — на пустом экране, до того как в нём появятся компоненты.
Проверять темы удобно, не пересобирая приложение: в @Preview есть параметр uiMode, который показывает экран в тёмном режиме прямо в Android Studio.
Что дальше
Тема есть — значит, готовые компоненты Material теперь встанут в ваш экран и будут выглядеть как ваши. Следующая глава про них: какие компоненты существуют, где смотреть их список и как читать документацию, чтобы находить нужный за полминуты, а не собирать своё из прямоугольников.
Углубиться
- Material Theme Builder — сайт, где по нескольким цветам макета генерируется готовый
Color.ktсразу для светлой и тёмной схемы. surfaceContainer,surfaceContainerHighи остальные уровни поверхностей: чем они заменили «тень по высоте» из старой версии Material.Shapes— скругления как часть темы: одно изменение меняет форму всех карточек и кнопок сразу.LocalContentColor— как компонент узнаёт, каким цветом рисовать содержимое, не спрашивая об этом явно.- Собственный
CompositionLocal— когда своё значение действительно стоит раздать вниз по дереву, а когда лучше обычный параметр.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Material Design 3 в Compose — developer.android.com — первоисточник по теме, ролям и типографике.
- Material Theme Builder — генератор схемы по цветам макета, сразу для светлой и тёмной темы.
- Спецификация цветовых ролей — m3.material.io — что означает каждая роль и где её применяют.
- CompositionLocal — developer.android.com — механизм, которым раздаётся тема, и когда заводить свой.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.