Динамический цвет Material You в Jetpack Compose: как сохранить фирменный цвет, когда побеждают обои
dynamicColorScheme() заменяет вашу палитру той, что построена из обоев пользователя. Практическое руководство по гармонизации фирменных цветов вместо их потери.
Включите динамический цвет в приложении на Compose — и первое, что вы заметите, это то, что ваш бренд исчезает. Зелёный Granyn, синий Hydrame — исчезли, вместо них то, что dynamicColorScheme() вывела из обоев пользователя. На стандартных синих обоях всё нормально. На оранжевых или пурпурных приложение, вся визуальная идентичность которого — «то самое зелёное» или «то самое синее», теперь выглядит как любое другое приложение в списке. Это Material You работает именно так, как задумано, и это же реальная проблема, если цвет — это то, по чему пользователь узнаёт ваше приложение среди множества других.
Решение не в том, чтобы отключить динамический цвет — пользователи, выбравшие тему на весь телефон, замечают, когда одно приложение отказывается ей соответствовать. Решение — гармонизация: сохранить нейтральные и интерактивные цвета, полученные из обоев, но подтолкнуть фирменный оттенок обратно в схему, а не позволять ему бесследно перезаписываться.
Что на самом деле даёт dynamicColorScheme
На API 31+ функции dynamicLightColorScheme(context) и dynamicDarkColorScheme(context) считывают android.R.color.system_accent1 вплоть до system_accent3 — тональные палитры, которые система извлекла из обоев, — и возвращают полноценную ColorScheme Material 3. Это удобно, потому что вы получаете схему, гарантированно согласованную с остальной частью ОС. Но это также схема, которая вообще ничего не знает о том, каким цветом должно быть ваше приложение.
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
else -> if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
На этом варианте останавливается большинство туториалов, и именно этот код привёл к тому, что иконка-капля Hydrame и её акцентный цвет внутри приложения перестали совпадать на половине протестированных мной телефонов.
Гармонизация вместо замены
В androidx.core.graphics.ColorUtils — без дополнительных зависимостей, он уже входит в core-ktx — есть blendHSL, с помощью которого можно сдвигать фиксированный фирменный цвет к динамическому primary схемы небольшими шагами, пока он не станет достаточно близким, чтобы ощущаться нативным, но не потеряет идентичность. Собственные рекомендации Material называют это «гармонизацией»: взять цвет, которым система не владеет (красный цвет ошибки, фирменный оттенок, серию визуализации данных), и частично смешать его с ближайшей динамической ролью, чтобы он не конфликтовал.
fun harmonize(designColor: Color, dynamicColor: Color, fraction: Float = 0.2f): Color {
val blended = ColorUtils.blendHSL(
designColor.toArgb(),
dynamicColor.toArgb(),
fraction,
)
return Color(blended)
}
Применительно к Hydrame, чей фирменный основной цвет — #3B82F6:
val scheme = dynamicColorScheme(context)
val harmonizedAccent = harmonize(
designColor = Color(0xFF3B82F6),
dynamicColor = scheme.primary,
fraction = 0.15f,
)
При fraction = 0.15f акцент всё ещё безошибочно читается как синий цвет Hydrame, но комфортно уживается рядом с тем тональным нейтральным цветом, который произвели обои, вместо того чтобы с ним конфликтовать. Гармонизированный цвет я использую только для элементов, несущих фирменную идентичность — отражение иконки приложения внутри интерфейса, кольцо прогресса капли воды, иллюстрации онбординга, — а всё остальное (поверхности, текст, разделители, кнопки) оставляю на собственных динамических ролях системы. Гармонизация каждого цвета схемы сводит на нет саму суть динамического цвета; цель — один узнаваемый акцент, выживающий внутри в остальном системно-нативной палитры, а не полное поглощение брендом.
Резервный вариант ниже API 31
Две трети логики резервного варианта выше умещаются в одну ветку when, но стоит осознанно подойти к тому, как выглядит схема для устройств до Android 12, потому что это не деградированная динамическая схема — это единственная схема на этих устройствах, и точка. Для каждого приложения я храню вручную собранную ColorScheme, используя lightColorScheme(primary = ..., secondary = ..., ...), заполненную точными фирменными значениями, уже существующими в дизайн-токенах каждого приложения (#22C55E у Granyn, #F59E0B у Mintly, #6366F1 у Subly), вместо попытки приблизительно угадать, что произвела бы динамическая схема. На этих устройствах проблемы фирменного цвета, о которой эта статья, попросту не существует — вы владеете всей палитрой, — так что не тратьте усилия на имитацию динамического поведения, которое всё равно недостижимо.
Тестирование не только светлой и тёмной темы, но и разных обоев
Динамический цвет добавляет измерение тестирования, которое легко пропустить: светлая тема, тёмная тема, а теперь ещё и диапазон оттенков обоев. Экран, который корректно выглядит на стандартных обоях Pixel, может иметь нечитаемый контраст на насыщенных обоях, потому что system_accent1 сдвигает оттенок и насыщенность одновременно. Перед публикацией оформленного экрана я проверяю его как минимум на холодных обоях, тёплых обоях и обоях в низконасыщенной серой гамме — это можно настроить через «Настройки → Обои и стиль → выбрать цвет», что позволяет задать конкретный оттенок, не подбирая подходящее изображение. Если коэффициент контраста вашего гармонизированного акцента относительно scheme.surface падает ниже 4.5:1 на любом из этих вариантов, значит фиксированный fraction выше слишком агрессивен для этого диапазона оттенков, и нужна проверка контраста, а не фиксированная константа:
fun harmonizeWithContrast(designColor: Color, scheme: ColorScheme, fraction: Float = 0.15f): Color {
val candidate = harmonize(designColor, scheme.primary, fraction)
return if (ColorUtils.calculateContrast(candidate.toArgb(), scheme.surface.toArgb()) >= 4.5) {
candidate
} else {
designColor // вернуться к неизменённому фирменному цвету вместо низкого контраста
}
}
Что проверить перед публикацией
- Каждый экран, несущий фирменный цвет (отражения иконки, иллюстрации, графики), использует гармонизированное значение, а не сырой динамический primary и не неизменённый фирменный hex.
- Резервная схема для API ниже 31 — это настоящая, вручную настроенная
ColorScheme, а не копия динамической с зашитыми значениями. - Контраст проверен как минимум на одних насыщенных и одних малонасыщенных обоях, а не только на обоях по умолчанию.
- Небрендовые поверхности (фоны, разделители, кнопки по умолчанию) остаются нетронутыми на динамических ролях системы — не поддавайтесь желанию гармонизировать всё подряд.
Динамический цвет — одна из немногих функций платформы Android, благодаря которой приложение ощущается принадлежащим конкретному телефону, а не обезличенным. Его стоит сохранить. Просто не стоит жертвовать ради этого своим фирменным цветом.
// По теме
Ещё из журнала
API SplashScreen в Android в 2026 году: холодный запуск без белой вспышки
Практическое руководство по API SplashScreen в Android — настройка темы, ограничения размера анимированной иконки, которые никто не читает, и keepOnScreenCondition для данных, которые ещё не готовы.
In-App Review API в Android: как просить оценку, не раздражая пользователя
Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.
Доступность на Android в 2026 году: чек-лист по TalkBack и семантике Compose, который реально работает
Практический чек-лист по доступности Android на 2026 год — TalkBack, семантика Compose, размер зон касания и тестовый прогон, который я делаю на каждом экране перед релизом.