При разработке UI-элемента во Flutter можно передавать стилистическую информацию несколькими способами. Например, получать её напрямую из конструктора или получать информацию из InheritedWidget
и так далее. В этом параграфе мы разберём, какой метод лучше использовать в той или иной ситуации.
Из коробки для определения темы 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}
Её единственный UI-параметр — это цвет. И поскольку кнопка используется в различных местах с разными цветами, передавать её цвет через конструктор — это хорошее решение.
Второй пример — это виджет для отображения времени в виде циферблата часов.
Часы будут использоваться в одном месте, и у них должен задаваться только цвет фона и цвет стрелок.
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, поэтому лучше вынести параметры в отдельный класс.
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}
Теперь можно создавать ClockStyle
в любом месте, и не засорять метод build
.
Четвёртый пример — это те же часы, но теперь они располагаются в нескольких местах приложения, но выглядеть при этом должны одинаково.
Есть несколько вариантов как это можно сделать:
- Скопировать
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}
ThemeExtension
ThemeExtension
— это инструмент который избавляет от необходимости создавать InheritedWidget
для кастомной темы. Вместо этого можно положить кастомную тему в 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}
Вывод
Во Flutter есть несколько способов работы со стилистической информацией.
Мы с вами прошли путь от передачи цветов через конструктор, до использования своей темы через InheritedWidget
и ThemeExtension
.
Надеюсь, вы поняли, что нет правильного и неправильного способа работы с темой: каждый из способов подходит для использования в определенных ситуациях. Главное сделать правильный и обоснованный выбор.