Skip to content

Commit a732a74

Browse files
authored
Introduce TabBar.tabAlignment (#125036)
fixes flutter/flutter#124195 This introduces `TabBar.tabAlignment` while keeping the default alignment for both M2 and M3.
1 parent e2ddf56 commit a732a74

File tree

5 files changed

+355
-24
lines changed

5 files changed

+355
-24
lines changed

dev/tools/gen_defaults/lib/tabs_template.dart

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ class TabsTemplate extends TokenTemplate {
1313
@override
1414
String generate() => '''
1515
class _${blockName}PrimaryDefaultsM3 extends TabBarTheme {
16-
_${blockName}PrimaryDefaultsM3(this.context)
16+
_${blockName}PrimaryDefaultsM3(this.context, this.isScrollable)
1717
: super(indicatorSize: TabBarIndicatorSize.label);
1818
1919
final BuildContext context;
2020
late final ColorScheme _colors = Theme.of(context).colorScheme;
2121
late final TextTheme _textTheme = Theme.of(context).textTheme;
22+
final bool isScrollable;
2223
2324
@override
2425
Color? get dividerColor => ${componentColor("md.comp.primary-navigation-tab.divider")};
@@ -68,15 +69,19 @@ class _${blockName}PrimaryDefaultsM3 extends TabBarTheme {
6869
6970
@override
7071
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
72+
73+
@override
74+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
7175
}
7276
7377
class _${blockName}SecondaryDefaultsM3 extends TabBarTheme {
74-
_${blockName}SecondaryDefaultsM3(this.context)
78+
_${blockName}SecondaryDefaultsM3(this.context, this.isScrollable)
7579
: super(indicatorSize: TabBarIndicatorSize.tab);
7680
7781
final BuildContext context;
7882
late final ColorScheme _colors = Theme.of(context).colorScheme;
7983
late final TextTheme _textTheme = Theme.of(context).textTheme;
84+
final bool isScrollable;
8085
8186
@override
8287
Color? get dividerColor => ${componentColor("md.comp.secondary-navigation-tab.divider")};
@@ -126,6 +131,9 @@ class _${blockName}SecondaryDefaultsM3 extends TabBarTheme {
126131
127132
@override
128133
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
134+
135+
@override
136+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
129137
}
130138
''';
131139

packages/flutter/lib/src/material/tab_bar_theme.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class TabBarTheme with Diagnosticable {
4040
this.overlayColor,
4141
this.splashFactory,
4242
this.mouseCursor,
43+
this.tabAlignment,
4344
});
4445

4546
/// Overrides the default value for [TabBar.indicator].
@@ -90,6 +91,9 @@ class TabBarTheme with Diagnosticable {
9091
/// If specified, overrides the default value of [TabBar.mouseCursor].
9192
final MaterialStateProperty<MouseCursor?>? mouseCursor;
9293

94+
/// Overrides the default value for [TabBar.tabAlignment].
95+
final TabAlignment? tabAlignment;
96+
9397
/// Creates a copy of this object but with the given fields replaced with the
9498
/// new values.
9599
TabBarTheme copyWith({
@@ -105,6 +109,7 @@ class TabBarTheme with Diagnosticable {
105109
MaterialStateProperty<Color?>? overlayColor,
106110
InteractiveInkFeatureFactory? splashFactory,
107111
MaterialStateProperty<MouseCursor?>? mouseCursor,
112+
TabAlignment? tabAlignment,
108113
}) {
109114
return TabBarTheme(
110115
indicator: indicator ?? this.indicator,
@@ -119,6 +124,7 @@ class TabBarTheme with Diagnosticable {
119124
overlayColor: overlayColor ?? this.overlayColor,
120125
splashFactory: splashFactory ?? this.splashFactory,
121126
mouseCursor: mouseCursor ?? this.mouseCursor,
127+
tabAlignment: tabAlignment ?? this.tabAlignment,
122128
);
123129
}
124130

@@ -149,6 +155,7 @@ class TabBarTheme with Diagnosticable {
149155
overlayColor: MaterialStateProperty.lerp<Color?>(a.overlayColor, b.overlayColor, t, Color.lerp),
150156
splashFactory: t < 0.5 ? a.splashFactory : b.splashFactory,
151157
mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor,
158+
tabAlignment: t < 0.5 ? a.tabAlignment : b.tabAlignment,
152159
);
153160
}
154161

@@ -166,6 +173,7 @@ class TabBarTheme with Diagnosticable {
166173
overlayColor,
167174
splashFactory,
168175
mouseCursor,
176+
tabAlignment,
169177
);
170178

171179
@override
@@ -188,6 +196,7 @@ class TabBarTheme with Diagnosticable {
188196
&& other.unselectedLabelStyle == unselectedLabelStyle
189197
&& other.overlayColor == overlayColor
190198
&& other.splashFactory == splashFactory
191-
&& other.mouseCursor == mouseCursor;
199+
&& other.mouseCursor == mouseCursor
200+
&& other.tabAlignment == tabAlignment;
192201
}
193202
}

packages/flutter/lib/src/material/tabs.dart

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,41 @@ enum TabBarIndicatorSize {
4949
label,
5050
}
5151

52+
/// Defines how tabs are aligned horizontally in a [TabBar].
53+
///
54+
/// See also:
55+
///
56+
/// * [TabBar], which displays a row of tabs.
57+
/// * [TabBarView], which displays a widget for the currently selected tab.
58+
/// * [TabBar.tabAlignment], which defines the horizontal alignment of the
59+
/// tabs within the [TabBar].
60+
enum TabAlignment {
61+
// TODO(tahatesser): Add a link to the Material Design spec for
62+
// horizontal offset when it is available.
63+
// It's currently sourced from androidx/compose/material3/TabRow.kt.
64+
/// If [TabBar.isScrollable] is true, tabs are aligned to the
65+
/// start of the [TabBar]. Otherwise throws an exception.
66+
///
67+
/// It is not recommended to set [TabAlignment.start] when
68+
/// [ThemeData.useMaterial3] is false.
69+
start,
70+
71+
/// If [TabBar.isScrollable] is true, tabs are aligned to the
72+
/// start of the [TabBar] with an offset of 52.0 pixels.
73+
/// Otherwise throws an exception.
74+
///
75+
/// It is not recommended to set [TabAlignment.startOffset] when
76+
/// [ThemeData.useMaterial3] is false.
77+
startOffset,
78+
79+
/// If [TabBar.isScrollable] is false, tabs are stretched to fill the
80+
/// [TabBar]. Otherwise throws an exception.
81+
fill,
82+
83+
/// Tabs are aligned to the center of the [TabBar].
84+
center,
85+
}
86+
5287
/// A Material Design [TabBar] tab.
5388
///
5489
/// If both [icon] and [text] are provided, the text is displayed below
@@ -306,9 +341,9 @@ class _TabLabelBar extends Flex {
306341
const _TabLabelBar({
307342
super.children,
308343
required this.onPerformLayout,
344+
required super.mainAxisSize,
309345
}) : super(
310346
direction: Axis.horizontal,
311-
mainAxisSize: MainAxisSize.max,
312347
mainAxisAlignment: MainAxisAlignment.start,
313348
crossAxisAlignment: CrossAxisAlignment.center,
314349
verticalDirection: VerticalDirection.down,
@@ -695,6 +730,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
695730
this.physics,
696731
this.splashFactory,
697732
this.splashBorderRadius,
733+
this.tabAlignment,
698734
}) : _isPrimary = true,
699735
assert(indicator != null || (indicatorWeight > 0.0));
700736

@@ -744,6 +780,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
744780
this.physics,
745781
this.splashFactory,
746782
this.splashBorderRadius,
783+
this.tabAlignment,
747784
}) : _isPrimary = false,
748785
assert(indicator != null || (indicatorWeight > 0.0));
749786

@@ -1027,6 +1064,25 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
10271064
/// If this property is null, it is interpreted as [BorderRadius.zero].
10281065
final BorderRadius? splashBorderRadius;
10291066

1067+
/// Specifies the horizontal alignment of the tabs within a [TabBar].
1068+
///
1069+
/// If [TabBar.isScrollable] is false, only [TabAlignment.fill] and
1070+
/// [TabAlignment.center] are supported. Otherwise an exception is thrown.
1071+
///
1072+
/// If [TabBar.isScrollable] is true, only [TabAlignment.start], [TabAlignment.startOffset],
1073+
/// and [TabAlignment.center] are supported. Otherwise an exception is thrown.
1074+
///
1075+
/// If this is null, then the value of [TabBarTheme.tabAlignment] is used.
1076+
///
1077+
/// If [TabBarTheme.tabAlignment] is null and [ThemeData.useMaterial3] is true,
1078+
/// then [TabAlignment.startOffset] is used if [isScrollable] is true,
1079+
/// otherwise [TabAlignment.fill] is used.
1080+
///
1081+
/// If [TabBarTheme.tabAlignment] is null and [ThemeData.useMaterial3] is false,
1082+
/// then [TabAlignment.center] is used if [isScrollable] is true,
1083+
/// otherwise [TabAlignment.fill] is used.
1084+
final TabAlignment? tabAlignment;
1085+
10301086
/// A size whose height depends on if the tabs have both icons and text.
10311087
///
10321088
/// [AppBar] uses this size to compute its own preferred size.
@@ -1089,10 +1145,10 @@ class _TabBarState extends State<TabBar> {
10891145
TabBarTheme get _defaults {
10901146
if (Theme.of(context).useMaterial3) {
10911147
return widget._isPrimary
1092-
? _TabsPrimaryDefaultsM3(context)
1093-
: _TabsSecondaryDefaultsM3(context);
1148+
? _TabsPrimaryDefaultsM3(context, widget.isScrollable)
1149+
: _TabsSecondaryDefaultsM3(context, widget.isScrollable);
10941150
} else {
1095-
return _TabsDefaultsM2(context);
1151+
return _TabsDefaultsM2(context, widget.isScrollable);
10961152
}
10971153
}
10981154

@@ -1378,10 +1434,32 @@ class _TabBarState extends State<TabBar> {
13781434
return true;
13791435
}
13801436

1437+
bool _debugTabAlignmentIsValid(TabAlignment tabAlignment) {
1438+
assert(() {
1439+
if (widget.isScrollable && tabAlignment == TabAlignment.fill) {
1440+
throw FlutterError(
1441+
'$tabAlignment is only valid for non-scrollable tab bars.',
1442+
);
1443+
}
1444+
if (!widget.isScrollable
1445+
&& (tabAlignment == TabAlignment.start
1446+
|| tabAlignment == TabAlignment.startOffset)) {
1447+
throw FlutterError(
1448+
'$tabAlignment is only valid for scrollable tab bars.',
1449+
);
1450+
}
1451+
return true;
1452+
}());
1453+
return true;
1454+
}
1455+
13811456
@override
13821457
Widget build(BuildContext context) {
13831458
assert(debugCheckHasMaterialLocalizations(context));
13841459
assert(_debugScheduleCheckHasValidTabsCount());
1460+
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
1461+
final TabAlignment effectiveTabAlignment = widget.tabAlignment ?? tabBarTheme.tabAlignment ?? _defaults.tabAlignment!;
1462+
assert(_debugTabAlignmentIsValid(effectiveTabAlignment));
13851463

13861464
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
13871465
if (_controller!.length == 0) {
@@ -1390,7 +1468,6 @@ class _TabBarState extends State<TabBar> {
13901468
);
13911469
}
13921470

1393-
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
13941471

13951472
final List<Widget> wrappedTabs = List<Widget>.generate(widget.tabs.length, (int index) {
13961473
const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0;
@@ -1491,7 +1568,7 @@ class _TabBarState extends State<TabBar> {
14911568
),
14921569
),
14931570
);
1494-
if (!widget.isScrollable) {
1571+
if (!widget.isScrollable && effectiveTabAlignment == TabAlignment.fill) {
14951572
wrappedTabs[index] = Expanded(child: wrappedTabs[index]);
14961573
}
14971574
}
@@ -1509,12 +1586,16 @@ class _TabBarState extends State<TabBar> {
15091586
defaults: _defaults,
15101587
child: _TabLabelBar(
15111588
onPerformLayout: _saveTabOffsets,
1589+
mainAxisSize: effectiveTabAlignment == TabAlignment.fill ? MainAxisSize.max : MainAxisSize.min,
15121590
children: wrappedTabs,
15131591
),
15141592
),
15151593
);
15161594

15171595
if (widget.isScrollable) {
1596+
final EdgeInsetsGeometry? effectivePadding = effectiveTabAlignment == TabAlignment.startOffset
1597+
? const EdgeInsetsDirectional.only(start: 56.0).add(widget.padding ?? EdgeInsets.zero)
1598+
: widget.padding;
15181599
_scrollController ??= _TabBarScrollController(this);
15191600
tabBar = ScrollConfiguration(
15201601
// The scrolling tabs should not show an overscroll indicator.
@@ -1523,7 +1604,7 @@ class _TabBarState extends State<TabBar> {
15231604
dragStartBehavior: widget.dragStartBehavior,
15241605
scrollDirection: Axis.horizontal,
15251606
controller: _scrollController,
1526-
padding: widget.padding,
1607+
padding: effectivePadding,
15271608
physics: widget.physics,
15281609
child: tabBar,
15291610
),
@@ -2030,10 +2111,11 @@ class TabPageSelector extends StatelessWidget {
20302111

20312112
// Hand coded defaults based on Material Design 2.
20322113
class _TabsDefaultsM2 extends TabBarTheme {
2033-
const _TabsDefaultsM2(this.context)
2114+
const _TabsDefaultsM2(this.context, this.isScrollable)
20342115
: super(indicatorSize: TabBarIndicatorSize.tab);
20352116

20362117
final BuildContext context;
2118+
final bool isScrollable;
20372119

20382120
@override
20392121
Color? get indicatorColor => Theme.of(context).indicatorColor;
@@ -2049,6 +2131,9 @@ class _TabsDefaultsM2 extends TabBarTheme {
20492131

20502132
@override
20512133
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
2134+
2135+
@override
2136+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
20522137
}
20532138

20542139
// BEGIN GENERATED TOKEN PROPERTIES - Tabs
@@ -2061,12 +2146,13 @@ class _TabsDefaultsM2 extends TabBarTheme {
20612146
// Token database version: v0_162
20622147

20632148
class _TabsPrimaryDefaultsM3 extends TabBarTheme {
2064-
_TabsPrimaryDefaultsM3(this.context)
2149+
_TabsPrimaryDefaultsM3(this.context, this.isScrollable)
20652150
: super(indicatorSize: TabBarIndicatorSize.label);
20662151

20672152
final BuildContext context;
20682153
late final ColorScheme _colors = Theme.of(context).colorScheme;
20692154
late final TextTheme _textTheme = Theme.of(context).textTheme;
2155+
final bool isScrollable;
20702156

20712157
@override
20722158
Color? get dividerColor => _colors.surfaceVariant;
@@ -2116,15 +2202,19 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme {
21162202

21172203
@override
21182204
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
2205+
2206+
@override
2207+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
21192208
}
21202209

21212210
class _TabsSecondaryDefaultsM3 extends TabBarTheme {
2122-
_TabsSecondaryDefaultsM3(this.context)
2211+
_TabsSecondaryDefaultsM3(this.context, this.isScrollable)
21232212
: super(indicatorSize: TabBarIndicatorSize.tab);
21242213

21252214
final BuildContext context;
21262215
late final ColorScheme _colors = Theme.of(context).colorScheme;
21272216
late final TextTheme _textTheme = Theme.of(context).textTheme;
2217+
final bool isScrollable;
21282218

21292219
@override
21302220
Color? get dividerColor => _colors.surfaceVariant;
@@ -2174,6 +2264,9 @@ class _TabsSecondaryDefaultsM3 extends TabBarTheme {
21742264

21752265
@override
21762266
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
2267+
2268+
@override
2269+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
21772270
}
21782271

21792272
// END GENERATED TOKEN PROPERTIES - Tabs

0 commit comments

Comments
 (0)