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

Что такое тема приложения

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

Какие ошибки допускают новички, стилизируя приложение

Я считаю,  что большинство новичков нарушают принцип DRY(Don’t repeat yourself). Очень часто в коде можно увидеть подобную картину.

Как видно из картинки выше, текстовые стили одинаковы и занимают суммарно 10 строк кода. Представьте, если в колонке не два текстовых поля, а десять :)

Самым простым решением этой проблемы будет создать абстрактный класс AppTextStyles со статическим полем и потом писать style: AppTextStyles.yourStyle.

У такого решения есть недостаток — что если у нас в приложении больше, чем одна тема? Скорее всего придется создать классы AppDarkTextStyles/AppLightTextStyles и потом, задавая стиль текстовому виджету, проверять, какая сейчас текущая тема и в зависимости от этого выбирать нужную константу. По сути, при каждом указании стиля нужно будет писать тернарный оператор. Не думаю, что это выглядит хорошо.

Во Flutter есть красивое решение этой проблемы — использовать встроенный механизм тем. В документации написано, что темы нужны для того, чтобы делиться стилями по всему приложению. В MaterialApp вы можете задать тему через свойство theme. Для этого вам нужно создать объект типа ThemeData, где вы укажете все необходимые стили и цвета. Например, вы можете указать все необходимые текстовые стили в свойстве textTheme.

После этого, можно обратиться к нужному стилю через Theme.of(context).textTheme. В таком варианте проблема с тем, что нужно использовать тернарный оператор в зависимости от темы, решена.

Теперь возникает другая сложность. Допустим вы задали все 27 свойств TextTheme и вам все равно мало:) У вас есть вариант создать новый стиль, используя метод copyWith() при добавлении стиля к виджету, однако это будет засорять build метод. Или вам банально не нравиться названия свойств TextTheme. Во Flutter на этот счет тоже есть решение - Theme Extensions!

Элегантный вариант настройки тем

Theme Extensions — это произвольное дополнение к теме. Благодаря этому механизму мы можем создать дополнения к текстовой теме, к цветам приложения или прописать все градиенты, которые используются в  приложении. Предлагаю рассмотреть мою структуру папки с темой.

Файл theme.dart представляет собой файл, в котором собраны все зависимости.

import 'package:flutter/material.dart';

part 'src/constants.dart';
part 'src/dark_theme.dart';
part 'src/light_theme.dart';
part 'src/text_theme.dart';
part 'src/theme_colors.dart';
part 'src/theme_text_styles.dart';
part 'src/theme_gradients.dart';

В файлах light_theme.dart и dark_theme.dart создаются светлая и темная темы. Для примера покажу светлую тему.

part of '../theme.dart';

ThemeData createLightTheme() {
  return ThemeData(
    textTheme: createTextTheme(),
    brightness: Brightness.light,
    scaffoldBackgroundColor: AppColors.white,
    extensions: <ThemeExtension<dynamic>>[
      ThemeColors.light,
      ThemeTextStyles.light,
      ThemeGradients.light,
    ],
    dialogTheme: DialogTheme(
      backgroundColor: AppColors.white,
      titleTextStyle: headline1.copyWith(
        color: AppColors.black,
        fontSize: 20,
        fontWeight: FontWeight.w500,
      ),
      contentTextStyle: headline1.copyWith(
        color: AppColors.black,
      ),
    ),
    focusColor: Colors.blue.withOpacity(0.2),
    appBarTheme: AppBarTheme(backgroundColor: Colors.white),
  );
}

В файле text_theme.dart создается текстовая тема, хотя в самом приложении я ни разу не обращаюсь к headline1 или другим свойствам.

part of '../theme.dart';

TextTheme createTextTheme() {
  return const TextTheme(
    headline1: headline1,
    headline2: headline2,
  );
}

constants.dart содержит в себе константные текстовые стили, которые я потом использую в theme_text_styles.dart, и класс AppColors, в котором прописаны базовые цвета через класс Colors, а также разные оттенки. В этом месте может возникнуть вопрос: зачем прописывать базовые цвета? Я это делаю для того, чтобы в дальнейшем избежать пересечения Colors и AppColors. Это дело вкуса :)

part of '../theme.dart';

const headline1 = TextStyle(fontWeight: FontWeight.w400, fontSize: 16);
const headline2 = TextStyle(fontWeight: FontWeight.w400, fontSize: 14);

abstract class AppColors {
  static const white = Colors.white;
  static const black = Colors.black;
  static const blue = Colors.blue;

  static const red = Colors.red;
  static const darkerRed = Color(0xFFCB5A5E);

  static const grey = Colors.grey;
  static const darkerGrey = Color(0xFF6C6C6C);
  static const darkestGrey = Color(0xFF626262);
  static const lighterGrey = Color(0xFF959595);
  static const lightGrey = Color(0xFF5d5d5d);

  static const lighterDark = Color(0xFF272727);
  static const lightDark = Color(0xFF1b1b1b);

  static const purpleAccent = Colors.purpleAccent;
}

Перейдем к самому интересному моменту — Theme Extensions. Допустим, у меня есть кнопка фильтра и ее цвет отличается от основных цветов ThemeData. Здесь на помощь приходят расширения.  С помощью них мы можем создать Color filterButtonFillColor и получить его через BuildContext. В этом же классе мы можем прописать какой цвет будет использоваться в светлой и темной темах.

part of '../theme.dart';

class ThemeColors extends ThemeExtension<ThemeColors> {
  final Color filterButtonFillColor;

  const ThemeColors({
    required this.filterButtonFillColor,
  });

  @override
  ThemeExtension<ThemeColors> copyWith({
    Color? filterButtonFillColor,
  }) {
    return ThemeColors(
      filterButtonFillColor:
          filterButtonFillColor ?? this.filterButtonFillColor,
    );
  }

  @override
  ThemeExtension<ThemeColors> lerp(
    ThemeExtension<ThemeColors>? other,
    double t,
  ) {
    if (other is! ThemeColors) {
      return this;
    }

    return ThemeColors(
      filterButtonFillColor:
          Color.lerp(filterButtonFillColor, other.filterButtonFillColor, t)!,
    );
  }

  static get light => ThemeColors(
        filterButtonFillColor: AppColors.grey,
      );

  static get dark => ThemeColors(
        filterButtonFillColor: AppColors.white,
      );
}

Последним шагом стоит добавить расширения для светлой и темных тем.

return ThemeData(
    textTheme: createTextTheme(),
    brightness: Brightness.light,
    scaffoldBackgroundColor: AppColors.white,
    extensions: <ThemeExtension<dynamic>>[
      ThemeColors.light,
      ThemeTextStyles.light,
      ThemeGradients.light,
    ],
  );

Я написал расширения для цветов, для текстовых стилей и градиентов. После этого мы можем получить необходимый нам цвет через Theme.of(context).extension<ThemeColors>!.neededColor. Однако, чтобы не писать такую конструкцию каждый раз, можно создать расширения над BuildContext и после этого писать context.color или context.text и т.д.

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:meta_app/presentation/themes/theme.dart';

extension BuildContextExt on BuildContext {
  AppLocalizations get localizations => AppLocalizations.of(this)!;

  ThemeTextStyles get text => Theme.of(this).extension<ThemeTextStyles>()!;

  ThemeColors get color => Theme.of(this).extension<ThemeColors>()!;

  ThemeGradients get gradient => Theme.of(this).extension<ThemeGradients>()!;

  bool get isDarkMode => Theme.of(this).brightness == Brightness.dark;
}

В конце статьи хочу обратить ваше внимание на следующие вещи:

  1. В Theme Extensions давайте названия, которые относятся к определенному виджету. Например, у вас есть виджет Content и текстовые стили для него лучше всего называть contentStatus, contentTitle…

  2. Не поленитесь создать несколько разных стилей. Допустим, у вас в Row находиться две кнопки с одинаковым цветом. Вы можете создать один цвет в расширениях, однако если потом дизайнер поменяет цвет первой кнопки и вы его изменете в коде, то сразу поменяется цвет другой. И вам все равно придется создавать отдельное свойство.

  3. Пробуйте экспериментировать! Вы можете создать также extension для ButtonStyles и других стилей.

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