2.19. Project: темизация

В этом параграфе мы поговорим о том, как стилизовать UI-элементы. Существует несколько способов (в конструкторе, через InheritedWidget, в классе ThemeExtension и так далее) — и у каждого из них есть свои преимущества и ограничения.

Давайте разберёмся, какой метод лучше использовать в той или иной ситуации.

Кастомные темы

Из коробки для определения темы Flutter использует класс ThemeData. Он передаётся как InheritedWidget внутри виджета MaterialApp или с помощью виджета Theme, и его используют все стандартные компоненты. Например, Scaffold по умолчанию использует ThemeData.scaffoldBackgroundColor.

При разработке кастомных UI компонентов нужно давать потребителям возможность определять цвета и другие UI-параметры. Потому что невозможно сделать UI компонент, который будет хорошо выглядеть во всех местах, куда его захотят интегрировать. Как минимум, нужно изменять цвета в зависимости от тёмной или светлой темы.

Разберем способы работы с UI-параметрами на примерах.

Передача через конструктор

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

Цвет — это её единственный параметр. И поскольку кнопка используется в различных местах с разными цветами, передавать её цвет через конструктор — это хорошее решение.

1class ColoredButton extends StatelessWidget {
2  final Color color;
3  final VoidCallback onTap;
4  const ColoredButton({
5    Key? key,
6    required this.color,
7    required this.onTap,
8  }) : super(key: key);
9
10  @override
11  Widget build(BuildContext context) {
12    ...
13  }
14}

Второй пример — это виджет для отображения времени в виде циферблата часов.

Часы будут использоваться в одном месте, и у них должен задаваться только цвет фона и цвет стрелок.

1class Clock extends StatelessWidget{
2  final Color backgroundColor;
3  final Color handsColor;
4  const Clock(this.backgroundColor, this.handsColor, super.key);
5
6 @override
7 Widget build(BuildContext context){
8 ...
9 }
10}

В таком случае мы можем передавать цвет в конструкторе, в этом нет ничего плохого.

Вынос параметров в отдельный класс

Дальше представим, что UI-параметров у наших часов стало больше.

Пример
1class Clock extends StatelessWidget {
2  final Color backgroundColor;
3  final Color hourHandColor;
4  final Color minuteHandColor;
5  final Color secondHandColor;
6  final Color shadowColor;
7  final Color numbersColor;
8  final Color borderColor;
9  final double hourHandWidth;
10  final double minuteHandWidth;
11  final double secondHandWidth;
12
13  const Clock({
14    required this.backgroundColor,
15    required this.hourHandColor,
16    required this.minuteHandColor,
17    required this.secondHandColor,
18    required this.shadowColor,
19    required this.numbersColor,
20    required this.borderColor,
21    required this.hourHandWidth,
22    required this.minuteHandWidth,
23    required this.secondHandWidth,
24    super.key,
25  });
26
27  @override
28  Widget build(BuildContext context) {
29    ...
30  }
31}

Такой виджет увеличивает метод build, поэтому лучше вынести параметры в отдельный класс — ClockStyle.

Теперь можно создавать ClockStyle в любом месте, и не засорять метод build.

Пример
1class ClockStyle {
2  final Color backgroundColor;
3  final Color hourHandColor;
4  final Color minuteHandColor;
5  final Color secondHandColor;
6  final Color shadowColor;
7  final Color numbersColor;
8  final Color borderColor;
9  final double hourHandWidth;
10  final double minuteHandWidth;
11  final double secondHandWidth;
12
13  const ClockStyle({
14    required this.backgroundColor,
15    required this.hourHandColor,
16    required this.minuteHandColor,
17    required this.secondHandColor,
18    required this.shadowColor,
19    required this.numbersColor,
20    required this.borderColor,
21    required this.hourHandWidth,
22    required this.minuteHandWidth,
23    required this.secondHandWidth,
24  });
25}
1class Clock extends StatelessWidget {
2  final ClockStyle style;
3
4  const Clock({
5    required this.style,
6    super.key,
7  });
8
9  @override
10  Widget build(BuildContext context) {
11    return SizedBox();
12  }
13}

