diff --git a/dev/tools/gen_defaults/generated/used_tokens.csv b/dev/tools/gen_defaults/generated/used_tokens.csv index c7eb2a390b..12754860bc 100644 --- a/dev/tools/gen_defaults/generated/used_tokens.csv +++ b/dev/tools/gen_defaults/generated/used_tokens.csv @@ -530,6 +530,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, @@ -589,6 +590,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..2bb5f44971 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -387,6 +387,39 @@ double _indexChangeProgress(TabController controller) { return (controllerValue - currentIndex).abs() / (currentIndex - previousIndex).abs(); } +class _DividerPainter extends CustomPainter { + _DividerPainter({ + required this.dividerColor, + required this.dividerHeight, + }); + + final Color dividerColor; + final double dividerHeight; + + @override + void paint(Canvas canvas, Size size) { + if (dividerHeight <= 0.0) { + return; + } + + final Paint paint = Paint() + ..color = dividerColor + ..strokeWidth = dividerHeight; + + canvas.drawLine( + Offset(0, size.height - (paint.strokeWidth / 2)), + Offset(size.width, size.height - (paint.strokeWidth / 2)), + paint, + ); + } + + @override + bool shouldRepaint(_DividerPainter oldDelegate) { + return oldDelegate.dividerColor != dividerColor + || oldDelegate.dividerHeight != dividerHeight; + } +} + class _IndicatorPainter extends CustomPainter { _IndicatorPainter({ required this.controller, @@ -397,6 +430,8 @@ class _IndicatorPainter extends CustomPainter { required this.indicatorPadding, required this.labelPaddings, this.dividerColor, + this.dividerHeight, + required this.showDivider, }) : super(repaint: controller.animation) { if (old != null) { saveTabOffsets(old._currentTabOffsets, old._currentTextDirection); @@ -408,8 +443,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 bool showDivider; // _currentTabOffsets and _currentTextDirection are set each time TabBar // layout is completed. These values can be null when TabBar contains no @@ -501,9 +538,11 @@ 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); + if (showDivider && dividerHeight !> 0) { + final Paint dividerPaint = Paint()..color = dividerColor!..strokeWidth = dividerHeight!; + final Offset dividerP1 = Offset(0, size.height - (dividerPaint.strokeWidth / 2)); + final Offset dividerP2 = Offset(size.width, size.height - (dividerPaint.strokeWidth / 2)); + canvas.drawLine(dividerP1, dividerP2, dividerPaint); } _painter!.paint(canvas, _currentRect!.topLeft, configuration); } @@ -718,6 +757,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.indicator, this.indicatorSize, this.dividerColor, + this.dividerHeight, this.labelColor, this.labelStyle, this.labelPadding, @@ -768,6 +808,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.indicator, this.indicatorSize, this.dividerColor, + this.dividerHeight, this.labelColor, this.labelStyle, this.labelPadding, @@ -895,6 +936,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 +1202,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 +1317,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: widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor, + dividerHeight: widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight, + showDivider: theme.useMaterial3 && !widget.isScrollable, ); } @@ -1299,7 +1349,9 @@ class _TabBarState extends State { widget.indicatorWeight != oldWidget.indicatorWeight || widget.indicatorSize != oldWidget.indicatorSize || widget.indicatorPadding != oldWidget.indicatorPadding || - widget.indicator != oldWidget.indicator) { + widget.indicator != oldWidget.indicator || + widget.dividerColor != oldWidget.dividerColor || + widget.dividerHeight != oldWidget.dividerHeight) { _initIndicatorPainter(); } @@ -1475,6 +1527,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)); @@ -1486,7 +1539,6 @@ class _TabBarState extends State { ); } - final List wrappedTabs = List.generate(widget.tabs.length, (int index) { const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0; EdgeInsetsGeometry? adjustedPadding; @@ -1627,6 +1679,24 @@ 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 = CustomPaint( + painter: _DividerPainter( + dividerColor: widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor!, + dividerHeight: widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight!, + ), + child: Align( + heightFactor: 1.0, + alignment: effectiveAlignment, + child: tabBar, + ), + ); + } } else if (widget.padding != null) { tabBar = Padding( padding: widget.padding!, @@ -2177,6 +2247,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 +2297,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 +2314,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 +2364,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 3b686c185d..0f69b10190 100644 --- a/packages/flutter/test/material/tab_bar_theme_test.dart +++ b/packages/flutter/test/material/tab_bar_theme_test.dart @@ -90,6 +90,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); @@ -127,27 +128,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), ); }); @@ -178,29 +184,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 { @@ -315,7 +326,7 @@ void main() { expect(unselectedLabel.text.style!.fontFamily, equals(unselectedLabelStyle.fontFamily)); }); - testWidgets('Tab bar label padding overrides theme label padding', (WidgetTester tester) async { + testWidgets('Material2 - Tab bar label padding overrides theme label padding', (WidgetTester tester) async { const double verticalPadding = 10.0; const double horizontalPadding = 10.0; const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric( @@ -336,7 +347,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(tabBarTheme: tabBarTheme), + theme: ThemeData(tabBarTheme: tabBarTheme, useMaterial3: false), home: Scaffold(body: RepaintBoundary( key: _painterKey, @@ -369,6 +380,61 @@ void main() { expect(tabOneRect.right, equals(tabTwoRect.left - (2 * horizontalPadding))); }); + testWidgets('Material3 - Tab bar label padding overrides theme label padding', (WidgetTester tester) async { + const double tabStartOffset = 52.0; + const double verticalPadding = 10.0; + const double horizontalPadding = 10.0; + const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ); + + const double verticalThemePadding = 20.0; + const double horizontalThemePadding = 20.0; + const EdgeInsetsGeometry themeLabelPadding = EdgeInsets.symmetric( + vertical: verticalThemePadding, + horizontal: horizontalThemePadding, + ); + + const double indicatorWeight = 2.0; // default value + + const TabBarTheme tabBarTheme = TabBarTheme(labelPadding: themeLabelPadding); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(tabBarTheme: tabBarTheme, useMaterial3: true), + home: Scaffold(body: + RepaintBoundary( + key: _painterKey, + child: TabBar( + tabs: _sizedTabs, + isScrollable: true, + controller: TabController(length: _sizedTabs.length, vsync: const TestVSync()), + labelPadding: labelPadding, + ), + ), + ), + ), + ); + + 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(horizontalPadding + tabStartOffset)); + expect(tabOneRect.top, equals(verticalPadding)); + expect(tabOneRect.bottom, equals(tabBar.bottom - verticalPadding - indicatorWeight)); + + // verify coordinates of tabTwo + expect(tabTwoRect.right, equals(tabStartOffset + horizontalThemePadding + tabOneRect.width + tabTwoRect.width + (horizontalThemePadding / 2))); + expect(tabTwoRect.top, equals(verticalPadding)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - verticalPadding - indicatorWeight)); + + // verify tabOne and tabTwo are separated by 2x horizontalPadding + expect(tabOneRect.right, equals(tabTwoRect.left - (2 * horizontalPadding))); + }); + testWidgets('Tab bar theme overrides label color (unselected)', (WidgetTester tester) async { const Color unselectedLabelColor = Colors.black; const TabBarTheme tabBarTheme = TabBarTheme(unselectedLabelColor: unselectedLabelColor); @@ -381,7 +447,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( @@ -390,12 +456,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'), ); }); @@ -549,11 +615,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, @@ -601,9 +668,10 @@ void main() { expect( tabBarBox, paints - // Divider + // Divider. ..line( color: theme.colorScheme.surfaceVariant, + strokeWidth: 1.0, ) // Tab indicator ..line( @@ -615,6 +683,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 @@ -692,7 +956,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. @@ -806,5 +1070,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 62e4f172fd..df4d34c376 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -701,10 +701,16 @@ void main() { expect(controller.index, 0); }); - testWidgets('Scrollable TabBar tap centers selected tab', (WidgetTester tester) async { + testWidgets('Material2 - Scrollable TabBar tap centers selected tab', (WidgetTester tester) async { final List tabs = ['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL']; const Key tabBarKey = Key('TabBar'); - await tester.pumpWidget(buildFrame(tabs: tabs, value: 'AAAAAA', isScrollable: true, tabBarKey: tabBarKey)); + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + useMaterial3: false, + )); final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); expect(controller, isNotNull); expect(controller.index, 0); @@ -720,12 +726,44 @@ void main() { expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(400.0, epsilon: 1.0)); }); - testWidgets('Scrollable TabBar, with padding, tap centers selected tab', (WidgetTester tester) async { + testWidgets('Material3 - Scrollable TabBar tap centers selected tab', (WidgetTester tester) async { + final List tabs = ['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL']; + const Key tabBarKey = Key('TabBar'); + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + useMaterial3: true, + )); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the right of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, greaterThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(452.0, epsilon: 1.0)); + }); + + testWidgets('Material2 - Scrollable TabBar, with padding, tap centers selected tab', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/112776 final List tabs = ['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL']; const Key tabBarKey = Key('TabBar'); const EdgeInsetsGeometry padding = EdgeInsets.only(right: 30, left: 60); - await tester.pumpWidget(buildFrame(tabs: tabs, value: 'AAAAAA', isScrollable: true, tabBarKey: tabBarKey, padding: padding)); + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + padding: padding, + useMaterial3: false, + )); final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); expect(controller, isNotNull); expect(controller.index, 0); @@ -741,7 +779,35 @@ void main() { expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(400.0, epsilon: 1.0)); }); - testWidgets('Scrollable TabBar, with padding and TextDirection.rtl, tap centers selected tab', (WidgetTester tester) async { + testWidgets('Material3 - Scrollable TabBar, with padding, tap centers selected tab', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/112776 + final List tabs = ['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL']; + const Key tabBarKey = Key('TabBar'); + const EdgeInsetsGeometry padding = EdgeInsets.only(right: 30, left: 60); + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + padding: padding, + useMaterial3: true, + )); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the right of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, greaterThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(452.0, epsilon: 1.0)); + }); + + testWidgets('Material2 - Scrollable TabBar, with padding and TextDirection.rtl, tap centers selected tab', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/112776 final List tabs = ['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL']; const Key tabBarKey = Key('TabBar'); @@ -753,6 +819,7 @@ void main() { tabBarKey: tabBarKey, padding: padding, textDirection: TextDirection.rtl, + useMaterial3: false, )); final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); expect(controller, isNotNull); @@ -769,10 +836,45 @@ void main() { expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(400.0, epsilon: 1.0)); }); - testWidgets('TabBar can be scrolled independent of the selection', (WidgetTester tester) async { + testWidgets('Material3 - Scrollable TabBar, with padding and TextDirection.rtl, tap centers selected tab', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/112776 + final List tabs = ['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE', 'FFFFFF', 'GGGGGG', 'HHHHHH', 'IIIIII', 'JJJJJJ', 'KKKKKK', 'LLLLLL']; + const Key tabBarKey = Key('TabBar'); + const EdgeInsetsGeometry padding = EdgeInsets.only(right: 30, left: 60); + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'AAAAAA', + isScrollable: true, + tabBarKey: tabBarKey, + padding: padding, + textDirection: TextDirection.rtl, + useMaterial3: true, + )); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + expect(tester.getSize(find.byKey(tabBarKey)).width, equals(800.0)); + // The center of the FFFFFF item is to the left of the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, lessThan(401.0)); + + await tester.tap(find.text('FFFFFF')); + await tester.pumpAndSettle(); + expect(controller.index, 5); + // The center of the FFFFFF item is now at the TabBar's center + expect(tester.getCenter(find.text('FFFFFF')).dx, moreOrLessEquals(348.0, epsilon: 1.0)); + }); + + testWidgets('Material2 - TabBar can be scrolled independent of the selection', (WidgetTester tester) async { final List tabs = ['AAAA', 'BBBB', 'CCCC', 'DDDD', 'EEEE', 'FFFF', 'GGGG', 'HHHH', 'IIII', 'JJJJ', 'KKKK', 'LLLL']; const Key tabBarKey = Key('TabBar'); - await tester.pumpWidget(buildFrame(tabs: tabs, value: 'AAAA', isScrollable: true, tabBarKey: tabBarKey)); + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'AAAA', + isScrollable: true, + tabBarKey: tabBarKey, + useMaterial3: false, + )); final TabController controller = DefaultTabController.of(tester.element(find.text('AAAA'))); expect(controller, isNotNull); expect(controller.index, 0); @@ -788,6 +890,31 @@ void main() { expect(controller.index, 0); }); + testWidgets('Material3 - TabBar can be scrolled independent of the selection', (WidgetTester tester) async { + final List tabs = ['AAAA', 'BBBB', 'CCCC', 'DDDD', 'EEEE', 'FFFF', 'GGGG', 'HHHH', 'IIII', 'JJJJ', 'KKKK', 'LLLL']; + const Key tabBarKey = Key('TabBar'); + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'AAAA', + isScrollable: true, + tabBarKey: tabBarKey, + useMaterial3: true, + )); + final TabController controller = DefaultTabController.of(tester.element(find.text('AAAA'))); + expect(controller, isNotNull); + expect(controller.index, 0); + + // Fling-scroll the TabBar to the left + expect(tester.getCenter(find.text('HHHH')).dx, lessThan(720.0)); + await tester.fling(find.byKey(tabBarKey), const Offset(-200.0, 0.0), 10000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + expect(tester.getCenter(find.text('HHHH')).dx, lessThan(500.0)); + + // Scrolling the TabBar doesn't change the selection + expect(controller.index, 0); + }); + testWidgets('TabBarView maintains state', (WidgetTester tester) async { final List tabs = ['AAAAAA', 'BBBBBB', 'CCCCCC', 'DDDDDD', 'EEEEEE']; String value = tabs[0]; @@ -2981,9 +3108,10 @@ void main() { expect(tabBarBox.size.width, tabRight); }); - testWidgets('TabBar with padding isScrollable: true', (WidgetTester tester) async { + testWidgets('Material3 - TabBar with padding isScrollable: true', (WidgetTester tester) async { const double indicatorWeight = 2.0; // default indicator weight const EdgeInsets padding = EdgeInsets.only(left: 3.0, top: 7.0, right: 5.0, bottom: 3.0); + const double tabStartOffset = 52.0; final List tabs = [ SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), @@ -3008,6 +3136,7 @@ void main() { tabs: tabs, ), ), + useMaterial3: true, ), ); @@ -3016,7 +3145,7 @@ void main() { expect(tabBarBox.size.height, tabBarHeight); // Tab0 width = 130, height = 30 - double tabLeft = padding.left; + double tabLeft = padding.left + tabStartOffset; double tabRight = tabLeft + 130.0; double tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 30.0) / 2.0; double tabBottom = tabTop + 30.0; @@ -3040,7 +3169,7 @@ void main() { expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); tabRight += padding.right; - expect(tabBarBox.size.width, tabRight); + expect(tabBarBox.size.width, tabRight + 320.0); // Right tab + remaining space of the stretched tab bar. }); testWidgets('TabBar with labelPadding', (WidgetTester tester) async { @@ -5950,15 +6079,12 @@ void main() { ); }); - testWidgets('Default TabAlignment', (WidgetTester tester) async { - final ThemeData theme = ThemeData(useMaterial3: true); + testWidgets('Material3 - Default TabAlignment', (WidgetTester tester) async { 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'), - )); + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', useMaterial3: true)); final Rect tabBar = tester.getRect(find.byType(TabBar)); Rect tabOneRect = tester.getRect(find.byType(Tab).first); @@ -5971,18 +6097,20 @@ void main() { expect(tabTwoRect.right, equals(tabTwoRight)); // Test default TabAlignment when isScrollable is true. - await tester.pumpWidget(MaterialApp( - theme: theme, - home: buildFrame(tabs: tabs, value: 'B', isScrollable: true), + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + useMaterial3: true, )); 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; + tabOneLeft = kTabLabelPadding.left + tabStartOffset; expect(tabOneRect.left, equals(tabOneLeft)); - tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + tabTwoRight = kTabLabelPadding.horizontal + tabStartOffset + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; expect(tabTwoRect.right, equals(tabTwoRight)); }); @@ -6044,6 +6172,262 @@ void main() { expect(tester.takeException(), isAssertionError); }); + testWidgets('Material3 - TabAlignment updates tabs alignment (non-scrollable TabBar)', (WidgetTester tester) async { + final List tabs = ['A', 'B']; + + // Test TabAlignment.fill (default) when isScrollable is false. + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', useMaterial3: true)); + + 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(buildFrame( + tabs: tabs, + value: 'B', + tabAlignment: TabAlignment.center, + useMaterial3: true, + )); + 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('Material3 - TabAlignment updates tabs alignment (scrollable TabBar)', (WidgetTester tester) async { + final List tabs = ['A', 'B']; + const double tabStartOffset = 52.0; + + // Test TabAlignment.startOffset (default) when isScrollable is true. + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + useMaterial3: true, + )); + + 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(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.start, + useMaterial3: true, + )); + 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(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.center, + useMaterial3: true, + )); + 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(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.startOffset, + useMaterial3: true, + )); + 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('Material3 - TabAlignment.start & TabAlignment.startOffset respects TextDirection.rtl', (WidgetTester tester) async { + final List tabs = ['A', 'B']; + const double tabStartOffset = 52.0; + + // Test TabAlignment.startOffset (default) when isScrollable is true. + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + textDirection: TextDirection.rtl, + useMaterial3: true, + )); + + 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(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.start, + textDirection: TextDirection.rtl, + useMaterial3: true, + )); + 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(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + tabAlignment: TabAlignment.startOffset, + textDirection: TextDirection.rtl, + useMaterial3: true, + )); + 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)); + }); + + testWidgets('Material3 - TabBar inherits the dividerColor of TabBarTheme', (WidgetTester tester) async { + const Color dividerColor = Colors.yellow; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: true, + tabBarTheme: const TabBarTheme(dividerColor: dividerColor), + ), + 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'), + ], + ), + ), + ), + ), + ); + + // 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); + }); + + // This is a regression test for https://github.com/flutter/flutter/pull/125974#discussion_r1239089151. + testWidgets('Divider can be constrained', (WidgetTester tester) async { + const Color dividerColor = Colors.yellow; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: true, + tabBarTheme: const TabBarTheme(dividerColor: dividerColor), + ), + home: Scaffold( + body: DefaultTabController( + length: 2, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: ColoredBox( + color: Colors.grey[200]!, + child: const TabBar.secondary( + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: [ + Tab(text: 'Test 1'), + Tab(text: 'Test 2'), + ], + ), + ) + ), + ), + ), + ), + ), + ); + + // Test tab bar width. + expect(tester.getSize(find.byType(TabBar)).width, 360); + // Test divider width. + expect(tester.getSize(find.byType(CustomPaint).at(1)).width, 360); + }); + group('Material 2', () { // These tests are only relevant for Material 2. Once Material 2 // support is deprecated and the APIs are removed, these tests @@ -6104,45 +6488,11 @@ void main() { ); }); - testWidgets('Material3 - TabBar inherits the dividerColor of TabBarTheme', (WidgetTester tester) async { - const Color dividerColor = Colors.yellow; - - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - useMaterial3: true, - tabBarTheme: const TabBarTheme(dividerColor: dividerColor), - ), - 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'), - ], - ), - ), - ), - ), - ); - - // 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); - }); - - testWidgets('Default TabAlignment', (WidgetTester tester) async { - final ThemeData theme = ThemeData(useMaterial3: false); + testWidgets('Material2 - Default TabAlignment', (WidgetTester tester) async { final List tabs = ['A', 'B']; // Test default TabAlignment when isScrollable is false. - await tester.pumpWidget(MaterialApp( - theme: theme, - home: buildFrame(tabs: tabs, value: 'B'), - )); + await tester.pumpWidget(buildFrame(tabs: tabs, value: 'B', useMaterial3: false)); final Rect tabBar = tester.getRect(find.byType(TabBar)); Rect tabOneRect = tester.getRect(find.byType(Tab).first); @@ -6155,9 +6505,11 @@ void main() { expect(tabTwoRect.right, equals(tabTwoRight)); // Test default TabAlignment when isScrollable is true. - await tester.pumpWidget(MaterialApp( - theme: theme, - home: buildFrame(tabs: tabs, value: 'B', isScrollable: true), + await tester.pumpWidget(buildFrame( + tabs: tabs, + value: 'B', + isScrollable: true, + useMaterial3: false, )); tabOneRect = tester.getRect(find.byType(Tab).first); @@ -6261,6 +6613,106 @@ void main() { ), ); }); + + testWidgets('Material2 - TabBar with padding isScrollable: true', (WidgetTester tester) async { + const double indicatorWeight = 2.0; // default indicator weight + const EdgeInsets padding = EdgeInsets.only(left: 3.0, top: 7.0, right: 5.0, bottom: 3.0); + + final List tabs = [ + SizedBox(key: UniqueKey(), width: 130.0, height: 30.0), + SizedBox(key: UniqueKey(), width: 140.0, height: 40.0), + SizedBox(key: UniqueKey(), width: 150.0, height: 50.0), + ]; + + final TabController controller = TabController( + vsync: const TestVSync(), + length: tabs.length, + ); + + await tester.pumpWidget( + boilerplate( + child: Container( + alignment: Alignment.topLeft, + child: TabBar( + padding: padding, + labelPadding: EdgeInsets.zero, + isScrollable: true, + controller: controller, + tabs: tabs, + ), + ), + useMaterial3: false, + ), + ); + + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + final double tabBarHeight = 50.0 + indicatorWeight + padding.top + padding.bottom; // 50 = max tab height + expect(tabBarBox.size.height, tabBarHeight); + + // Tab0 width = 130, height = 30 + double tabLeft = padding.left; + double tabRight = tabLeft + 130.0; + double tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 30.0) / 2.0; + double tabBottom = tabTop + 30.0; + Rect tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[0].key!)), tabRect); + + // Tab1 width = 140, height = 40 + tabLeft = tabRight; + tabRight = tabLeft + 140.0; + tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 40.0) / 2.0; + tabBottom = tabTop + 40.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[1].key!)), tabRect); + + // Tab2 width = 150, height = 50 + tabLeft = tabRight; + tabRight = tabLeft + 150.0; + tabTop = (tabBarHeight - indicatorWeight + (padding.top - padding.bottom) - 50.0) / 2.0; + tabBottom = tabTop + 50.0; + tabRect = Rect.fromLTRB(tabLeft, tabTop, tabRight, tabBottom); + expect(tester.getRect(find.byKey(tabs[2].key!)), tabRect); + + tabRight += padding.right; + expect(tabBarBox.size.width, tabRight); + }); + + testWidgets('Material2 - 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)); + }); }); }