diff --git a/dev/tools/gen_defaults/generated/used_tokens.csv b/dev/tools/gen_defaults/generated/used_tokens.csv index 6fb5c5ac68..0054b8cfab 100644 --- a/dev/tools/gen_defaults/generated/used_tokens.csv +++ b/dev/tools/gen_defaults/generated/used_tokens.csv @@ -529,6 +529,7 @@ md.comp.primary-navigation-tab.active.hover.state-layer.opacity, md.comp.primary-navigation-tab.active.pressed.state-layer.color, md.comp.primary-navigation-tab.active.pressed.state-layer.opacity, md.comp.primary-navigation-tab.divider.color, +md.comp.primary-navigation-tab.divider.height, md.comp.primary-navigation-tab.inactive.focus.state-layer.color, md.comp.primary-navigation-tab.inactive.focus.state-layer.opacity, md.comp.primary-navigation-tab.inactive.hover.state-layer.color, @@ -588,6 +589,7 @@ md.comp.search-view.header.supporting-text.color, md.comp.search-view.header.supporting-text.text-style, md.comp.secondary-navigation-tab.active.label-text.color, md.comp.secondary-navigation-tab.divider.color, +md.comp.secondary-navigation-tab.divider.height, md.comp.secondary-navigation-tab.focus.state-layer.color, md.comp.secondary-navigation-tab.focus.state-layer.opacity, md.comp.secondary-navigation-tab.hover.state-layer.color, diff --git a/dev/tools/gen_defaults/lib/tabs_template.dart b/dev/tools/gen_defaults/lib/tabs_template.dart index f6388a8745..f520f617b2 100644 --- a/dev/tools/gen_defaults/lib/tabs_template.dart +++ b/dev/tools/gen_defaults/lib/tabs_template.dart @@ -24,6 +24,9 @@ class _${blockName}PrimaryDefaultsM3 extends TabBarTheme { @override Color? get dividerColor => ${componentColor("md.comp.primary-navigation-tab.divider")}; + @override + double? get dividerHeight => ${getToken('md.comp.primary-navigation-tab.divider.height')}; + @override Color? get indicatorColor => ${componentColor("md.comp.primary-navigation-tab.active-indicator")}; @@ -71,7 +74,7 @@ class _${blockName}PrimaryDefaultsM3 extends TabBarTheme { InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; @override - TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; static double indicatorWeight = ${getToken('md.comp.primary-navigation-tab.active-indicator.height')}; } @@ -88,6 +91,9 @@ class _${blockName}SecondaryDefaultsM3 extends TabBarTheme { @override Color? get dividerColor => ${componentColor("md.comp.secondary-navigation-tab.divider")}; + @override + double? get dividerHeight => ${getToken('md.comp.secondary-navigation-tab.divider.height')}; + @override Color? get indicatorColor => ${componentColor("md.comp.primary-navigation-tab.active-indicator")}; @@ -135,7 +141,7 @@ class _${blockName}SecondaryDefaultsM3 extends TabBarTheme { InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; @override - TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; } '''; diff --git a/packages/flutter/lib/src/material/tab_bar_theme.dart b/packages/flutter/lib/src/material/tab_bar_theme.dart index 87c43d7acf..e65e8b3c4d 100644 --- a/packages/flutter/lib/src/material/tab_bar_theme.dart +++ b/packages/flutter/lib/src/material/tab_bar_theme.dart @@ -32,6 +32,7 @@ class TabBarTheme with Diagnosticable { this.indicatorColor, this.indicatorSize, this.dividerColor, + this.dividerHeight, this.labelColor, this.labelPadding, this.labelStyle, @@ -55,6 +56,9 @@ class TabBarTheme with Diagnosticable { /// Overrides the default value for [TabBar.dividerColor]. final Color? dividerColor; + /// Overrides the default value for [TabBar.dividerHeight]. + final double? dividerHeight; + /// Overrides the default value for [TabBar.labelColor]. /// /// If [labelColor] is a [MaterialStateColor], then the effective color will @@ -101,6 +105,7 @@ class TabBarTheme with Diagnosticable { Color? indicatorColor, TabBarIndicatorSize? indicatorSize, Color? dividerColor, + double? dividerHeight, Color? labelColor, EdgeInsetsGeometry? labelPadding, TextStyle? labelStyle, @@ -116,6 +121,7 @@ class TabBarTheme with Diagnosticable { indicatorColor: indicatorColor ?? this.indicatorColor, indicatorSize: indicatorSize ?? this.indicatorSize, dividerColor: dividerColor ?? this.dividerColor, + dividerHeight: dividerHeight ?? this.dividerHeight, labelColor: labelColor ?? this.labelColor, labelPadding: labelPadding ?? this.labelPadding, labelStyle: labelStyle ?? this.labelStyle, @@ -147,6 +153,7 @@ class TabBarTheme with Diagnosticable { indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), indicatorSize: t < 0.5 ? a.indicatorSize : b.indicatorSize, dividerColor: Color.lerp(a.dividerColor, b.dividerColor, t), + dividerHeight: t < 0.5 ? a.dividerHeight : b.dividerHeight, labelColor: Color.lerp(a.labelColor, b.labelColor, t), labelPadding: EdgeInsetsGeometry.lerp(a.labelPadding, b.labelPadding, t), labelStyle: TextStyle.lerp(a.labelStyle, b.labelStyle, t), @@ -165,6 +172,7 @@ class TabBarTheme with Diagnosticable { indicatorColor, indicatorSize, dividerColor, + dividerHeight, labelColor, labelPadding, labelStyle, @@ -189,6 +197,7 @@ class TabBarTheme with Diagnosticable { && other.indicatorColor == indicatorColor && other.indicatorSize == indicatorSize && other.dividerColor == dividerColor + && other.dividerHeight == dividerHeight && other.labelColor == labelColor && other.labelPadding == labelPadding && other.labelStyle == labelStyle diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 756e61c7f2..29f53dcb05 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -397,6 +397,8 @@ class _IndicatorPainter extends CustomPainter { required this.indicatorPadding, required this.labelPaddings, this.dividerColor, + this.dividerHeight, + required this.width, }) : super(repaint: controller.animation) { if (old != null) { saveTabOffsets(old._currentTabOffsets, old._currentTextDirection); @@ -408,8 +410,10 @@ class _IndicatorPainter extends CustomPainter { final TabBarIndicatorSize? indicatorSize; final EdgeInsetsGeometry indicatorPadding; final List tabKeys; - final Color? dividerColor; final List labelPaddings; + final Color? dividerColor; + final double? dividerHeight; + final double width; // _currentTabOffsets and _currentTextDirection are set each time TabBar // layout is completed. These values can be null when TabBar contains no @@ -502,8 +506,10 @@ class _IndicatorPainter extends CustomPainter { 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); + final Paint dividerPaint = Paint()..color = dividerColor!..strokeWidth = dividerHeight!; + final Offset dividerP1 = Offset(-width, size.height - (dividerPaint.strokeWidth / 2)); + final Offset dividerP2 = Offset(width, size.height - (dividerPaint.strokeWidth / 2)); + canvas.drawLine(dividerP1, dividerP2, dividerPaint); } _painter!.paint(canvas, _currentRect!.topLeft, configuration); } @@ -718,6 +724,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.indicator, this.indicatorSize, this.dividerColor, + this.dividerHeight, this.labelColor, this.labelStyle, this.labelPadding, @@ -768,6 +775,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.indicator, this.indicatorSize, this.dividerColor, + this.dividerHeight, this.labelColor, this.labelStyle, this.labelPadding, @@ -895,6 +903,13 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { /// [ColorScheme.surfaceVariant] will be used, otherwise divider will not be drawn. final Color? dividerColor; + /// The height of the divider. + /// + /// If null and [ThemeData.useMaterial3] is true, [TabBarTheme.dividerHeight] is used. + /// If that is also null and [ThemeData.useMaterial3] is true, 1.0 will be used. + /// Otherwise divider will not be drawn. + final double? dividerHeight; + /// The color of selected tab labels. /// /// If null, then [TabBarTheme.labelColor] is used. If that is also null and @@ -1154,8 +1169,8 @@ class _TabBarState extends State { TabBarTheme get _defaults { if (Theme.of(context).useMaterial3) { return widget._isPrimary - ? _TabsPrimaryDefaultsM3(context, widget.isScrollable) - : _TabsSecondaryDefaultsM3(context, widget.isScrollable); + ? _TabsPrimaryDefaultsM3(context, widget.isScrollable) + : _TabsSecondaryDefaultsM3(context, widget.isScrollable); } else { return _TabsDefaultsM2(context, widget.isScrollable); } @@ -1269,8 +1284,10 @@ class _TabBarState extends State { indicatorPadding: widget.indicatorPadding, tabKeys: _tabKeys, old: _indicatorPainter, - dividerColor: theme.useMaterial3 ? widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor : null, labelPaddings: _labelPaddings, + dividerColor: theme.useMaterial3 ? widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor : null, + dividerHeight: theme.useMaterial3 ? widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight : null, + width: MediaQuery.sizeOf(context).width, ); } @@ -1475,6 +1492,7 @@ class _TabBarState extends State { Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); assert(_debugScheduleCheckHasValidTabsCount()); + final ThemeData theme = Theme.of(context); final TabBarTheme tabBarTheme = TabBarTheme.of(context); final TabAlignment effectiveTabAlignment = widget.tabAlignment ?? tabBarTheme.tabAlignment ?? _defaults.tabAlignment!; assert(_debugTabAlignmentIsValid(effectiveTabAlignment)); @@ -1627,6 +1645,17 @@ class _TabBarState extends State { child: tabBar, ), ); + if (theme.useMaterial3) { + final AlignmentGeometry effectiveAlignment = switch (effectiveTabAlignment) { + TabAlignment.center => Alignment.center, + TabAlignment.start || TabAlignment.startOffset || TabAlignment.fill => AlignmentDirectional.centerStart, + }; + tabBar = Align( + heightFactor: 1.0, + alignment: effectiveAlignment, + child: tabBar, + ); + } } else if (widget.padding != null) { tabBar = Padding( padding: widget.padding!, @@ -2177,6 +2206,9 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme { @override Color? get dividerColor => _colors.surfaceVariant; + @override + double? get dividerHeight => 1.0; + @override Color? get indicatorColor => _colors.primary; @@ -2224,7 +2256,7 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme { InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; @override - TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; static double indicatorWeight = 3.0; } @@ -2241,6 +2273,9 @@ class _TabsSecondaryDefaultsM3 extends TabBarTheme { @override Color? get dividerColor => _colors.surfaceVariant; + @override + double? get dividerHeight => 1.0; + @override Color? get indicatorColor => _colors.primary; @@ -2288,7 +2323,7 @@ class _TabsSecondaryDefaultsM3 extends TabBarTheme { InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; @override - TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill; } // 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 4b8c5a9243..4de788b1e1 100644 --- a/packages/flutter/test/material/tab_bar_theme_test.dart +++ b/packages/flutter/test/material/tab_bar_theme_test.dart @@ -88,6 +88,7 @@ void main() { expect(const TabBarTheme().indicatorColor, null); expect(const TabBarTheme().indicatorSize, null); expect(const TabBarTheme().dividerColor, null); + expect(const TabBarTheme().dividerHeight, null); expect(const TabBarTheme().labelColor, null); expect(const TabBarTheme().labelPadding, null); expect(const TabBarTheme().labelStyle, null); @@ -125,27 +126,32 @@ void main() { 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!)); + const double tabStartOffset = 52.0; // Verify tabOne coordinates. - expect(tabOneRect.left, equals(kTabLabelPadding.left)); + expect(tabOneRect.left, equals(kTabLabelPadding.left + tabStartOffset)); expect(tabOneRect.top, equals(kTabLabelPadding.top)); expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); // Verify tabTwo coordinates. - expect(tabTwoRect.right, equals(tabBar.width - kTabLabelPadding.right)); + final double tabTwoRight = tabStartOffset + kTabLabelPadding.horizontal + tabOneRect.width + + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, tabTwoRight); 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. + // Verify tabOne and tabTwo are separated by right padding of tabOne and left padding of tabTwo. expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); - // Test default indicator color and divider color. + // Test default indicator & divider color. final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); expect( tabBarBox, paints - ..line(color: theme.colorScheme.surfaceVariant) - // Indicator is a rrect in the primary tab bar. + ..line( + color: theme.colorScheme.surfaceVariant, + strokeWidth: 1.0, + ) ..rrect(color: theme.colorScheme.primary), ); }); @@ -176,29 +182,34 @@ void main() { 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!)); + const double tabStartOffset = 52.0; // Verify tabOne coordinates. - expect(tabOneRect.left, equals(kTabLabelPadding.left)); + expect(tabOneRect.left, equals(kTabLabelPadding.left + tabStartOffset)); expect(tabOneRect.top, equals(kTabLabelPadding.top)); expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); // Verify tabTwo coordinates. - expect(tabTwoRect.right, equals(tabBar.width - kTabLabelPadding.right)); + final double tabTwoRight = tabStartOffset + kTabLabelPadding.horizontal + tabOneRect.width + + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, tabTwoRight); 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. + // Verify tabOne and tabTwo are separated by right padding of tabOne and left padding of tabTwo. expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); - // Test default indicator color and divider color. + // Test default indicator & divider color. final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); expect( tabBarBox, paints - ..line(color: theme.colorScheme.surfaceVariant) - // Indicator is a line in the secondary tab bar. + ..line( + color: theme.colorScheme.surfaceVariant, + strokeWidth: 1.0, + ) ..line(color: theme.colorScheme.primary), - ); + ); }); testWidgets('Tab bar theme overrides label color (selected)', (WidgetTester tester) async { @@ -379,7 +390,7 @@ void main() { expect(iconRenderObject.text.style!.color, equals(unselectedLabelColor)); }); - testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { + testWidgets('Tab bar default tab indicator size (primary)', (WidgetTester tester) async { await tester.pumpWidget(buildTabBar(useMaterial3: true, isScrollable: true)); await expectLater( @@ -388,12 +399,12 @@ void main() { ); }); - testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { + testWidgets('Tab bar default tab indicator size (secondary)', (WidgetTester tester) async { await tester.pumpWidget(buildTabBar(useMaterial3: true, isScrollable: true)); await expectLater( find.byKey(_painterKey), - matchesGoldenFile('tab_bar.default.tab_indicator_size.png'), + matchesGoldenFile('tab_bar_secondary.default.tab_indicator_size.png'), ); }); @@ -547,11 +558,12 @@ void main() { expect( tabBarBox, paints - // Divider + // Divider. ..line( color: theme.colorScheme.surfaceVariant, + strokeWidth: 1.0, ) - // Tab indicator + // Tab indicator. ..line( color: theme.colorScheme.primary, strokeWidth: indicatorWeight, @@ -599,9 +611,10 @@ void main() { expect( tabBarBox, paints - // Divider + // Divider. ..line( color: theme.colorScheme.surfaceVariant, + strokeWidth: 1.0, ) // Tab indicator ..line( @@ -613,6 +626,202 @@ void main() { ); }); + testWidgets('TabBar divider can use TabBarTheme.dividerColor & TabBarTheme.dividerHeight', (WidgetTester tester) async { + const Color dividerColor = Color(0xff00ff00); + const double dividerHeight = 10.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarTheme( + dividerColor: dividerColor, + dividerHeight: dividerHeight, + ), + useMaterial3: true, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: TabController(length: 3, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + // Test divider color. + expect(tabBarBox, paints..line(color: dividerColor, strokeWidth: dividerHeight)); + }); + + testWidgets('dividerColor & dividerHeight overrides TabBarTheme.dividerColor', (WidgetTester tester) async { + const Color dividerColor = Color(0xff0000ff); + const double dividerHeight = 8.0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: true, + tabBarTheme: const TabBarTheme( + dividerColor: Colors.pink, + dividerHeight: 5.0, + ), + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + dividerColor: dividerColor, + dividerHeight: dividerHeight, + controller: TabController(length: 3, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + // Test divider color. + expect(tabBarBox, paints..line(color: dividerColor, strokeWidth: dividerHeight)); + }); + + testWidgets('TabBar respects TabBarTheme.tabAlignment', (WidgetTester tester) async { + // Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarTheme(tabAlignment: TabAlignment.center), + useMaterial3: true, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: TabController(length: 2, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + const double availableWidth = 800.0; + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + double tabOneLeft = (availableWidth / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = (availableWidth / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarTheme(tabAlignment: TabAlignment.start), + useMaterial3: true, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + isScrollable: true, + controller: TabController(length: 2, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabBar.tabAlignment overrides TabBarTheme.tabAlignment', (WidgetTester tester) async { + /// Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarTheme(tabAlignment: TabAlignment.fill), + useMaterial3: true, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + tabAlignment: TabAlignment.center, + controller: TabController(length: 2, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + const double availableWidth = 800.0; + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + double tabOneLeft = (availableWidth / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = (availableWidth / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + + /// Test scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarTheme(tabAlignment: TabAlignment.center), + useMaterial3: true, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + controller: TabController(length: 2, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + group('Material 2', () { // These tests are only relevant for Material 2. Once Material 2 // support is deprecated and the APIs are removed, these tests @@ -690,7 +899,7 @@ void main() { 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. + // Verify tabOne and tabTwo are separated by right padding of tabOne and left padding of tabTwo. expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); // Test default indicator color. @@ -804,5 +1013,68 @@ void main() { ), ); }); + + testWidgets('TabBar respects TabBarTheme.tabAlignment', (WidgetTester tester) async { + // Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarTheme(tabAlignment: TabAlignment.center), + useMaterial3: false, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: TabController(length: 2, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final Rect tabOneRect = tester.getRect(find.byType(Tab).first); + final Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + final double tabOneLeft = (800 / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + final double tabTwoRight = (800 / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabBar.tabAlignment overrides TabBarTheme.tabAlignment', (WidgetTester tester) async { + // Test non-scrollable tab bar. + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tabBarTheme: const TabBarTheme(tabAlignment: TabAlignment.fill), + useMaterial3: false, + ), + home: Scaffold( + appBar: AppBar( + bottom: TabBar( + tabAlignment: TabAlignment.center, + controller: TabController(length: 2, vsync: const TestVSync()), + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 3'), + ], + ), + ), + ), + ), + ); + + final Rect tabOneRect = tester.getRect(find.byType(Tab).first); + final Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + final double tabOneLeft = (800 / 2) - tabOneRect.width - kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + final double tabTwoRight = (800 / 2) + tabTwoRect.width + kTabLabelPadding.right; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); }); } diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index c2194170fe..d6f977155d 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -5951,11 +5951,12 @@ void main() { testWidgets('Default TabAlignment', (WidgetTester tester) async { final ThemeData theme = ThemeData(useMaterial3: true); final List tabs = ['A', 'B']; + const double tabStartOffset = 52.0; // Test default TabAlignment when isScrollable is false. await tester.pumpWidget(MaterialApp( theme: theme, - home: buildFrame(tabs: tabs, value: 'B'), + home: buildFrame(tabs: tabs, value: 'B', useMaterial3: theme.useMaterial3), )); final Rect tabBar = tester.getRect(find.byType(TabBar)); @@ -5971,7 +5972,12 @@ void main() { // Test default TabAlignment when isScrollable is true. await tester.pumpWidget(MaterialApp( theme: theme, - home: buildFrame(tabs: tabs, value: 'B', isScrollable: true), + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + useMaterial3: theme.useMaterial3, + ), )); tabOneRect = tester.getRect(find.byType(Tab).first); @@ -5979,8 +5985,8 @@ void main() { // Tabs should be aligned to the start of the TabBar. tabOneLeft = kTabLabelPadding.left; - expect(tabOneRect.left, equals(tabOneLeft)); - tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabOneRect.left, equals(tabOneLeft + tabStartOffset)); + tabTwoRight = kTabLabelPadding.horizontal + tabStartOffset + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; expect(tabTwoRect.right, equals(tabTwoRight)); }); @@ -6042,6 +6048,220 @@ void main() { expect(tester.takeException(), isAssertionError); }); + testWidgets('TabAlignment updates tabs alignment (non-scrollable TabBar)', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + + // Test TabAlignment.fill (default) when isScrollable is false. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B'), + )); + + const double availableWidth = 800.0; + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // By defaults tabs should fill the width of the TabBar. + double tabOneLeft = ((availableWidth / 2) - tabOneRect.width) / 2; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = availableWidth - ((availableWidth / 2) - tabTwoRect.width) / 2; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.center when isScrollable is false. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.center), + ), + ); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should not fill the width of the TabBar. + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabAlignment updates tabs alignment (scrollable TabBar)', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + const double tabStartOffset = 52.0; + + // Test TabAlignment.startOffset (default) when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + useMaterial3: theme.useMaterial3, + ), + )); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // By default tabs should be aligned to the start of the TabBar with + // an horizontal offset of 52.0 pixels. + double tabOneLeft = kTabLabelPadding.left + tabStartOffset; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = tabStartOffset + kTabLabelPadding.horizontal + tabOneRect.width + + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.start when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.start, + useMaterial3: theme.useMaterial3, + ), + )); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar. + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.center when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.center, + useMaterial3: theme.useMaterial3, + ), + )); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be centered in the TabBar. + tabOneLeft = (tabBar.width / 2) - tabOneRect.width - kTabLabelPadding.right; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = (tabBar.width / 2) + tabTwoRect.width + kTabLabelPadding.left; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.startOffset when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.startOffset, + useMaterial3: theme.useMaterial3, + ), + )); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar with an + // horizontal offset of 52.0 pixels. + tabOneLeft = kTabLabelPadding.left + tabStartOffset; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = tabStartOffset + kTabLabelPadding.horizontal + tabOneRect.width + + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabAlignment.start & TabAlignment.startOffset respects TextDirection.rtl', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + const double tabStartOffset = 52.0; + + // Test TabAlignment.startOffset (default) when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + textDirection: TextDirection.rtl, + useMaterial3: theme.useMaterial3, + ), + )); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar with an + // horizontal offset of 52.0 pixels. + double tabOneRight = tabBar.width - kTabLabelPadding.right - tabStartOffset; + expect(tabOneRect.right, equals(tabOneRight)); + double tabTwoLeft = tabBar.width - tabStartOffset - kTabLabelPadding.horizontal - tabOneRect.width + - kTabLabelPadding.right - tabTwoRect.width; + expect(tabTwoRect.left, equals(tabTwoLeft)); + + // Test TabAlignment.start when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.start, + textDirection: TextDirection.rtl, + useMaterial3: theme.useMaterial3, + ), + )); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar. + tabOneRight = tabBar.width - kTabLabelPadding.right; + expect(tabOneRect.right, equals(tabOneRight)); + tabTwoLeft = tabBar.width - kTabLabelPadding.horizontal - tabOneRect.width + - kTabLabelPadding.left - tabTwoRect.width; + expect(tabTwoRect.left, equals(tabTwoLeft)); + + // Test TabAlignment.startOffset when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.startOffset, + textDirection: TextDirection.rtl, + useMaterial3: theme.useMaterial3, + ), + )); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should be aligned to the start of the TabBar with an + // horizontal offset of 52.0 pixels. + tabOneRight = tabBar.width - kTabLabelPadding.right - tabStartOffset; + expect(tabOneRect.right, equals(tabOneRight)); + tabTwoLeft = tabBar.width - tabStartOffset - kTabLabelPadding.horizontal - tabOneRect.width + - kTabLabelPadding.right - tabTwoRect.width; + expect(tabTwoRect.left, equals(tabTwoLeft)); + }); + group('Material 2', () { // These tests are only relevant for Material 2. Once Material 2 // support is deprecated and the APIs are removed, these tests @@ -6103,14 +6323,15 @@ void main() { }); testWidgets('Material3 - TabBar inherits the dividerColor of TabBarTheme', (WidgetTester tester) async { - const Color dividerColor = Colors.yellow; + const Color dividerColor = Color(0xff00ff00); + final ThemeData theme = ThemeData( + useMaterial3: true, + tabBarTheme: const TabBarTheme(dividerColor: dividerColor), + ); await tester.pumpWidget( MaterialApp( - theme: ThemeData( - useMaterial3: true, - tabBarTheme: const TabBarTheme(dividerColor: dividerColor), - ), + theme: theme, home: Scaffold( appBar: AppBar( bottom: TabBar( @@ -6126,10 +6347,9 @@ void main() { ), ); - // Test painter's divider color. - final CustomPaint paint = tester.widget(find.byType(CustomPaint).last); - // ignore: avoid_dynamic_calls - expect((paint.painter as dynamic).dividerColor, dividerColor); + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + // Test divider color. + expect(tabBarBox, paints..line(color: dividerColor)); }); testWidgets('Default TabAlignment', (WidgetTester tester) async { @@ -6259,6 +6479,43 @@ void main() { ), ); }); + + testWidgets('TabAlignment updates tabs alignment (non-scrollable TabBar)', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: false); + final List tabs = ['A', 'B']; + + // Test TabAlignment.fill (default) when isScrollable is false. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B'), + )); + + final Rect tabBar = tester.getRect(find.byType(TabBar)); + Rect tabOneRect = tester.getRect(find.byType(Tab).first); + Rect tabTwoRect = tester.getRect(find.byType(Tab).last); + + // By default tabs should fill the width of the TabBar. + double tabOneLeft = ((tabBar.width / 2) - tabOneRect.width) / 2; + expect(tabOneRect.left, equals(tabOneLeft)); + double tabTwoRight = tabBar.width - ((tabBar.width / 2) - tabTwoRect.width) / 2; + expect(tabTwoRect.right, equals(tabTwoRight)); + + // Test TabAlignment.center when isScrollable is false. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.center), + )); + await tester.pumpAndSettle(); + + tabOneRect = tester.getRect(find.byType(Tab).first); + tabTwoRect = tester.getRect(find.byType(Tab).last); + + // Tabs should not fill the width of the TabBar. + tabOneLeft = kTabLabelPadding.left; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); }); }