Содержание

Делаем свой UI Kit для Flutter: кнопки

В любом серьёзном проекте мобильного приложения рано или поздно появляется UI Kit – набор готовых решений пользовательского интерфейса. Всевозможные кнопки, поля ввода, радиобаттоны и чекбоксы – любые переиспользуемые виджеты.

Грамотно подготовленный UI Kit экономит время дизайнеров и программистов, как следствие – удешевляет разработку проекта. Благодаря набору унифицированных компонентов удаётся сохранить преемственность интерфейса на разных экранах, да и разработчикам импонирует использование принципа DRY. Если в дальнейшем потребуется изменение дизайна элементов, то перенастроить их будет довольно легко.

Однако реализация такой библиотеки компонентов может оказаться непростой задачей. Скажем, у Flutter есть механизм тем, но его возможностей может оказаться недостаточно для сложной дизайн-системы. К тому же, как программисты, мы формируем довольно противоречивые требования к таким компонентам – с одной стороны, хочется писать как можно меньше бойлерплейта и скрыть в их цифровом нутре максимум дефолтных значений (таких как цвет, отступы, размеры и т.д.), а с другой – хочется иметь максимальную гибкость, чтобы при необходимости добавить нестандартную кнопку не копировать код виджета со слезами на глазах.

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

Поскольку я не являюсь дизайнером, то возьму за основу дизайн-систему VKUI, которой команда ВКонтакте любезно поделилась на Хабре.

Итак, представим, что в нашем проекте появилась разработанная дизайнерами система компонентов, похожая на эту:

Если внимательно посмотреть на матрицу кнопок, то можно заметить, что осями координат являются размер (small, medium, large) и тип (primary, secondary и т.д.) кнопок. Пересечение строки и столбца этой матрицы даёт конкретный вид кнопки. Идея заключается в том, чтобы предоставить возможность указания типа и размера кнопки и на основе этих данных получить готовый стиль.

Матрица кнопок

Давайте создадим новый проект и приступим к работе. Я буду использовать недавно вышедший Flutter 2.2, который поддерживает null safety – учитывайте это, если ещё не мигрировали на вторую версию Flutter.

Базовый стиль

В библиотеке VKUI девять типов кнопок, в общем случае их может быть больше или меньше, мы же ограничимся четырьмя – primary, secondary, outlined и error. Это позволит продемонстрировать идею, но не потребует слишком много усилий на реализацию.

От типа кнопки обычно зависят лишь некоторые характеристики компонента – цвет фона, наличие рамки и т.д. Другие характеристики, напротив, остаются неизменными для всех типов (или редко меняются) – это могут быть отступы, радиус скругления, стиль текста. Поэтому имеет смысл подготовить базовый стиль компонента, а котором указаны редко изменяемые свойства и который служит основой для всех остальных стилей. Конкретные стили для каждого типа будут переопределять необходимые свойства базового стиля.

Давайте создадим файл theme.dart и определим в нём базовый стиль кнопок для нашего проекта:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
final baseButtonStyle = TextButton.styleFrom(
  minimumSize: const Size(92, 48),
  padding: const EdgeInsets.symmetric(horizontal: 16),
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  textStyle: const TextStyle(
    fontSize: 14,
    fontWeight: FontWeight.w500,
    letterSpacing: 0.2,
  ),
);

Теперь можно приступать к реализации типов наших кнопок.

Определяем тип кнопок

Мы уже определились с тем, какие типы кнопок нам будут доступны и кажется разумным определить перечисление этих типов. Давайте создадим файл button_type.dart и напишем в нём следующий код:

1
enum ButtonType { primary, secondary, outlined, error }

Как я уже говорил, общая идея заключается в том, что мы берём базовый стиль кнопки и в зависимости от выбранного типа меняем те или иные свойства компонента. В нашем примере договоримся, что будем менять цвет фона и текста и, может быть, цвет рамки вокруг кнопки. Для удобства, я опишу в отдельном файле специальный класс CustomButtonStyle, в котором укажу изменяемые атрибуты стилей:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class CustomButtonStyle {
  const CustomButtonStyle({
    this.backgroundColor,
    this.foregroundColor,
    this.side,
  });

  final Color? backgroundColor;
  final Color? foregroundColor;
  final BorderSide? side;
}

Все поля необязательные (если не определено, то используется значение базового стиля), поэтому помечены как nullable-типы.

Теперь нам нужно указать отличия от базового стиля для каждого типа кнопки. Давайте определим класс CustomButtonThemeData, в котором опишем поля, совпадающие по названию с типами кнопок, каждое из которых будет хранить кастомный стиль – это поможет нам не пропустить стиль для какого-нибудь типа. Статическая типизация – очень полезная штука!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class CustomButtonThemeData {
  CustomButtonThemeData({
    required this.primary,
    required this.secondary,
    required this.outlined,
    required this.error,
  });

  final CustomButtonStyle primary;
  final CustomButtonStyle secondary;
  final CustomButtonStyle outlined;
  final CustomButtonStyle error;
}

Для каждой разрабатываемой нами темы мы будем создавать экземпляр класса CustomButtonThemeData, поля которого будут хранить стиль кнопки для соответствующего типа. Если в дальнейшем потребуется добавить новый тип, то будет достаточно добавить соответствующее поле и компилятор подскажет вам, какие файлы необходимо обновить.

Напомню, что в нашем примере у кнопок может быть четыре типа:

  • primary: цвет фона совпадает с главным (основным, первичным) цветом темы, цвет текста – белый;
  • secondary: серый цвет фона, цвет текста совпадает с основным;
  • outlined: цвет фона прозрачный, цвет текста и рамки – первичный;
  • error: красный цвет фона, белый цвет текста.

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

Итак, вот четыре цвета для нашей первой темы:

1
2
3
4
const blue = Color(0xFF1BA1E2);
const secondary = Color.fromRGBO(0, 28, 61, 0.05);
const white = Colors.white;
const error = Color(0xFFFF3B30);

Теперь нам нужно создать объект CustomButtonThemeData с нужными свойствами. Один и тот же цвет иногда будет использоваться в разных типах, хотелось бы избежать утомительного описывания свойств каждого типа, когда количество тем будет увеличиваться. Поэтому предлагаю определить конструктор-фабрику, который подставит свойства в нужные места и вернёт требуемый объект. В моём случае он выглядит следующим образом:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
factory CustomButtonThemeData.fromColors({
    required Color primary,
    required Color inverse,
    required Color secondary,
    required Color error,
  }) {
    return CustomButtonThemeData(
      primary: CustomButtonStyle(
        backgroundColor: primary,
        foregroundColor: inverse,
      ),
      secondary: CustomButtonStyle(
        backgroundColor: secondary,
        foregroundColor: primary,
      ),
      outlined: CustomButtonStyle(
        backgroundColor: Colors.transparent,
        foregroundColor: primary,
        side: BorderSide(
          color: primary,
          width: 2,
        ),
      ),
      error: CustomButtonStyle(
        backgroundColor: error,
        foregroundColor: inverse,
      ),
    );
  }

С использованием этого конструктора подготовим объект blueButtonThemeData:

1
2
3
4
5
6
final blueButtonThemeData = CustomButtonThemeData.fromColors(
  primary: blue,
  inverse: white,
  secondary: secondary,
  error: error,
);

Теперь мы готовы реализовать подстановку нужных стилей для каждого типа. Для этого я предлагаю написать расширение (extension) типа ButtonStyle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension ButtonStyleByType on ButtonStyle {
  ButtonStyle byType(ButtonType type, CustomButtonThemeData data) {
    switch (type) {
      case ButtonType.primary:
        return _copyWithCustom(data.primary);
      case ButtonType.secondary:
        return _copyWithCustom(data.secondary);
      case ButtonType.outlined:
        return _copyWithCustom(data.outlined);
      case ButtonType.error:
        return _copyWithCustom(data.error);
    }
  }

  ButtonStyle _copyWithCustom(CustomButtonStyle style) {
    return copyWith(
      backgroundColor: MaterialStateProperty.all(style.backgroundColor),
      foregroundColor: MaterialStateProperty.all(style.foregroundColor),
      side: MaterialStateProperty.all(style.side),
    );
  }
}

