Если ваши пользовательские виджеты принимают String и TextStyle или любые другие стили: цвет, толщину, textAlign и т. д., вы делаете это неправильно! Вместо этого ваши виджеты должны иметь свойства типа Widget. Вы когда-нибудь задумывались, почему TextButton(), ListTile() и другие принимают виджеты (не строки) и как они стилизуют виджет Text(), который вы им передаете? — Они делают это, заключая своих дочерних элементов в DefaultTextStyle или AnimatedDefaultTextStyle. Этот подход очень гибкий и не требует сотен параметров стиля в конструкторах виджетов.

В этой статье я покажу вам, почему код слева не прошел мой PR-обзор и какую проблему решает код справа.

Примечания:

  • _WidgetContainer просто добавляет цвет фона и отступы, а также отображает дочерние элементы в столбце. Его результат вы увидите далее в статье.
  • Если вам интересно, почему я пишу context.textTheme, а не Theme.of(context).textTheme, вам стоит прочитать статью Отсутствующие расширения во Flutter.


Когда подход TextStyle ломается

Во-первых, давайте представим несколько итераций, которые может пройти аналогичный виджет с 2 String свойствами. Посмотрите, как быстро будет расти код и насколько сложным он станет.

1. Добавление цветов

class CustomWidgetWithColor extends StatelessWidget {
  const CustomWidgetWithColor({
    super.key,
    required this.title,
    // 1
    this.titleColor,
    required this.description,
    // 2
    this.descriptionColor,
  });

  final String title;
  // 3
  final Color? titleColor;
  final String description;
  // 4
  final Color? descriptionColor;

  @override
  Widget build(BuildContext context) {
    return _WidgetContainer(
      children: [
        Text(
          title,
          style: context.textTheme.headlineMedium?.copyWith(
            // 5
            color: titleColor ?? Colors.black,
          ),
        ),
        Text(
          description,
          style: context.textTheme.bodySmall?.copyWith(
            // 6
            color: descriptionColor ?? Colors.black54,
          ),
        ),
      ],
    );
  }
}

Просто чтобы добавить возможность менять цвет текста, нам нужно добавить 2 свойства и изменить код в 6 местах!

2. Добавление полужирного шрифта и выравнивание

class CustomWidgetWithEverything extends StatelessWidget {
  const CustomWidgetWithEverything({
    super.key,
    required this.title,
    this.titleColor,
    // 1
    this.isTitleBold = false,
    // 2
    this.titleAlignment = TextAlign.start,
    required this.description,
    this.descriptionColor,
    // 3
    this.isDescriptionBold = false,
  });

  final String title;
  final Color? titleColor;
  // 4
  final bool isTitleBold;
  // 5
  final TextAlign titleAlignment;
  final String description;
  final Color? descriptionColor;
  // 6
  final bool isDescriptionBold;

  @override
  Widget build(BuildContext context) {
    return _WidgetContainer(
      children: [
        Text(
          title,
          // 7
          textAlign: titleAlignment,
          style: context.textTheme.headlineMedium?.copyWith(
            color: titleColor ?? Colors.black,
            // 8
            fontWeight: isTitleBold ? FontWeight.bold : FontWeight.normal,
          ),
        ),
        Text(
          description,
          style: context.textTheme.bodySmall?.copyWith(
            color: descriptionColor ?? Colors.black54,
            // 9
            fontWeight: isDescriptionBold ? FontWeight.bold : FontWeight.normal,
          ),
        ),
      ],
    );
  }
}

Еще 3 свойства и изменения кода в 9 местах!

❗️ В общей сложности это уже 5 новых объектов и 15 изменений по сравнению с исходной версией!

На следующий день вы получаете еще один запрос: описание должно поддерживать курсив и выравнивание по центру; Заголовок также должен поддерживать светлый шрифт в дополнение к полужирному. Вы уже должны видеть, к чему это приведет — в «логический» ад.

3. Шагните в неправильном направлении ❌: TextStyle

Иногда я вижу, что виджет принимает несколько параметров TextStyle для разных текстов, но, к сожалению, textAlign все равно нужно передавать отдельно. Код будет иметь 3 дополнительных свойства и изменения в 9 местах по сравнению с исходной версией:

class CustomWidgetWithTextStyle extends StatelessWidget {
  const CustomWidgetWithTextStyle({
    super.key,
    required this.title,
    // 1
    this.titleStyle,
    // 2
    this.titleAlignment = TextAlign.start,
    required this.description,
    // 3
    this.descriptionStyle,
  });

  final String title;
  // 4
  final TextStyle? titleStyle;
  // 5
  final TextAlign? titleAlignment;
  final String description;
  // 6
  final TextStyle? descriptionStyle;

  @override
  Widget build(BuildContext context) {
    return _WidgetContainer(
      children: [
        Text(
          title,
          // 7
          textAlign: titleAlignment,
          style: context.textTheme.headlineMedium
              ?.copyWith(color: Colors.black)
              // 8
              .merge(titleStyle),
        ),
        Text(
          description,
          style: context.textTheme.bodySmall
              ?.copyWith(color: Colors.black54)
              // 9
              .merge(descriptionStyle),
        ),
      ],
    );
  }
}

Более того, возможности настройки по-прежнему довольно ограничены. Вы не можете изменить maxLines, overflow, softWrap и textWidthBasis (даже не знаете, что это такое). 😅). К счастью, у нас есть решение!

Решение ⭐: DefaultTextStyle.merge()

