diff --git a/dev/tools/gen_defaults/lib/tabs_template.dart b/dev/tools/gen_defaults/lib/tabs_template.dart index 22ed0e3eb1..aa7e77ec40 100644 --- a/dev/tools/gen_defaults/lib/tabs_template.dart +++ b/dev/tools/gen_defaults/lib/tabs_template.dart @@ -13,12 +13,13 @@ class TabsTemplate extends TokenTemplate { @override String generate() => ''' class _${blockName}PrimaryDefaultsM3 extends TabBarTheme { - _${blockName}PrimaryDefaultsM3(this.context) + _${blockName}PrimaryDefaultsM3(this.context, this.isScrollable) : super(indicatorSize: TabBarIndicatorSize.label); final BuildContext context; late final ColorScheme _colors = Theme.of(context).colorScheme; late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; @override Color? get dividerColor => ${componentColor("md.comp.primary-navigation-tab.divider")}; @@ -68,15 +69,19 @@ class _${blockName}PrimaryDefaultsM3 extends TabBarTheme { @override InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; } class _${blockName}SecondaryDefaultsM3 extends TabBarTheme { - _${blockName}SecondaryDefaultsM3(this.context) + _${blockName}SecondaryDefaultsM3(this.context, this.isScrollable) : super(indicatorSize: TabBarIndicatorSize.tab); final BuildContext context; late final ColorScheme _colors = Theme.of(context).colorScheme; late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; @override Color? get dividerColor => ${componentColor("md.comp.secondary-navigation-tab.divider")}; @@ -126,6 +131,9 @@ class _${blockName}SecondaryDefaultsM3 extends TabBarTheme { @override InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : 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 3c4b9e0c72..87c43d7acf 100644 --- a/packages/flutter/lib/src/material/tab_bar_theme.dart +++ b/packages/flutter/lib/src/material/tab_bar_theme.dart @@ -40,6 +40,7 @@ class TabBarTheme with Diagnosticable { this.overlayColor, this.splashFactory, this.mouseCursor, + this.tabAlignment, }); /// Overrides the default value for [TabBar.indicator]. @@ -90,6 +91,9 @@ class TabBarTheme with Diagnosticable { /// If specified, overrides the default value of [TabBar.mouseCursor]. final MaterialStateProperty? mouseCursor; + /// Overrides the default value for [TabBar.tabAlignment]. + final TabAlignment? tabAlignment; + /// Creates a copy of this object but with the given fields replaced with the /// new values. TabBarTheme copyWith({ @@ -105,6 +109,7 @@ class TabBarTheme with Diagnosticable { MaterialStateProperty? overlayColor, InteractiveInkFeatureFactory? splashFactory, MaterialStateProperty? mouseCursor, + TabAlignment? tabAlignment, }) { return TabBarTheme( indicator: indicator ?? this.indicator, @@ -119,6 +124,7 @@ class TabBarTheme with Diagnosticable { overlayColor: overlayColor ?? this.overlayColor, splashFactory: splashFactory ?? this.splashFactory, mouseCursor: mouseCursor ?? this.mouseCursor, + tabAlignment: tabAlignment ?? this.tabAlignment, ); } @@ -149,6 +155,7 @@ class TabBarTheme with Diagnosticable { overlayColor: MaterialStateProperty.lerp(a.overlayColor, b.overlayColor, t, Color.lerp), splashFactory: t < 0.5 ? a.splashFactory : b.splashFactory, mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, + tabAlignment: t < 0.5 ? a.tabAlignment : b.tabAlignment, ); } @@ -166,6 +173,7 @@ class TabBarTheme with Diagnosticable { overlayColor, splashFactory, mouseCursor, + tabAlignment, ); @override @@ -188,6 +196,7 @@ class TabBarTheme with Diagnosticable { && other.unselectedLabelStyle == unselectedLabelStyle && other.overlayColor == overlayColor && other.splashFactory == splashFactory - && other.mouseCursor == mouseCursor; + && other.mouseCursor == mouseCursor + && other.tabAlignment == tabAlignment; } } diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index a326e2b116..ceaaa38a1e 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -49,6 +49,41 @@ enum TabBarIndicatorSize { label, } +/// Defines how tabs are aligned horizontally in a [TabBar]. +/// +/// See also: +/// +/// * [TabBar], which displays a row of tabs. +/// * [TabBarView], which displays a widget for the currently selected tab. +/// * [TabBar.tabAlignment], which defines the horizontal alignment of the +/// tabs within the [TabBar]. +enum TabAlignment { + // TODO(tahatesser): Add a link to the Material Design spec for + // horizontal offset when it is available. + // It's currently sourced from androidx/compose/material3/TabRow.kt. + /// If [TabBar.isScrollable] is true, tabs are aligned to the + /// start of the [TabBar]. Otherwise throws an exception. + /// + /// It is not recommended to set [TabAlignment.start] when + /// [ThemeData.useMaterial3] is false. + start, + + /// If [TabBar.isScrollable] is true, tabs are aligned to the + /// start of the [TabBar] with an offset of 52.0 pixels. + /// Otherwise throws an exception. + /// + /// It is not recommended to set [TabAlignment.startOffset] when + /// [ThemeData.useMaterial3] is false. + startOffset, + + /// If [TabBar.isScrollable] is false, tabs are stretched to fill the + /// [TabBar]. Otherwise throws an exception. + fill, + + /// Tabs are aligned to the center of the [TabBar]. + center, +} + /// A Material Design [TabBar] tab. /// /// If both [icon] and [text] are provided, the text is displayed below @@ -306,9 +341,9 @@ class _TabLabelBar extends Flex { const _TabLabelBar({ super.children, required this.onPerformLayout, + required super.mainAxisSize, }) : super( direction: Axis.horizontal, - mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, verticalDirection: VerticalDirection.down, @@ -695,6 +730,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.physics, this.splashFactory, this.splashBorderRadius, + this.tabAlignment, }) : _isPrimary = true, assert(indicator != null || (indicatorWeight > 0.0)); @@ -744,6 +780,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.physics, this.splashFactory, this.splashBorderRadius, + this.tabAlignment, }) : _isPrimary = false, assert(indicator != null || (indicatorWeight > 0.0)); @@ -1027,6 +1064,25 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { /// If this property is null, it is interpreted as [BorderRadius.zero]. final BorderRadius? splashBorderRadius; + /// Specifies the horizontal alignment of the tabs within a [TabBar]. + /// + /// If [TabBar.isScrollable] is false, only [TabAlignment.fill] and + /// [TabAlignment.center] are supported. Otherwise an exception is thrown. + /// + /// If [TabBar.isScrollable] is true, only [TabAlignment.start], [TabAlignment.startOffset], + /// and [TabAlignment.center] are supported. Otherwise an exception is thrown. + /// + /// If this is null, then the value of [TabBarTheme.tabAlignment] is used. + /// + /// If [TabBarTheme.tabAlignment] is null and [ThemeData.useMaterial3] is true, + /// then [TabAlignment.startOffset] is used if [isScrollable] is true, + /// otherwise [TabAlignment.fill] is used. + /// + /// If [TabBarTheme.tabAlignment] is null and [ThemeData.useMaterial3] is false, + /// then [TabAlignment.center] is used if [isScrollable] is true, + /// otherwise [TabAlignment.fill] is used. + final TabAlignment? tabAlignment; + /// A size whose height depends on if the tabs have both icons and text. /// /// [AppBar] uses this size to compute its own preferred size. @@ -1089,10 +1145,10 @@ class _TabBarState extends State { TabBarTheme get _defaults { if (Theme.of(context).useMaterial3) { return widget._isPrimary - ? _TabsPrimaryDefaultsM3(context) - : _TabsSecondaryDefaultsM3(context); + ? _TabsPrimaryDefaultsM3(context, widget.isScrollable) + : _TabsSecondaryDefaultsM3(context, widget.isScrollable); } else { - return _TabsDefaultsM2(context); + return _TabsDefaultsM2(context, widget.isScrollable); } } @@ -1378,10 +1434,32 @@ class _TabBarState extends State { return true; } + bool _debugTabAlignmentIsValid(TabAlignment tabAlignment) { + assert(() { + if (widget.isScrollable && tabAlignment == TabAlignment.fill) { + throw FlutterError( + '$tabAlignment is only valid for non-scrollable tab bars.', + ); + } + if (!widget.isScrollable + && (tabAlignment == TabAlignment.start + || tabAlignment == TabAlignment.startOffset)) { + throw FlutterError( + '$tabAlignment is only valid for scrollable tab bars.', + ); + } + return true; + }()); + return true; + } + @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); assert(_debugScheduleCheckHasValidTabsCount()); + final TabBarTheme tabBarTheme = TabBarTheme.of(context); + final TabAlignment effectiveTabAlignment = widget.tabAlignment ?? tabBarTheme.tabAlignment ?? _defaults.tabAlignment!; + assert(_debugTabAlignmentIsValid(effectiveTabAlignment)); final MaterialLocalizations localizations = MaterialLocalizations.of(context); if (_controller!.length == 0) { @@ -1390,7 +1468,6 @@ class _TabBarState extends State { ); } - final TabBarTheme tabBarTheme = TabBarTheme.of(context); final List wrappedTabs = List.generate(widget.tabs.length, (int index) { const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0; @@ -1491,7 +1568,7 @@ class _TabBarState extends State { ), ), ); - if (!widget.isScrollable) { + if (!widget.isScrollable && effectiveTabAlignment == TabAlignment.fill) { wrappedTabs[index] = Expanded(child: wrappedTabs[index]); } } @@ -1509,12 +1586,16 @@ class _TabBarState extends State { defaults: _defaults, child: _TabLabelBar( onPerformLayout: _saveTabOffsets, + mainAxisSize: effectiveTabAlignment == TabAlignment.fill ? MainAxisSize.max : MainAxisSize.min, children: wrappedTabs, ), ), ); if (widget.isScrollable) { + final EdgeInsetsGeometry? effectivePadding = effectiveTabAlignment == TabAlignment.startOffset + ? const EdgeInsetsDirectional.only(start: 56.0).add(widget.padding ?? EdgeInsets.zero) + : widget.padding; _scrollController ??= _TabBarScrollController(this); tabBar = ScrollConfiguration( // The scrolling tabs should not show an overscroll indicator. @@ -1523,7 +1604,7 @@ class _TabBarState extends State { dragStartBehavior: widget.dragStartBehavior, scrollDirection: Axis.horizontal, controller: _scrollController, - padding: widget.padding, + padding: effectivePadding, physics: widget.physics, child: tabBar, ), @@ -2030,10 +2111,11 @@ class TabPageSelector extends StatelessWidget { // Hand coded defaults based on Material Design 2. class _TabsDefaultsM2 extends TabBarTheme { - const _TabsDefaultsM2(this.context) + const _TabsDefaultsM2(this.context, this.isScrollable) : super(indicatorSize: TabBarIndicatorSize.tab); final BuildContext context; + final bool isScrollable; @override Color? get indicatorColor => Theme.of(context).indicatorColor; @@ -2049,6 +2131,9 @@ class _TabsDefaultsM2 extends TabBarTheme { @override InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; } // BEGIN GENERATED TOKEN PROPERTIES - Tabs @@ -2061,12 +2146,13 @@ class _TabsDefaultsM2 extends TabBarTheme { // Token database version: v0_162 class _TabsPrimaryDefaultsM3 extends TabBarTheme { - _TabsPrimaryDefaultsM3(this.context) + _TabsPrimaryDefaultsM3(this.context, this.isScrollable) : super(indicatorSize: TabBarIndicatorSize.label); final BuildContext context; late final ColorScheme _colors = Theme.of(context).colorScheme; late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; @override Color? get dividerColor => _colors.surfaceVariant; @@ -2116,15 +2202,19 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme { @override InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill; } class _TabsSecondaryDefaultsM3 extends TabBarTheme { - _TabsSecondaryDefaultsM3(this.context) + _TabsSecondaryDefaultsM3(this.context, this.isScrollable) : super(indicatorSize: TabBarIndicatorSize.tab); final BuildContext context; late final ColorScheme _colors = Theme.of(context).colorScheme; late final TextTheme _textTheme = Theme.of(context).textTheme; + final bool isScrollable; @override Color? get dividerColor => _colors.surfaceVariant; @@ -2174,6 +2264,9 @@ class _TabsSecondaryDefaultsM3 extends TabBarTheme { @override InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; + + @override + TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : 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 eb5176f66d..5b80df9f5b 100644 --- a/packages/flutter/test/material/tab_bar_theme_test.dart +++ b/packages/flutter/test/material/tab_bar_theme_test.dart @@ -96,6 +96,7 @@ void main() { expect(const TabBarTheme().overlayColor, null); expect(const TabBarTheme().splashFactory, null); expect(const TabBarTheme().mouseCursor, null); + expect(const TabBarTheme().tabAlignment, null); }); test('TabBarTheme lerp special cases', () { @@ -138,7 +139,7 @@ void main() { // Verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo. expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); - // Verify divider color and indicator color. + // Test default indicator color and divider color. final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); expect( tabBarBox, @@ -189,7 +190,7 @@ void main() { // Verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo. expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); - // Verify divider color and indicator color. + // Test default indicator color and divider color. final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); expect( tabBarBox, @@ -464,14 +465,57 @@ void main() { ); }); + testWidgets('TabAlignment.fill from TabBarTheme only supports non-scrollable tab bar', (WidgetTester tester) async { + const TabBarTheme tabBarTheme = TabBarTheme(tabAlignment: TabAlignment.fill); + + // Test TabAlignment.fill from TabBarTheme with non-scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.fill from TabBarTheme with scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme, isScrollable: true)); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets( + 'TabAlignment.start & TabAlignment.startOffset from TabBarTheme only supports scrollable tab bar', + (WidgetTester tester) async { + TabBarTheme tabBarTheme = const TabBarTheme(tabAlignment: TabAlignment.start); + + // Test TabAlignment.start from TabBarTheme with scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme, isScrollable: true)); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.start from TabBarTheme with non-scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + expect(tester.takeException(), isAssertionError); + + tabBarTheme = const TabBarTheme(tabAlignment: TabAlignment.startOffset); + + // Test TabAlignment.startOffset from TabBarTheme with scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme, isScrollable: true)); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.startOffset from TabBarTheme with non-scrollable tab bar. + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); + + expect(tester.takeException(), isAssertionError); + }); + group('Material 2', () { // Tests that are only relevant for Material 2. Once ThemeData.useMaterial3 // is turned on by default, these tests can be removed. - testWidgets('Tab bar defaults', (WidgetTester tester) async { - // tests for the default label color and label styles when tabBarTheme and tabBar do not provide any + testWidgets('Tab bar defaults (primary)', (WidgetTester tester) async { + // Test default label color and label styles. await tester.pumpWidget(buildTabBar()); + final ThemeData theme = ThemeData(useMaterial3: false); final RenderParagraph selectedLabel = _getText(tester, _tab1Text); expect(selectedLabel.text.style!.fontFamily, equals('Roboto')); expect(selectedLabel.text.style!.fontSize, equals(14.0)); @@ -481,7 +525,7 @@ void main() { expect(unselectedLabel.text.style!.fontSize, equals(14.0)); expect(unselectedLabel.text.style!.color, equals(Colors.white.withAlpha(0xB2))); - // tests for the default value of labelPadding when tabBarTheme and tabBar do not provide one + // Test default labelPadding. await tester.pumpWidget(buildTabBar(tabs: _sizedTabs, isScrollable: true)); const double indicatorWeight = 2.0; @@ -489,21 +533,62 @@ void main() { final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); - // verify coordinates of tabOne + // Verify tabOne coordinates. expect(tabOneRect.left, equals(kTabLabelPadding.left)); expect(tabOneRect.top, equals(kTabLabelPadding.top)); expect(tabOneRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); - // verify coordinates of tabTwo + // Verify tabTwo coordinates. expect(tabTwoRect.right, equals(tabBar.width - kTabLabelPadding.right)); expect(tabTwoRect.top, equals(kTabLabelPadding.top)); expect(tabTwoRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); - // verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo + // Verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo. expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); + // Test default indicator color. final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); - expect(tabBarBox, paints..line(color: const Color(0xff2196f3))); + expect(tabBarBox, paints..line(color: theme.indicatorColor)); + }); + + testWidgets('Tab bar defaults (secondary)', (WidgetTester tester) async { + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar(secondaryTabBar: true)); + + final ThemeData theme = ThemeData(useMaterial3: false); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals('Roboto')); + expect(selectedLabel.text.style!.fontSize, equals(14.0)); + expect(selectedLabel.text.style!.color, equals(Colors.white)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals('Roboto')); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + + // Test default labelPadding. + await tester.pumpWidget(buildTabBar(tabs: _sizedTabs, isScrollable: true)); + + const double indicatorWeight = 2.0; + final Rect tabBar = tester.getRect(find.byType(TabBar)); + final Rect tabOneRect = tester.getRect(find.byKey(_sizedTabs[0].key!)); + final Rect tabTwoRect = tester.getRect(find.byKey(_sizedTabs[1].key!)); + + // Verify tabOne coordinates. + expect(tabOneRect.left, equals(kTabLabelPadding.left)); + 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)); + expect(tabTwoRect.top, equals(kTabLabelPadding.top)); + expect(tabTwoRect.bottom, equals(tabBar.bottom - kTabLabelPadding.bottom - indicatorWeight)); + + // Verify tabOne and tabTwo is separated by right padding of tabOne and left padding of tabTwo. + expect(tabOneRect.right, equals(tabTwoRect.left - kTabLabelPadding.left - kTabLabelPadding.right)); + + // Test default indicator color. + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + expect(tabBarBox, paints..line(color: theme.indicatorColor)); }); testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index b1ad1b5bdc..aa78dcd188 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -114,6 +114,7 @@ Widget buildFrame({ Duration? animationDuration, EdgeInsetsGeometry? padding, TextDirection textDirection = TextDirection.ltr, + TabAlignment? tabAlignment, }) { if (secondaryTabBar) { return boilerplate( @@ -128,6 +129,7 @@ Widget buildFrame({ isScrollable: isScrollable, indicatorColor: indicatorColor, padding: padding, + tabAlignment: tabAlignment, ), ), ); @@ -145,6 +147,7 @@ Widget buildFrame({ isScrollable: isScrollable, indicatorColor: indicatorColor, padding: padding, + tabAlignment: tabAlignment, ), ), ); @@ -5717,12 +5720,106 @@ void main() { ); }); + testWidgets('Default TabAlignment', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + + // Test default TabAlignment 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); + + // 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 default TabAlignment when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', isScrollable: 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; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); + + testWidgets('TabAlignment.fill only supports non-scrollable tab bar', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + + // Test TabAlignment.fill with non-scrollable tab bar. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.fill), + )); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.fill with scrollable tab bar. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.fill, isScrollable: true), + )); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('TabAlignment.start & TabAlignment.startOffset only supports scrollable tab bar', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + + // Test TabAlignment.start with scrollable tab bar. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.start, isScrollable: true), + )); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.start with non-scrollable tab bar. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.start), + )); + + expect(tester.takeException(), isAssertionError); + + // Test TabAlignment.startOffset with scrollable tab bar. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.startOffset, isScrollable: true), + )); + + expect(tester.takeException(), isNull); + + // Test TabAlignment.startOffset with non-scrollable tab bar. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', tabAlignment: TabAlignment.startOffset), + )); + + expect(tester.takeException(), isAssertionError); + }); + group('Material 2', () { // Tests that are only relevant for Material 2. Once ThemeData.useMaterial3 // is turned on by default, these tests can be removed. testWidgets('TabBar default selected/unselected text style', (WidgetTester tester) async { - final ThemeData theme = ThemeData(); + final ThemeData theme = ThemeData(useMaterial3: false); final List tabs = ['A', 'B', 'C']; const String selectedValue = 'A'; @@ -5758,7 +5855,10 @@ void main() { const Color labelColor = Color(0xff0000ff); await tester.pumpWidget( Theme( - data: ThemeData(tabBarTheme: const TabBarTheme(labelColor: labelColor)), + data: ThemeData( + tabBarTheme: const TabBarTheme(labelColor: labelColor), + useMaterial3: false, + ), child: buildFrame(tabs: tabs, value: selectedValue), ), ); @@ -5808,6 +5908,42 @@ void main() { // ignore: avoid_dynamic_calls expect((paint.painter as dynamic).dividerColor, dividerColor); }); + + testWidgets('Default TabAlignment', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: false); + final List tabs = ['A', 'B']; + + // Test default TabAlignment 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); + + // 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 default TabAlignment when isScrollable is true. + await tester.pumpWidget(MaterialApp( + theme: theme, + home: buildFrame(tabs: tabs, value: 'B', isScrollable: 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; + expect(tabOneRect.left, equals(tabOneLeft)); + tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width; + expect(tabTwoRect.right, equals(tabTwoRight)); + }); }); }