Передача параметров через InheritedWidget

Теперь представим, что наши часы должны располагаться в нескольких местах приложения, но выглядеть при этом должны одинаково.

Есть несколько вариантов как это можно сделать:

  • Скопировать ClockStyle во все места, но это нарушает принцип DRY (Don't repeat yourself), и такой код будет сложно поддерживать.
  • Передавать ClockStyle через конструктор. Но это будет очень неудобно и такое решение тоже сложно поддерживать.
  • Сделать ClockStyle синглтоном. Но тут не стоит забывать про светлую и тёмную тему. Это значит, наш синглтон должен быть мутабельным. Заводить глобальные переменные плохо, так как вы не сможете контролировать доступ к ним.
  • Передавать ClockStyle через InheritedWidget. Это хороший вариант, потому что появляется единое место установки темы. При этом её можно переопределить для определенной части приложения, если это требуется.

Для соблюдения семантики стоит переименовать ClockStyle в ClockTheme. И тогда получится такой виджет темы для наших часов:

1class ClockTheme extends InheritedWidget {
2  final ClockThemeData data;
3
4  const ClockTheme({
5    required this.data,
6    required super.child,
7    super.key,
8  });
9
10  static ClockThemeData? maybeOf(BuildContext context) {
11    final theme = context.dependOnInheritedWidgetOfExactType<ClockTheme>();
12
13    return theme?.data;
14  }
15
16  @override
17  bool updateShouldNotify(ClockTheme oldWidget) => oldWidget.data != data;
18}

В самом виджете часов теперь можно убрать передачу темы через конструктор.

1class Clock extends StatelessWidget {
2  const Clock({super.key});
3
4  @override
5  Widget build(BuildContext context) {
6    ...
7  }
8}

Как можно заметить, в статическом методе ClockTheme.maybeOf используется метод dependOnInheritedWidgetOfExactType. Сделано это для того, чтобы исключить возможность установки цветов из темы в initState.

Пример
1class Clock extends StatefulWidget {
2  const Clock({Key? key}) : super(key: key);
3
4  @override
5  State<Clock> createState() => _ClockState();
6}
7
8class _ClockState extends State<Clock> {
9  late final Color _backgroundColor;
10
11  @override
12  void initState() {
13    super.initState();
14    final theme =
15        ClockTheme.maybeOf(context) ?? ClockThemeData.defaultThemeData;
16    _backgroundColor = theme.backgroundColor;
17  }
18
19  @override
20  Widget build(BuildContext context) {
21    ...
22  }
23}

Так делать нельзя, потому что при изменении темы, например, при переключении с тёмной на светлую, переменная _backgroundColor сохранит своё значение. И интерфейс не поменяет цвет. В таком случае поможет только полное перестроение дерева через смену ключа у корневого виджета. Но это плохая практика.

Получать тему можно, например, в методе didChangeDependencies . Но стоит учитывать, что этот метод может вызываться несколько раз. Поэтому переменные, которые в нём устанавливаются, не должны быть final .

Пример
1class Clock extends StatefulWidget {
2  const Clock({Key? key}) : super(key: key);
3
4  @override
5  State<Clock> createState() => _ClockState();
6}
7
8class _ClockState extends State<Clock> {
9  late Color _backgroundColor;
10
11  @override
12  void didChangeDependencies() {
13    super.didChangeDependencies();
14    final theme =
15        ClockTheme.maybeOf(context) ?? ClockThemeData.defaultThemeData;
16    _backgroundColor = theme.backgroundColor;
17  }
18
19  @override
20  Widget build(BuildContext context) {
21    ...
22  }
23}

Но самый простой способ это получать тему сразу в методе build.

1class Clock extends StatelessWidget {
2  const Clock({super.key});
3
4  @override
5  Widget build(BuildContext context) {
6    final theme = ClockTheme.maybeOf(context) ?? ClockThemeData.defaultThemeData;
7    final _backgroundColor = theme.backgroundColor;
8    ...
9  }
10}

Но есть ещё один инструмент, который избавит от необходимости создавать InheritedWidget для кастомной темы. Это ThemeExtension, его стоит рассмотреть отдельно.

ThemeExtension

Здесь мы положим кастомную тему в extensions у класса ThemeData , а получать будем через Theme.*of*(context).extension() .

Под капотом Theme.extensions устроены крайне просто. Это всего лишь Map<Object, ThemeExtension<dynamic>> где в качестве ключа выступает тип расширения, а в значении — само расширение.

Переделаем ClockThemeData с использованием ThemeExtension.
1class ClockThemeData extends ThemeExtension<ClockThemeData> {
2  static ClockThemeData defaultThemeData = ClockThemeData(...);
3
4  final Color backgroundColor;
5  final Color hourHandColor;
6  final Color minuteHandColor;
7  final Color secondHandColor;
8  final Color shadowColor;
9  final Color numbersColor;
10  final Color borderColor;
11  final double hourHandWidth;
12  final double minuteHandWidth;
13  final double secondHandWidth;
14
15  const ClockThemeData({
16    required this.backgroundColor,
17    required this.hourHandColor,
18    required this.minuteHandColor,
19    required this.secondHandColor,
20    required this.shadowColor,
21    required this.numbersColor,
22    required this.borderColor,
23    required this.hourHandWidth,
24    required this.minuteHandWidth,
25    required this.secondHandWidth,
26  });
27
28  @override
29  ThemeExtension<ClockThemeData> copyWith() {
30   ...
31  }
32
33  @override
34  ThemeExtension<ClockThemeData> lerp(
35    ThemeExtension<ClockThemeData>? other,
36    double t,
37  ) {
38    if (other is! ClockThemeData?) {
39      return this;
40    }
41
42    return ClockThemeData(
43      backgroundColor: Color.lerp(backgroundColor, other?.backgroundColor, t)!,
44      hourHandColor: Color.lerp(hourHandColor, other?.hourHandColor, t)!,
45      secondHandColor: Color.lerp(secondHandColor, other?.secondHandColor, t)!,
46      //...
47      hourHandWidth: lerpDouble(hourHandWidth, other?.hourHandWidth, t)!,
48      minuteHandWidth: lerpDouble(minuteHandWidth, other?.minuteHandWidth, t)!,
49      secondHandWidth: lerpDouble(secondHandWidth, other?.secondHandWidth, t)!,
50    );
51  }
52}

Метод lerp это linear interpolation. То есть в этом методе нужно найти среднее между двумя темами. Этот метод используется при изменении темы для того, чтобы переход был плавным — например, при переключении с тёмной на светлую тему.

В его реализации нет ничего сложного, цвета интерполируются с помощью метода Color.lerp, числа с помощью lerpDouble. Для Duration есть метод lerpDuration. Исходя из этого метода можно понять, что, например, строковых значений в теме быть не должно. Иначе её невозможно будет интерполировать.

Интегрировать ThemeExtension можно через виджет Theme или MaterialApp

1    Theme(
2      data: ThemeData(
3        extensions: [
4          ClockThemeData(
5            ...
6          ),
7        ],
8      ),
9      child: ...,
10    )
11
12    MaterialApp(
13      theme: ThemeData(
14        extensions: [
15          ClockThemeData(
16          ...
17          ),
18        ],
19      ),
20    )

Сам виджет часов будет выглядеть следующим образом:

1class Clock extends StatelessWidget {
2  const Clock({super.key});
3
4  @override
5  Widget build(BuildContext context) {
6    final theme = Theme.of(context).extension<ClockThemeData>() ?? ClockThemeData.defaultThemeData;
7    final _backgroundColor = theme.backgroundColor;
8    ...
9  }
10}

На этом всё!

Мы с вами прошли путь от передачи цветов через конструктор, до использования своей темы через InheritedWidget и ThemeExtension .

Как видите, нет правильного и неправильного способа работы с темой: каждый из способов подходит для использования в определённых ситуациях. И теперь вы знаете, как сделать правильный и обоснованный выбор.

Чтобы закрепить знания, советуем пройти квиз. А в следующем параграфе мы поговорим о навигации — механизме перемещения между экранами приложения.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E
Предыдущий параграф2.18. Project: обработка ошибок
Следующий параграф2.20. Project: основы навигации