Теперь я покажу вам лучший способ обработки текста в пользовательских виджетах. Давайте изменим тип свойств String title и String description на Widget и удалим выравнивание. Вместо того, чтобы объединять стиль текста по умолчанию с предоставленным и передавать его в виджеты Text(), мы обернем заголовок и описание в DefaultTextStyle.merge() и предоставим стиль там. Вот код:

class TheBestCustomWidget extends StatelessWidget {
  const TheBestCustomWidget({
    super.key,
    required this.title,
    required this.description,
  });

  final Widget title;
  final Widget description;

  @override
  Widget build(BuildContext context) {
    return _WidgetContainer(
      children: [
        DefaultTextStyle.merge(
          style: context.textTheme.headlineMedium?.copyWith(color: Colors.black),
          child: title,
        ),
        DefaultTextStyle.merge(
          style: context.textTheme.bodySmall?.copyWith(color: Colors.black54),
          child: description,
        ),
      ],
    );
  }
}

Для базового использования этот виджет может стать немного сложнее в использовании, потому что вместо простой строки теперь вы должны передавать Text(“some string”). Но это именно то место, которое дает нам возможность настройки без триллиона логических флагов 🎉

TheBestCustomWidget(
  title: Text(
    'The Best Title',
    textAlign: TextAlign.center,
    style: TextStyle(
      color: Colors.purple,
      fontWeight: FontWeight.bold,
    ),
  ),
  description: Text(
    'DefaultTextStyle.merge() is the best!',
    textAlign: TextAlign.center,
    style: TextStyle(
      color: Colors.black,
      fontSize: 16,
    ),
  ),
)

Как видите, заголовок имеет размер headlineMedium, но фиолетовый цвет, полужирный шрифт и выравнивание по центру. Описание черного цвета (не black54), имеет размер 16 и выравнивание по центру. Все работало как положено, стили по умолчанию объединены со стилями, переданными в виджеты Text().

Не только TextStyle!

Таким же образом мы можем настроить и другие виджеты. Например, с помощью IconTheme мы можем установить цвет по умолчанию и даже РАЗМЕР для значков.

class TheBestCustomWidgetWithIconsSupport extends StatelessWidget {
  const TheBestCustomWidgetWithIconsSupport({
    super.key,
    required this.title,
    required this.description,
  });

  final Widget title;
  final Widget description;

  @override
  Widget build(BuildContext context) {
    // 1. IconTheme
    return IconTheme.merge(
      // 2. IconThemeData
      data: const IconThemeData(
        color: Colors.red,
        size: 32,
      ),
      child: _WidgetContainer(
        children: [
          DefaultTextStyle.merge(
            style: context.textTheme.headlineMedium?.copyWith(color: Colors.black),
            child: title,
          ),
          DefaultTextStyle.merge(
            style: context.textTheme.bodySmall?.copyWith(color: Colors.black54),
            child: description,
          ),
        ],
      ),
    );
  }
}

Кроме того, поскольку наши свойства имеют тип Widget, мы можем передать все, что нам нужно. Даже Row() с форматированным текстом и значком!

TheBestCustomWidgetWithIconsSupport(
  // Row
  title: Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      // Rich text
      Text.rich(
        TextSpan(
          children: [
            TextSpan(
              text: 'The',
              style: TextStyle(color: Colors.purple),
            ),
            TextSpan(
              text: ' Best ',
              style: TextStyle(fontStyle: FontStyle.italic),
            ),
            TextSpan(text: 'Title!'),
          ],
          style: TextStyle(
            color: Colors.purple,
            fontWeight: FontWeight.bold,
          ),
        ),
        textAlign: TextAlign.center,
      ),
      SizedBox(width: 5),
      // Icon
      Icon(Icons.favorite),
    ],
  ),
  description: Text(
    'DefaultTextStyle & IconTheme are the best!',
    textAlign: TextAlign.center,
    style: TextStyle(
      color: Colors.black,
      fontWeight: FontWeight.normal,
      fontSize: 16,
    ),
  ),
)

Примечания:

  • Вместо RichText следует использовать Text.rich(), потому что первый более низкоуровневый и не использует DefaultTextStyle.

Иногда вам действительно нужно передать String, TextStyle, Color или другой стиль для виджетов, но я надеюсь, что после изучения DefaultTextStyle и IconTheme вы найдете им хорошее применение! Кроме того, есть еще больше способов предоставить стили по умолчанию для виджетов, например, переопределить тему для части пользовательского интерфейса.

Бонусный совет ⚡️

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

class TheBestCustomWidgetWithExtensions extends StatelessWidget {
  const TheBestCustomWidgetWithExtensions({
    super.key,
    required this.title,
    required this.description,
  });

  final Widget title;
  final Widget description;

  @override
  Widget build(BuildContext context) {
    return _WidgetContainer(
      children: [
        title
            .textStyle(context.textTheme.headlineMedium)
            .foregroundColor(Colors.black),
        description
            .textStyle(context.textTheme.bodySmall)
            .foregroundColor(Colors.black54),
      ],
    );
  }
}

extension on Widget {
  Widget textStyle(TextStyle? style, {TextAlign? align}) {
    return DefaultTextStyle.merge(
      style: style,
      textAlign: align,
      child: this,
    );
  }

  Widget foregroundColor(Color color) {
    return IconTheme.merge(
      data: IconThemeData(color: color),
      child: textStyle(TextStyle(color: color)),
    );
  }
}

Эта идея взята из твита @luke_pighetti. Там вы можете найти больше примеров модификаторов виджетов во Flutter!

Кроме того, я приглашаю вас ознакомиться с моей статьей о пользовательской теме Flutter с ThemeExtensions + Templates:



Спасибо, что прочитали. Пока 👋