From 02d5c7595b89c032691eedf837bf92f84d960a76 Mon Sep 17 00:00:00 2001 From: Pierre-Louis <6655696+guidezpl@users.noreply.github.com> Date: Thu, 23 Mar 2023 23:49:59 +0100 Subject: [PATCH] Add support for secondary tab bar (#122756) Add support for secondary tab bar --- dev/tools/gen_defaults/lib/tabs_template.dart | 65 +++++- examples/api/lib/material/tabs/tab_bar.0.dart | 20 +- examples/api/lib/material/tabs/tab_bar.1.dart | 33 +-- examples/api/lib/material/tabs/tab_bar.2.dart | 117 ++++++++++ .../test/material/tabs/tab_bar.0_test.dart | 51 +++++ .../test/material/tabs/tab_bar.1_test.dart | 51 +++++ .../test/material/tabs/tab_bar.2_test.dart | 71 ++++++ packages/flutter/lib/src/material/tabs.dart | 189 +++++++++++++--- .../test/material/tab_bar_theme_test.dart | 212 ++++++++++++------ packages/flutter/test/material/tabs_test.dart | 124 ++++++++-- 10 files changed, 802 insertions(+), 131 deletions(-) create mode 100644 examples/api/lib/material/tabs/tab_bar.2.dart create mode 100644 examples/api/test/material/tabs/tab_bar.0_test.dart create mode 100644 examples/api/test/material/tabs/tab_bar.1_test.dart create mode 100644 examples/api/test/material/tabs/tab_bar.2_test.dart diff --git a/dev/tools/gen_defaults/lib/tabs_template.dart b/dev/tools/gen_defaults/lib/tabs_template.dart index a901b41262..22ed0e3eb1 100644 --- a/dev/tools/gen_defaults/lib/tabs_template.dart +++ b/dev/tools/gen_defaults/lib/tabs_template.dart @@ -12,8 +12,8 @@ class TabsTemplate extends TokenTemplate { @override String generate() => ''' -class _${blockName}DefaultsM3 extends TabBarTheme { - _${blockName}DefaultsM3(this.context) +class _${blockName}PrimaryDefaultsM3 extends TabBarTheme { + _${blockName}PrimaryDefaultsM3(this.context) : super(indicatorSize: TabBarIndicatorSize.label); final BuildContext context; @@ -69,5 +69,64 @@ class _${blockName}DefaultsM3 extends TabBarTheme { @override InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; } -'''; + +class _${blockName}SecondaryDefaultsM3 extends TabBarTheme { + _${blockName}SecondaryDefaultsM3(this.context) + : super(indicatorSize: TabBarIndicatorSize.tab); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get dividerColor => ${componentColor("md.comp.secondary-navigation-tab.divider")}; + + @override + Color? get indicatorColor => ${componentColor("md.comp.primary-navigation-tab.active-indicator")}; + + @override + Color? get labelColor => ${componentColor("md.comp.secondary-navigation-tab.active.label-text")}; + + @override + TextStyle? get labelStyle => ${textStyle("md.comp.secondary-navigation-tab.label-text")}; + + @override + Color? get unselectedLabelColor => ${componentColor("md.comp.secondary-navigation-tab.inactive.label-text")}; + + @override + TextStyle? get unselectedLabelStyle => ${textStyle("md.comp.secondary-navigation-tab.label-text")}; + + @override + MaterialStateProperty get overlayColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.secondary-navigation-tab.hover.state-layer')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.secondary-navigation-tab.focus.state-layer')}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.secondary-navigation-tab.pressed.state-layer')}; + } + return null; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor('md.comp.secondary-navigation-tab.hover.state-layer')}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor('md.comp.secondary-navigation-tab.focus.state-layer')}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor('md.comp.secondary-navigation-tab.pressed.state-layer')}; + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} +'''; + } diff --git a/examples/api/lib/material/tabs/tab_bar.0.dart b/examples/api/lib/material/tabs/tab_bar.0.dart index fa0453d89e..68e899ae2d 100644 --- a/examples/api/lib/material/tabs/tab_bar.0.dart +++ b/examples/api/lib/material/tabs/tab_bar.0.dart @@ -6,24 +6,22 @@ import 'package:flutter/material.dart'; -void main() => runApp(const MyApp()); +void main() => runApp(const TabBarApp()); -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - static const String _title = 'Flutter Code Sample'; +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); @override Widget build(BuildContext context) { - return const MaterialApp( - title: _title, - home: MyStatelessWidget(), + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: const TabBarExample(), ); } } -class MyStatelessWidget extends StatelessWidget { - const MyStatelessWidget({super.key}); +class TabBarExample extends StatelessWidget { + const TabBarExample({super.key}); @override Widget build(BuildContext context) { @@ -32,7 +30,7 @@ class MyStatelessWidget extends StatelessWidget { length: 3, child: Scaffold( appBar: AppBar( - title: const Text('TabBar Widget'), + title: const Text('TabBar Sample'), bottom: const TabBar( tabs: [ Tab( diff --git a/examples/api/lib/material/tabs/tab_bar.1.dart b/examples/api/lib/material/tabs/tab_bar.1.dart index 8cbc760854..e9ea194937 100644 --- a/examples/api/lib/material/tabs/tab_bar.1.dart +++ b/examples/api/lib/material/tabs/tab_bar.1.dart @@ -6,34 +6,31 @@ import 'package:flutter/material.dart'; -void main() => runApp(const MyApp()); +void main() => runApp(const TabBarApp()); -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - static const String _title = 'Flutter Code Sample'; +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); @override Widget build(BuildContext context) { - return const MaterialApp( - title: _title, - home: MyStatefulWidget(), + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: const TabBarExample(), ); } } -class MyStatefulWidget extends StatefulWidget { - const MyStatefulWidget({super.key}); +class TabBarExample extends StatefulWidget { + const TabBarExample({super.key}); @override - State createState() => _MyStatefulWidgetState(); + State createState() => _TabBarExampleState(); } /// [AnimationController]s can be created with `vsync: this` because of /// [TickerProviderStateMixin]. -class _MyStatefulWidgetState extends State - with TickerProviderStateMixin { - late TabController _tabController; +class _TabBarExampleState extends State with TickerProviderStateMixin { + late final TabController _tabController; @override void initState() { @@ -41,11 +38,17 @@ class _MyStatefulWidgetState extends State _tabController = TabController(length: 3, vsync: this); } + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('TabBar Widget'), + title: const Text('TabBar Sample'), bottom: TabBar( controller: _tabController, tabs: const [ diff --git a/examples/api/lib/material/tabs/tab_bar.2.dart b/examples/api/lib/material/tabs/tab_bar.2.dart new file mode 100644 index 0000000000..dd39f73494 --- /dev/null +++ b/examples/api/lib/material/tabs/tab_bar.2.dart @@ -0,0 +1,117 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Flutter code sample for [TabBar]. + +import 'package:flutter/material.dart'; + +void main() => runApp(const TabBarApp()); + +class TabBarApp extends StatelessWidget { + const TabBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: const TabBarExample(), + ); + } +} + +class TabBarExample extends StatelessWidget { + const TabBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + initialIndex: 1, + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Primary and secondary TabBar'), + bottom: const TabBar( + dividerColor: Colors.transparent, + tabs: [ + Tab( + text: 'Flights', + icon: Icon(Icons.flight), + ), + Tab( + text: 'Trips', + icon: Icon(Icons.luggage), + ), + Tab( + text: 'Explore', + icon: Icon(Icons.explore), + ), + ], + ), + ), + body: const TabBarView( + children: [ + NestedTabBar('Flights'), + NestedTabBar('Trips'), + NestedTabBar('Explore'), + ], + ), + ), + ); + } +} + +class NestedTabBar extends StatefulWidget { + const NestedTabBar(this.outerTab, {super.key}); + + final String outerTab; + + @override + State createState() => _NestedTabBarState(); +} + +class _NestedTabBarState extends State with TickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar.secondary( + controller: _tabController, + tabs: const [ + Tab(text: 'Overview'), + Tab(text: 'Specifications'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + Card( + margin: const EdgeInsets.all(16.0), + child: Center(child: Text('${widget.outerTab}: Overview tab')), + ), + Card( + margin: const EdgeInsets.all(16.0), + child: Center(child: Text('${widget.outerTab}: Specifications tab')), + ), + ], + ), + ), + ], + ); + } +} diff --git a/examples/api/test/material/tabs/tab_bar.0_test.dart b/examples/api/test/material/tabs/tab_bar.0_test.dart new file mode 100644 index 0000000000..ac4f86784c --- /dev/null +++ b/examples/api/test/material/tabs/tab_bar.0_test.dart @@ -0,0 +1,51 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/tabs/tab_bar.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch tabs in the TabBar', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TabBarApp(), + ); + + final TabBar tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + final Finder tab1 = find.widgetWithIcon(Tab, Icons.cloud_outlined); + final Finder tab2 = find.widgetWithIcon(Tab, Icons.beach_access_sharp); + final Finder tab3 = find.widgetWithIcon(Tab, Icons.brightness_5_sharp); + + const String tabBarViewText1 = "It's cloudy here"; + const String tabBarViewText2 = "It's rainy here"; + const String tabBarViewText3 = "It's sunny here"; + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsOneWidget); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab1); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsOneWidget); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab2); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsOneWidget); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab3); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsOneWidget); + }); +} diff --git a/examples/api/test/material/tabs/tab_bar.1_test.dart b/examples/api/test/material/tabs/tab_bar.1_test.dart new file mode 100644 index 0000000000..f2078708d9 --- /dev/null +++ b/examples/api/test/material/tabs/tab_bar.1_test.dart @@ -0,0 +1,51 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/tabs/tab_bar.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch tabs in the TabBar', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TabBarApp(), + ); + + final TabBar tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.tabs.length, 3); + + final Finder tab1 = find.widgetWithIcon(Tab, Icons.cloud_outlined); + final Finder tab2 = find.widgetWithIcon(Tab, Icons.beach_access_sharp); + final Finder tab3 = find.widgetWithIcon(Tab, Icons.brightness_5_sharp); + + const String tabBarViewText1 = "It's cloudy here"; + const String tabBarViewText2 = "It's rainy here"; + const String tabBarViewText3 = "It's sunny here"; + + expect(find.text(tabBarViewText1), findsOneWidget); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab1); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsOneWidget); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab2); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsOneWidget); + expect(find.text(tabBarViewText3), findsNothing); + + await tester.tap(tab3); + await tester.pumpAndSettle(); + + expect(find.text(tabBarViewText1), findsNothing); + expect(find.text(tabBarViewText2), findsNothing); + expect(find.text(tabBarViewText3), findsOneWidget); + }); +} diff --git a/examples/api/test/material/tabs/tab_bar.2_test.dart b/examples/api/test/material/tabs/tab_bar.2_test.dart new file mode 100644 index 0000000000..d5ff520f71 --- /dev/null +++ b/examples/api/test/material/tabs/tab_bar.2_test.dart @@ -0,0 +1,71 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/tabs/tab_bar.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Switch tabs in the TabBar', (WidgetTester tester) async { + const String primaryTabLabel1 = 'Flights'; + const String primaryTabLabel2 = 'Trips'; + const String primaryTabLabel3 = 'Explore'; + const String secondaryTabLabel1 = 'Overview'; + const String secondaryTabLabel2 = 'Specifications'; + + await tester.pumpWidget( + const example.TabBarApp(), + ); + + final TabBar primaryTabBar = tester.widget(find.byType(TabBar).last); + expect(primaryTabBar.tabs.length, 3); + + final TabBar secondaryTabBar = tester.widget(find.byType(TabBar).first); + expect(secondaryTabBar.tabs.length, 2); + + final Finder primaryTab1 = find.widgetWithText(Tab, primaryTabLabel1); + final Finder primaryTab2 = find.widgetWithText(Tab, primaryTabLabel2); + final Finder primaryTab3 = find.widgetWithText(Tab, primaryTabLabel3); + final Finder secondaryTab2 = find.widgetWithText(Tab, secondaryTabLabel2); + + String tabBarViewText = '$primaryTabLabel2: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(primaryTab1); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel1: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(secondaryTab2); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel1: $secondaryTabLabel2 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(primaryTab2); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel2: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(secondaryTab2); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel2: $secondaryTabLabel2 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(primaryTab3); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel3: $secondaryTabLabel1 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + + await tester.tap(secondaryTab2); + await tester.pumpAndSettle(); + + tabBarViewText = '$primaryTabLabel3: $secondaryTabLabel2 tab'; + expect(find.text(tabBarViewText), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/src/material/tabs.dart b/packages/flutter/lib/src/material/tabs.dart index 4b459aa675..a326e2b116 100644 --- a/packages/flutter/lib/src/material/tabs.dart +++ b/packages/flutter/lib/src/material/tabs.dart @@ -167,24 +167,27 @@ class _TabStyle extends AnimatedWidget { const _TabStyle({ required Animation animation, required this.isSelected, + required this.isPrimary, required this.labelColor, required this.unselectedLabelColor, required this.labelStyle, required this.unselectedLabelStyle, + required this.defaults, required this.child, }) : super(listenable: animation); final TextStyle? labelStyle; final TextStyle? unselectedLabelStyle; final bool isSelected; + final bool isPrimary; final Color? labelColor; final Color? unselectedLabelColor; + final TabBarTheme defaults; final Widget child; MaterialStateColor _resolveWithLabelColor(BuildContext context) { final ThemeData themeData = Theme.of(context); final TabBarTheme tabBarTheme = TabBarTheme.of(context); - final TabBarTheme defaults = themeData.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); final Animation animation = listenable as Animation; // labelStyle.color (and tabBarTheme.labelStyle.color) is not considered @@ -219,9 +222,7 @@ class _TabStyle extends AnimatedWidget { @override Widget build(BuildContext context) { - final ThemeData themeData = Theme.of(context); final TabBarTheme tabBarTheme = TabBarTheme.of(context); - final TabBarTheme defaults = themeData.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); final Animation animation = listenable as Animation; final Set states = isSelected @@ -604,7 +605,10 @@ class _TabBarScrollController extends ScrollController { } } -/// A Material Design widget that displays a horizontal row of tabs. +/// A Material Design primary tab bar. +/// +/// Primary tabs are placed at the top of the content pane under a top app bar. +/// They display the main content destinations. /// /// Typically created as the [AppBar.bottom] part of an [AppBar] and in /// conjunction with a [TabBarView]. @@ -635,12 +639,23 @@ class _TabBarScrollController extends ScrollController { /// ** See code in examples/api/lib/material/tabs/tab_bar.1.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This sample showcases nested Material 3 [TabBar]s. It consists of a primary +/// [TabBar] with nested a secondary [TabBar]. The primary [TabBar] uses a +/// [DefaultTabController] while the secondary [TabBar] uses a [TabController]. +/// +/// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart ** +/// {@end-tool} +/// /// See also: /// +/// * [TabBar.secondary], for a secondary tab bar. /// * [TabBarView], which displays page views that correspond to each tab. /// * [TabController], which coordinates tab selection between a [TabBar] and a [TabBarView]. +/// * https://m3.material.io/components/tab-bar/overview, the Material 3 +/// tab bar specification. class TabBar extends StatefulWidget implements PreferredSizeWidget { - /// Creates a Material Design tab bar. + /// Creates a Material Design primary tab bar. /// /// The [tabs] argument must not be null and its length must match the [controller]'s /// [TabController.length]. @@ -680,7 +695,57 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { this.physics, this.splashFactory, this.splashBorderRadius, - }) : assert(indicator != null || (indicatorWeight > 0.0)); + }) : _isPrimary = true, + assert(indicator != null || (indicatorWeight > 0.0)); + + /// Creates a Material Design secondary tab bar. + /// + /// Secondary tabs are used within a content area to further separate related + /// content and establish hierarchy. + /// + /// {@tool dartpad} + /// This sample showcases nested Material 3 [TabBar]s. It consists of a primary + /// [TabBar] with nested a secondary [TabBar]. The primary [TabBar] uses a + /// [DefaultTabController] while the secondary [TabBar] uses a [TabController]. + /// + /// ** See code in examples/api/lib/material/tabs/tab_bar.2.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TabBar], for a primary tab bar. + /// * [TabBarView], which displays page views that correspond to each tab. + /// * [TabController], which coordinates tab selection between a [TabBar] and a [TabBarView]. + /// * https://m3.material.io/components/tab-bar/overview, the Material 3 + /// tab bar specification. + const TabBar.secondary({ + super.key, + required this.tabs, + this.controller, + this.isScrollable = false, + this.padding, + this.indicatorColor, + this.automaticIndicatorColorAdjustment = true, + this.indicatorWeight = 2.0, + this.indicatorPadding = EdgeInsets.zero, + this.indicator, + this.indicatorSize, + this.dividerColor, + this.labelColor, + this.labelStyle, + this.labelPadding, + this.unselectedLabelColor, + this.unselectedLabelStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.overlayColor, + this.mouseCursor, + this.enableFeedback, + this.onTap, + this.physics, + this.splashFactory, + this.splashBorderRadius, + }) : _isPrimary = false, + assert(indicator != null || (indicatorWeight > 0.0)); /// Typically a list of two or more [Tab] widgets. /// @@ -993,6 +1058,11 @@ class TabBar extends StatefulWidget implements PreferredSizeWidget { return false; } + /// Whether this tab bar is a primary tab bar. + /// + /// Otherwise, it is a secondary tab bar. + final bool _isPrimary; + @override State createState() => _TabBarState(); } @@ -1016,10 +1086,19 @@ class _TabBarState extends State { _labelPaddings = List.filled(widget.tabs.length, EdgeInsets.zero, growable: true); } + TabBarTheme get _defaults { + if (Theme.of(context).useMaterial3) { + return widget._isPrimary + ? _TabsPrimaryDefaultsM3(context) + : _TabsSecondaryDefaultsM3(context); + } else { + return _TabsDefaultsM2(context); + } + } + Decoration _getIndicator() { final ThemeData theme = Theme.of(context); final TabBarTheme tabBarTheme = TabBarTheme.of(context); - final TabBarTheme defaults = theme.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); if (widget.indicator != null) { return widget.indicator!; @@ -1030,7 +1109,7 @@ class _TabBarState extends State { Color color = widget.indicatorColor ?? (theme.useMaterial3 - ? tabBarTheme.indicatorColor ?? defaults.indicatorColor! + ? tabBarTheme.indicatorColor ?? _defaults.indicatorColor! : Theme.of(context).indicatorColor); // ThemeData tries to avoid this by having indicatorColor avoid being the // primaryColor. However, it's possible that the tab bar is on a @@ -1046,12 +1125,13 @@ class _TabBarState extends State { // TODO(xu-baolin): Remove automatic adjustment to white color indicator // with a better long-term solution. // https://github.com/flutter/flutter/pull/68171#pullrequestreview-517753917 - if (widget.automaticIndicatorColorAdjustment && color.value == Material.maybeOf(context)?.color?.value) { + if (widget.automaticIndicatorColorAdjustment && + color.value == Material.maybeOf(context)?.color?.value) { color = Colors.white; } return UnderlineTabIndicator( - borderRadius: theme.useMaterial3 + borderRadius: theme.useMaterial3 && widget._isPrimary // 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, @@ -1107,16 +1187,15 @@ class _TabBarState extends State { void _initIndicatorPainter() { final ThemeData theme = Theme.of(context); final TabBarTheme tabBarTheme = TabBarTheme.of(context); - final TabBarTheme defaults = theme.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); _indicatorPainter = !_controllerIsValid ? null : _IndicatorPainter( controller: _controller!, indicator: _getIndicator(), - indicatorSize: widget.indicatorSize ?? tabBarTheme.indicatorSize ?? defaults.indicatorSize!, + indicatorSize: widget.indicatorSize ?? tabBarTheme.indicatorSize ?? _defaults.indicatorSize!, indicatorPadding: widget.indicatorPadding, tabKeys: _tabKeys, old: _indicatorPainter, - dividerColor: theme.useMaterial3 ? widget.dividerColor ?? tabBarTheme.dividerColor ?? defaults.dividerColor : null, + dividerColor: theme.useMaterial3 ? widget.dividerColor ?? tabBarTheme.dividerColor ?? _defaults.dividerColor : null, labelPaddings: _labelPaddings, ); } @@ -1262,14 +1341,16 @@ class _TabBarState extends State { widget.onTap?.call(index); } - Widget _buildStyledTab(Widget child, bool isSelected, Animation animation) { + Widget _buildStyledTab(Widget child, bool isSelected, Animation animation, TabBarTheme defaults) { return _TabStyle( animation: animation, isSelected: isSelected, + isPrimary: widget._isPrimary, labelColor: widget.labelColor, unselectedLabelColor: widget.unselectedLabelColor, labelStyle: widget.labelStyle, unselectedLabelStyle: widget.unselectedLabelStyle, + defaults: defaults, child: child, ); } @@ -1309,9 +1390,7 @@ class _TabBarState extends State { ); } - final ThemeData theme = Theme.of(context); final TabBarTheme tabBarTheme = TabBarTheme.of(context); - final TabBarTheme defaults = theme.useMaterial3 ? _TabsDefaultsM3(context) : _TabsDefaultsM2(context); final List wrappedTabs = List.generate(widget.tabs.length, (int index) { const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0; @@ -1353,22 +1432,22 @@ class _TabBarState extends State { // The user tapped on a tab, the tab controller's animation is running. assert(_currentIndex != previousIndex); final Animation animation = _ChangeAnimation(_controller!); - wrappedTabs[_currentIndex!] = _buildStyledTab(wrappedTabs[_currentIndex!], true, animation); - wrappedTabs[previousIndex] = _buildStyledTab(wrappedTabs[previousIndex], false, animation); + wrappedTabs[_currentIndex!] = _buildStyledTab(wrappedTabs[_currentIndex!], true, animation, _defaults); + wrappedTabs[previousIndex] = _buildStyledTab(wrappedTabs[previousIndex], false, animation, _defaults); } else { // The user is dragging the TabBarView's PageView left or right. final int tabIndex = _currentIndex!; final Animation centerAnimation = _DragAnimation(_controller!, tabIndex); - wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, centerAnimation); + wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, centerAnimation, _defaults); if (_currentIndex! > 0) { final int tabIndex = _currentIndex! - 1; final Animation previousAnimation = ReverseAnimation(_DragAnimation(_controller!, tabIndex)); - wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, previousAnimation); + wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, previousAnimation, _defaults); } if (_currentIndex! < widget.tabs.length - 1) { final int tabIndex = _currentIndex! + 1; final Animation nextAnimation = ReverseAnimation(_DragAnimation(_controller!, tabIndex)); - wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, nextAnimation); + wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, nextAnimation, _defaults); } } } @@ -1389,7 +1468,7 @@ class _TabBarState extends State { final MaterialStateProperty defaultOverlay = MaterialStateProperty.resolveWith( (Set states) { final Set effectiveStates = selectedState..addAll(states); - return defaults.overlayColor?.resolve(effectiveStates); + return _defaults.overlayColor?.resolve(effectiveStates); }, ); wrappedTabs[index] = InkWell( @@ -1397,7 +1476,7 @@ class _TabBarState extends State { onTap: () { _handleTap(index); }, enableFeedback: widget.enableFeedback ?? true, overlayColor: widget.overlayColor ?? tabBarTheme.overlayColor ?? defaultOverlay, - splashFactory: widget.splashFactory ?? tabBarTheme.splashFactory ?? defaults.splashFactory, + splashFactory: widget.splashFactory ?? tabBarTheme.splashFactory ?? _defaults.splashFactory, borderRadius: widget.splashBorderRadius, child: Padding( padding: EdgeInsets.only(bottom: widget.indicatorWeight), @@ -1422,10 +1501,12 @@ class _TabBarState extends State { child: _TabStyle( animation: kAlwaysDismissedAnimation, isSelected: false, + isPrimary: widget._isPrimary, labelColor: widget.labelColor, unselectedLabelColor: widget.unselectedLabelColor, labelStyle: widget.labelStyle, unselectedLabelStyle: widget.unselectedLabelStyle, + defaults: _defaults, child: _TabLabelBar( onPerformLayout: _saveTabOffsets, children: wrappedTabs, @@ -1979,8 +2060,8 @@ class _TabsDefaultsM2 extends TabBarTheme { // Token database version: v0_162 -class _TabsDefaultsM3 extends TabBarTheme { - _TabsDefaultsM3(this.context) +class _TabsPrimaryDefaultsM3 extends TabBarTheme { + _TabsPrimaryDefaultsM3(this.context) : super(indicatorSize: TabBarIndicatorSize.label); final BuildContext context; @@ -2037,4 +2118,62 @@ class _TabsDefaultsM3 extends TabBarTheme { InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; } +class _TabsSecondaryDefaultsM3 extends TabBarTheme { + _TabsSecondaryDefaultsM3(this.context) + : super(indicatorSize: TabBarIndicatorSize.tab); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color? get dividerColor => _colors.surfaceVariant; + + @override + Color? get indicatorColor => _colors.primary; + + @override + Color? get labelColor => _colors.onSurface; + + @override + TextStyle? get labelStyle => _textTheme.titleSmall; + + @override + Color? get unselectedLabelColor => _colors.onSurfaceVariant; + + @override + TextStyle? get unselectedLabelStyle => _textTheme.titleSmall; + + @override + MaterialStateProperty get overlayColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.onSurface.withOpacity(0.12); + } + return null; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurface.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.onSurface.withOpacity(0.12); + } + return null; + }); + } + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} + // 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 68da0603ad..eb5176f66d 100644 --- a/packages/flutter/test/material/tab_bar_theme_test.dart +++ b/packages/flutter/test/material/tab_bar_theme_test.dart @@ -31,14 +31,30 @@ final List _sizedTabs = [ SizedBox(key: UniqueKey(), width: 100.0, height: 50.0), ]; -Widget _withTheme( - TabBarTheme? theme, { +Widget buildTabBar({ + TabBarTheme? tabBarTheme, + bool secondaryTabBar = false, List tabs = _tabs, bool isScrollable = false, bool useMaterial3 = false, }) { + if (secondaryTabBar) { + return MaterialApp( + theme: ThemeData(tabBarTheme: tabBarTheme, useMaterial3: useMaterial3), + home: Scaffold( + body: RepaintBoundary( + key: _painterKey, + child: TabBar.secondary( + tabs: tabs, + isScrollable: isScrollable, + controller: TabController(length: tabs.length, vsync: const TestVSync()), + ), + ), + ), + ); + } return MaterialApp( - theme: ThemeData(tabBarTheme: theme, useMaterial3: useMaterial3), + theme: ThemeData(tabBarTheme: tabBarTheme, useMaterial3: useMaterial3), home: Scaffold( body: RepaintBoundary( key: _painterKey, @@ -52,12 +68,17 @@ Widget _withTheme( ); } -RenderParagraph _iconRenderObject(WidgetTester tester, IconData icon) { + +RenderParagraph _getIcon(WidgetTester tester, IconData icon) { return tester.renderObject( find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), ); } +RenderParagraph _getText(WidgetTester tester, String text) { + return tester.renderObject(find.text(text)); +} + void main() { test('TabBarTheme copyWith, ==, hashCode, defaults', () { expect(const TabBarTheme(), const TabBarTheme().copyWith()); @@ -82,60 +103,113 @@ void main() { expect(identical(TabBarTheme.lerp(theme, theme, 0.5), theme), true); }); - testWidgets('Tab bar defaults', (WidgetTester tester) async { - // tests for the default label color and label styles when tabBarTheme and tabBar do not provide any - await tester.pumpWidget(_withTheme(null, useMaterial3: true)); + testWidgets('Tab bar defaults (primary)', (WidgetTester tester) async { + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar(useMaterial3: true)); final ThemeData theme = ThemeData(useMaterial3: true); - final RenderParagraph selectedRenderObject = tester.renderObject(find.text(_tab1Text)); - expect(selectedRenderObject.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); - expect(selectedRenderObject.text.style!.fontSize, equals(14.0)); - expect(selectedRenderObject.text.style!.color, equals(theme.colorScheme.primary)); - final RenderParagraph unselectedRenderObject = tester.renderObject(find.text(_tab2Text)); - expect(unselectedRenderObject.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); - expect(unselectedRenderObject.text.style!.fontSize, equals(14.0)); - expect(unselectedRenderObject.text.style!.color, equals(theme.colorScheme.onSurfaceVariant)); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(selectedLabel.text.style!.fontSize, equals(14.0)); + expect(selectedLabel.text.style!.color, equals(theme.colorScheme.primary)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(theme.colorScheme.onSurfaceVariant)); - // tests for the default value of labelPadding when tabBarTheme and tabBar do not provide one - await tester.pumpWidget(_withTheme(null, tabs: _sizedTabs, isScrollable: true)); + // 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 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)); + // Verify divider color and indicator color. final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); expect( tabBarBox, paints ..line(color: theme.colorScheme.surfaceVariant) + // Indicator is a rrect in the primary tab bar. ..rrect(color: theme.colorScheme.primary), ); }); + testWidgets('Tab bar defaults (secondary)', (WidgetTester tester) async { + // Test default label color and label styles. + await tester.pumpWidget(buildTabBar(secondaryTabBar: true, useMaterial3: true)); + + final ThemeData theme = ThemeData(useMaterial3: true); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(selectedLabel.text.style!.fontSize, equals(14.0)); + expect(selectedLabel.text.style!.color, equals(theme.colorScheme.onSurface)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(theme.textTheme.titleSmall!.fontFamily)); + expect(unselectedLabel.text.style!.fontSize, equals(14.0)); + expect(unselectedLabel.text.style!.color, equals(theme.colorScheme.onSurfaceVariant)); + + // Test default labelPadding. + await tester.pumpWidget(buildTabBar( + secondaryTabBar: true, + tabs: _sizedTabs, + isScrollable: true, + useMaterial3: 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)); + + // Verify divider color and indicator color. + final RenderBox tabBarBox = tester.firstRenderObject(find.byType(TabBar)); + expect( + tabBarBox, + paints + ..line(color: theme.colorScheme.surfaceVariant) + // Indicator is a line in the secondary tab bar. + ..line(color: theme.colorScheme.primary), + ); + }); + testWidgets('Tab bar theme overrides label color (selected)', (WidgetTester tester) async { const Color labelColor = Colors.black; const TabBarTheme tabBarTheme = TabBarTheme(labelColor: labelColor); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); - final RenderParagraph textRenderObject = tester.renderObject(find.text(_tab1Text)); - expect(textRenderObject.text.style!.color, equals(labelColor)); - final RenderParagraph iconRenderObject = _iconRenderObject(tester, Icons.looks_one); - expect(iconRenderObject.text.style!.color, equals(labelColor)); + final RenderParagraph tabLabel = _getText(tester, _tab1Text); + expect(tabLabel.text.style!.color, equals(labelColor)); + final RenderParagraph tabIcon = _getIcon(tester, Icons.looks_one); + expect(tabIcon.text.style!.color, equals(labelColor)); }); testWidgets('Tab bar theme overrides label padding', (WidgetTester tester) async { @@ -151,8 +225,8 @@ void main() { const TabBarTheme tabBarTheme = TabBarTheme(labelPadding: labelPadding); - await tester.pumpWidget(_withTheme( - tabBarTheme, + await tester.pumpWidget(buildTabBar( + tabBarTheme: tabBarTheme, tabs: _sizedTabs, isScrollable: true, )); @@ -183,12 +257,12 @@ void main() { unselectedLabelStyle: unselectedLabelStyle, ); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); - final RenderParagraph selectedRenderObject = tester.renderObject(find.text(_tab1Text)); - expect(selectedRenderObject.text.style!.fontFamily, equals(labelStyle.fontFamily)); - final RenderParagraph unselectedRenderObject = tester.renderObject(find.text(_tab2Text)); - expect(unselectedRenderObject.text.style!.fontFamily, equals(unselectedLabelStyle.fontFamily)); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(labelStyle.fontFamily)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(unselectedLabelStyle.fontFamily)); }); testWidgets('Tab bar theme with just label style specified', (WidgetTester tester) async { @@ -198,14 +272,14 @@ void main() { labelStyle: labelStyle, ); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); - final RenderParagraph selectedRenderObject = tester.renderObject(find.text(_tab1Text)); - expect(selectedRenderObject.text.style!.fontFamily, equals(labelStyle.fontFamily)); - final RenderParagraph unselectedRenderObject = tester.renderObject(find.text(_tab2Text)); - expect(unselectedRenderObject.text.style!.fontFamily, equals('Roboto')); - expect(unselectedRenderObject.text.style!.fontSize, equals(14.0)); - expect(unselectedRenderObject.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(labelStyle.fontFamily)); + 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))); }); testWidgets('Tab bar label styles override theme label styles', (WidgetTester tester) async { @@ -220,8 +294,9 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(tabBarTheme: tabBarTheme), - home: Scaffold(body: TabBar( + theme: ThemeData(tabBarTheme: tabBarTheme), + home: Scaffold( + body: TabBar( tabs: _tabs, controller: TabController(length: _tabs.length, vsync: const TestVSync()), labelStyle: labelStyle, @@ -231,10 +306,10 @@ void main() { ), ); - final RenderParagraph selectedRenderObject = tester.renderObject(find.text(_tab1Text)); - expect(selectedRenderObject.text.style!.fontFamily, equals(labelStyle.fontFamily)); - final RenderParagraph unselectedRenderObject = tester.renderObject(find.text(_tab2Text)); - expect(unselectedRenderObject.text.style!.fontFamily, equals(unselectedLabelStyle.fontFamily)); + final RenderParagraph selectedLabel = _getText(tester, _tab1Text); + expect(selectedLabel.text.style!.fontFamily, equals(labelStyle.fontFamily)); + final RenderParagraph unselectedLabel = _getText(tester, _tab2Text); + expect(unselectedLabel.text.style!.fontFamily, equals(unselectedLabelStyle.fontFamily)); }); testWidgets('Tab bar label padding overrides theme label padding', (WidgetTester tester) async { @@ -295,16 +370,25 @@ void main() { const Color unselectedLabelColor = Colors.black; const TabBarTheme tabBarTheme = TabBarTheme(unselectedLabelColor: unselectedLabelColor); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); final RenderParagraph textRenderObject = tester.renderObject(find.text(_tab2Text)); expect(textRenderObject.text.style!.color, equals(unselectedLabelColor)); - final RenderParagraph iconRenderObject = _iconRenderObject(tester, Icons.looks_two); + final RenderParagraph iconRenderObject = _getIcon(tester, Icons.looks_two); expect(iconRenderObject.text.style!.color, equals(unselectedLabelColor)); }); testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { - await tester.pumpWidget(_withTheme(null, useMaterial3: true, isScrollable: true)); + await tester.pumpWidget(buildTabBar(useMaterial3: true, isScrollable: true)); + + await expectLater( + find.byKey(_painterKey), + matchesGoldenFile('tab_bar.default.tab_indicator_size.png'), + ); + }); + + testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { + await tester.pumpWidget(buildTabBar(useMaterial3: true, isScrollable: true)); await expectLater( find.byKey(_painterKey), @@ -315,7 +399,7 @@ void main() { testWidgets('Tab bar theme overrides tab indicator size (tab)', (WidgetTester tester) async { const TabBarTheme tabBarTheme = TabBarTheme(indicatorSize: TabBarIndicatorSize.tab); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); await expectLater( find.byKey(_painterKey), @@ -326,7 +410,7 @@ void main() { testWidgets('Tab bar theme overrides tab indicator size (label)', (WidgetTester tester) async { const TabBarTheme tabBarTheme = TabBarTheme(indicatorSize: TabBarIndicatorSize.label); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); await expectLater( find.byKey(_painterKey), @@ -337,7 +421,7 @@ void main() { testWidgets('Tab bar theme overrides tab mouse cursor', (WidgetTester tester) async { const TabBarTheme tabBarTheme = TabBarTheme(mouseCursor: MaterialStateMouseCursor.textable); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); final Offset tabBar = tester.getCenter( find.ancestor(of: find.text('tab 1'),matching: find.byType(TabBar)), @@ -356,7 +440,7 @@ void main() { ), ); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); await expectLater( find.byKey(_painterKey), @@ -372,7 +456,7 @@ void main() { ), ); - await tester.pumpWidget(_withTheme(tabBarTheme)); + await tester.pumpWidget(buildTabBar(tabBarTheme: tabBarTheme)); await expectLater( find.byKey(_painterKey), @@ -386,19 +470,19 @@ void main() { testWidgets('Tab bar defaults', (WidgetTester tester) async { // tests for the default label color and label styles when tabBarTheme and tabBar do not provide any - await tester.pumpWidget(_withTheme(null)); + await tester.pumpWidget(buildTabBar()); - final RenderParagraph selectedRenderObject = tester.renderObject(find.text(_tab1Text)); - expect(selectedRenderObject.text.style!.fontFamily, equals('Roboto')); - expect(selectedRenderObject.text.style!.fontSize, equals(14.0)); - expect(selectedRenderObject.text.style!.color, equals(Colors.white)); - final RenderParagraph unselectedRenderObject = tester.renderObject(find.text(_tab2Text)); - expect(unselectedRenderObject.text.style!.fontFamily, equals('Roboto')); - expect(unselectedRenderObject.text.style!.fontSize, equals(14.0)); - expect(unselectedRenderObject.text.style!.color, equals(Colors.white.withAlpha(0xB2))); + 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))); // tests for the default value of labelPadding when tabBarTheme and tabBar do not provide one - await tester.pumpWidget(_withTheme(null, tabs: _sizedTabs, isScrollable: true)); + await tester.pumpWidget(buildTabBar(tabs: _sizedTabs, isScrollable: true)); const double indicatorWeight = 2.0; final Rect tabBar = tester.getRect(find.byType(TabBar)); @@ -423,7 +507,7 @@ void main() { }); testWidgets('Tab bar default tab indicator size', (WidgetTester tester) async { - await tester.pumpWidget(_withTheme(null)); + await tester.pumpWidget(buildTabBar()); await expectLater( find.byKey(_painterKey), diff --git a/packages/flutter/test/material/tabs_test.dart b/packages/flutter/test/material/tabs_test.dart index e6080a1c01..b1ad1b5bdc 100644 --- a/packages/flutter/test/material/tabs_test.dart +++ b/packages/flutter/test/material/tabs_test.dart @@ -106,6 +106,7 @@ class _NestedTabBarContainer extends StatelessWidget { Widget buildFrame({ Key? tabBarKey, + bool secondaryTabBar = false, required List tabs, required String value, bool isScrollable = false, @@ -114,6 +115,24 @@ Widget buildFrame({ EdgeInsetsGeometry? padding, TextDirection textDirection = TextDirection.ltr, }) { + if (secondaryTabBar) { + return boilerplate( + textDirection: textDirection, + child: DefaultTabController( + animationDuration: animationDuration, + initialIndex: tabs.indexOf(value), + length: tabs.length, + child: TabBar.secondary( + key: tabBarKey, + tabs: tabs.map((String tab) => Tab(text: tab)).toList(), + isScrollable: isScrollable, + indicatorColor: indicatorColor, + padding: padding, + ), + ), + ); + } + return boilerplate( textDirection: textDirection, child: DefaultTabController( @@ -238,6 +257,10 @@ class TestScrollPhysics extends ScrollPhysics { SpringDescription get spring => _kDefaultSpring; } +RenderParagraph _getText(WidgetTester tester, String text) { + return tester.renderObject(find.text(text)); +} + void main() { setUp(() { debugResetSemanticsIdCounter(); @@ -358,12 +381,12 @@ void main() { expect(find.byType(TabBar), paints..line(color: Colors.blue[500])); }); - testWidgets('TabBar default selected/unselected text style', (WidgetTester tester) async { + testWidgets('TabBar default selected/unselected label style (primary)', (WidgetTester tester) async { final ThemeData theme = ThemeData(useMaterial3: true); final List tabs = ['A', 'B', 'C']; const String selectedValue = 'A'; - const String unSelectedValue = 'C'; + const String unselectedValue = 'C'; await tester.pumpWidget( Theme( data: theme, @@ -375,20 +398,95 @@ void main() { expect(find.text('C'), findsOneWidget); // Test selected label text style. - expect(tester.renderObject(find.text(selectedValue)).text.style!.fontFamily, 'Roboto'); - expect(tester.renderObject(find.text(selectedValue)).text.style!.fontSize, 14.0); - expect(tester.renderObject( - find.text(selectedValue)).text.style!.color, - theme.colorScheme.primary, - ); + final RenderParagraph selectedLabel = _getText(tester, selectedValue); + expect(selectedLabel.text.style!.fontFamily, 'Roboto'); + expect(selectedLabel.text.style!.fontSize, 14.0); + expect(selectedLabel.text.style!.color, theme.colorScheme.primary); // Test unselected label text style. - expect(tester.renderObject(find.text(unSelectedValue)).text.style!.fontFamily, 'Roboto'); - expect(tester.renderObject(find.text(unSelectedValue)).text.style!.fontSize, 14.0); - expect(tester.renderObject( - find.text(unSelectedValue)).text.style!.color, - theme.colorScheme.onSurfaceVariant, + final RenderParagraph unselectedLabel = _getText(tester, unselectedValue); + expect(unselectedLabel.text.style!.fontFamily, 'Roboto'); + expect(unselectedLabel.text.style!.fontSize, 14.0); + expect(unselectedLabel.text.style!.color, theme.colorScheme.onSurfaceVariant); + }); + + testWidgets('TabBar default selected/unselected label style (secondary)', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B', 'C']; + + const String selectedValue = 'A'; + const String unselectedValue = 'C'; + await tester.pumpWidget( + Theme( + data: theme, + child: buildFrame(tabs: tabs, value: selectedValue, secondaryTabBar: true), + ), ); + expect(find.text('A'), findsOneWidget); + expect(find.text('B'), findsOneWidget); + expect(find.text('C'), findsOneWidget); + + // Test selected label text style. + final RenderParagraph selectedLabel = _getText(tester, selectedValue); + expect(selectedLabel.text.style!.fontFamily, 'Roboto'); + expect(selectedLabel.text.style!.fontSize, 14.0); + expect(selectedLabel.text.style!.color, theme.colorScheme.onSurface); + + // Test unselected label text style. + final RenderParagraph unselectedLabel = _getText(tester, unselectedValue); + expect(unselectedLabel.text.style!.fontFamily, 'Roboto'); + expect(unselectedLabel.text.style!.fontSize, 14.0); + expect(unselectedLabel.text.style!.color, theme.colorScheme.onSurfaceVariant); + }); + + testWidgets('TabBar default overlay (primary)', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + + const String selectedValue = 'A'; + const String unselectedValue = 'B'; + await tester.pumpWidget( + Theme( + data: theme, + child: buildFrame(tabs: tabs, value: selectedValue), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + + await gesture.moveTo(tester.getCenter(find.text(unselectedValue))); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08))); + }); + + testWidgets('TabBar default overlay (secondary)', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final List tabs = ['A', 'B']; + + const String selectedValue = 'A'; + const String unselectedValue = 'B'; + await tester.pumpWidget( + Theme( + data: theme, + child: buildFrame(tabs: tabs, value: selectedValue, secondaryTabBar: true), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text(selectedValue))); + await tester.pumpAndSettle(); + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08))); + + await gesture.moveTo(tester.getCenter(find.text(unselectedValue))); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..rect(color: theme.colorScheme.onSurface.withOpacity(0.08))); }); testWidgets('TabBar tap selects tab', (WidgetTester tester) async {