При разработке UI-элемента во Flutter можно передавать стилистическую информацию несколькими способами. Например, получать её напрямую из конструктора или получать информацию из InheritedWidget
и так далее. В этом параграфе мы разберём, какой метод лучше использовать в той или иной ситуации.
Из коробки для определения темы Flutter использует класс ThemeData. Он передаётся как InheritedWidget
внутри виджета MaterialApp
или с помощью виджета Theme
, и его используют все стандартные компоненты. Например, Scaffold
по умолчанию использует ThemeData.scaffoldBackgroundColor
.
Кастомные темы
При разработке кастомных UI компонентов нужно давать потребителям возможность определять цвета и другие UI-параметры. Потому что невозможно сделать UI компонент, который будет хорошо выглядеть во всех местах, куда его захотят интегрировать. Как минимум, нужно изменять цвета в зависимости от тёмной или светлой темы.
Разберем способы работы с UI-параметрами на примерах.
Первый пример — это кнопка. Эта кнопка будет располагаться на множестве экранов. Но при этом на разных экранах может быть свой цвет.
class ColoredButton extends StatelessWidget {
final Color color;
final VoidCallback onTap;
const ColoredButton({
Key? key,
required this.color,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
...
}
}
Её единственный UI-параметр — это цвет. И поскольку кнопка используется в различных местах с разными цветами, передавать её цвет через конструктор — это хорошее решение.
Второй пример — это виджет для отображения времени в виде циферблата часов.
Часы будут использоваться в одном месте, и у них должен задаваться только цвет фона и цвет стрелок.
class Clock extends StatelessWidget{
final Color backgroundColor;
final Color handsColor;
const Clock(this.backgroundColor, this.handsColor, super.key);
@override
Widget build(BuildContext context){
...
}
}
В таком случае мы можем передавать цвет в конструкторе, в этом нет ничего плохого.
Третий пример — это те же часы, но с большим количеством UI-параметров.
class Clock extends StatelessWidget {
final Color backgroundColor;
final Color hourHandColor;
final Color minuteHandColor;
final Color secondHandColor;
final Color shadowColor;
final Color numbersColor;
final Color borderColor;
final double hourHandWidth;
final double minuteHandWidth;
final double secondHandWidth;
const Clock({
required this.backgroundColor,
required this.hourHandColor,
required this.minuteHandColor,
required this.secondHandColor,
required this.shadowColor,
required this.numbersColor,
required this.borderColor,
required this.hourHandWidth,
required this.minuteHandWidth,
required this.secondHandWidth,
super.key,
});
@override
Widget build(BuildContext context) {
...
}
}
Такой виджет увеличивает метод build, поэтому лучше вынести параметры в отдельный класс.
class ClockStyle {
final Color backgroundColor;
final Color hourHandColor;
final Color minuteHandColor;
final Color secondHandColor;
final Color shadowColor;
final Color numbersColor;
final Color borderColor;
final double hourHandWidth;
final double minuteHandWidth;
final double secondHandWidth;
const ClockStyle({
required this.backgroundColor,
required this.hourHandColor,
required this.minuteHandColor,
required this.secondHandColor,
required this.shadowColor,
required this.numbersColor,
required this.borderColor,
required this.hourHandWidth,
required this.minuteHandWidth,
required this.secondHandWidth,
});
}
class Clock extends StatelessWidget {
final ClockStyle style;
const Clock({
required this.style,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox();
}
}
Теперь можно создавать ClockStyle
в любом месте, и не засорять метод build
.
Четвёртый пример — это те же часы, но теперь они располагаются в нескольких местах приложения, но выглядеть при этом должны одинаково.
Есть несколько вариантов как это можно сделать:
- Скопировать
ClockStyle
во все места, но это нарушает принцип DRY (Don't repeat yourself), и такой код будет сложно поддерживать. - Передавать
ClockStyle
через конструктор. Но это будет очень неудобно и такое решение тоже сложно поддерживать. - Сделать
ClockStyle
синглтоном. Но тут не стоит забывать про светлую и тёмную тему. Это значит, наш синглтон должен быть мутабельным. Заводить глобальные переменные плохо, так как вы не сможете контролировать доступ к ним. - Передавать
ClockStyle
черезInheritedWidget
. Это хороший вариант, потому что появляется единое место установки темы. При этом её можно переопределить для определенной части приложения, если это требуется.
Для соблюдения семантики стоит переименовать ClockStyle
в ClockTheme
.
И тогда получится такой виджет темы для наших часов:
class ClockTheme extends InheritedWidget {
final ClockThemeData data;
const ClockTheme({
required this.data,
required super.child,
super.key,
});
static ClockThemeData? maybeOf(BuildContext context) {
final theme = context.dependOnInheritedWidgetOfExactType<ClockTheme>();
return theme?.data;
}
@override
bool updateShouldNotify(ClockTheme oldWidget) => oldWidget.data != data;
}
В самом виджете часов теперь можно убрать передачу темы через конструктор.
class Clock extends StatelessWidget {
const Clock({super.key});
@override
Widget build(BuildContext context) {
...
}
}
Как можно заметить, в статическом методе ClockTheme.maybeOf
используется метод dependOnInheritedWidgetOfExactType
. Сделано это для того, чтобы исключить возможность установки цветов из темы в initState
.
Пример:
class Clock extends StatefulWidget {
const Clock({Key? key}) : super(key: key);
@override
State<Clock> createState() => _ClockState();
}
class _ClockState extends State<Clock> {
late final Color _backgroundColor;
@override
void initState() {
super.initState();
final theme =
ClockTheme.maybeOf(context) ?? ClockThemeData.defaultThemeData;
_backgroundColor = theme.backgroundColor;
}
@override
Widget build(BuildContext context) {
...
}
}
Так делать нельзя, потому что при изменении темы, например, при переключении с тёмной на светлую, переменная _backgroundColor
сохранит своё значение. И интерфейс не поменяет цвет. В таком случае поможет только полное перестроение дерева через смену ключа у корневого виджета. Но это плохая практика.
Получать тему можно, например, в методе didChangeDependencies
. Но стоит учитывать, что этот метод может вызываться несколько раз. Поэтому переменные, которые в нём устанавливаются, не должны быть final
.
class Clock extends StatefulWidget {
const Clock({Key? key}) : super(key: key);
@override
State<Clock> createState() => _ClockState();
}
class _ClockState extends State<Clock> {
late Color _backgroundColor;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final theme =
ClockTheme.maybeOf(context) ?? ClockThemeData.defaultThemeData;
_backgroundColor = theme.backgroundColor;
}
@override
Widget build(BuildContext context) {
...
}
}
Но самый простой способ это получать тему сразу в методе build
.
class Clock extends StatelessWidget {
const Clock({super.key});
@override
Widget build(BuildContext context) {
final theme = ClockTheme.maybeOf(context) ?? ClockThemeData.defaultThemeData;
final _backgroundColor = theme.backgroundColor;
...
}
}
ThemeExtension
ThemeExtension
— это инструмент который избавляет от необходимости создавать InheritedWidget
для кастомной темы. Вместо этого можно положить кастомную тему в extensions
у класса ThemeData
, а получать через Theme.*of*(context).extension()
.
Под капотом Theme.extensions
устроены крайне просто. Это всего лишь Map<Object, ThemeExtension<dynamic>>
где в качестве ключа выступает тип расширения, а в значении — само расширение.
Переделаем ClockThemeData
с использованием ThemeExtension
.
class ClockThemeData extends ThemeExtension<ClockThemeData> {
static ClockThemeData defaultThemeData = ClockThemeData(...);
final Color backgroundColor;
final Color hourHandColor;
final Color minuteHandColor;
final Color secondHandColor;
final Color shadowColor;
final Color numbersColor;
final Color borderColor;
final double hourHandWidth;
final double minuteHandWidth;
final double secondHandWidth;
const ClockThemeData({
required this.backgroundColor,
required this.hourHandColor,
required this.minuteHandColor,
required this.secondHandColor,
required this.shadowColor,
required this.numbersColor,
required this.borderColor,
required this.hourHandWidth,
required this.minuteHandWidth,
required this.secondHandWidth,
});
@override
ThemeExtension<ClockThemeData> copyWith() {
...
}
@override
ThemeExtension<ClockThemeData> lerp(
ThemeExtension<ClockThemeData>? other,
double t,
) {
if (other is! ClockThemeData?) {
return this;
}
return ClockThemeData(
backgroundColor: Color.lerp(backgroundColor, other?.backgroundColor, t)!,
hourHandColor: Color.lerp(hourHandColor, other?.hourHandColor, t)!,
secondHandColor: Color.lerp(secondHandColor, other?.secondHandColor, t)!,
//...
hourHandWidth: lerpDouble(hourHandWidth, other?.hourHandWidth, t)!,
minuteHandWidth: lerpDouble(minuteHandWidth, other?.minuteHandWidth, t)!,
secondHandWidth: lerpDouble(secondHandWidth, other?.secondHandWidth, t)!,
);
}
}
Метод lerp
это linear interpolation. То есть в этом методе нужно найти среднее между двумя темами. Этот метод используется при изменении темы для того, чтобы переход был плавным — например, при переключении с темной на светлую тему.
В его реализации нет ничего сложного, цвета интерполируются с помощью метода Color.lerp
, числа с помощью lerpDouble
. Для Duration
есть метод lerpDuration
. Исходя из этого метода можно понять, что, например, строковых значений в теме быть не должно. Иначе её невозможно будет интерполировать.
Интегрировать ThemeExtension можно через виджет Theme или MaterialApp
Theme(
data: ThemeData(
extensions: [
ClockThemeData(
...
),
],
),
child: ...,
)
MaterialApp(
theme: ThemeData(
extensions: [
ClockThemeData(
...
),
],
),
)
Сам виджет часов будет выглядеть следующим образом:
class Clock extends StatelessWidget {
const Clock({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).extension<ClockThemeData>() ?? ClockThemeData.defaultThemeData;
final _backgroundColor = theme.backgroundColor;
...
}
}
Вывод
Во Flutter есть несколько способов работы со стилистической информацией.
Мы с вами прошли путь от передачи цветов через конструктор, до использования своей темы через InheritedWidget
и ThemeExtension
.
Надеюсь, вы поняли, что нет правильного и неправильного способа работы с темой: каждый из способов подходит для использования в определенных ситуациях. Главное сделать правильный и обоснованный выбор.