Если ваши пользовательские виджеты принимают 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:
Спасибо, что прочитали. Пока 👋