Давайте проверим нашу идею в действии. Опишем виджет кнопки с поддержкой типов стилей следующим образом:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class BaseButton extends StatelessWidget {
  const BaseButton({
    Key? key,
    required this.type,
    this.onPressed,
    required this.child,
  }) : super(key: key);

  final ButtonType type;
  final void Function()? onPressed;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final defaultStyle = baseButtonStyle.byType(type, blueButtonThemeData);
    return TextButton(
      style: defaultStyle,
      onPressed: onPressed,
      child: child,
    );
  }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class HomeScreen extends StatefulWidget {
  @override
  _HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  Widget get _buttonTitle => Text('button');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: _buildGrid(),
      ),
    );
  }

  Widget _buildGrid() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        SizedBox(height: 20),
        _buildPrimaryRow(),
        SizedBox(height: 20),
        _buildSecondaryRow(),
        SizedBox(height: 20),
        _buildOutlinedRow(),
        SizedBox(height: 20),
        _buildErrorRow(),
      ],
    );
  }

  Widget _buildPrimaryRow() {
    return _buildRow([
      Label('Primary'),
      BaseButton(
        type: ButtonType.primary,
        child: _buttonTitle,
        onPressed: () {},
      ),
    ]);
  }

  Widget _buildSecondaryRow() {
    return _buildRow([
      Label('Secondary'),
      BaseButton(
        type: ButtonType.secondary,
        child: _buttonTitle,
        onPressed: () {},
      ),
    ]);
  }

  Widget _buildOutlinedRow() {
    return _buildRow([
      Label('Outlined'),
      BaseButton(
        type: ButtonType.outlined,
        child: _buttonTitle,
        onPressed: () {},
      ),
    ]);
  }

  Widget _buildErrorRow() {
    return _buildRow([
      Label('Error'),
      BaseButton(
        type: ButtonType.error,
        child: _buttonTitle,
        onPressed: () {},
      ),
    ]);
  }

  Widget _buildRow(List<Widget> children) {
    return Row(
      children: children.map((e) => Expanded(child: Center(child: e))).toList(),
    );
  }
}

Здесь Label – это простой текстовый компонент, который был вынесен в отдельный класс из соображений удобства. Результат можно увидеть на следующем изображении:

Выглядит неплохо, но есть одна неприятность: если в обычную кнопку не передать метод в атрибут onPressed, то она становится неактивной и визуально это выражается в изменении цвета – кнопка выглядит серой. В нашем же случае никаких визуальных изменений не будет (хотя при наведении на такую кнопку курсора мыши можно заметить, что индикатор курсора меняется).

Чтобы исправить этот недостаток, нам потребуется доработать метод копирования стиля в расширении ButtonStyleByType: вместо MaterialStateProperty.all воспользуемся конструктором MaterialStateProperty.resolveWith, который позволяет определить факт “неактивности” кнопки и установить стиль для такого состояния. Подробнее об этом можно почитать в документации.

Давайте добавим цвет фона и текста для неактивной кнопки:

1
2
const disabledBackground = Color(0xFF979592);
const disabledForeground = Color(0xFFD1D1D6);

Также добавим необходимые поля в класс CustomButtonStyle и фабричный конструктор CustomButtonThemeData:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class CustomButtonStyle {
  const CustomButtonStyle({
    this.backgroundColor,
    this.foregroundColor,
    this.disabledBackgroundColor,
    this.disabledForegroundColor,
    this.side,
    this.disabledSide,
  });

  final Color? backgroundColor;
  final Color? foregroundColor;
  final Color? disabledBackgroundColor;
  final Color? disabledForegroundColor;
  final BorderSide? side;
  final BorderSide? disabledSide;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
factory CustomButtonThemeData.fromColors({
    required Color primary,
    required Color inverse,
    required Color secondary,
    required Color error,
    required Color disabledBackground,
    required Color disabledForeground,
  }) {
    return CustomButtonThemeData(
      primary: CustomButtonStyle(
        backgroundColor: primary,
        foregroundColor: inverse,
        disabledBackgroundColor: disabledBackground,
        disabledForegroundColor: disabledForeground,
      ),
      secondary: CustomButtonStyle(
        backgroundColor: secondary,
        foregroundColor: primary,
        disabledBackgroundColor: disabledBackground,
        disabledForegroundColor: disabledForeground,
      ),
      outlined: CustomButtonStyle(
        backgroundColor: Colors.transparent,
        foregroundColor: primary,
        disabledForegroundColor: disabledForeground,
        side: BorderSide(
          color: primary,
          width: 2,
        ),
        disabledSide: BorderSide(
          color: disabledBackground,
          width: 2,
        ),
      ),
      error: CustomButtonStyle(
        backgroundColor: error,
        foregroundColor: inverse,
        disabledBackgroundColor: disabledBackground,
        disabledForegroundColor: disabledForeground,
      ),
    );
  }

Метод _copyWithCustom в расширении ButtonStyleByType теперь выглядит следующим образом:

1
2
3
4
5
6
7
ButtonStyle _copyWithCustom(CustomButtonStyle style) {
    return copyWith(
      backgroundColor: MaterialStateProperty.resolveWith((states) => states.contains(MaterialState.disabled) ? style.disabledBackgroundColor : style.backgroundColor),
      foregroundColor: MaterialStateProperty.resolveWith((states) => states.contains(MaterialState.disabled) ? style.disabledForegroundColor : style.foregroundColor),
      side: MaterialStateProperty.resolveWith((states) => states.contains(MaterialState.disabled) ? style.disabledSide : style.side),
    );
  }

Осталось передать нужные цвета в объект blueButtonThemeData и можно проверять результат изменений:

Кнопки в неактивном состоянии

Со стилями для разных типов кнопок разобрались, пора переходить к размерам.

Размеры кнопок

С осью размеров кнопок поступаем схожим образом: сначала указываем возможные размеры (масштабы), потом определяем стили для каждого размера, а затем добавляем эти стили к уже имеющемуся стилю компонента.

Итак, давайте создадим файл button_scale.dart, в котором опишем следующее перечисление:

1
enum ButtonScale { small, medium, large }

В моём примере доступно всего три размера кнопок, но их может быть больше. По аналогии с предыдущим разделом опишем расширение типа ButtonStyle, которое позволяет копировать имеющийся стиль с изменёнными размерами и отступами:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
extension ButtonStyleByScale on ButtonStyle {
  ButtonStyle byScale(ButtonScale scale) {
    switch (scale) {
      case ButtonScale.small:
        return copyWith(minimumSize: MaterialStateProperty.all(const Size(75, 30)));
      case ButtonScale.medium:
        return copyWith(minimumSize: MaterialStateProperty.all(const Size(78, 36)));
      case ButtonScale.large:
        return copyWith(
          minimumSize: MaterialStateProperty.all(const Size(88, 44)),
          padding: MaterialStateProperty.all(EdgeInsets.symmetric(horizontal: 20)),
        );
    }
  }
}

Теперь добавим в наш виджет BaseButton поддержку указания размера кнопки:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class BaseButton extends StatelessWidget {
  const BaseButton({
    Key? key,
    required this.type,
    required this.scale,
    this.onPressed,
    required this.child,
  }) : super(key: key);

  final ButtonType type;
  final ButtonScale scale;
  final void Function()? onPressed;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final defaultStyle = baseButtonStyle.byType(type, blueButtonThemeData).byScale(scale);
    return TextButton(
      style: defaultStyle,
      onPressed: onPressed,
      child: child,
    );
  }
}

Осталось добавить значения параметра scale в месте использования компонента BaseButton, результат можно увидеть ниже:

Использование своих стилей

На текущий момент у нас есть простой способ настроить стили для кнопки. Однако иногда требуется большая гибкость, поэтому кажется разумным иметь возможность переопределить какие-то атрибуты кнопки в каком-то конкретном месте. Для реализации этой возможности мы добавим виджету BaseButton поле style и будем объединять с ним стиль кнопки, если он передан:

1
style: style != null ? style!.merge(defaultStyle) : defaultStyle,

В качестве примера создадим несколько кнопок, в которых некоторые стили были переопределены:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  Widget _buildCustomRow() {
    return _buildRow([
      Label('Custom style'),
      BaseButton(
        type: ButtonType.primary,
        scale: ButtonScale.small,
        child: _buttonTitle,
        onPressed: () {},
        style: TextButton.styleFrom(
          backgroundColor: Colors.green,
          primary: Colors.yellow,
        ),
      ),
      BaseButton(
        type: ButtonType.primary,
        scale: ButtonScale.medium,
        child: _buttonTitle,
        onPressed: () {},
        style: TextButton.styleFrom(
          padding: EdgeInsets.zero,
          minimumSize: Size(60, 60),
          shape: StadiumBorder(),
          elevation: 5,
        ),
      ),
      BaseButton(
        type: ButtonType.outlined,
        scale: ButtonScale.large,
        child: _buttonTitle,
        onPressed: () {},
        style: TextButton.styleFrom(
          side: BorderSide(color: Colors.green, width: 3),
          shape: StadiumBorder(),
        ),
      ),
    ]);
  }

На рисунке ниже, кнопки с указанными стилями расположены в нижней строке:

Добавляем тёмный режим

В двадцать первом веке все приличные приложения предлагают возможность перейти на тёмную сторону – убивать для этого никого не надо, обычно достаточно просто переключить ползунок в настройках. Мы тоже хотим, чтобы наши кнопки поддерживали переход в такой режим без лишних манипуляций. И на самом деле, у нас уже всё готово для этого, осталось настроить тему и организовать переключение. Давайте разберёмся, как это сделать.

Начнём с подготовки самой темы.

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

1
2
3
4
5
const darkBlue = Color(0xFF0A84FF);
const darkSecondary = Color.fromRGBO(120, 120, 128, 0.32);
const darkError = Color(0xFFFF375F);
const darkDisabledBackground = Color(0xFF757575);
const darkDisabledForeground = Colors.white70;

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

1
2
3
4
5
6
7
8
final darkBlueButtonThemeData = CustomButtonThemeData.fromColors(
  primary: darkBlue,
  inverse: white,
  secondary: darkSecondary,
  error: darkError,
  disabledBackground: darkDisabledBackground,
  disabledForeground: darkDisabledForeground,
);

Теперь нужно подготовить светлую и тёмную темы с базовыми стилями для кнопок. За основу можно взять стандартные варианты:

1
2
3
4
5
6
7
final lightTheme = ThemeData.light().copyWith(
  textButtonTheme: TextButtonThemeData(style: baseButtonStyle),
);

final darkTheme = ThemeData.dark().copyWith(
  textButtonTheme: TextButtonThemeData(style: baseButtonStyle),
);

Используем эти объекты в виджете MaterialApp, заодно установим светлый или тёмный режим:

1
2
3
4
5
6
7
    return MaterialApp(
      ...
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: ThemeMode.light,
      ...
    );

Когда я размышлял о реализации данного подхода, я предполагал, что смогу узнать выбранный режим с помощью MediaQuery. Идея была в том, чтобы в виджете BaseButton получить значение platformBrightness и на его основе подставить нужный вариант темы:

1
final baseStyle = MediaQuery.of(context).platformBrightness == Brightness.dark ? darkBlueButtonThemeData : blueButtonThemeData;

Однако оказалось, что не все платформы поддерживают это свойство и для них оно всегда равно Brightness.light. Своё приложение я делаю под десктопный линукс и там данный параметр как раз не меняется. Следовательно, нам нужен механизм, который бы хранил выбранный режим и при изменении значения вызывал бы перерисовку экранов. Здесь можно воспользоваться привычными вам менеджерами состояний а-ля BLoC, MobX и т.п, но наш пример совсем не об этом, поэтому обойдёмся обычным ValueNotifier.

