Unverified Commit 378668db authored by Viren Khatri's avatar Viren Khatri Committed by GitHub

added MaterialStateColor support to TabBarTheme.labelColor (#109541)

parent d29668dd
...@@ -55,6 +55,12 @@ class TabBarTheme with Diagnosticable { ...@@ -55,6 +55,12 @@ class TabBarTheme with Diagnosticable {
final Color? dividerColor; final Color? dividerColor;
/// Overrides the default value for [TabBar.labelColor]. /// Overrides the default value for [TabBar.labelColor].
///
/// If [labelColor] is a [MaterialStateColor], then the effective color will
/// depend on the [MaterialState.selected] state, i.e. if the [Tab] is
/// selected or not. In case of unselected state, this [MaterialStateColor]'s
/// resolved color will be used even if [TabBar.unselectedLabelColor] or
/// [unselectedLabelColor] is non-null.
final Color? labelColor; final Color? labelColor;
/// Overrides the default value for [TabBar.labelPadding]. /// Overrides the default value for [TabBar.labelPadding].
......
...@@ -166,7 +166,7 @@ class Tab extends StatelessWidget implements PreferredSizeWidget { ...@@ -166,7 +166,7 @@ class Tab extends StatelessWidget implements PreferredSizeWidget {
class _TabStyle extends AnimatedWidget { class _TabStyle extends AnimatedWidget {
const _TabStyle({ const _TabStyle({
required Animation<double> animation, required Animation<double> animation,
required this.selected, required this.isSelected,
required this.labelColor, required this.labelColor,
required this.unselectedLabelColor, required this.unselectedLabelColor,
required this.labelStyle, required this.labelStyle,
...@@ -176,11 +176,47 @@ class _TabStyle extends AnimatedWidget { ...@@ -176,11 +176,47 @@ class _TabStyle extends AnimatedWidget {
final TextStyle? labelStyle; final TextStyle? labelStyle;
final TextStyle? unselectedLabelStyle; final TextStyle? unselectedLabelStyle;
final bool selected; final bool isSelected;
final Color? labelColor; final Color? labelColor;
final Color? unselectedLabelColor; final Color? unselectedLabelColor;
final Widget child; final Widget child;
MaterialStateColor _resolveWithLabelColor(BuildContext context) {
final ThemeData themeData = Theme.of(context);
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
final TabBarTheme defaults = themeData.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context);
final Animation<double> animation = listenable as Animation<double>;
// labelStyle.color (and tabBarTheme.labelStyle.color) is not considered
// as it'll be a breaking change without a possible migration plan. for
// details: https://github.com/flutter/flutter/pull/109541#issuecomment-1294241417
Color selectedColor = labelColor
?? tabBarTheme.labelColor
?? defaults.labelColor!;
final Color unselectedColor;
if (selectedColor is MaterialStateColor) {
unselectedColor = selectedColor.resolve(const <MaterialState>{});
selectedColor = selectedColor.resolve(const <MaterialState>{MaterialState.selected});
} else {
// unselectedLabelColor and tabBarTheme.unselectedLabelColor are ignored
// when labelColor is a MaterialStateColor.
unselectedColor = unselectedLabelColor
?? tabBarTheme.unselectedLabelColor
?? (themeData.useMaterial3
? defaults.unselectedLabelColor!
: selectedColor.withAlpha(0xB2)); // 70% alpha
}
return MaterialStateColor.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return Color.lerp(selectedColor, unselectedColor, animation.value)!;
}
return Color.lerp(unselectedColor, selectedColor, animation.value)!;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context); final ThemeData themeData = Theme.of(context);
...@@ -188,6 +224,10 @@ class _TabStyle extends AnimatedWidget { ...@@ -188,6 +224,10 @@ class _TabStyle extends AnimatedWidget {
final TabBarTheme defaults = themeData.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); final TabBarTheme defaults = themeData.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context);
final Animation<double> animation = listenable as Animation<double>; final Animation<double> animation = listenable as Animation<double>;
final Set<MaterialState> states = isSelected
? const <MaterialState>{MaterialState.selected}
: const <MaterialState>{};
// To enable TextStyle.lerp(style1, style2, value), both styles must have // To enable TextStyle.lerp(style1, style2, value), both styles must have
// the same value of inherit. Force that to be inherit=true here. // the same value of inherit. Force that to be inherit=true here.
final TextStyle defaultStyle = (labelStyle final TextStyle defaultStyle = (labelStyle
...@@ -199,21 +239,10 @@ class _TabStyle extends AnimatedWidget { ...@@ -199,21 +239,10 @@ class _TabStyle extends AnimatedWidget {
?? labelStyle ?? labelStyle
?? defaults.unselectedLabelStyle! ?? defaults.unselectedLabelStyle!
).copyWith(inherit: true); ).copyWith(inherit: true);
final TextStyle textStyle = selected final TextStyle textStyle = isSelected
? TextStyle.lerp(defaultStyle, defaultUnselectedStyle, animation.value)! ? TextStyle.lerp(defaultStyle, defaultUnselectedStyle, animation.value)!
: TextStyle.lerp(defaultUnselectedStyle, defaultStyle, animation.value)!; : TextStyle.lerp(defaultUnselectedStyle, defaultStyle, animation.value)!;
final Color color = _resolveWithLabelColor(context).resolve(states);
final Color selectedColor = labelColor
?? tabBarTheme.labelColor
?? defaults.labelColor!;
final Color unselectedColor = unselectedLabelColor
?? tabBarTheme.unselectedLabelColor
?? (themeData.useMaterial3
? defaults.unselectedLabelColor!
: selectedColor.withAlpha(0xB2)); // 70% alpha
final Color color = selected
? Color.lerp(selectedColor, unselectedColor, animation.value)!
: Color.lerp(unselectedColor, selectedColor, animation.value)!;
return DefaultTextStyle( return DefaultTextStyle(
style: textStyle.copyWith(color: color), style: textStyle.copyWith(color: color),
...@@ -738,7 +767,8 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { ...@@ -738,7 +767,8 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// ///
/// If [automaticIndicatorColorAdjustment] is true, /// If [automaticIndicatorColorAdjustment] is true,
/// then the [indicatorColor] will be automatically adjusted to [Colors.white] /// then the [indicatorColor] will be automatically adjusted to [Colors.white]
/// when the [indicatorColor] is same as [Material.color] of the [Material] parent widget. /// when the [indicatorColor] is same as [Material.color] of the [Material]
/// parent widget.
final bool automaticIndicatorColorAdjustment; final bool automaticIndicatorColorAdjustment;
/// Defines how the selected tab indicator's size is computed. /// Defines how the selected tab indicator's size is computed.
...@@ -762,23 +792,50 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { ...@@ -762,23 +792,50 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// The color of selected tab labels. /// The color of selected tab labels.
/// ///
/// If [ThemeData.useMaterial3] is false, unselected tab labels are rendered with /// If null, then [TabBarTheme.labelColor] is used. If that is also null and
/// the same color with 70% opacity unless [unselectedLabelColor] is non-null. /// [ThemeData.useMaterial3] is true, [ColorScheme.primary] will be used,
/// /// otherwise the color of the [ThemeData.primaryTextTheme]'s
/// If this property is null and [ThemeData.useMaterial3] is true, [ColorScheme.primary]
/// will be used, otherwise the color of the [ThemeData.primaryTextTheme]'s
/// [TextTheme.bodyLarge] text color is used. /// [TextTheme.bodyLarge] text color is used.
///
/// If [labelColor] (or, if null, [TabBarTheme.labelColor]) is a
/// [MaterialStateColor], then the effective tab color will depend on the
/// [MaterialState.selected] state, i.e. if the [Tab] is selected or not,
/// ignoring [unselectedLabelColor] even if it's non-null.
///
/// Note: [labelStyle]'s color and [TabBarTheme.labelStyle]'s color do not
/// affect the effective [labelColor].
///
/// See also:
///
/// * [unselectedLabelColor], for color of unselected tab labels.
final Color? labelColor; final Color? labelColor;
/// The color of unselected tab labels. /// The color of unselected tab labels.
/// ///
/// If this property is null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurfaceVariant] /// If [labelColor] (or, if null, [TabBarTheme.labelColor]) is a
/// will be used, otherwise unselected tab labels are rendered with the /// [MaterialStateColor], then the unselected tabs are rendered with
/// [labelColor] with 70% opacity. /// that [MaterialStateColor]'s resolved color for unselected state, even if
/// [unselectedLabelColor] is non-null.
///
/// If null, then [TabBarTheme.unselectedLabelColor] is used. If that is also
/// null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurfaceVariant]
/// will be used, otherwise unselected tab labels are rendered with
/// [labelColor] at 70% opacity.
///
/// Note: [unselectedLabelStyle]'s color and
/// [TabBarTheme.unselectedLabelStyle]'s color are ignored in
/// [unselectedLabelColor]'s precedence calculation.
///
/// See also:
///
/// * [labelColor], for color of selected tab labels.
final Color? unselectedLabelColor; final Color? unselectedLabelColor;
/// The text style of the selected tab labels. /// The text style of the selected tab labels.
/// ///
/// This does not influence color of the tab labels even if [TextStyle.color]
/// is non-null. Refer [labelColor] to color selected tab labels instead.
///
/// If [unselectedLabelStyle] is null, then this text style will be used for /// If [unselectedLabelStyle] is null, then this text style will be used for
/// both selected and unselected label styles. /// both selected and unselected label styles.
/// ///
...@@ -787,6 +844,18 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { ...@@ -787,6 +844,18 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// [TextTheme.bodyLarge] definition is used. /// [TextTheme.bodyLarge] definition is used.
final TextStyle? labelStyle; final TextStyle? labelStyle;
/// The text style of the unselected tab labels.
///
/// This does not influence color of the tab labels even if [TextStyle.color]
/// is non-null. Refer [unselectedLabelColor] to color unselected tab labels
/// instead.
///
/// If this property is null and [ThemeData.useMaterial3] is true,
/// [TextTheme.titleSmall] will be used, otherwise then the [labelStyle] value
/// is used. If [labelStyle] is null, the text style of the
/// [ThemeData.primaryTextTheme]'s [TextTheme.bodyLarge] definition is used.
final TextStyle? unselectedLabelStyle;
/// The padding added to each of the tab labels. /// The padding added to each of the tab labels.
/// ///
/// If there are few tabs with both icon and text and few /// If there are few tabs with both icon and text and few
...@@ -796,14 +865,6 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { ...@@ -796,14 +865,6 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// If this property is null, then kTabLabelPadding is used. /// If this property is null, then kTabLabelPadding is used.
final EdgeInsetsGeometry? labelPadding; final EdgeInsetsGeometry? labelPadding;
/// The text style of the unselected tab labels.
///
/// If this property is null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall]
/// will be used, otherwise then the [labelStyle] value is used. If [labelStyle]
/// is null, the text style of the [ThemeData.primaryTextTheme]'s
/// [TextTheme.bodyLarge] definition is used.
final TextStyle? unselectedLabelStyle;
/// Defines the ink response focus, hover, and splash colors. /// Defines the ink response focus, hover, and splash colors.
/// ///
/// If non-null, it is resolved against one of [MaterialState.focused], /// If non-null, it is resolved against one of [MaterialState.focused],
...@@ -1209,10 +1270,10 @@ class _TabBarState extends State<TabBar> { ...@@ -1209,10 +1270,10 @@ class _TabBarState extends State<TabBar> {
widget.onTap?.call(index); widget.onTap?.call(index);
} }
Widget _buildStyledTab(Widget child, bool selected, Animation<double> animation) { Widget _buildStyledTab(Widget child, bool isSelected, Animation<double> animation) {
return _TabStyle( return _TabStyle(
animation: animation, animation: animation,
selected: selected, isSelected: isSelected,
labelColor: widget.labelColor, labelColor: widget.labelColor,
unselectedLabelColor: widget.unselectedLabelColor, unselectedLabelColor: widget.unselectedLabelColor,
labelStyle: widget.labelStyle, labelStyle: widget.labelStyle,
...@@ -1368,7 +1429,7 @@ class _TabBarState extends State<TabBar> { ...@@ -1368,7 +1429,7 @@ class _TabBarState extends State<TabBar> {
painter: _indicatorPainter, painter: _indicatorPainter,
child: _TabStyle( child: _TabStyle(
animation: kAlwaysDismissedAnimation, animation: kAlwaysDismissedAnimation,
selected: false, isSelected: false,
labelColor: widget.labelColor, labelColor: widget.labelColor,
unselectedLabelColor: widget.unselectedLabelColor, unselectedLabelColor: widget.unselectedLabelColor,
labelStyle: widget.labelStyle, labelStyle: widget.labelStyle,
......
...@@ -3950,6 +3950,141 @@ void main() { ...@@ -3950,6 +3950,141 @@ void main() {
expect(iconTheme.color, equals(selectedTabColor)); expect(iconTheme.color, equals(selectedTabColor));
}); });
testWidgets('TabBar colors labels correctly', (WidgetTester tester) async {
MaterialStateColor buildMSC(Color selectedColor, Color unselectedColor) {
return MaterialStateColor
.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return selectedColor;
}
return unselectedColor;
});
}
final Color materialLabelColor = buildMSC(const Color(0x00000000), const Color(0x00000001));
const Color labelColor = Color(0x00000002);
const Color unselectedLabelColor = Color(0x00000003);
// this is to make sure labelStyles (in TabBar and in TabBarTheme) don't
// affect label's color. for details: https://github.com/flutter/flutter/pull/109541#issuecomment-1294241417
const TextStyle labelStyle = TextStyle(color: Color(0x00000004));
const TextStyle unselectedLabelStyle = TextStyle(color: Color(0x00000005));
final TabBarTheme materialTabBarTheme = TabBarTheme(
labelColor: buildMSC(const Color(0x00000006), const Color(0x00000007)),
unselectedLabelColor: const Color(0x00000008),
labelStyle: TextStyle(color: buildMSC(const Color(0x00000009), const Color(0x00000010))),
unselectedLabelStyle: const TextStyle(color: Color(0x00000011)),
);
const TabBarTheme tabBarTheme = TabBarTheme(
labelColor: Color(0x00000012),
unselectedLabelColor: Color(0x00000013),
labelStyle: TextStyle(color: Color(0x00000014)),
unselectedLabelStyle: TextStyle(color: Color(0x00000015)),
);
const TabBarTheme tabBarThemeWithNullUnselectedLabelColor = TabBarTheme(
labelColor: Color(0x00000016),
labelStyle: TextStyle(color: Color(0x00000017)),
unselectedLabelStyle: TextStyle(color: Color(0x00000018)),
);
Widget buildTabBar({
bool isLabelColorMSC = false,
bool isLabelColorNull = false,
bool isUnselectedLabelColorNull = false,
bool isTabBarThemeMSC = false,
bool isTabBarThemeNull = false,
bool isTabBarThemeUnselectedLabelColorNull = false,
}) {
final TabBarTheme? effectiveTheme = isTabBarThemeNull
? null : isTabBarThemeUnselectedLabelColorNull
? tabBarThemeWithNullUnselectedLabelColor
: isTabBarThemeMSC
? materialTabBarTheme
: tabBarTheme;
return boilerplate(
child: Theme(
data: ThemeData(tabBarTheme: effectiveTheme),
child: DefaultTabController(
length: 2,
child: TabBar(
labelColor: isLabelColorNull ? null : isLabelColorMSC ? materialLabelColor : labelColor,
unselectedLabelColor: isUnselectedLabelColorNull ? null : unselectedLabelColor,
labelStyle: labelStyle,
unselectedLabelStyle: unselectedLabelStyle,
tabs: const <Widget>[Text('1'), Text('2')],
),
),
),
);
}
// Returns int `color.value`s instead of Color `color`s to prevent false
// negative due to object types being different (Color != MaterialStateColor)
// when `expect`ing.
int? getTab1Color() => IconTheme.of(tester.element(find.text('1'))).color?.value;
int? getTab2Color() => IconTheme.of(tester.element(find.text('2'))).color?.value;
int getSelectedColor(Color color) => (color as MaterialStateColor)
.resolve(<MaterialState>{MaterialState.selected}).value;
int getUnselectedColor(Color color) => (color as MaterialStateColor)
.resolve(<MaterialState>{}).value;
// highest precedence: labelColor as MaterialStateColor
await tester.pumpWidget(buildTabBar(isLabelColorMSC: true));
expect(getTab1Color(), equals(getSelectedColor(materialLabelColor)));
expect(getTab2Color(), equals(getUnselectedColor(materialLabelColor)));
// next precedence: labelColor and unselectedLabelColor
await tester.pumpWidget(buildTabBar());
expect(getTab1Color(), equals(labelColor.value));
expect(getTab2Color(), equals(unselectedLabelColor.value));
// next precedence: tabBarTheme.labelColor as MaterialStateColor
await tester.pumpWidget(buildTabBar(
isLabelColorNull: true,
isTabBarThemeMSC: true,
));
expect(getTab1Color(), equals(getSelectedColor(materialTabBarTheme.labelColor!)));
expect(getTab2Color(), equals(getUnselectedColor(materialTabBarTheme.labelColor!)));
// next precedence: tabBarTheme.labelColor and
// tabBarTheme.unselectedLabelColor
await tester.pumpWidget(buildTabBar(
isLabelColorNull: true,
isUnselectedLabelColorNull: true,
));
expect(getTab1Color(), equals(tabBarTheme.labelColor!.value));
expect(getTab2Color(), equals(tabBarTheme.unselectedLabelColor!.value));
// next precedence: labelColor and labelColor at 70% opacity
await tester.pumpWidget(buildTabBar(
isUnselectedLabelColorNull: true,
isTabBarThemeUnselectedLabelColorNull: true,
));
expect(getTab1Color(), equals(labelColor.value));
expect(getTab2Color(), equals(labelColor.withAlpha(0xB2).value));
// next precedence: tabBarTheme.labelColor and tabBarTheme.labelColor at 70%
// opacity
await tester.pumpWidget(buildTabBar(
isLabelColorNull: true,
isUnselectedLabelColorNull: true,
isTabBarThemeUnselectedLabelColorNull: true,
));
expect(getTab1Color(), equals(tabBarThemeWithNullUnselectedLabelColor.labelColor!.value));
expect(getTab2Color(), equals(tabBarThemeWithNullUnselectedLabelColor.labelColor!.withAlpha(0xB2).value));
// last precedence: themeData.primaryTextTheme.bodyText1.color and
// themeData.primaryTextTheme.bodyText1.color.withAlpha(0xB2)
await tester.pumpWidget(buildTabBar(
isLabelColorNull: true,
isUnselectedLabelColorNull: true,
isTabBarThemeNull: true,
));
expect(getTab1Color(), equals(ThemeData().primaryTextTheme.bodyText1!.color!.value));
expect(getTab2Color(), equals(ThemeData().primaryTextTheme.bodyText1!.color!.withAlpha(0xB2).value));
});
testWidgets('Replacing the tabController after disposing the old one', (WidgetTester tester) async { testWidgets('Replacing the tabController after disposing the old one', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/32428 // Regression test for https://github.com/flutter/flutter/issues/32428
...@@ -4309,7 +4444,7 @@ void main() { ...@@ -4309,7 +4444,7 @@ void main() {
expect(pageView.physics.toString().contains('ClampingScrollPhysics'), isFalse); expect(pageView.physics.toString().contains('ClampingScrollPhysics'), isFalse);
}); });
testWidgets('TabController changes offset attribute', (WidgetTester tester) async { testWidgets('TabController.offset changes reflect labelColor', (WidgetTester tester) async {
final TabController controller = TabController( final TabController controller = TabController(
vsync: const TestVSync(), vsync: const TestVSync(),
length: 2, length: 2,
...@@ -4318,11 +4453,24 @@ void main() { ...@@ -4318,11 +4453,24 @@ void main() {
late Color firstColor; late Color firstColor;
late Color secondColor; late Color secondColor;
await tester.pumpWidget( Widget buildTabBar({bool labelColorIsMaterialStateColor = false}) {
boilerplate( final Color labelColor = labelColorIsMaterialStateColor
? MaterialStateColor
.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return Colors.white;
} else {
// this is a third color to also test if unselectedLabelColor
// is ignored when labelColor is MaterialStateColor
return Colors.transparent;
}
})
: Colors.white;
return boilerplate(
child: TabBar( child: TabBar(
controller: controller, controller: controller,
labelColor: Colors.white, labelColor: labelColor,
unselectedLabelColor: Colors.black, unselectedLabelColor: Colors.black,
tabs: <Widget>[ tabs: <Widget>[
Builder(builder: (BuildContext context) { Builder(builder: (BuildContext context) {
...@@ -4335,29 +4483,50 @@ void main() { ...@@ -4335,29 +4483,50 @@ void main() {
}), }),
], ],
), ),
),
); );
}
expect(firstColor, equals(Colors.white)); Future<void> testLabelColor({
expect(secondColor, equals(Colors.black)); required Color selectedColor,
required Color unselectedColor,
}) async {
expect(firstColor, equals(selectedColor));
expect(secondColor, equals(unselectedColor));
controller.offset = 0.6; controller.offset = 0.6;
await tester.pump(); await tester.pump();
expect(firstColor, equals(Color.lerp(Colors.white, Colors.black, 0.6))); expect(firstColor, equals(Color.lerp(selectedColor, unselectedColor, 0.6)));
expect(secondColor, equals(Color.lerp(Colors.black, Colors.white, 0.6))); expect(secondColor, equals(Color.lerp(unselectedColor, selectedColor, 0.6)));
controller.index = 1; controller.index = 1;
await tester.pump(); await tester.pump();
expect(firstColor, equals(Colors.black)); expect(firstColor, equals(unselectedColor));
expect(secondColor, equals(Colors.white)); expect(secondColor, equals(selectedColor));
controller.offset = 0.6; controller.offset = 0.6;
await tester.pump(); await tester.pump();
expect(firstColor, equals(Colors.black)); expect(firstColor, equals(unselectedColor));
expect(secondColor, equals(Colors.white)); expect(secondColor, equals(selectedColor));
controller.offset = -0.6;
await tester.pump();
expect(firstColor, equals(Color.lerp(selectedColor, unselectedColor, 0.4)));
expect(secondColor, equals(Color.lerp(unselectedColor, selectedColor, 0.4)));
}
await tester.pumpWidget(buildTabBar());
await testLabelColor(selectedColor: Colors.white, unselectedColor: Colors.black);
// reset
controller.index = 0;
await tester.pump();
await tester.pumpWidget(buildTabBar(labelColorIsMaterialStateColor: true));
await testLabelColor(selectedColor: Colors.white, unselectedColor: Colors.transparent);
}); });
testWidgets('Crash on dispose', (WidgetTester tester) async { testWidgets('Crash on dispose', (WidgetTester tester) async {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment