Update `TabBar` and `TabBar.secondary` to use indicator height/color M3 tokens (#145753)
fixes [Secondary `TabBar` indicator height token is missing ](https://github.com/flutter/flutter/issues/124965)
### Code sample
<details>
<summary>expand to view the code sample</summary>
```dart
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Sample'),
bottom: const TabBar.secondary(
tabs: <Widget>[
Tab(icon: Icon(Icons.directions_car)),
Tab(icon: Icon(Icons.directions_transit)),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
),
),
);
}
}
```
</details>
diff --git a/dev/tools/gen_defaults/generated/used_tokens.csv b/dev/tools/gen_defaults/generated/used_tokens.csv
index 12446e1..260505f 100644
--- a/dev/tools/gen_defaults/generated/used_tokens.csv
+++ b/dev/tools/gen_defaults/generated/used_tokens.csv
@@ -599,6 +599,8 @@
md.comp.search-view.header.input-text.text-style,
md.comp.search-view.header.supporting-text.color,
md.comp.search-view.header.supporting-text.text-style,
+md.comp.secondary-navigation-tab.active-indicator.color,
+md.comp.secondary-navigation-tab.active-indicator.height,
md.comp.secondary-navigation-tab.active.label-text.color,
md.comp.secondary-navigation-tab.focus.state-layer.color,
md.comp.secondary-navigation-tab.focus.state-layer.opacity,
diff --git a/dev/tools/gen_defaults/lib/tabs_template.dart b/dev/tools/gen_defaults/lib/tabs_template.dart
index 0dca794..fbe7c6f 100644
--- a/dev/tools/gen_defaults/lib/tabs_template.dart
+++ b/dev/tools/gen_defaults/lib/tabs_template.dart
@@ -78,7 +78,12 @@
@override
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
- static double indicatorWeight = ${getToken('md.comp.primary-navigation-tab.active-indicator.height')};
+ static double indicatorWeight(TabBarIndicatorSize indicatorSize) {
+ return switch (indicatorSize) {
+ TabBarIndicatorSize.label => ${getToken('md.comp.primary-navigation-tab.active-indicator.height')},
+ TabBarIndicatorSize.tab => ${getToken('md.comp.secondary-navigation-tab.active-indicator.height')},
+ };
+ }
// TODO(davidmartos96): This value doesn't currently exist in
// https://m3.material.io/components/tabs/specs
@@ -104,7 +109,7 @@
double? get dividerHeight => ${getToken("md.comp.divider.thickness")};
@override
- Color? get indicatorColor => ${componentColor("md.comp.primary-navigation-tab.active-indicator")};
+ Color? get indicatorColor => ${componentColor("md.comp.secondary-navigation-tab.active-indicator")};
@override
Color? get labelColor => ${componentColor("md.comp.secondary-navigation-tab.active.label-text")};
@@ -151,6 +156,8 @@
@override
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
+
+ static double indicatorWeight = ${getToken('md.comp.secondary-navigation-tab.active-indicator.height')};
}
''';
diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart
index 776e639..bfcdb95 100644
--- a/packages/flutter/lib/src/material/tabs.dart
+++ b/packages/flutter/lib/src/material/tabs.dart
@@ -1343,11 +1343,20 @@
color = Colors.white;
}
- final bool primaryWithLabelIndicator = widget._isPrimary && indicatorSize == TabBarIndicatorSize.label;
- final double effectiveIndicatorWeight = theme.useMaterial3 && primaryWithLabelIndicator
- ? math.max(widget.indicatorWeight, _TabsPrimaryDefaultsM3.indicatorWeight)
+ final double effectiveIndicatorWeight = theme.useMaterial3
+ ? math.max(
+ widget.indicatorWeight,
+ switch (widget._isPrimary) {
+ true => _TabsPrimaryDefaultsM3.indicatorWeight(indicatorSize),
+ false => _TabsSecondaryDefaultsM3.indicatorWeight,
+ },
+ )
: widget.indicatorWeight;
// Only Material 3 primary TabBar with label indicatorSize should be rounded.
+ final bool primaryWithLabelIndicator = switch (indicatorSize) {
+ TabBarIndicatorSize.label => widget._isPrimary,
+ TabBarIndicatorSize.tab => false,
+ };
final BorderRadius? effectiveBorderRadius = theme.useMaterial3 && primaryWithLabelIndicator
? BorderRadius.only(
topLeft: Radius.circular(effectiveIndicatorWeight),
@@ -2429,7 +2438,12 @@
@override
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
- static double indicatorWeight = 3.0;
+ static double indicatorWeight(TabBarIndicatorSize indicatorSize) {
+ return switch (indicatorSize) {
+ TabBarIndicatorSize.label => 3.0,
+ TabBarIndicatorSize.tab => 2.0,
+ };
+ }
// TODO(davidmartos96): This value doesn't currently exist in
// https://m3.material.io/components/tabs/specs
@@ -2502,6 +2516,8 @@
@override
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.startOffset : TabAlignment.fill;
+
+ static double indicatorWeight = 2.0;
}
// END GENERATED TOKEN PROPERTIES - Tabs
diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart
index 8a1d25a..d7e6a65 100644
--- a/packages/flutter/test/material/tabs_test.dart
+++ b/packages/flutter/test/material/tabs_test.dart
@@ -13,9 +13,15 @@
import 'feedback_tester.dart';
import 'tabs_utils.dart';
-Widget boilerplate({ Widget? child, TextDirection textDirection = TextDirection.ltr, bool? useMaterial3, TabBarTheme? tabBarTheme }) {
+Widget boilerplate({
+ Widget? child,
+ TextDirection textDirection = TextDirection.ltr,
+ ThemeData? theme,
+ TabBarTheme? tabBarTheme,
+ bool? useMaterial3,
+}) {
return Theme(
- data: ThemeData(useMaterial3: useMaterial3, tabBarTheme: tabBarTheme),
+ data: theme ?? ThemeData(useMaterial3: useMaterial3, tabBarTheme: tabBarTheme),
child: Localizations(
locale: const Locale('en', 'US'),
delegates: const <LocalizationsDelegate<dynamic>>[
@@ -346,45 +352,48 @@
});
testWidgets('TabBar default tab indicator (primary)', (WidgetTester tester) async {
- final ThemeData theme = ThemeData(useMaterial3: true);
+ final ThemeData theme = ThemeData();
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
-
final TabController controller = createTabController(
vsync: const TestVSync(),
length: tabs.length,
);
+ const double indicatorWeightLabel = 3.0;
+ const double indicatorWeightTab = 2.0;
- await tester.pumpWidget(
- MaterialApp(
+ Widget buildTab({ TabBarIndicatorSize? indicatorSize }) {
+ return MaterialApp(
home: boilerplate(
- useMaterial3: theme.useMaterial3,
+ theme: theme,
child: Container(
alignment: Alignment.topLeft,
child: TabBar(
+ indicatorSize: indicatorSize,
controller: controller,
tabs: tabs,
),
),
),
- ),
- );
+ );
+ }
- final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
+ // Test default tab indicator (TabBarIndicatorSize.label).
+ await tester.pumpWidget(buildTab());
+
+ RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
expect(tabBarBox.size.height, 48.0);
- const double indicatorWeight = 3.0;
-
+ // Check tab indicator size and color.
final RRect rrect = RRect.fromLTRBAndCorners(
64.75,
- tabBarBox.size.height - indicatorWeight,
+ tabBarBox.size.height - indicatorWeightLabel,
135.25,
tabBarBox.size.height,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
);
-
expect(
tabBarBox,
paints
@@ -392,23 +401,52 @@
color: theme.colorScheme.primary,
rrect: rrect,
));
+
+ // Test default tab indicator (TabBarIndicatorSize.tab).
+ await tester.pumpWidget(buildTab(indicatorSize: TabBarIndicatorSize.tab));
+ await tester.pumpAndSettle();
+
+ tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
+ expect(tabBarBox.size.height, 48.0);
+
+ const double indicatorY = 48 - (indicatorWeightTab / 2.0);
+ const double indicatorLeft = indicatorWeightTab / 2.0;
+ const double indicatorRight = 200.0 - (indicatorWeightTab / 2.0);
+
+ // Check tab indicator size and color.
+ expect(
+ tabBarBox,
+ paints
+ // Divider.
+ ..line(
+ color: theme.colorScheme.outlineVariant,
+ )
+ // Tab indicator.
+ ..line(
+ color: theme.colorScheme.primary,
+ strokeWidth: indicatorWeightTab,
+ p1: const Offset(indicatorLeft, indicatorY),
+ p2: const Offset(indicatorRight, indicatorY),
+ ),
+ );
});
testWidgets('TabBar default tab indicator (secondary)', (WidgetTester tester) async {
- final ThemeData theme = ThemeData(useMaterial3: true);
+ final ThemeData theme = ThemeData();
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
-
final TabController controller = createTabController(
vsync: const TestVSync(),
length: tabs.length,
);
+ const double indicatorWeight = 2.0;
+ // Test default tab indicator.
await tester.pumpWidget(
MaterialApp(
home: boilerplate(
- useMaterial3: theme.useMaterial3,
+ theme: theme,
child: Container(
alignment: Alignment.topLeft,
child: TabBar.secondary(
@@ -423,26 +461,26 @@
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
expect(tabBarBox.size.height, 48.0);
- const double indicatorWeight = 2.0;
const double indicatorY = 48 - (indicatorWeight / 2.0);
const double indicatorLeft = indicatorWeight / 2.0;
const double indicatorRight = 200.0 - (indicatorWeight / 2.0);
+ // Check tab indicator size and color.
expect(
tabBarBox,
paints
- // Divider
+ // Divider.
..line(
color: theme.colorScheme.outlineVariant,
)
- // Tab indicator
+ // Tab indicator.
..line(
color: theme.colorScheme.primary,
strokeWidth: indicatorWeight,
p1: const Offset(indicatorLeft, indicatorY),
p2: const Offset(indicatorRight, indicatorY),
),
- );
+ );
});
testWidgets('TabBar default overlay (primary)', (WidgetTester tester) async {