Итак, план на следующие несколько абзацев такой:

  1. Добавляем ValueNotifier, который будет знать о выбранном режиме;
  2. Добавляем на экран переключатель режима;
  3. Используем нужную тему в качестве базовой, в зависимости от выбранного режима.

Делаем раз – добавляем объект ValueNotifier и соответствующий билдер для перерисовки экранов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
final ValueNotifier<ThemeMode> themeModeNotifier = ValueNotifier(ThemeMode.light);
...
    return ValueListenableBuilder<ThemeMode>(
      valueListenable: themeModeNotifier,
      builder: (_, mode, __) => MaterialApp(
        ...
        themeMode: mode,
        ...
      ),
    );

Делаем два – добавляем переключатель на экран:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  Widget _buildModeSwitcher() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Label('Dark mode:'),
        SizedBox(width: 10),
        Switch(
          value: themeModeNotifier.value == ThemeMode.dark,
          onChanged: (value) {
            themeModeNotifier.value = value ? ThemeMode.dark : ThemeMode.light;
          },
        ),
      ],
    );
  }

Делаем три – используем выбранную тему в кнопках:

1
2
3
CustomButtonThemeData get buttonTheme => themeModeNotifier.value == ThemeMode.dark ? darkBlueButtonThemeData : blueButtonThemeData;
...
final defaultStyle = baseButtonStyle.byType(type, buttonTheme).byScale(scale);

Результат можно увидеть ниже:

We need more themes!

Пожалуй, две темы – это слишком скучно. Как насчёт нескольких вариантов для светлой и тёмной темы? Что ж, если вам необходимо это, то вы пришли по адресу.

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

Как и в предыдущем разделе, для начала нам нужно определить цвета для новых тем. Я добавлю по три темы для светлого и тёмного режима и для простоты изменю только основные цвета – для иллюстрации идеи этого вполне достаточно.

Я добавил следующие основные цвета, для светлого и тёмного режима, соответственно:

1
2
3
4
5
6
7
const teal = Color(0xFF00ABA9);
const yellow = Color(0xFFE3C800);
const green = Color(0xFF60A917);

const darkTeal = Color(0xFF64D2FF);
const darkYellow = Color(0xFFFFD60A);
const darkGreen = Color(0xFF32D74B);

Теперь добавим новые темы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
final tealButtonThemeData = CustomButtonThemeData.fromColors(
  primary: teal,
  inverse: white,
  secondary: secondary,
  error: error,
  disabledBackground: disabledBackground,
  disabledForeground: disabledForeground,
);

final yellowButtonThemeData = CustomButtonThemeData.fromColors(
  primary: yellow,
  inverse: white,
  secondary: secondary,
  error: error,
  disabledBackground: disabledBackground,
  disabledForeground: disabledForeground,
);

final greenButtonThemeData = CustomButtonThemeData.fromColors(
  primary: green,
  inverse: white,
  secondary: secondary,
  error: error,
  disabledBackground: disabledBackground,
  disabledForeground: disabledForeground,
);

final darkTealButtonThemeData = CustomButtonThemeData.fromColors(
  primary: darkTeal,
  inverse: white,
  secondary: darkSecondary,
  error: darkError,
  disabledBackground: darkDisabledBackground,
  disabledForeground: darkDisabledForeground,
);

final darkYellowButtonThemeData = CustomButtonThemeData.fromColors(
  primary: darkYellow,
  inverse: white,
  secondary: darkSecondary,
  error: darkError,
  disabledBackground: darkDisabledBackground,
  disabledForeground: darkDisabledForeground,
);

final darkGreenButtonThemeData = CustomButtonThemeData.fromColors(
  primary: darkGreen,
  inverse: white,
  secondary: darkSecondary,
  error: darkError,
  disabledBackground: darkDisabledBackground,
  disabledForeground: darkDisabledForeground,
);

