Skip to content

Commit 2c71881

Browse files
authored
Fix Scrollable TabBar for Material 3 (#131409)
fixes [Material 3 `TabBar` does not take full width when `isScrollable: true`](flutter/flutter#117722) ### Description 1. Fixed the divider doesn't stretch to take all the available width in the scrollable tab bar in M3 2. Added `dividerHeight` property. ### Code sample <details> <summary>expand to view the code sample</summary> ```dart import 'package:flutter/material.dart'; /// Flutter code sample for [TabBar]. void main() => runApp(const TabBarApp()); class TabBarApp extends StatelessWidget { const TabBarApp({super.key}); @OverRide Widget build(BuildContext context) { return const MaterialApp( debugShowCheckedModeBanner: false, home: TabBarExample(), ); } } class TabBarExample extends StatefulWidget { const TabBarExample({super.key}); @OverRide State<TabBarExample> createState() => _TabBarExampleState(); } class _TabBarExampleState extends State<TabBarExample> { bool rtl = false; bool customColors = false; bool removeDivider = false; Color dividerColor = Colors.amber; Color indicatorColor = Colors.red; @OverRide Widget build(BuildContext context) { return DefaultTabController( initialIndex: 1, length: 3, child: Directionality( textDirection: rtl ? TextDirection.rtl : TextDirection.ltr, child: Scaffold( appBar: AppBar( title: const Text('TabBar Sample'), actions: <Widget>[ IconButton.filledTonal( tooltip: 'Switch direction', icon: const Icon(Icons.swap_horiz), onPressed: () { setState(() { rtl = !rtl; }); }, ), IconButton.filledTonal( tooltip: 'Use custom colors', icon: const Icon(Icons.color_lens), onPressed: () { setState(() { customColors = !customColors; }); }, ), IconButton.filledTonal( tooltip: 'Show/hide divider', icon: const Icon(Icons.remove_rounded), onPressed: () { setState(() { removeDivider = !removeDivider; }); }, ), ], ), body: Column( children: <Widget>[ const Spacer(), const Text('Scrollable - TabAlignment.start'), TabBar( isScrollable: true, tabAlignment: TabAlignment.start, dividerColor: customColors ? dividerColor : null, indicatorColor: customColors ? indicatorColor : null, dividerHeight: removeDivider ? 0 : null, tabs: const <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), const Text('Scrollable - TabAlignment.startOffset'), TabBar( isScrollable: true, tabAlignment: TabAlignment.startOffset, dividerColor: customColors ? dividerColor : null, indicatorColor: customColors ? indicatorColor : null, dividerHeight: removeDivider ? 0 : null, tabs: const <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), const Text('Scrollable - TabAlignment.center'), TabBar( isScrollable: true, tabAlignment: TabAlignment.center, dividerColor: customColors ? dividerColor : null, indicatorColor: customColors ? indicatorColor : null, dividerHeight: removeDivider ? 0 : null, tabs: const <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), const Spacer(), const Text('Non-scrollable - TabAlignment.fill'), TabBar( tabAlignment: TabAlignment.fill, dividerColor: customColors ? dividerColor : null, indicatorColor: customColors ? indicatorColor : null, dividerHeight: removeDivider ? 0 : null, tabs: const <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), const Text('Non-scrollable - TabAlignment.center'), TabBar( tabAlignment: TabAlignment.center, dividerColor: customColors ? dividerColor : null, indicatorColor: customColors ? indicatorColor : null, dividerHeight: removeDivider ? 0 : null, tabs: const <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), const Spacer(), const Text('Secondary - TabAlignment.fill'), TabBar.secondary( tabAlignment: TabAlignment.fill, dividerColor: customColors ? dividerColor : null, indicatorColor: customColors ? indicatorColor : null, dividerHeight: removeDivider ? 0 : null, tabs: const <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), const Text('Secondary - TabAlignment.center'), TabBar.secondary( tabAlignment: TabAlignment.center, dividerColor: customColors ? dividerColor : null, indicatorColor: customColors ? indicatorColor : null, dividerHeight: removeDivider ? 0 : null, tabs: const <Widget>[ Tab( icon: Icon(Icons.cloud_outlined), ), Tab( icon: Icon(Icons.beach_access_sharp), ), Tab( icon: Icon(Icons.brightness_5_sharp), ), ], ), const Spacer(), ], ), ), ), ); } } ``` </details> ### Before ![Screenshot 2023-07-27 at 14 12 36](https://github.com/flutter/flutter/assets/48603081/1c08a9d2-ac15-4d33-8fa1-c765b4b10f92) ### After ![Screenshot 2023-07-27 at 14 13 12](https://github.com/flutter/flutter/assets/48603081/7e662dfe-9f32-46c9-a128-3024a4782882) This also contains regression test for flutter/flutter#125974 (comment) ```dart // This is a regression test for flutter/flutter#125974 (comment). testWidgets('Divider can be constrained', (WidgetTester tester) async { ``` ![Screenshot 2023-07-27 at 14 16 37](https://github.com/flutter/flutter/assets/48603081/ac2ef49b-2410-46d0-8ae2-d9b77236abba)
1 parent 9c471a9 commit 2c71881

File tree

6 files changed

+967
-95
lines changed

6 files changed

+967
-95
lines changed

dev/tools/gen_defaults/generated/used_tokens.csv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ md.comp.primary-navigation-tab.active.hover.state-layer.opacity,
530530
md.comp.primary-navigation-tab.active.pressed.state-layer.color,
531531
md.comp.primary-navigation-tab.active.pressed.state-layer.opacity,
532532
md.comp.primary-navigation-tab.divider.color,
533+
md.comp.primary-navigation-tab.divider.height,
533534
md.comp.primary-navigation-tab.inactive.focus.state-layer.color,
534535
md.comp.primary-navigation-tab.inactive.focus.state-layer.opacity,
535536
md.comp.primary-navigation-tab.inactive.hover.state-layer.color,
@@ -589,6 +590,7 @@ md.comp.search-view.header.supporting-text.color,
589590
md.comp.search-view.header.supporting-text.text-style,
590591
md.comp.secondary-navigation-tab.active.label-text.color,
591592
md.comp.secondary-navigation-tab.divider.color,
593+
md.comp.secondary-navigation-tab.divider.height,
592594
md.comp.secondary-navigation-tab.focus.state-layer.color,
593595
md.comp.secondary-navigation-tab.focus.state-layer.opacity,
594596
md.comp.secondary-navigation-tab.hover.state-layer.color,

dev/tools/gen_defaults/lib/tabs_template.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class _${blockName}PrimaryDefaultsM3 extends TabBarTheme {
2424
@override
2525
Color? get dividerColor => ${componentColor("md.comp.primary-navigation-tab.divider")};
2626
27+
@override
28+
double? get dividerHeight => ${getToken('md.comp.primary-navigation-tab.divider.height')};
29+
2730
@override
2831
Color? get indicatorColor => ${componentColor("md.comp.primary-navigation-tab.active-indicator")};
2932
@@ -71,7 +74,7 @@ class _${blockName}PrimaryDefaultsM3 extends TabBarTheme {
7174
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
7275
7376
@override
74-
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
77+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
7578
7679
static double indicatorWeight = ${getToken('md.comp.primary-navigation-tab.active-indicator.height')};
7780
}
@@ -88,6 +91,9 @@ class _${blockName}SecondaryDefaultsM3 extends TabBarTheme {
8891
@override
8992
Color? get dividerColor => ${componentColor("md.comp.secondary-navigation-tab.divider")};
9093
94+
@override
95+
double? get dividerHeight => ${getToken('md.comp.secondary-navigation-tab.divider.height')};
96+
9197
@override
9298
Color? get indicatorColor => ${componentColor("md.comp.primary-navigation-tab.active-indicator")};
9399
@@ -135,7 +141,7 @@ class _${blockName}SecondaryDefaultsM3 extends TabBarTheme {
135141
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
136142
137143
@override
138-
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
144+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
139145
}
140146
''';
141147

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class TabBarTheme with Diagnosticable {
3232
this.indicatorColor,
3333
this.indicatorSize,
3434
this.dividerColor,
35+
this.dividerHeight,
3536
this.labelColor,
3637
this.labelPadding,
3738
this.labelStyle,
@@ -55,6 +56,9 @@ class TabBarTheme with Diagnosticable {
5556
/// Overrides the default value for [TabBar.dividerColor].
5657
final Color? dividerColor;
5758

59+
/// Overrides the default value for [TabBar.dividerHeight].
60+
final double? dividerHeight;
61+
5862
/// Overrides the default value for [TabBar.labelColor].
5963
///
6064
/// If [labelColor] is a [MaterialStateColor], then the effective color will
@@ -101,6 +105,7 @@ class TabBarTheme with Diagnosticable {
101105
Color? indicatorColor,
102106
TabBarIndicatorSize? indicatorSize,
103107
Color? dividerColor,
108+
double? dividerHeight,
104109
Color? labelColor,
105110
EdgeInsetsGeometry? labelPadding,
106111
TextStyle? labelStyle,
@@ -116,6 +121,7 @@ class TabBarTheme with Diagnosticable {
116121
indicatorColor: indicatorColor ?? this.indicatorColor,
117122
indicatorSize: indicatorSize ?? this.indicatorSize,
118123
dividerColor: dividerColor ?? this.dividerColor,
124+
dividerHeight: dividerHeight ?? this.dividerHeight,
119125
labelColor: labelColor ?? this.labelColor,
120126
labelPadding: labelPadding ?? this.labelPadding,
121127
labelStyle: labelStyle ?? this.labelStyle,
@@ -147,6 +153,7 @@ class TabBarTheme with Diagnosticable {
147153
indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t),
148154
indicatorSize: t < 0.5 ? a.indicatorSize : b.indicatorSize,
149155
dividerColor: Color.lerp(a.dividerColor, b.dividerColor, t),
156+
dividerHeight: t < 0.5 ? a.dividerHeight : b.dividerHeight,
150157
labelColor: Color.lerp(a.labelColor, b.labelColor, t),
151158
labelPadding: EdgeInsetsGeometry.lerp(a.labelPadding, b.labelPadding, t),
152159
labelStyle: TextStyle.lerp(a.labelStyle, b.labelStyle, t),
@@ -165,6 +172,7 @@ class TabBarTheme with Diagnosticable {
165172
indicatorColor,
166173
indicatorSize,
167174
dividerColor,
175+
dividerHeight,
168176
labelColor,
169177
labelPadding,
170178
labelStyle,
@@ -189,6 +197,7 @@ class TabBarTheme with Diagnosticable {
189197
&& other.indicatorColor == indicatorColor
190198
&& other.indicatorSize == indicatorSize
191199
&& other.dividerColor == dividerColor
200+
&& other.dividerHeight == dividerHeight
192201
&& other.labelColor == labelColor
193202
&& other.labelPadding == labelPadding
194203
&& other.labelStyle == labelStyle

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

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,39 @@ double _indexChangeProgress(TabController controller) {
387387
return (controllerValue - currentIndex).abs() / (currentIndex - previousIndex).abs();
388388
}
389389

390+
class _DividerPainter extends CustomPainter {
391+
_DividerPainter({
392+
required this.dividerColor,
393+
required this.dividerHeight,
394+
});
395+
396+
final Color dividerColor;
397+
final double dividerHeight;
398+
399+
@override
400+
void paint(Canvas canvas, Size size) {
401+
if (dividerHeight <= 0.0) {
402+
return;
403+
}
404+
405+
final Paint paint = Paint()
406+
..color = dividerColor
407+
..strokeWidth = dividerHeight;
408+
409+
canvas.drawLine(
410+
Offset(0, size.height - (paint.strokeWidth / 2)),
411+
Offset(size.width, size.height - (paint.strokeWidth / 2)),
412+
paint,
413+
);
414+
}
415+
416+
@override
417+
bool shouldRepaint(_DividerPainter oldDelegate) {
418+
return oldDelegate.dividerColor != dividerColor
419+
|| oldDelegate.dividerHeight != dividerHeight;
420+
}
421+
}
422+
390423
class _IndicatorPainter extends CustomPainter {
391424
_IndicatorPainter({
392425
required this.controller,
@@ -397,6 +430,8 @@ class _IndicatorPainter extends CustomPainter {
397430
required this.indicatorPadding,
398431
required this.labelPaddings,
399432
this.dividerColor,
433+
this.dividerHeight,
434+
required this.showDivider,
400435
}) : super(repaint: controller.animation) {
401436
if (old != null) {
402437
saveTabOffsets(old._currentTabOffsets, old._currentTextDirection);
@@ -408,8 +443,10 @@ class _IndicatorPainter extends CustomPainter {
408443
final TabBarIndicatorSize? indicatorSize;
409444
final EdgeInsetsGeometry indicatorPadding;
410445
final List<GlobalKey> tabKeys;
411-
final Color? dividerColor;
412446
final List<EdgeInsetsGeometry> labelPaddings;
447+
final Color? dividerColor;
448+
final double? dividerHeight;
449+
final bool showDivider;
413450

414451
// _currentTabOffsets and _currentTextDirection are set each time TabBar
415452
// layout is completed. These values can be null when TabBar contains no
@@ -501,9 +538,11 @@ class _IndicatorPainter extends CustomPainter {
501538
size: _currentRect!.size,
502539
textDirection: _currentTextDirection,
503540
);
504-
if (dividerColor != null) {
505-
final Paint dividerPaint = Paint()..color = dividerColor!..strokeWidth = 1;
506-
canvas.drawLine(Offset(0, size.height), Offset(size.width, size.height), dividerPaint);
541+
if (showDivider && dividerHeight !> 0) {
542+
final Paint dividerPaint = Paint()..color = dividerColor!..strokeWidth = dividerHeight!;
543+
final Offset dividerP1 = Offset(0, size.height - (dividerPaint.strokeWidth / 2));
544+
final Offset dividerP2 = Offset(size.width, size.height - (dividerPaint.strokeWidth / 2));
545+
canvas.drawLine(dividerP1, dividerP2, dividerPaint);
507546
}
508547
_painter!.paint(canvas, _currentRect!.topLeft, configuration);
509548
}
@@ -718,6 +757,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
718757
this.indicator,
719758
this.indicatorSize,
720759
this.dividerColor,
760+
this.dividerHeight,
721761
this.labelColor,
722762
this.labelStyle,
723763
this.labelPadding,
@@ -768,6 +808,7 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
768808
this.indicator,
769809
this.indicatorSize,
770810
this.dividerColor,
811+
this.dividerHeight,
771812
this.labelColor,
772813
this.labelStyle,
773814
this.labelPadding,
@@ -895,6 +936,13 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
895936
/// [ColorScheme.surfaceVariant] will be used, otherwise divider will not be drawn.
896937
final Color? dividerColor;
897938

939+
/// The height of the divider.
940+
///
941+
/// If null and [ThemeData.useMaterial3] is true, [TabBarTheme.dividerHeight] is used.
942+
/// If that is also null and [ThemeData.useMaterial3] is true, 1.0 will be used.
943+
/// Otherwise divider will not be drawn.
944+
final double? dividerHeight;
945+
898946
/// The color of selected tab labels.
899947
///
900948
/// If null, then [TabBarTheme.labelColor] is used. If that is also null and
@@ -1154,8 +1202,8 @@ class _TabBarState extends State<TabBar> {
11541202
TabBarTheme get _defaults {
11551203
if (Theme.of(context).useMaterial3) {
11561204
return widget._isPrimary
1157-
? _TabsPrimaryDefaultsM3(context, widget.isScrollable)
1158-
: _TabsSecondaryDefaultsM3(context, widget.isScrollable);
1205+
? _TabsPrimaryDefaultsM3(context, widget.isScrollable)
1206+
: _TabsSecondaryDefaultsM3(context, widget.isScrollable);
11591207
} else {
11601208
return _TabsDefaultsM2(context, widget.isScrollable);
11611209
}
@@ -1269,8 +1317,10 @@ class _TabBarState extends State<TabBar> {
12691317
indicatorPadding: widget.indicatorPadding,
12701318
tabKeys: _tabKeys,
12711319
old: _indicatorPainter,
1272-
dividerColor: theme.useMaterial3 ? widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor : null,
12731320
labelPaddings: _labelPaddings,
1321+
dividerColor: widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor,
1322+
dividerHeight: widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight,
1323+
showDivider: theme.useMaterial3 && !widget.isScrollable,
12741324
);
12751325
}
12761326

@@ -1299,7 +1349,9 @@ class _TabBarState extends State<TabBar> {
12991349
widget.indicatorWeight != oldWidget.indicatorWeight ||
13001350
widget.indicatorSize != oldWidget.indicatorSize ||
13011351
widget.indicatorPadding != oldWidget.indicatorPadding ||
1302-
widget.indicator != oldWidget.indicator) {
1352+
widget.indicator != oldWidget.indicator ||
1353+
widget.dividerColor != oldWidget.dividerColor ||
1354+
widget.dividerHeight != oldWidget.dividerHeight) {
13031355
_initIndicatorPainter();
13041356
}
13051357

@@ -1475,6 +1527,7 @@ class _TabBarState extends State<TabBar> {
14751527
Widget build(BuildContext context) {
14761528
assert(debugCheckHasMaterialLocalizations(context));
14771529
assert(_debugScheduleCheckHasValidTabsCount());
1530+
final ThemeData theme = Theme.of(context);
14781531
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
14791532
final TabAlignment effectiveTabAlignment = widget.tabAlignment ?? tabBarTheme.tabAlignment ?? _defaults.tabAlignment!;
14801533
assert(_debugTabAlignmentIsValid(effectiveTabAlignment));
@@ -1486,7 +1539,6 @@ class _TabBarState extends State<TabBar> {
14861539
);
14871540
}
14881541

1489-
14901542
final List<Widget> wrappedTabs = List<Widget>.generate(widget.tabs.length, (int index) {
14911543
const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0;
14921544
EdgeInsetsGeometry? adjustedPadding;
@@ -1627,6 +1679,24 @@ class _TabBarState extends State<TabBar> {
16271679
child: tabBar,
16281680
),
16291681
);
1682+
if (theme.useMaterial3) {
1683+
final AlignmentGeometry effectiveAlignment = switch (effectiveTabAlignment) {
1684+
TabAlignment.center => Alignment.center,
1685+
TabAlignment.start || TabAlignment.startOffset || TabAlignment.fill => AlignmentDirectional.centerStart,
1686+
};
1687+
1688+
tabBar = CustomPaint(
1689+
painter: _DividerPainter(
1690+
dividerColor: widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor!,
1691+
dividerHeight: widget.dividerHeight ?? tabBarTheme.dividerHeight ?? _defaults.dividerHeight!,
1692+
),
1693+
child: Align(
1694+
heightFactor: 1.0,
1695+
alignment: effectiveAlignment,
1696+
child: tabBar,
1697+
),
1698+
);
1699+
}
16301700
} else if (widget.padding != null) {
16311701
tabBar = Padding(
16321702
padding: widget.padding!,
@@ -2177,6 +2247,9 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme {
21772247
@override
21782248
Color? get dividerColor => _colors.surfaceVariant;
21792249

2250+
@override
2251+
double? get dividerHeight => 1.0;
2252+
21802253
@override
21812254
Color? get indicatorColor => _colors.primary;
21822255

@@ -2224,7 +2297,7 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme {
22242297
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
22252298

22262299
@override
2227-
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
2300+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
22282301

22292302
static double indicatorWeight = 3.0;
22302303
}
@@ -2241,6 +2314,9 @@ class _TabsSecondaryDefaultsM3 extends TabBarTheme {
22412314
@override
22422315
Color? get dividerColor => _colors.surfaceVariant;
22432316

2317+
@override
2318+
double? get dividerHeight => 1.0;
2319+
22442320
@override
22452321
Color? get indicatorColor => _colors.primary;
22462322

@@ -2288,7 +2364,7 @@ class _TabsSecondaryDefaultsM3 extends TabBarTheme {
22882364
InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory;
22892365

22902366
@override
2291-
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
2367+
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
22922368
}
22932369

22942370
// END GENERATED TOKEN PROPERTIES - Tabs

0 commit comments

Comments
 (0)