Вы передаёте ThemeData в MaterialApp(theme: ...), и любой виджет ниже читает её через Theme.of(context). Material-виджеты сами берут из неё цвета, стили текста и формы, поэтому большинству экранов код с цветами вообще не нужен.
**colorScheme**: цветовые роли, которыми пользуется каждый Material-компонент; собирается через ColorScheme.fromSeed(seedColor: brand)
**textTheme и fontFamily**: именованные стили текста вроде titleLarge и bodyMedium и один шрифт на все сразу
Темы компонентов: appBarTheme, filledButtonTheme, inputDecorationTheme дают всем экземплярам виджета одинаковый вид, не трогая места вызова
Как читать: Theme.of(context).colorScheme.primary; вызов ещё и подписывает виджет, так что он перестроится при смене темы
Как поменять часть темы: оберните поддерево в Theme(data: Theme.of(context).copyWith(...))
FlutterMiddle
Темизация и дизайн-система
ThemeData, ColorScheme.fromSeed, Material 3, ThemeExtension, шрифты, тёмная тема
Закрыто вопросов
0 / 22
- 0Повторить
- 4Часто спрашивают
Вопросы
fromSeed считает ваш цвет семенем: строит из его оттенка тональные палитры и берёт каждую роль (primary, surface, error…) с фиксированного тона, поэтому любой цвет и его пара on читаемы по построению. Вот почему primary близок к вашему hex, но не равен ему.
Работайте ролями: primary/onPrimary для главного действия, surface/onSurface для фона страницы и текста на нём, onSurfaceVariant для второстепенного текста, outlineVariant для разделителей
Слои задаются тонами поверхности: surfaceContainerLowest…surfaceContainerHighest разделяют карточки и шторки вместо теней
Тёмная тема не сводится к инверсии: то же семя с brightness: Brightness.dark даёт более светлый primary, потому что средний тон на тёмной поверхности нечитаем
Если hex закреплён договором: dynamicSchemeVariant: DynamicSchemeVariant.fidelity держит роли ближе к семени, а для точного значения нужны copyWith(primary: brand) и проверка контраста своими руками
TextTheme хранит набор именованных стилей текста внутри ThemeData, 15 ролей от displayLarge до labelSmall. Если вы пользуетесь ролью, изменение типографики делается в одном месте, а не в каждом файле, куда скопировали один и тот же TextStyle.
Пять семейств по три размера: display для крупных чисел, headline для первой строки экрана, title для апп-баров и карточек, body для абзацев, label для кнопок и подписей; у каждого есть Large, Medium и Small
Берите роль: Theme.of(context).textTheme.titleMedium; для разового отличия делайте copyWith от роли, чтобы сохранились семейство, высота строки и межбуквенный интервал
Меняйте централизованно: textTheme.apply(fontFamily: 'Inter') для всех ролей, copyWith(titleLarge: ...) для одной
Частая ошибка: зашить color: в роль: в тёмной теме останется тот же серый; пусть цвет приходит из colorScheme
Положите файлы шрифта в проект, объявите их в pubspec.yaml в разделе flutter: fonts: и используйте имя семейства в ThemeData(fontFamily: ...) или в TextStyle. Свои иконки подключают либо как иконочный шрифт через IconData, либо как SVG-файлы, которые рисует пакет flutter_svg.
Одно семейство, несколько файлов: каждое начертание отдельной записью asset: со своим weight:; вес без своего файла откатывается на ближайший
Встроенные наборы иконок: uses-material-design: true подключает шрифт для Icons.*; для CupertinoIcons нужен пакет cupertino_icons
Иконочный шрифт: сгенерируйте его из SVG дизайнера (IcoMoon, FlutterIcon), объявите как обычный шрифт и отдайте наружу константы static const IconData
**Держите IconData константой**: релизная сборка вырезает из иконочных шрифтов неиспользуемые глифы (tree shaking), и не-const IconData роняет такую сборку, если не передать --no-tree-shake-icons
**google_fonts**: удобен, но по умолчанию качает шрифты в рантайме; если типографика должна работать офлайн, кладите файлы в ассеты
Material-виджеты следуют Material Design от Google и настраиваются через ThemeData; Cupertino-виджеты повторяют контролы iOS и настраиваются через CupertinoThemeData. И те и другие являются обычными Flutter-виджетами, поэтому их можно смешивать: CupertinoSwitch внутри MaterialApp работает.
Ни те ни другие не нативные: Flutter рисует их сам на любой платформе; Cupertino выглядит как iOS, но не вызывает UIKit
**Что MaterialApp даёт Cupertino**: CupertinoTheme, выведенную из вашей ThemeData (яркость и основной цвет), и Cupertino-локализации, которые этим виджетам нужны
В обратную сторону хуже: под голым CupertinoApp нет Material-темы, MaterialLocalizations и предка Material, поэтому виджеты вроде TextField и ListTile падают на assert или рисуются без стилей
Как выбирать: один дизайн-язык на приложение, обычно Material под бренд; Cupertino-контролы или конструкторы .adaptive берите там, где пользователь iOS ждёт привычный контрол
Передайте в MaterialApp и theme, и darkTheme, а выбор оставьте themeMode: ThemeMode.system следует настройке устройства, light и dark включают одну из тем принудительно. Обе темы стройте одной функцией, принимающей Brightness, чтобы между ними отличалась только цветовая схема.
Один билдер, два вызова: ColorScheme.fromSeed(seedColor: brand, brightness: b) подбирает тона, читаемые на тёмном; темы компонентов, формы и типографика лежат в одном месте, и радиус карточки не может оказаться верным в светлой теме и неверным в тёмной
Яркость живёт в схеме: ThemeData берёт её из colorScheme.brightness; ломаются в тёмной теме как раз виджеты, где зашит Colors.white или Colors.black вместо onSurface
Переключатель в настройках: храните выбранный ThemeMode в состоянии над MaterialApp (ValueNotifier, провайдер, кубит) и сохраняйте его на диск
**Восстанавливайте до runApp**: иначе первый кадр нарисуется системной темой и приложение заметно перещёлкнется
MediaQuery.platformBrightnessOf сообщает системную настройку телефона, а не тему, которой рисует приложение. С themeMode: ThemeMode.light приложение остаётся светлым, пока телефон в тёмном режиме, поэтому виджет выбирает белый логотип и рисует его на светлом апп-баре.
Две разные яркости: platformBrightnessOf возвращает настройку ОС; Theme.of(context).brightness (то же, что colorScheme.brightness) отражает то, что выбрал MaterialApp с учётом themeMode. Они совпадают, только пока themeMode равен system
Исправление: читать Theme.of(context).brightness; тогда логотип следует и переключателю в приложении, и локальному переопределению Theme, например тёмному промоблоку внутри светлого экрана
Ещё лучше обойтись без ветвления: хранить путь к ассету в ThemeExtension или поставлять один монохромный SVG, тонированный colorScheme.onSurface, тогда логотип не может разойтись с темой
Когда платформенное значение уместно: только там, где нужна сама настройка ОС, например чтобы показать на экране настроек, что сейчас означает «Системная»
Как поймать: виджет-тест с tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark и themeMode: ThemeMode.light, который проверяет, какой ассет получил Image; остальное найдёт grep по platformBrightness за пределами настройки темы
Theme.of(context) ищет тему вверх по дереву от переданного контекста, а этот контекст принадлежит App, который стоит выше построенного им же MaterialApp. Там никакой Theme нет, поэтому вызов возвращает ThemeData.fallback() с базовой фиолетовой схемой.
**Читайте тему ниже MaterialApp**: перенесите обращение в build виджета HomePage или оберните home в Builder: его context уже находится под темой
**MaterialApp(builder: ...): этот колбэк тоже выполняется под темой, и обёрткам на всё приложение, которым она нужна, место там
То же правило в других местах**: Navigator.of и Scaffold.of ломаются так же, если контекст выше виджета, который их предоставляет
**Не в initState**: Theme.of регистрирует зависимость, а там это падает на assert; вызывайте его в build или didChangeDependencies
Стиль закэширован в поле late final, инициализатор которого выполняется один раз, при первом build. Смена темы перестраивает виджет, потому что Theme.of зарегистрировал зависимость, но build продолжает брать TextStyle светлой темы, пока State не будет уничтожен.
Почему карточка обновляется, а текст нет: Card читает тему в собственном build каждый раз; _priceStyle вычислен однажды и больше не пересчитывается
Исправление: получать стиль внутри build: Theme.of выполняет поиск за константное время, а copyWith дешёвый, так что кэшировать нечего
Если значение действительно дорогое: пересчитывайте его в didChangeDependencies, который вызывается после каждого изменения inherited-виджета, от которого зависит State; не в initState, где вызов Theme.of падает на assertion
Та же ловушка в других местах: любой результат X.of(context), сохранённый в поле: размеры из MediaQuery, строки из Localizations, DefaultTextStyle
Как поймать: виджет-тест, который собирает приложение через pumpWidget с ThemeMode.light, затем с ThemeMode.dark, и ожидает, что цвет Text равен primary тёмной схемы; golden-тесты в обеих темах тоже это покажут
Theme(data: ThemeData(...)) не дополняет тему поддерева, а заменяет её целиком: у новой ThemeData дефолтный шрифт, дефолтные темы компонентов и нет расширений. Локальную тему нужно выводить из унаследованной через Theme.of(context).copyWith(...).
Что потерялось: fontFamily и textTheme, все темы компонентов вроде filledButtonTheme, список extensions и тёмный режим: этот блок остаётся светлым, когда приложение тёмное
Исправление: data: theme.copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.red, brightness: theme.brightness)), где в theme лежит Theme.of(context)
Ещё точнее: чтобы перекрасить только кнопки, оберните поддерево в FilledButtonTheme(data: ...), а остальную тему не трогайте
Почему это тихо компилируется: сама новая ThemeData корректна, просто это полноценная тема, которая ничего не знает о вашей
WidgetStatePropertyAll(brand) отвечает brand на любое состояние, включая disabled, а значение из style побеждает и тему компонента, и дефолты кнопки, поэтому цвет выключенного состояния из дефолтов просто не получает слова.
Порядок разрешения: style самого виджета, затем ThemeData.filledButtonTheme, затем собственные дефолты FilledButton; по каждому свойству отдельно побеждает первое ненулевое значение
Состояния: каждое свойство задаётся как WidgetStateProperty, которое вычисляется по текущим WidgetState кнопки: pressed, hovered, focused, disabled
Исправление: FilledButton.styleFrom(backgroundColor: brand): для disabled он возвращает null, и это состояние проваливается к дефолтам; WidgetStateProperty.resolveWith нужен только для своей логики по состояниям
**style работает как заплатка, а не замена**: если задать только backgroundColor, отступы, форма и стиль текста останутся из темы
Общий вид выносите в тему: filledButtonTheme: FilledButtonThemeData(style: ...); style в месте вызова оставьте для настоящих исключений
Flutter рисует только те веса, файлы которых объявлены в pubspec.yaml. Запрошенный FontWeight он сопоставляет с ближайшим объявленным файлом этого семейства, и вес без своего файла молча берёт соседний: если положили только 400 и 700, w500 нарисуется как 400, а w600 нарисуется как 700.
**По записи asset: на файл, у каждой правильный weight:: объявляйте тот вес, которым файл является на самом деле (Medium объявляйте как 500), и держите его согласованным с весом, записанным в самом файле шрифта
Все веса под одним family:**: если объявить Inter и Inter-Bold двумя семействами, fontWeight между ними уже не переключит
Отсутствующий вес берётся у соседа, а не рисуется: спросите дизайнера, какие веса реально нужны, и положите ровно эти файлы
Вариативные шрифты: задавайте ось явно через fontVariations: [FontVariation.weight(600)], а не полагайтесь на один fontWeight
**google_fonts**: подгружает начертания в рантайме; положите файлы в ассеты и поставьте GoogleFonts.config.allowRuntimeFetching = false, чтобы отсутствие сети не меняло типографику
Под дизайн-токенами понимают именованные решения дизайн-системы: цвета, стили текста, отступы, радиусы, хранящиеся как данные, а не как литералы. Примитивные токены называют сырые значения (teal600), семантические называют назначение (surfaceMuted, textDanger), и виджеты должны видеть только семантические.
Три слоя: примитивы (палитра), семантические токены, которые в каждой теме указывают на свои примитивы, и токены компонентов (button.radius) только там, где одному виджету нужно своё
Где они живут во Flutter: семантические цвета в первую очередь ложатся на роли ColorScheme, то, чему роли нет, уходит в ThemeExtension, стили текста попадают в TextTheme, токены компонентов уходят в темы компонентов вроде filledButtonTheme
Зачем семантика: тёмная тема, второй бренд или правка контраста меняют то, на что указывает surfaceMuted, а не сорок виджетов
Один источник правды: дизайнеры держат токены как переменные Figma; выгрузите их в JSON (Tokens Studio, Style Dictionary) и генерируйте Dart-классы, а не перепечатывайте hex руками
Отступы тоже считаются токенами: фиксированная шкала (4, 8, 12, 16, 24…) за именованными константами, чтобы EdgeInsets.all(13) бросался в глаза на ревью
ThemeExtension<T> даёт встроенный способ хранить свои токены внутри ThemeData: вы наследуетесь от него, регистрируете экземпляры в ThemeData.extensions и читаете их через Theme.of(context).extension<T>(), и тогда они переключаются и анимируются вместе с темой, как встроенные цвета.
Что реализовать: final-поля, copyWith и lerp(other, t), который AnimatedTheme вызывает на каждом кадре смены темы
Регистрируйте в светлой и тёмной: расширению, которое есть только в одной теме, не с чем интерполироваться, и при переключении оно прыгает
Интерполируйте то, что непрерывно: цвета через Color.lerp, градиенты через Gradient.lerp; значение, которое не может быть дробным, переключайте на t < 0.5
Оберните обращение: extension<T>() возвращает null, если ничего не зарегистрировано; статический of(context) превращает это в одну понятную ошибку вместо ! в каждом виджете
Частая ошибка: держать токены в const-файле, который виджеты импортируют напрямую: до него не дотянется никакое переключение темы
Material 3, дефолт с Flutter 3.16, красит каждый компонент ролями ColorScheme вместо primaryColor, разделяет слои тоном поверхности вместо тени и меняет типографику, формы и ряд виджетов. Видимые изменения заметить легко; тихие регрессии приносят старые переопределения.
Апп-бар: больше не primaryColor: это плоский surface, который переключается на surfaceContainer, когда под него уезжает контент
Компоненты: главным стилем становится FilledButton, радиусы скругления растут, Switch, Slider, Chip, диалоги и FAB перерисованы, а Typography.material2021 сдвигает высоту строк и переносы
Тихие регрессии: зашитый текст Colors.white теперь лежит на светлой поверхности; код, задающий ThemeData.primaryColor, компилируется, но компоненты его больше не читают, и переопределение ничего не делает; старые переопределения ButtonStyle с радиусами M2 продолжают бороться с новыми дефолтами
**useMaterial3: false: временный флаг для миграции, а не долгосрочный вариант: Material 2 больше не развивают
Как мигрировать**: экран за экраном с golden-тестами и чинить в основном удалением переопределений: многие обходы M2 боролись с дефолтами, которые M3 уже делает правильно
Каждый Theme заодно предоставляет CupertinoTheme (MaterialBasedCupertinoThemeData), которая берёт из ThemeData лишь несколько значений: яркость, основной цвет, контрастный к нему цвет и фон scaffold (плюс цвет ручек выделения). Всё остальное, включая шрифты, остаётся дефолтами iOS, пока это не задано в cupertinoOverrideTheme.
Что переходит: brightness, primaryColor из colorScheme.primary, primaryContrastingColor из colorScheme.onPrimary и scaffoldBackgroundColor (плюс selectionHandleColor из textSelectionTheme)
Что не переходит: barBackgroundColor и весь CupertinoTextThemeData, поэтому ваш шрифт не доезжает до заголовка CupertinoNavigationBar; темы Material-компонентов здесь ничего не значат
**cupertinoOverrideTheme**: заданные поля побеждают, оставленные null по-прежнему приходят из Material-темы; applyThemeToAll: true заставляет контролы вроде CupertinoSwitch брать primaryColor вместо системного зелёного
Читайте нужную тему: внутри Cupertino-виджетов берите CupertinoTheme.of(context).textTheme, а не Material-овский textTheme
Где это бьёт: Slider.adaptive, Checkbox.adaptive и CircularProgressIndicator.adaptive на iOS строят настоящие Cupertino-виджеты, поэтому следуют этому мосту; Switch.adaptive остаётся Material-Switch, нарисованным в стиле iOS, и по-прежнему читает SwitchThemeData
Встроенной палитры обоев во Flutter нет: её читают через DynamicColorBuilder из пакета dynamic_color, используют эти схемы, когда они не null, а во всех остальных случаях откатываются на ColorScheme.fromSeed(seedColor: brandSeed).
Где это доступно: Android 12 и новее; на iOS, в вебе и на старом Android билдер отдаёт null, так что запасной вариант становится основным путём, а не редким случаем
Гармонизируйте фиксированные цвета: зелёный статуса или серия на графике могут конфликтовать с чужими обоями; harmonizeWith(scheme.primary) сдвигает оттенок к палитре, но цвет по-прежнему читается как зелёный
Откажитесь, когда цвет и есть продукт: банковская карта, блок с логотипом, приложение, где всё держится на бренде: динамика отдаёт ваш primary той фотографии, которую выбрал пользователь
Тестируйте враждебные палитры: команда разрабатывает на дефолтных обоях; golden-тест с заведомо кричащей ColorScheme рано покажет нечитаемые состояния
Берите ThemeExtension, когда значение по природе относится к теме и должно интерполироваться вместе с ней, и свой InheritedWidget, когда это не так: другое время жизни, другая частота изменений или значения, которым в ThemeData делать нечего.
Что даёт и чего стоит ThemeExtension
**Оно едет внутри ThemeData.lerp: его токены интерполируются за 200 мс смены темы вместе со встроенными цветами
Оно доступно везде, где работает Theme.of**: включая диалоги и переходы роутов, а Theme(data: ...) переопределяет его для поддерева
Платить приходится широкой зависимостью: виджет, читающий только spacing, перестраивается на любой смене цвета, а все поля обязаны быть иммутабельными и уметь lerp
Свой виджет нужен, когда
Частота изменений другая: живой акцент от играющего трека, брейкпоинт, который следует за размером окна
Он несёт поведение: контроллер, а не значения для отрисовки
Сверху нет Material: под голым CupertinoApp нет ThemeData, которую можно расширить
Нужны перестроения по аспектам: InheritedModel позволяет зависеть от spacing и игнорировать цвет
В обоих случаях дайте maybeOf и of с понятным сообщением об ошибке и выберите для каждого токена ровно один дом.
MaterialApp оборачивает приложение в AnimatedTheme, который интерполирует ThemeData за 200 мс (kThemeAnimationDuration), поэтому всё, что прыгает, либо находится снаружи этого виджета, либо является значением, которое ThemeData.lerp не интерполирует.
Почему значения прыгают
**Часть полей переключается скачком на t == 0.5**: platform, pageTransitionsTheme, splashFactory, materialTapTargetSize; они меняются за один кадр намеренно
**brightness тоже переключается скачком: светлый или тёмный логотип, выбранный по нему, меняется мгновенно, а не плавно
Расширение, зарегистрированное только в одной теме**: если оно есть только в новой теме, оно появляется целиком уже на t = 0; регистрируйте каждый ThemeExtension в обеих
**lerp, который возвращает this или other: обычная причина прыгающего градиента, и эта ошибка самодельная
Откуда белый кадр
Перестроение выше MaterialApp**: сменившийся key или FutureBuilder над сохранённым режимом темы, который ничего не рисует, пока грузится SharedPreferences; читайте сохранённый режим до runApp
Всё, что рисует себя само: картинки и platform views не интерполируются; плавно подменяйте их через AnimatedSwitcher (кросс-фейд)
Для отладки замедлите переключение через themeAnimationDuration и запишите экран.
Пакет экспортирует семантические токены, фабрику ThemeData и брендовые компоненты (но не сырые константы), и эта поверхность версионируется как любой публичный API.
Фабрика темы на яркость (и на бренд): AcmeTheme.light() и .dark(); приложение отдаёт результат в MaterialApp, а не собирает ColorScheme само
Токены за типизированным аксессором: context.acme.spacing.md, чтобы ни одно приложение не писало EdgeInsets.all(16); всё внутри lib/src остаётся приватным, а линт implementation_imports ловит приложения, которые туда лезут
Semver на имена, а не на пиксели: переименовать surfaceMuted значит выпустить мажорную версию, поменять hex за ним значит выпустить минорную с записью в changelog; сначала помечайте устаревшим, потом удаляйте
Как приложения зависят от пакета: между репозиториями используют закреплённую версию с приватного pub-сервера или git:-зависимость по тегу, чтобы каждое приложение обновлялось в своём темпе; в монорепозитории path: допустим, только если CI собирает и прогоняет golden-тесты всех приложений на каждое изменение дизайн-системы
Golden-тесты живут в пакете: по одному на компонент и тему, чтобы правка токена приезжала понятным дифом картинок
Сделайте это падением CI, а не комментарием на ревью: свой линт на сырые литералы вне пакета дизайн-системы, закоммиченный baseline, чтобы число нарушений могло только уменьшаться, и golden- и контрастные тесты по темам для того, чего линт не видит.
Правила: одно на Color(...) и Colors.* вне пакета дизайн-системы, второе на числа в EdgeInsets и SizedBox, которых нет в шкале отступов; пишутся на custom_lint или как плагин анализатора в Dart 3.10 и новее
Падать должен CI: подчёркивание в IDE ничего не роняет; запускайте линт в CI с уровнем error (custom_lint требует отдельного шага dart run custom_lint)
Храповик по baseline: закоммитьте текущее число нарушений по папкам и роняйте сборку, только когда оно растёт: правило защищает новый код с первого дня без PR на 400 файлов
Golden по темам: по одному на компонент в светлой и тёмной теме; настоящий токен в неправильной роли (error у просто выбранного чипа) виден только на дифе картинок
Контраст как юнит-тест: проверяйте каждую пару on* каждой ColorScheme на 4.5:1, чтобы правка токена не выпустила нечитаемый текст
Обход допустим только с причиной: // ignore: разрешён лишь с комментарием, и игноры считаются в том же baseline
Мигрируйте по паттерну strangler, фича за фичей, за слоем совместимости: сначала определите токены, затем направьте каждую легаси-константу на один из них, а дальше удаляйте константы экран за экраном, пока CI храповиком уменьшает остаток.
Сначала инвентаризация: сгрепайте литералы Color(0x...): обычно там заметно меньше уникальных значений, чем литералов: один и тот же серый, набранный девятью способами
Сведите их к токенам вместе с дизайнером: решения «и так сойдёт» принимаются один раз здесь, а не в каждом файле тем, кому достался тикет
Обкатайте тему на одном тихом экране: вместе с golden-тестами; именно там всплывут забытые токены
Влейте compat-файл: @Deprecated('Use context.acme.surfaceMuted') const kCardGrey = ...; в этот день визуально ничего не меняется, зато анализатор перечисляет все места вызова
Мигрируйте по фичам: снимайте golden до изменения, чтобы заметно поехавший цвет был решением, которое кто-то подтвердил
Никогда не открывайте PR на 400 файлов: его нельзя отревьюить, и один неправильный серый спрячется среди остальных 399
Пропишите compat-файлу версию удаления: иначе временный мост тихо станет дизайн-системой