From a52293843caab47a3ef3cdae9ff41f9bca482991 Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Wed, 30 Nov 2022 19:58:07 +0200 Subject: [PATCH] [Reland] Add Material 3 support for `TabBar` (#116283) * Add Material 3 support for `TabBar` * M3 `TabBar` revert fix and tests --- dev/tools/gen_defaults/bin/gen_defaults.dart | 2 + dev/tools/gen_defaults/lib/tabs_template.dart | 73 ++++++ .../lib/src/material/tab_bar_theme.dart | 56 ++-- .../lib/src/material/tab_indicator.dart | 43 ++- packages/flutter/lib/src/material/tabs.dart | 190 ++++++++++++-- .../test/material/tab_bar_theme_test.dart | 90 ++++++- packages/flutter/test/material/tabs_test.dart | 247 ++++++++++++++++++ 7 files changed, 629 insertions(+), 72 deletions(-) create mode 100644 dev/tools/gen_defaults/lib/tabs_template.dart diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index de9b2b894473..ce9e035398c2 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -46,6 +46,7 @@ import 'package:gen_defaults/segmented_button_template.dart'; import 'package:gen_defaults/slider_template.dart'; import 'package:gen_defaults/surface_tint.dart'; import 'package:gen_defaults/switch_template.dart'; +import 'package:gen_defaults/tabs_template.dart'; import 'package:gen_defaults/text_field_template.dart'; import 'package:gen_defaults/typography_template.dart'; @@ -165,5 +166,6 @@ Future main(List args) async { SurfaceTintTemplate('SurfaceTint', '$materialLib/elevation_overlay.dart', tokens).updateFile(); SwitchTemplate('Switch', '$materialLib/switch.dart', tokens).updateFile(); TextFieldTemplate('TextField', '$materialLib/text_field.dart', tokens).updateFile(); + TabsTemplate('Tabs', '$materialLib/tabs.dart', tokens).updateFile(); TypographyTemplate('Typography', '$materialLib/typography.dart', tokens).updateFile(); } diff --git a/dev/tools/gen_defaults/lib/tabs_template.dart b/dev/tools/gen_defaults/lib/tabs_template.dart new file mode 100644 index 000000000000..a901b4126289 --- /dev/null +++ b/dev/tools/gen_defaults/lib/tabs_template.dart @@ -0,0 +1,73 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'template.dart'; + +class TabsTemplate extends TokenTemplate { + const TabsTemplate(super.blockName, super.fileName, super.tokens, { + super.colorSchemePrefix = '_colors.', + super.textThemePrefix = '_textTheme.', + }); + + @override + String generate() => ''' +class _${blockName}DefaultsM3 extends TabBarTheme { + _${blockName}DefaultsM3(this.context) + : super(indicatorSize: TabBarIndicatorSize.label); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get dividerColor => ${componentColor("md.comp.primary-navigation-tab.divider")}; + + @override + Color? get indicatorColor => ${componentColor("md.comp.primary-navigation-tab.active-indicator")}; + + @override + Color? get labelColor => ${componentColor("md.comp.primary-navigation-tab.with-label-text.active.label-text")}; + + @override + TextStyle? get labelStyle => ${textStyle("md.comp.primary-navigation-tab.with-label-text.label-text")}; + + @override + Color? get unselectedLabelColor => ${componentColor("md.comp.primary-navigation-tab.with-label-text.inactive.label-text")}; + + @override + TextStyle? get unselectedLabelStyle => ${textStyle("md.comp.primary-navigation-tab.with-label-text.label-text")}; + + @override + MaterialStateProperty get overlayColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.primary-navigation-tab.active.hover.state-layer')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.primary-navigation-tab.active.focus.state-layer')}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.primary-navigation-tab.active.pressed.state-layer')}; + } + return null; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.primary-navigation-tab.inactive.hover.state-layer')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.primary-navigation-tab.inactive.focus.state-layer')}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.primary-navigation-tab.inactive.pressed.state-layer')}; + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +'''; +} diff --git a/packages/flutter/lib/src/material/tab_bar_theme.dart b/packages/flutter/lib/src/material/tab_bar_theme.dart index 70ea0e0e2219..ee6bc4077e88 100644 --- a/packages/flutter/lib/src/material/tab_bar_theme.dart +++ b/packages/flutter/lib/src/material/tab_bar_theme.dart @@ -29,7 +29,9 @@ class TabBarTheme with Diagnosticable { /// Creates a tab bar theme that can be used with [ThemeData.tabBarTheme]. const TabBarTheme({ this.indicator, + this.indicatorColor, this.indicatorSize, + this.dividerColor, this.labelColor, this.labelPadding, this.labelStyle, @@ -43,9 +45,15 @@ class TabBarTheme with Diagnosticable { /// Overrides the default value for [TabBar.indicator]. final Decoration? indicator; + /// Overrides the default value for [TabBar.indicatorColor]. + final Color? indicatorColor; + /// Overrides the default value for [TabBar.indicatorSize]. final TabBarIndicatorSize? indicatorSize; + /// Overrides the default value for [TabBar.dividerColor]. + final Color? dividerColor; + /// Overrides the default value for [TabBar.labelColor]. final Color? labelColor; @@ -80,7 +88,9 @@ class TabBarTheme with Diagnosticable { /// new values. TabBarTheme copyWith({ Decoration? indicator, + Color? indicatorColor, TabBarIndicatorSize? indicatorSize, + Color? dividerColor, Color? labelColor, EdgeInsetsGeometry? labelPadding, TextStyle? labelStyle, @@ -92,7 +102,9 @@ class TabBarTheme with Diagnosticable { }) { return TabBarTheme( indicator: indicator ?? this.indicator, + indicatorColor: indicatorColor ?? this.indicatorColor, indicatorSize: indicatorSize ?? this.indicatorSize, + dividerColor: dividerColor ?? this.dividerColor, labelColor: labelColor ?? this.labelColor, labelPadding: labelPadding ?? this.labelPadding, labelStyle: labelStyle ?? this.labelStyle, @@ -120,13 +132,15 @@ class TabBarTheme with Diagnosticable { assert(t != null); return TabBarTheme( indicator: Decoration.lerp(a.indicator, b.indicator, t), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), indicatorSize: t < 0.5 ? a.indicatorSize : b.indicatorSize, + dividerColor: Color.lerp(a.dividerColor, b.dividerColor, t), labelColor: Color.lerp(a.labelColor, b.labelColor, t), labelPadding: EdgeInsetsGeometry.lerp(a.labelPadding, b.labelPadding, t), labelStyle: TextStyle.lerp(a.labelStyle, b.labelStyle, t), unselectedLabelColor: Color.lerp(a.unselectedLabelColor, b.unselectedLabelColor, t), unselectedLabelStyle: TextStyle.lerp(a.unselectedLabelStyle, b.unselectedLabelStyle, t), - overlayColor: _LerpColors(a.overlayColor, b.overlayColor, t), + overlayColor: MaterialStateProperty.lerp(a.overlayColor, b.overlayColor, t, Color.lerp), splashFactory: t < 0.5 ? a.splashFactory : b.splashFactory, mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, ); @@ -135,7 +149,9 @@ class TabBarTheme with Diagnosticable { @override int get hashCode => Object.hash( indicator, + indicatorColor, indicatorSize, + dividerColor, labelColor, labelPadding, labelStyle, @@ -156,7 +172,9 @@ class TabBarTheme with Diagnosticable { } return other is TabBarTheme && other.indicator == indicator + && other.indicatorColor == indicatorColor && other.indicatorSize == indicatorSize + && other.dividerColor == dividerColor && other.labelColor == labelColor && other.labelPadding == labelPadding && other.labelStyle == labelStyle @@ -167,39 +185,3 @@ class TabBarTheme with Diagnosticable { && other.mouseCursor == mouseCursor; } } - - -@immutable -class _LerpColors implements MaterialStateProperty { - const _LerpColors(this.a, this.b, this.t); - - final MaterialStateProperty? a; - final MaterialStateProperty? b; - final double t; - - @override - Color? resolve(Set states) { - final Color? resolvedA = a?.resolve(states); - final Color? resolvedB = b?.resolve(states); - return Color.lerp(resolvedA, resolvedB, t); - } - - @override - int get hashCode { - return Object.hash(a, b, t); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other.runtimeType != runtimeType) { - return false; - } - return other is _LerpColors - && other.a == a - && other.b == b - && other.t == t; - } -} diff --git a/packages/flutter/lib/src/material/tab_indicator.dart b/packages/flutter/lib/src/material/tab_indicator.dart index 313a55a7e40f..276dc8429957 100644 --- a/packages/flutter/lib/src/material/tab_indicator.dart +++ b/packages/flutter/lib/src/material/tab_indicator.dart @@ -20,11 +20,18 @@ class UnderlineTabIndicator extends Decoration { /// /// The [borderSide] and [insets] arguments must not be null. const UnderlineTabIndicator({ + this.borderRadius, this.borderSide = const BorderSide(width: 2.0, color: Colors.white), this.insets = EdgeInsets.zero, }) : assert(borderSide != null), assert(insets != null); + /// The radius of the indicator's corners. + /// + /// If this value is non-null, rounded rectangular tab indicator is + /// drawn, otherwise rectangular tab indictor is drawn. + final BorderRadius? borderRadius; + /// The color and weight of the horizontal line drawn below the selected tab. final BorderSide borderSide; @@ -60,7 +67,7 @@ class UnderlineTabIndicator extends Decoration { @override BoxPainter createBoxPainter([ VoidCallback? onChanged ]) { - return _UnderlinePainter(this, onChanged); + return _UnderlinePainter(this, borderRadius, onChanged); } Rect _indicatorRectFor(Rect rect, TextDirection textDirection) { @@ -77,15 +84,25 @@ class UnderlineTabIndicator extends Decoration { @override Path getClipPath(Rect rect, TextDirection textDirection) { + if (borderRadius != null) { + return Path()..addRRect( + borderRadius!.toRRect(_indicatorRectFor(rect, textDirection)) + ); + } return Path()..addRect(_indicatorRectFor(rect, textDirection)); } } class _UnderlinePainter extends BoxPainter { - _UnderlinePainter(this.decoration, super.onChanged) + _UnderlinePainter( + this.decoration, + this.borderRadius, + super.onChanged, + ) : assert(decoration != null); final UnderlineTabIndicator decoration; + final BorderRadius? borderRadius; @override void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { @@ -93,8 +110,24 @@ class _UnderlinePainter extends BoxPainter { assert(configuration.size != null); final Rect rect = offset & configuration.size!; final TextDirection textDirection = configuration.textDirection!; - final Rect indicator = decoration._indicatorRectFor(rect, textDirection).deflate(decoration.borderSide.width / 2.0); - final Paint paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.square; - canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, paint); + final Paint paint; + if (borderRadius != null) { + paint = Paint()..color = decoration.borderSide.color; + final Rect indicator = decoration._indicatorRectFor(rect, textDirection) + .inflate(decoration.borderSide.width / 4.0); + final RRect rrect = RRect.fromRectAndCorners( + indicator, + topLeft: borderRadius!.topLeft, + topRight: borderRadius!.topRight, + bottomRight: borderRadius!.bottomRight, + bottomLeft: borderRadius!.bottomLeft, + ); + canvas.drawRRect(rrect, paint); + } else { + paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.square; + final Rect indicator = decoration._indicatorRectFor(rect, textDirection) + .deflate(decoration.borderSide.width / 2.0); + canvas.drawLine(indicator.bottomLeft, indicator.bottomRight, paint); + } } } diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index c7b51235cef6..4d502d2f9058 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'app_bar.dart'; +import 'color_scheme.dart'; import 'colors.dart'; import 'constants.dart'; import 'debug.dart'; @@ -21,6 +22,7 @@ import 'material_state.dart'; import 'tab_bar_theme.dart'; import 'tab_controller.dart'; import 'tab_indicator.dart'; +import 'text_theme.dart'; import 'theme.dart'; const double _kTabHeight = 46.0; @@ -183,18 +185,19 @@ class _TabStyle extends AnimatedWidget { Widget build(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 animation = listenable as Animation; // To enable TextStyle.lerp(style1, style2, value), both styles must have // the same value of inherit. Force that to be inherit=true here. final TextStyle defaultStyle = (labelStyle ?? tabBarTheme.labelStyle - ?? themeData.primaryTextTheme.bodyLarge! + ?? defaults.labelStyle! ).copyWith(inherit: true); final TextStyle defaultUnselectedStyle = (unselectedLabelStyle ?? tabBarTheme.unselectedLabelStyle ?? labelStyle - ?? themeData.primaryTextTheme.bodyLarge! + ?? defaults.unselectedLabelStyle! ).copyWith(inherit: true); final TextStyle textStyle = selected ? TextStyle.lerp(defaultStyle, defaultUnselectedStyle, animation.value)! @@ -202,10 +205,12 @@ class _TabStyle extends AnimatedWidget { final Color selectedColor = labelColor ?? tabBarTheme.labelColor - ?? themeData.primaryTextTheme.bodyLarge!.color!; + ?? defaults.labelColor!; final Color unselectedColor = unselectedLabelColor ?? tabBarTheme.unselectedLabelColor - ?? selectedColor.withAlpha(0xB2); // 70% alpha + ?? (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)!; @@ -327,6 +332,7 @@ class _IndicatorPainter extends CustomPainter { required this.tabKeys, required _IndicatorPainter? old, required this.indicatorPadding, + this.dividerColor, }) : assert(controller != null), assert(indicator != null), super(repaint: controller.animation) { @@ -340,6 +346,7 @@ class _IndicatorPainter extends CustomPainter { final TabBarIndicatorSize? indicatorSize; final EdgeInsetsGeometry indicatorPadding; final List tabKeys; + final Color? dividerColor; // _currentTabOffsets and _currentTextDirection are set each time TabBar // layout is completed. These values can be null when TabBar contains no @@ -431,6 +438,10 @@ class _IndicatorPainter extends CustomPainter { size: _currentRect!.size, textDirection: _currentTextDirection, ); + if (dividerColor != null) { + final Paint dividerPaint = Paint()..color = dividerColor!..strokeWidth = 1; + canvas.drawLine(Offset(0, size.height), Offset(size.width, size.height), dividerPaint); + } _painter!.paint(canvas, _currentRect!.topLeft, configuration); } @@ -630,6 +641,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.indicatorPadding = EdgeInsets.zero, this.indicator, this.indicatorSize, + this.dividerColor, this.labelColor, this.labelStyle, this.labelPadding, @@ -744,18 +756,27 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { /// [indicator] properties. final TabBarIndicatorSize? indicatorSize; + /// The color of the divider. + /// + /// If null and [ThemeData.useMaterial3] is true, [TabBarTheme.dividerColor] + /// color is used. If that is null and [ThemeData.useMaterial3] is true, + /// [ColorScheme.surfaceVariant] will be used, otherwise divider will not be drawn. + final Color? dividerColor; + /// The color of selected tab labels. /// - /// Unselected tab labels are rendered with the same color rendered at 70% - /// opacity unless [unselectedLabelColor] is non-null. + /// If [ThemeData.useMaterial3] is false, unselected tab labels are rendered with + /// the same color with 70% opacity unless [unselectedLabelColor] is non-null. /// - /// If this parameter is null, then 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. final Color? labelColor; /// The color of unselected tab labels. /// - /// If this property is null, unselected tab labels are rendered with the + /// If this property is null and [ThemeData.useMaterial3] is true, [ColorScheme.onSurfaceVariant] + /// will be used, otherwise unselected tab labels are rendered with the /// [labelColor] with 70% opacity. final Color? unselectedLabelColor; @@ -764,8 +785,9 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { /// If [unselectedLabelStyle] is null, then this text style will be used for /// both selected and unselected label styles. /// - /// If this property is null, then the text style of the - /// [ThemeData.primaryTextTheme]'s [TextTheme.bodyLarge] definition is used. + /// If this property is null and [ThemeData.useMaterial3] is true, [TextTheme.titleSmall] + /// will be used, otherwise the text style of the [ThemeData.primaryTextTheme]'s + /// [TextTheme.bodyLarge] definition is used. final TextStyle? labelStyle; /// The padding added to each of the tab labels. @@ -779,8 +801,9 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { /// The text style of the unselected tab labels. /// - /// If this property is null, then the [labelStyle] value is used. If [labelStyle] - /// is null, then the text style of the [ThemeData.primaryTextTheme]'s + /// 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; @@ -939,16 +962,22 @@ class _TabBarState extends State { _tabKeys = widget.tabs.map((Widget tab) => GlobalKey()).toList(); } - Decoration get _indicator { + Decoration _getIndicator() { + final ThemeData theme = Theme.of(context); + final TabBarTheme tabBarTheme = TabBarTheme.of(context); + final TabBarTheme defaults = theme.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); + if (widget.indicator != null) { return widget.indicator!; } - final TabBarTheme tabBarTheme = TabBarTheme.of(context); if (tabBarTheme.indicator != null) { return tabBarTheme.indicator!; } - Color color = widget.indicatorColor ?? Theme.of(context).indicatorColor; + Color color = widget.indicatorColor + ?? (theme.useMaterial3 + ? tabBarTheme.indicatorColor ?? defaults.indicatorColor! + : Theme.of(context).indicatorColor); // ThemeData tries to avoid this by having indicatorColor avoid being the // primaryColor. However, it's possible that the tab bar is on a // Material that isn't the primaryColor. In that case, if the indicator @@ -968,6 +997,16 @@ class _TabBarState extends State { } return UnderlineTabIndicator( + borderRadius: theme.useMaterial3 + // TODO(tahatesser): Make sure this value matches Material 3 Tabs spec + // when `preferredSize`and `indicatorWeight` are updated to support Material 3 + // https://m3.material.io/components/tabs/specs#149a189f-9039-4195-99da-15c205d20e30, + // https://github.com/flutter/flutter/issues/116136 + ? const BorderRadius.only( + topLeft: Radius.circular(3.0), + topRight: Radius.circular(3.0), + ) + : null, borderSide: BorderSide( width: widget.indicatorWeight, color: color, @@ -1012,13 +1051,18 @@ class _TabBarState extends State { } void _initIndicatorPainter() { + final ThemeData theme = Theme.of(context); + final TabBarTheme tabBarTheme = TabBarTheme.of(context); + final TabBarTheme defaults = theme.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); + _indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter( controller: _controller!, - indicator: _indicator, - indicatorSize: widget.indicatorSize ?? TabBarTheme.of(context).indicatorSize, + indicator: _getIndicator(), + indicatorSize: widget.indicatorSize ?? tabBarTheme.indicatorSize ?? defaults.indicatorSize!, indicatorPadding: widget.indicatorPadding, tabKeys: _tabKeys, old: _indicatorPainter, + dividerColor: theme.useMaterial3 ? widget.dividerColor ?? defaults.dividerColor : null, ); } @@ -1210,7 +1254,9 @@ class _TabBarState extends State { ); } + final ThemeData theme = Theme.of(context); final TabBarTheme tabBarTheme = TabBarTheme.of(context); + final TabBarTheme defaults = theme.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); final List wrappedTabs = List.generate(widget.tabs.length, (int index) { const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0; @@ -1275,20 +1321,26 @@ class _TabBarState extends State { // the same share of the tab bar's overall width. final int tabCount = widget.tabs.length; for (int index = 0; index < tabCount; index += 1) { - final Set states = { + final Set selectedState = { if (index == _currentIndex) MaterialState.selected, }; - final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs(widget.mouseCursor, states) - ?? tabBarTheme.mouseCursor?.resolve(states) - ?? MaterialStateMouseCursor.clickable.resolve(states); + final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs(widget.mouseCursor, selectedState) + ?? tabBarTheme.mouseCursor?.resolve(selectedState) + ?? MaterialStateMouseCursor.clickable.resolve(selectedState); + final MaterialStateProperty defaultOverlay = MaterialStateProperty.resolveWith( + (Set states) { + final Set effectiveStates = selectedState..addAll(states); + return defaults.overlayColor?.resolve(effectiveStates); + }, + ); wrappedTabs[index] = InkWell( mouseCursor: effectiveMouseCursor, onTap: () { _handleTap(index); }, enableFeedback: widget.enableFeedback ?? true, - overlayColor: widget.overlayColor ?? tabBarTheme.overlayColor, - splashFactory: widget.splashFactory ?? tabBarTheme.splashFactory, + overlayColor: widget.overlayColor ?? tabBarTheme.overlayColor ?? defaultOverlay, + splashFactory: widget.splashFactory ?? tabBarTheme.splashFactory ?? defaults.splashFactory, borderRadius: widget.splashBorderRadius, child: Padding( padding: EdgeInsets.only(bottom: widget.indicatorWeight), @@ -1818,3 +1870,95 @@ class TabPageSelector extends StatelessWidget { ); } } + +// Hand coded defaults based on Material Design 2. +class _TabsDefaultsM2 extends TabBarTheme { + const _TabsDefaultsM2(this.context) + : super(indicatorSize: TabBarIndicatorSize.tab); + + final BuildContext context; + + @override + Color? get indicatorColor => Theme.of(context).indicatorColor; + + @override + Color? get labelColor => Theme.of(context).primaryTextTheme.bodyLarge!.color!; + + @override + TextStyle? get labelStyle => Theme.of(context).primaryTextTheme.bodyLarge; + + @override + TextStyle? get unselectedLabelStyle => Theme.of(context).primaryTextTheme.bodyLarge; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Tabs + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_143 + +class _TabsDefaultsM3 extends TabBarTheme { + _TabsDefaultsM3(this.context) + : super(indicatorSize: TabBarIndicatorSize.label); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get dividerColor => _colors.surfaceVariant; + + @override + Color? get indicatorColor => _colors.primary; + + @override + Color? get labelColor => _colors.primary; + + @override + TextStyle? get labelStyle => _textTheme.titleSmall; + + @override + Color? get unselectedLabelColor => _colors.onSurfaceVariant; + + @override + TextStyle? get unselectedLabelStyle => _textTheme.titleSmall; + + @override + MaterialStateProperty get overlayColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.primary.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.primary.withOpacity(0.12); + } + return null; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.primary.withOpacity(0.12); + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} + +// END GENERATED TOKEN PROPERTIES - Tabs diff --git a/packages/flutter/test/material/tab_bar_theme_test.dart b/packages/flutter/test/material/tab_bar_theme_test.dart index d6a4863f9bf6..18a63c4c7e2c 100644 --- a/packages/flutter/test/material/tab_bar_theme_test.dart +++ b/packages/flutter/test/material/tab_bar_theme_test.dart @@ -11,6 +11,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../rendering/mock_canvas.dart'; + const String _tab1Text = 'tab 1'; const String _tab2Text = 'tab 2'; const String _tab3Text = 'tab 3'; @@ -32,9 +34,10 @@ Widget _withTheme( TabBarTheme? theme, { List tabs = _tabs, bool isScrollable = false, + bool useMaterial3 = false, }) { return MaterialApp( - theme: ThemeData(tabBarTheme: theme), + theme: ThemeData(tabBarTheme: theme, useMaterial3: useMaterial3), home: Scaffold( body: RepaintBoundary( key: _painterKey, @@ -60,7 +63,9 @@ void main() { expect(const TabBarTheme().hashCode, const TabBarTheme().copyWith().hashCode); expect(const TabBarTheme().indicator, null); + expect(const TabBarTheme().indicatorColor, null); expect(const TabBarTheme().indicatorSize, null); + expect(const TabBarTheme().dividerColor, null); expect(const TabBarTheme().labelColor, null); expect(const TabBarTheme().labelPadding, null); expect(const TabBarTheme().labelStyle, null); @@ -71,18 +76,19 @@ void main() { expect(const TabBarTheme().mouseCursor, null); }); - testWidgets('Tab bar defaults - label style and selected/unselected label colors', (WidgetTester tester) async { + testWidgets('Tab bar defaults', (WidgetTester tester) async { // tests for the default label color and label styles when tabBarTheme and tabBar do not provide any - await tester.pumpWidget(_withTheme(null)); + await tester.pumpWidget(_withTheme(null, useMaterial3: true)); + final ThemeData theme = ThemeData(useMaterial3: true); final RenderParagraph selectedRenderObject = tester.renderObject(find.text(_tab1Text)); - expect(selectedRenderObject.text.style!.fontFamily, equals('Roboto')); + expect(selectedRenderObject.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); expect(selectedRenderObject.text.style!.fontSize, equals(14.0)); - expect(selectedRenderObject.text.style!.color, equals(Colors.white)); + expect(selectedRenderObject.text.style!.color, equals(theme.colorScheme.primary)); final RenderParagraph unselectedRenderObject = tester.renderObject(find.text(_tab2Text)); - expect(unselectedRenderObject.text.style!.fontFamily, equals('Roboto')); + expect(unselectedRenderObject.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); expect(unselectedRenderObject.text.style!.fontSize, equals(14.0)); - expect(unselectedRenderObject.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + expect(unselectedRenderObject.text.style!.color, equals(theme.colorScheme.onSurfaceVariant)); // tests for the default value of labelPadding when tabBarTheme and tabBar do not provide one await tester.pumpWidget(_withTheme(null, tabs: _sizedTabs, isScrollable: true)); @@ -104,7 +110,16 @@ void main() { // verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); + + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + expect( + tabBarBox, + paints + ..line(color: theme.colorScheme.surfaceVariant) + ..rrect(color: theme.colorScheme.primary), + ); }); + testWidgets('Tab bar theme overrides label color (selected)', (WidgetTester tester) async { const Color labelColor = Colors.black; const TabBarTheme tabBarTheme = TabBarTheme(labelColor: labelColor); @@ -282,6 +297,15 @@ void main() { expect(iconRenderObject.text.style!.color, equals(unselectedLabelColor)); }); + testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { + await tester.pumpWidget(_withTheme(null, useMaterial3: true, isScrollable: true)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar.default.tab_indicator_size.png'), + ); + }); + testWidgets('Tab bar theme overrides tab indicator size (tab)', (WidgetTester tester) async { const TabBarTheme tabBarTheme = TabBarTheme(indicatorSize: TabBarIndicatorSize.tab); @@ -349,4 +373,56 @@ void main() { matchesGoldenFile('tab_bar_theme.beveled_rect_indicator.png'), ); }); + + group('Material 2', () { + // Tests that are only relevant for Material 2. Once ThemeData.useMaterial3 + // is turned on by default, these tests can be removed. + + testWidgets('Tab bar defaults', (WidgetTester tester) async { + // tests for the default label color and label styles when tabBarTheme and tabBar do not provide any + await tester.pumpWidget(_withTheme(null)); + + final RenderParagraph selectedRenderObject = tester.renderObject(find.text(_tab1Text)); + expect(selectedRenderObject.text.style!.fontFamily, equals('Roboto')); + expect(selectedRenderObject.text.style!.fontSize, equals(14.0)); + expect(selectedRenderObject.text.style!.color, equals(Colors.white)); + final RenderParagraph unselectedRenderObject = tester.renderObject(find.text(_tab2Text)); + expect(unselectedRenderObject.text.style!.fontFamily, equals('Roboto')); + expect(unselectedRenderObject.text.style!.fontSize, equals(14.0)); + expect(unselectedRenderObject.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + + // tests for the default value of labelPadding when tabBarTheme and tabBar do not provide one + await tester.pumpWidget(_withTheme(null, tabs: _sizedTabs, isScrollable: true)); + + const double indicatorWeight = 2.0; + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + + // verify coordinates of tabOne + expect(tabOneRect.left, equals(kTabLabelPadding.left)); + expect(tabOneRect.top, equals(kTabLabelPadding.top)); + expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // verify coordinates of tabTwo + expect(tabTwoRect.right, equals(tabBar.width - kTabLabelPadding.right)); + expect(tabTwoRect.top, equals(kTabLabelPadding.top)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo + expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); + + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + expect(tabBarBox, paints..line(color: const Color(0xff2196f3))); + }); + + testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { + await tester.pumpWidget(_withTheme(null)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar.m2.default.tab_indicator_size.png'), + ); + }); + }); } diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index ef0074bd4248..f14e71613a65 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/mock_canvas.dart'; @@ -357,6 +358,39 @@ void main() { expect(find.byType(TabBar), paints..line(color: Colors.blue[500])); }); + testWidgets('TabBar default selected/unselected text style', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B', 'C']; + + const String selectedValue = 'A'; + const String unSelectedValue = 'C'; + await tester.pumpWidget( + Theme( + data: theme, + child: buildFrame(tabs: tabs, value: selectedValue), + ), + ); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label text style. + expect(tester.renderObject(find.text(selectedValue)).text.style!.fontFamily, 'Roboto'); + expect(tester.renderObject(find.text(selectedValue)).text.style!.fontSize, 14.0); + expect(tester.renderObject( + find.text(selectedValue)).text.style!.color, + theme.colorScheme.primary, + ); + + // Test unselected label text style. + expect(tester.renderObject(find.text(unSelectedValue)).text.style!.fontFamily, 'Roboto'); + expect(tester.renderObject(find.text(unSelectedValue)).text.style!.fontSize, 14.0); + expect(tester.renderObject( + find.text(unSelectedValue)).text.style!.color, + theme.colorScheme.onSurfaceVariant, + ); + }); + testWidgets('TabBar tap selects tab', (WidgetTester tester) async { final List tabs = ['A', 'B', 'C']; @@ -5088,6 +5122,219 @@ void main() { expect(tester.takeException(), isAssertionError); }); + + testWidgets('Tab has correct selected/unselected hover color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B', 'C']; + + await tester.pumpWidget(Theme( + data: theme, + child: buildFrame(tabs: tabs, value: 'C')), + ); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect( + inkFeatures, + isNot(paints + ..rect( + color: theme.colorScheme.onSurface.withOpacity(0.08), + )) + ); + expect( + inkFeatures, + isNot(paints + ..rect( + color: theme.colorScheme.primary.withOpacity(0.08), + )) + ); + + // Start hovering unselected tab. + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(Tab).first)); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints + ..rect( + color: theme.colorScheme.onSurface.withOpacity(0.08), + ) + ); + + // Start hovering selected tab. + await gesture.moveTo(tester.getCenter(find.byType(Tab).last)); + await tester.pumpAndSettle(); + expect( + inkFeatures, + paints + ..rect( + color: theme.colorScheme.primary.withOpacity(0.08), + ), + ); + }); + + testWidgets('Tab has correct selected/unselected focus color', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B', 'C']; + + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B'), + ), + ); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect( + inkFeatures, + isNot(paints + ..rect( + color: theme.colorScheme.onSurface.withOpacity(0.12), + )) + ); + expect( + inkFeatures, + isNot(paints + ..rect( + color: theme.colorScheme.primary.withOpacity(0.12), + )) + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(tester.binding.focusManager.primaryFocus?.hasPrimaryFocus, isTrue); + expect( + inkFeatures, + paints + ..rect( + color: theme.colorScheme.onSurface.withOpacity(0.12), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(tester.binding.focusManager.primaryFocus?.hasPrimaryFocus, isTrue); + expect( + inkFeatures, + paints + ..rect( + color: theme.colorScheme.primary.withOpacity(0.12), + ), + ); + }); + + testWidgets('Tab has correct selected/unselected pressed color', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B', 'C']; + + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B'), + ), + ); + + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect( + inkFeatures, + isNot(paints + ..rect( + color: theme.colorScheme.primary.withOpacity(0.12), + )) + ); + + // Press unselected tab. + TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('A'))); + await tester.pumpAndSettle(); // Let the press highlight animation finish. + expect( + inkFeatures, + paints + ..rect( + color: theme.colorScheme.primary.withOpacity(0.12), + ), + ); + + // Release pressed gesture. + await gesture.up(); + await tester.pumpAndSettle(); + + // Press selected tab. + gesture = await tester.startGesture(tester.getCenter(find.text('B'))); + await tester.pumpAndSettle(); // Let the press highlight animation finish. + expect( + inkFeatures, + paints + ..rect( + color: theme.colorScheme.primary.withOpacity(0.12), + ), + ); + }); + + group('Material 2', () { + // Tests that are only relevant for Material 2. Once ThemeData.useMaterial3 + // is turned on by default, these tests can be removed. + + testWidgets('TabBar default selected/unselected text style', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + final List tabs = ['A', 'B', 'C']; + + const String selectedValue = 'A'; + const String unSelectedValue = 'C'; + await tester.pumpWidget(buildFrame(tabs: tabs, value: selectedValue)); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label text style. + expect(tester.renderObject(find.text(selectedValue)).text.style!.fontFamily, 'Roboto'); + expect(tester.renderObject(find.text(selectedValue)).text.style!.fontSize, 14.0); + expect(tester.renderObject( + find.text(selectedValue)).text.style!.color, + theme.primaryTextTheme.bodyLarge!.color, + ); + + // Test unselected label text style. + expect(tester.renderObject(find.text(unSelectedValue)).text.style!.fontFamily, 'Roboto'); + expect(tester.renderObject(find.text(unSelectedValue)).text.style!.fontSize, 14.0); + expect(tester.renderObject( + find.text(unSelectedValue)).text.style!.color, + theme.primaryTextTheme.bodyLarge!.color!.withAlpha(0xB2) // 70% alpha, + ); + }); + + testWidgets('TabBar default unselectedLabelColor inherits labelColor with 70% opacity', (WidgetTester tester) async { + // This is a regression test for https://github.com/flutter/flutter/pull/116273 + final List tabs = ['A', 'B', 'C']; + + const String selectedValue = 'A'; + const String unSelectedValue = 'C'; + const Color labelColor = Color(0xff0000ff); + await tester.pumpWidget( + Theme( + data: ThemeData(tabBarTheme: const TabBarTheme(labelColor: labelColor)), + child: buildFrame(tabs: tabs, value: selectedValue), + ), + ); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label color. + expect(tester.renderObject( + find.text(selectedValue)).text.style!.color, + labelColor, + ); + + // Test unselected label color. + expect(tester.renderObject( + find.text(unSelectedValue)).text.style!.color, + labelColor.withAlpha(0xB2) // 70% alpha, + ); + }); + }); } class KeepAliveInk extends StatefulWidget {