В моём примере количество тем для каждого из режимов совпадает, но в общем случае это не обязательно должно быть так. В любом случае, нам нужен механизм для определения текущей темы – здесь снова могут прийти на помощь менеджеры состояний. Однако у нас довольно простой пример, поэтому я снова обойдусь ValueNotifier.

Ещё одно замечание, прежде чем мы продолжим: в моём примере четыре основных цвета для каждого из режимов, причём я не хочу, чтобы “светлый” цвет использовался в тёмном режиме и наоборот, но в общем случае это может быть не так. Чтобы избежать указанные пересечения, можно подготовить перечисление (enum) основных цветов, а для быстрого доступа к нужной теме – словарь вида “основной цвет”-“тема” для каждого из режимов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
enum ThemeColors { blue, teal, yellow, green }

final themes = {
  ThemeColors.blue: blueButtonThemeData,
  ThemeColors.teal: tealButtonThemeData,
  ThemeColors.yellow: yellowButtonThemeData,
  ThemeColors.green: greenButtonThemeData,
};

final darkThemes = {
  ThemeColors.blue: darkBlueButtonThemeData,
  ThemeColors.teal: darkTealButtonThemeData,
  ThemeColors.yellow: darkYellowButtonThemeData,
  ThemeColors.green: darkGreenButtonThemeData,
};

Теперь можно добавить объект ValueNotifier для хранения основного цвета, изменить логику получения выбранной темы в геттере buttonTheme и добавить соответствующий билдер для перерисовки экранов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
final ValueNotifier<ThemeColors> themeColorNotifier = ValueNotifier(ThemeColors.blue);

CustomButtonThemeData get buttonTheme => themeModeNotifier.value == ThemeMode.dark ? themes[themeColorNotifier.value]! : darkThemes[themeColorNotifier.value]!;

class Application extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<ThemeMode>(
      valueListenable: themeModeNotifier,
      builder: (_, mode, __) => ValueListenableBuilder(
        valueListenable: themeColorNotifier,
        builder: (_, __, ___) => MaterialApp(
          ...
        ),
      ),
    );
  }
}

Всё, что нам осталось сделать, это добавить переключатели цвета на экране. Здесь неплохо подойдёт компонент Radio, ведь у нас целых четыре уникальных цвета. Кроме того не помешает добавить текстовую метку для указания цвета. Давайте создадим новый виджет ColorItem следующего вида:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class ColorItem extends StatelessWidget {
  const ColorItem({
    Key? key,
    required this.value,
    required this.title,
  }) : super(key: key);

  final ThemeColors value;
  final String title;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Radio<ThemeColors>(
          value: value,
          groupValue: themeColorNotifier.value,
          onChanged: (value) {
            if (value != null) {
              themeColorNotifier.value = value;
            }
          },
        ),
        SizedBox(width: 10),
        Text(title),
      ],
    );
  }
}

С помощью этого виджета добавим возможность выбора цвета:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  Widget _buildColorSwitcher() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        ColorItem(value: ThemeColors.blue, title: 'Blue'),
        SizedBox(width: 20),
        ColorItem(value: ThemeColors.teal, title: 'Teal'),
        SizedBox(width: 20),
        ColorItem(value: ThemeColors.yellow, title: 'Yellow'),
        SizedBox(width: 20),
        ColorItem(value: ThemeColors.green, title: 'Green'),
      ],
    );
  }

Итоговый результат можно увидеть ниже:

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

В заключение отмечу, что представленная реализация данной идеи, несомненно, может быть улучшена или изменена исходя из ваших потребностей. Например, чтобы не передавать в каждую кнопку два обязательных аргумента (тип и размер), можно “зашить” один из них в название – например, можно добавить именованные конструкторы вида BaseButton.small(...), BaseButton.Medium(...), BaseButton.large(...) или подготовить набор компонентов ButtonS, ButtonM, ButtonL.

Похожий подход можно использовать и при создании других компонентов – в одном из проектов я использовал его для создания UI Kit текстовых виджетов. Если у вас есть подготовленная дизайн-система, можно изучить возможность применения такого подхода в других частях проекта и получить дополнительную гибкость в будущем.

Код проекта доступен на GitHub.