При разработке 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 .

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

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.13. Project: логи, обработка ошибок
Следующий параграф2.15. Project: navigation