Unverified Commit ad009fb7 authored by Taha Tesser's avatar Taha Tesser Committed by GitHub

Fix Material 3 tab indicator weight and position (#125883)

fixes https://github.com/flutter/flutter/issues/123112

### Description
1. Add proper M3 indicator height aka`IndictorWeight` from the M3 specs for the primary tab bar with label indicator size.
https://github.com/flutter/flutter/blob/db6074ade4e4fde664e6258d671faf356e1b6e85/dev/tools/gen_defaults/data/navigation_tab_primary.json#L9
(this was held due to `indicatorWeight` having a hard-coded value) 

and added a secondary tab bar indicator height.
2. Set a minimum value for the rounded indicator to maintain the indicator shape.
3. With proper indicator height, the rounded indicator position is also fixed.
4. Fix round indicator is shown for the primary tab bar with tab indicator size.
5. Above changes fix https://github.com/flutter/flutter/issues/123112.
6. Fix the `startOffset` const value from https://github.com/flutter/flutter/pull/125036  to match docs and move it to a variable.
parent 9d9ad2d5
......@@ -72,6 +72,8 @@ class _${blockName}PrimaryDefaultsM3 extends TabBarTheme {
@override
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
static double indicatorWeight = ${tokens['md.comp.primary-navigation-tab.active-indicator.height']};
}
class _${blockName}SecondaryDefaultsM3 extends TabBarTheme {
......
......@@ -108,8 +108,7 @@ class _UnderlinePainter extends BoxPainter {
final Paint paint;
if (borderRadius != null) {
paint = Paint()..color = decoration.borderSide.color;
final Rect indicator = decoration._indicatorRectFor(rect, textDirection)
.inflate(decoration.borderSide.width / 4.0);
final Rect indicator = decoration._indicatorRectFor(rect, textDirection);
final RRect rrect = RRect.fromRectAndCorners(
indicator,
topLeft: borderRadius!.topLeft,
......
......@@ -27,6 +27,7 @@ import 'theme.dart';
const double _kTabHeight = 46.0;
const double _kTextAndIconTabHeight = 72.0;
const double _kStartOffset = 52.0;
/// Defines how the bounds of the selected tab indicator are computed.
///
......@@ -821,8 +822,16 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget {
/// The thickness of the line that appears below the selected tab.
///
/// The value of this parameter must be greater than zero and its default
/// value is 2.0.
/// The value of this parameter must be greater than zero.
///
/// If [ThemeData.useMaterial3] is true and [TabBar] is used to create a
/// primary tab bar, the default value is 3.0. If the provided value is less
/// than 3.0, the default value is used.
///
/// If [ThemeData.useMaterial3] is true and [TabBar.secondary] is used to
/// create a secondary tab bar, the default value is 2.0.
///
/// If [ThemeData.useMaterial3] is false, the default value is 2.0.
///
/// If [indicator] is specified or provided from [TabBarTheme],
/// this property is ignored.
......@@ -1152,7 +1161,7 @@ class _TabBarState extends State<TabBar> {
}
}
Decoration _getIndicator() {
Decoration _getIndicator(TabBarIndicatorSize indicatorSize) {
final ThemeData theme = Theme.of(context);
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
......@@ -1186,19 +1195,25 @@ class _TabBarState extends State<TabBar> {
color = Colors.white;
}
final bool primaryWithLabelIndicator = widget._isPrimary && indicatorSize == TabBarIndicatorSize.label;
final double effectiveIndicatorWeight = theme.useMaterial3 && primaryWithLabelIndicator
? math.max(widget.indicatorWeight, _TabsPrimaryDefaultsM3.indicatorWeight)
: widget.indicatorWeight;
// Only Material 3 primary TabBar with label indicatorSize should be rounded.
final BorderRadius? effectiveBorderRadius = theme.useMaterial3 && primaryWithLabelIndicator
? BorderRadius.only(
topLeft: Radius.circular(effectiveIndicatorWeight),
topRight: Radius.circular(effectiveIndicatorWeight),
)
: null;
return UnderlineTabIndicator(
borderRadius: theme.useMaterial3 && widget._isPrimary
borderRadius: effectiveBorderRadius,
borderSide: BorderSide(
// TODO(tahatesser): Make sure this value matches Material 3 Tabs spec
// when `preferredSize`and `indicatorWeight` are updated to support Material 3
// https://m3.material.io/components/tabs/specs#149a189f-9039-4195-99da-15c205d20e30,
// https://github.com/flutter/flutter/issues/116136
? const BorderRadius.only(
topLeft: Radius.circular(3.0),
topRight: Radius.circular(3.0),
)
: null,
borderSide: BorderSide(
width: widget.indicatorWeight,
width: effectiveIndicatorWeight,
color: color,
),
);
......@@ -1243,10 +1258,13 @@ class _TabBarState extends State<TabBar> {
void _initIndicatorPainter() {
final ThemeData theme = Theme.of(context);
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
final TabBarIndicatorSize indicatorSize = widget.indicatorSize
?? tabBarTheme.indicatorSize
?? _defaults.indicatorSize!;
_indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter(
controller: _controller!,
indicator: _getIndicator(),
indicator: _getIndicator(indicatorSize),
indicatorSize: widget.indicatorSize ?? tabBarTheme.indicatorSize ?? _defaults.indicatorSize!,
indicatorPadding: widget.indicatorPadding,
tabKeys: _tabKeys,
......@@ -1594,7 +1612,7 @@ class _TabBarState extends State<TabBar> {
if (widget.isScrollable) {
final EdgeInsetsGeometry? effectivePadding = effectiveTabAlignment == TabAlignment.startOffset
? const EdgeInsetsDirectional.only(start: 56.0).add(widget.padding ?? EdgeInsets.zero)
? const EdgeInsetsDirectional.only(start: _kStartOffset).add(widget.padding ?? EdgeInsets.zero)
: widget.padding;
_scrollController ??= _TabBarScrollController(this);
tabBar = ScrollConfiguration(
......@@ -2205,6 +2223,8 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme {
@override
TabAlignment? get tabAlignment => isScrollable ? TabAlignment.start : TabAlignment.fill;
static double indicatorWeight = 3.0;
}
class _TabsSecondaryDefaultsM3 extends TabBarTheme {
......
......@@ -507,6 +507,112 @@ void main() {
expect(tester.takeException(), isAssertionError);
});
testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (primary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(
tabBarTheme: const TabBarTheme(indicatorSize: TabBarIndicatorSize.tab),
useMaterial3: true,
);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Scaffold(
body: Container(
alignment: Alignment.topLeft,
child: TabBar(
controller: controller,
tabs: tabs,
),
),
),
),
);
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);
expect(
tabBarBox,
paints
// Divider
..line(
color: theme.colorScheme.surfaceVariant,
)
// Tab indicator
..line(
color: theme.colorScheme.primary,
strokeWidth: indicatorWeight,
p1: const Offset(indicatorLeft, indicatorY),
p2: const Offset(indicatorRight, indicatorY),
),
);
});
testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (secondary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(
tabBarTheme: const TabBarTheme(indicatorSize: TabBarIndicatorSize.label),
useMaterial3: true,
);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Scaffold(
body: Container(
alignment: Alignment.topLeft,
child: TabBar.secondary(
controller: controller,
tabs: tabs,
),
),
),
),
);
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);
expect(
tabBarBox,
paints
// Divider
..line(
color: theme.colorScheme.surfaceVariant,
)
// Tab indicator
..line(
color: theme.colorScheme.primary,
strokeWidth: indicatorWeight,
p1: const Offset(65.5, indicatorY),
p2: const Offset(134.5, indicatorY),
),
);
});
group('Material 2', () {
// Tests that are only relevant for Material 2. Once ThemeData.useMaterial3
// is turned on by default, these tests can be removed.
......@@ -599,5 +705,103 @@ void main() {
matchesGoldenFile('tab_bar.m2.default.tab_indicator_size.png'),
);
});
testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (primary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(
tabBarTheme: const TabBarTheme(indicatorSize: TabBarIndicatorSize.tab),
useMaterial3: false,
);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Scaffold(
body: Container(
alignment: Alignment.topLeft,
child: TabBar(
controller: controller,
tabs: tabs,
),
),
),
),
);
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);
expect(
tabBarBox,
paints
// Tab indicator
..line(
color: theme.indicatorColor,
strokeWidth: indicatorWeight,
p1: const Offset(indicatorLeft, indicatorY),
p2: const Offset(indicatorRight, indicatorY),
),
);
});
testWidgets('TabBarTheme.indicatorSize provides correct tab indicator (secondary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(
tabBarTheme: const TabBarTheme(indicatorSize: TabBarIndicatorSize.label),
useMaterial3: false,
);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Scaffold(
body: Container(
alignment: Alignment.topLeft,
child: TabBar.secondary(
controller: controller,
tabs: tabs,
),
),
),
),
);
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);
expect(
tabBarBox,
paints
// Tab indicator
..line(
color: theme.indicatorColor,
strokeWidth: indicatorWeight,
p1: const Offset(66.0, indicatorY),
p2: const Offset(134.0, indicatorY),
),
);
});
});
}
......@@ -442,6 +442,104 @@ void main() {
expect(unselectedLabel.text.style!.color, theme.colorScheme.onSurfaceVariant);
});
testWidgets('TabBar default tab indicator (primary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(useMaterial3: true);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: boilerplate(
child: Container(
alignment: Alignment.topLeft,
child: TabBar(
controller: controller,
tabs: tabs,
),
),
),
),
);
final RenderBox tabBarBox = tester.firstRenderObject<RenderBox>(find.byType(TabBar));
expect(tabBarBox.size.height, 48.0);
const double indicatorWeight = 3.0;
expect(
tabBarBox,
paints
..rrect(
color: theme.colorScheme.primary,
rrect: RRect.fromLTRBAndCorners(
64.5,
tabBarBox.size.height - indicatorWeight,
135.5,
tabBarBox.size.height,
topLeft: const Radius.circular(3.0),
topRight: const Radius.circular(3.0),
),
));
});
testWidgets('TabBar default tab indicator (secondary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(useMaterial3: true);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: boilerplate(
child: Container(
alignment: Alignment.topLeft,
child: TabBar.secondary(
controller: controller,
tabs: tabs,
),
),
),
),
);
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);
expect(
tabBarBox,
paints
// Divider
..line(
color: theme.colorScheme.surfaceVariant,
)
// 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 {
final ThemeData theme = ThemeData(useMaterial3: true);
final List<String> tabs = <String>['A', 'B'];
......@@ -5944,6 +6042,98 @@ void main() {
tabTwoRight = kTabLabelPadding.horizontal + tabOneRect.width + kTabLabelPadding.left + tabTwoRect.width;
expect(tabTwoRect.right, equals(tabTwoRight));
});
testWidgets('TabBar default tab indicator (primary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(useMaterial3: false);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: boilerplate(
child: Container(
alignment: Alignment.topLeft,
child: TabBar(
controller: controller,
tabs: tabs,
),
),
),
),
);
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);
expect(
tabBarBox,
paints
..line(
color: theme.indicatorColor,
strokeWidth: indicatorWeight,
p1: const Offset(indicatorLeft, indicatorY),
p2: const Offset(indicatorRight, indicatorY),
),
);
});
testWidgets('TabBar default tab indicator (secondary)', (WidgetTester tester) async {
final ThemeData theme = ThemeData(useMaterial3: false);
final List<Widget> tabs = List<Widget>.generate(4, (int index) {
return Tab(text: 'Tab $index');
});
final TabController controller = TabController(
vsync: const TestVSync(),
length: tabs.length,
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: boilerplate(
child: Container(
alignment: Alignment.topLeft,
child: TabBar.secondary(
controller: controller,
tabs: tabs,
),
),
),
),
);
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);
expect(
tabBarBox,
paints
..line(
color: theme.indicatorColor,
strokeWidth: indicatorWeight,
p1: const Offset(indicatorLeft, indicatorY),
p2: const Offset(indicatorRight, indicatorY),
),
);
});
});
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment