Перейти к основному содержимому
Мобилка17ГЛАВА 17Material 3: тема, которую вам уже сгенерировалиодна разметка

Material 3: тема, которую вам уже сгенерировали

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

В самой первой главе Android Studio создала проект, а в нём — папку ui/theme с тремя файлами. Мы её открыли, увидели непонятное и пошли дальше. С тех пор прошло одиннадцать глав: мы верстали экраны, собирали UI Kit, писали списки — и всё это время делали цвета и шрифты руками, объектами UiKitColors и UiKitType.

Пора вернуться. Эти три файла — не мусор шаблона, а готовая система оформления, которая умеет то, чего наши объекты не умеют совсем: подставлять другой набор цветов, когда телефон переключается в тёмную тему. Без единой правки в разметке.

Ещё важнее другое. Все готовые компоненты Material — кнопки, поля ввода, карточки, панели — берут цвета и шрифты оттуда. Пока вы красите свои компоненты вручную, а рядом ставите готовую кнопку, экран разъезжается: половина элементов вашего оттенка, половина — стандартного фиолетового.

Эта глава закрывает разрыв между «я крашу руками» и «в проекте есть тема».

Откройте ui/theme​

Три файла, которые лежат там с первого дня:

ТРИ ФАЙЛА, КОТОРЫЕ ЛЕЖАТ С ПЕРВОГО ДНЯColor.ktзначения цветовval Purple40 = Color(0xFF6650a4)Type.ktстили текстаbodyLarge, titleMedium…Shapes.ktскругленияsmall, medium, largeTheme.ktсобирает три части в MaterialThemeAndroid Studio создала их в первый день — и до этой главы они стояли без дела
Color.kt, Type.kt и Shapes.kt хранят части оформления; Theme.kt собирает их в MaterialTheme

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

Разметка в обоих случаях одна и та же. Но в тёмной теме жёстко заданный белый фон так и остался белым — и выбился из экрана, а текст на нём стал нечитаемым. Роль подставила нужное значение сама.

ОДИН И ТОТ ЖЕ ЭКРАН В ДВУХ ТЕМАХжёстко заданный цветтекст видентот же белыйв тёмной теме останется белым — и выбьется из экранароль: surface / onSurfaceсветлаятёмнаязначение подставит тема, разметка не меняетсяпара «цвет и то, что на нём»: primary → onPrimary, surface → onSurfaceконтраст гарантирован обоими значениями сразу — их подбирали вместепоэтому цвет в Material называется ролью, а не именем: не «фиолетовый», а «главный»тёмная тема после этого получается почти бесплатно
Жёстко заданный цвет одинаков в обеих темах; роль подставляет подходящее значение, а пара «цвет и то, что на нём» гарантирует контраст

Второе, что видно в выводе: роли ходят парами. primary и onPrimary, surface и onSurface. Приставка on означает «то, что лежит на этом»: onPrimary — цвет текста и значков поверх primary. Значения в паре подобраны так, чтобы контраста хватало, и это избавляет от целого класса ошибок вроде серого текста на светло-сером фоне.

РольДля чего
primary / onPrimaryглавное действие: основная кнопка, активный переключатель
secondary / onSecondaryвторостепенные акценты, чипы, фильтры
error / onErrorошибки: рамка поля, текст под ним
surface / onSurfaceповерхность и текст на ней: карточки, диалоги, панели
surfaceVariant / onSurfaceVariantприглушённая поверхность: подписи, второстепенный текст
background / onBackgroundфон экрана целиком
outlineграницы, разделители, рамка неактивного поля
Отвечено вопросов: 0 из 2
Почему цвета в Material называются ролями, а не именами вроде «фиолетовый»?
Что означает приставка on в onSurface?
Отвечено верно: 0 из 2

Как тема попадает в компонент​

Второй вопрос, который возникает сразу: если 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,
)
}
}
ТЕМУ НЕ ПЕРЕДАЮТ ПАРАМЕТРОМAppTheme {объявили один разScreen()ничего про тему не знает и не принимаетProductCard()MaterialTheme.colorScheme.surfaceзначение достаётся из окружения в момент отрисовки — как переменная средывложенная тема переопределяет её только внутри своего блока
Тема объявляется один раз наверху и достаётся из окружения на любой глубине — передавать её параметром не нужно
Интересный факт

Механизм называется 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, потом замените обращения в компонентах, и только потом удалите старые объекты. Если удалить сразу, компилятор завалит вас ошибками во всех файлах разом, и станет непонятно, что уже перенесено, а что нет.

Отвечено вопросов: 0 из 2
Как ProductCard получает доступ к теме, если ей никто её не передавал?
Что нужно дописать, чтобы заработала тёмная тема, если все цвета взяты из ролей?
Отвечено верно: 0 из 2

Динамический цвет и почему его выключают​

Начиная с 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

На чемпионате​

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

Порядок первого часа, который экономит время потом:

  1. Выключить dynamicColor.
  2. Перенести цвета макета в Color.kt под ролями Material, а не под своими именами.
  3. Собрать Typography по стилям макета.
  4. Проверить оба режима — светлый и тёмный — на пустом экране, до того как в нём появятся компоненты.

Проверять темы удобно, не пересобирая приложение: в @Preview есть параметр uiMode, который показывает экран в тёмном режиме прямо в Android Studio.

Отвечено вопросов: 0 из 2
В макете цвет карточки #FFFFFF. Как записать его в проекте?
Почему на чемпионате выключают динамический цвет?
Отвечено верно: 0 из 2

Что дальше​

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

Отвечено вопросов: 0 из 1
Зачем вообще нужна была рукодельная тема из главы про UI Kit, если есть MaterialTheme?
Отвечено верно: 0 из 1

Углубиться​

  • Material Theme Builder — сайт, где по нескольким цветам макета генерируется готовый Color.kt сразу для светлой и тёмной схемы.
  • surfaceContainer, surfaceContainerHigh и остальные уровни поверхностей: чем они заменили «тень по высоте» из старой версии Material.
  • Shapes — скругления как часть темы: одно изменение меняет форму всех карточек и кнопок сразу.
  • LocalContentColor — как компонент узнаёт, каким цветом рисовать содержимое, не спрашивая об этом явно.
  • Собственный CompositionLocal — когда своё значение действительно стоит раздать вниз по дереву, а когда лучше обычный параметр.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоIntroduction to Material 3 (Color Theming, Typography, Shapes)Philipp Lackner
ВидеоMaterial You in Compose appsAndroid Developers
ВидеоMaterial 3 Theming & Dynamic Color in Jetpack ComposeComposeNCode
ВидеоJetpack Compose - Theme | Customizing Colors & TypographyCheezy Code
ВидеоMaterial 3 Dynamic Colors Feature in Android Studio with Jetpack ComposeStevdza-San

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.