diff --git a/packages/flutter/lib/src/material/navigation_bar.dart b/packages/flutter/lib/src/material/navigation_bar.dart index 44afd3c5c72..ca1f9db8acf 100644 --- a/packages/flutter/lib/src/material/navigation_bar.dart +++ b/packages/flutter/lib/src/material/navigation_bar.dart @@ -104,6 +104,7 @@ class NavigationBar extends StatelessWidget { this.indicatorShape, this.height, this.labelBehavior, + this.overlayColor, }) : assert(destinations.length >= 2), assert(0 <= selectedIndex && selectedIndex < destinations.length); @@ -207,6 +208,10 @@ class NavigationBar extends StatelessWidget { /// [NavigationDestinationLabelBehavior.alwaysShow]. final NavigationDestinationLabelBehavior? labelBehavior; + /// The highlight color that's typically used to indicate that + /// the [NavigationDestination] is focused, hovered, or pressed. + final MaterialStateProperty? overlayColor; + VoidCallback _handleTap(int index) { return onDestinationSelected != null ? () => onDestinationSelected!(index) @@ -249,6 +254,7 @@ class NavigationBar extends StatelessWidget { labelBehavior: effectiveLabelBehavior, indicatorColor: indicatorColor, indicatorShape: indicatorShape, + overlayColor: overlayColor, onTap: _handleTap(i), child: destinations[i], ); @@ -509,7 +515,8 @@ class _NavigationDestinationBuilderState extends State<_NavigationDestinationBui child: _IndicatorInkWell( iconKey: iconKey, labelBehavior: info.labelBehavior, - customBorder: navigationBarTheme.indicatorShape ?? defaults.indicatorShape, + customBorder: info.indicatorShape ?? navigationBarTheme.indicatorShape ?? defaults.indicatorShape, + overlayColor: info.overlayColor ?? navigationBarTheme.overlayColor, onTap: widget.enabled ? info.onTap : null, child: Row( children: [ @@ -532,6 +539,7 @@ class _IndicatorInkWell extends InkResponse { const _IndicatorInkWell({ required this.iconKey, required this.labelBehavior, + super.overlayColor, super.customBorder, super.onTap, super.child, @@ -569,6 +577,7 @@ class _NavigationDestinationInfo extends InheritedWidget { required this.labelBehavior, required this.indicatorColor, required this.indicatorShape, + required this.overlayColor, required this.onTap, required super.child, }); @@ -635,6 +644,12 @@ class _NavigationDestinationInfo extends InheritedWidget { /// This is used by destinations to override the indicator shape. final ShapeBorder? indicatorShape; + /// The highlight color that's typically used to indicate that + /// the [NavigationDestination] is focused, hovered, or pressed. + /// + /// This is used by destinations to override the overlay color. + final MaterialStateProperty? overlayColor; + /// The callback that should be called when this destination is tapped. /// /// This is computed by calling [NavigationBar.onDestinationSelected] diff --git a/packages/flutter/lib/src/material/navigation_bar_theme.dart b/packages/flutter/lib/src/material/navigation_bar_theme.dart index 2de555f6395..adb9e3985ce 100644 --- a/packages/flutter/lib/src/material/navigation_bar_theme.dart +++ b/packages/flutter/lib/src/material/navigation_bar_theme.dart @@ -52,6 +52,7 @@ class NavigationBarThemeData with Diagnosticable { this.labelTextStyle, this.iconTheme, this.labelBehavior, + this.overlayColor, }); /// Overrides the default value of [NavigationBar.height]. @@ -91,6 +92,9 @@ class NavigationBarThemeData with Diagnosticable { /// Overrides the default value of [NavigationBar.labelBehavior]. final NavigationDestinationLabelBehavior? labelBehavior; + /// Overrides the default value of [NavigationBar.overlayColor]. + final MaterialStateProperty? overlayColor; + /// Creates a copy of this object with the given fields replaced with the /// new values. NavigationBarThemeData copyWith({ @@ -104,6 +108,7 @@ class NavigationBarThemeData with Diagnosticable { MaterialStateProperty? labelTextStyle, MaterialStateProperty? iconTheme, NavigationDestinationLabelBehavior? labelBehavior, + MaterialStateProperty? overlayColor, }) { return NavigationBarThemeData( height: height ?? this.height, @@ -116,6 +121,7 @@ class NavigationBarThemeData with Diagnosticable { labelTextStyle: labelTextStyle ?? this.labelTextStyle, iconTheme: iconTheme ?? this.iconTheme, labelBehavior: labelBehavior ?? this.labelBehavior, + overlayColor: overlayColor ?? this.overlayColor, ); } @@ -139,6 +145,7 @@ class NavigationBarThemeData with Diagnosticable { labelTextStyle: MaterialStateProperty.lerp(a?.labelTextStyle, b?.labelTextStyle, t, TextStyle.lerp), iconTheme: MaterialStateProperty.lerp(a?.iconTheme, b?.iconTheme, t, IconThemeData.lerp), labelBehavior: t < 0.5 ? a?.labelBehavior : b?.labelBehavior, + overlayColor: MaterialStateProperty.lerp(a?.overlayColor, b?.overlayColor, t, Color.lerp), ); } @@ -154,6 +161,7 @@ class NavigationBarThemeData with Diagnosticable { labelTextStyle, iconTheme, labelBehavior, + overlayColor, ); @override @@ -165,16 +173,17 @@ class NavigationBarThemeData with Diagnosticable { return false; } return other is NavigationBarThemeData - && other.height == height - && other.backgroundColor == backgroundColor - && other.elevation == elevation - && other.shadowColor == shadowColor - && other.surfaceTintColor == surfaceTintColor - && other.indicatorColor == indicatorColor - && other.indicatorShape == indicatorShape - && other.labelTextStyle == labelTextStyle - && other.iconTheme == iconTheme - && other.labelBehavior == labelBehavior; + && other.height == height + && other.backgroundColor == backgroundColor + && other.elevation == elevation + && other.shadowColor == shadowColor + && other.surfaceTintColor == surfaceTintColor + && other.indicatorColor == indicatorColor + && other.indicatorShape == indicatorShape + && other.labelTextStyle == labelTextStyle + && other.iconTheme == iconTheme + && other.labelBehavior == labelBehavior + && other.overlayColor == overlayColor; } @override @@ -190,6 +199,7 @@ class NavigationBarThemeData with Diagnosticable { properties.add(DiagnosticsProperty>('labelTextStyle', labelTextStyle, defaultValue: null)); properties.add(DiagnosticsProperty>('iconTheme', iconTheme, defaultValue: null)); properties.add(DiagnosticsProperty('labelBehavior', labelBehavior, defaultValue: null)); + properties.add(DiagnosticsProperty>('overlayColor', overlayColor, defaultValue: null)); } } diff --git a/packages/flutter/test/material/navigation_bar_test.dart b/packages/flutter/test/material/navigation_bar_test.dart index 598b9a3664e..9e01060611f 100644 --- a/packages/flutter/test/material/navigation_bar_test.dart +++ b/packages/flutter/test/material/navigation_bar_test.dart @@ -12,6 +12,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; @@ -937,7 +938,7 @@ void main() { }); testWidgetsWithLeakTracking('Material3 - Navigation destination updates indicator color and shape', (WidgetTester tester) async { - final ThemeData theme = ThemeData(useMaterial3: true); + final ThemeData theme = ThemeData(); const Color color = Color(0xff0000ff); const ShapeBorder shape = RoundedRectangleBorder(); @@ -945,20 +946,22 @@ void main() { return MaterialApp( theme: theme, home: Scaffold( - bottomNavigationBar: NavigationBar( - indicatorColor: indicatorColor, - indicatorShape: indicatorShape, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.ac_unit), - label: 'AC', - ), - NavigationDestination( - icon: Icon(Icons.access_alarm), - label: 'Alarm', - ), - ], - onDestinationSelected: (int i) { }, + bottomNavigationBar: RepaintBoundary( + child: NavigationBar( + indicatorColor: indicatorColor, + indicatorShape: indicatorShape, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) { }, + ), ), ), ); @@ -970,11 +973,22 @@ void main() { expect(_getIndicatorDecoration(tester)?.color, theme.colorScheme.secondaryContainer); expect(_getIndicatorDecoration(tester)?.shape, const StadiumBorder()); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + // Test default indicator color and shape with ripple. + await expectLater(find.byType(NavigationBar), matchesGoldenFile('m3.navigation_bar.default.indicator.inkwell.shape.png')); + await tester.pumpWidget(buildNavigationBar(indicatorColor: color, indicatorShape: shape)); // Test custom indicator color and shape. expect(_getIndicatorDecoration(tester)?.color, color); expect(_getIndicatorDecoration(tester)?.shape, shape); + + // Test custom indicator color and shape with ripple. + await expectLater(find.byType(NavigationBar), matchesGoldenFile('m3.navigation_bar.custom.indicator.inkwell.shape.png')); }); testWidgetsWithLeakTracking('Destinations respect their disabled state', (WidgetTester tester) async { @@ -1014,6 +1028,86 @@ void main() { expect(selectedIndex, 1); }); + testWidgetsWithLeakTracking('NavigationBar respects overlayColor in active/pressed/hovered states', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color hoverColor = Color(0xff0000ff); + const Color focusColor = Color(0xff00ffff); + const Color pressedColor = Color(0xffff00ff); + final MaterialStateProperty overlayColor = MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.hovered)) { + return hoverColor; + } + if (states.contains(MaterialState.focused)) { + return focusColor; + } + if (states.contains(MaterialState.pressed)) { + return pressedColor; + } + return Colors.transparent; + }); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + bottomNavigationBar: RepaintBoundary( + child: NavigationBar( + overlayColor: overlayColor, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) { }, + ), + ), + ), + )); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + + // Test hovered state. + expect( + inkFeatures, + kIsWeb + ? (paints..rrect()..rrect()..circle(color: hoverColor)) + : (paints..circle(color: hoverColor)), + ); + + await gesture.down(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + // Test pressed state. + expect( + inkFeatures, + kIsWeb + ? (paints..circle()..circle()..circle(color: pressedColor)) + : (paints..circle()..circle(color: pressedColor)), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Press tab to focus the navigation bar. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Test focused state. + expect( + inkFeatures, + kIsWeb ? (paints..circle()..circle(color: focusColor)) : (paints..circle()..circle(color: focusColor)), + ); + }); + group('Material 2', () { // These tests are only relevant for Material 2. Once Material 2 // support is deprecated and the APIs are removed, these tests diff --git a/packages/flutter/test/material/navigation_bar_theme_test.dart b/packages/flutter/test/material/navigation_bar_theme_test.dart index 71d27bafc23..028c0ffbdeb 100644 --- a/packages/flutter/test/material/navigation_bar_theme_test.dart +++ b/packages/flutter/test/material/navigation_bar_theme_test.dart @@ -7,9 +7,11 @@ @Tags(['reduced-test-set']) library; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; @@ -48,6 +50,7 @@ void main() { labelTextStyle: MaterialStatePropertyAll(TextStyle(fontSize: 7.0)), iconTheme: MaterialStatePropertyAll(IconThemeData(color: Color(0x00000097))), labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, + overlayColor: MaterialStatePropertyAll(Color(0x00000096)), ).debugFillProperties(builder); final List description = builder.properties @@ -61,12 +64,11 @@ void main() { expect(description[3], 'indicatorColor: Color(0x00000098)'); expect(description[4], 'indicatorShape: CircleBorder(BorderSide(width: 0.0, style: none))'); expect(description[5], 'labelTextStyle: MaterialStatePropertyAll(TextStyle(inherit: true, size: 7.0))'); - // Ignore instance address for IconThemeData. expect(description[6].contains('iconTheme: MaterialStatePropertyAll(IconThemeData'), isTrue); expect(description[6].contains('(color: Color(0x00000097))'), isTrue); - expect(description[7], 'labelBehavior: NavigationDestinationLabelBehavior.alwaysHide'); + expect(description[8], 'overlayColor: MaterialStatePropertyAll(Color(0x00000096))'); }); testWidgetsWithLeakTracking('NavigationBarThemeData values are used when no NavigationBar properties are specified', (WidgetTester tester) async { @@ -216,6 +218,86 @@ void main() { await expectLater(find.byType(NavigationBar), matchesGoldenFile('indicator_custom_label_style.png')); }); + + testWidgetsWithLeakTracking('NavigationBar respects NavigationBarTheme.overlayColor in active/pressed/hovered states', (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color hoverColor = Color(0xff0000ff); + const Color focusColor = Color(0xff00ffff); + const Color pressedColor = Color(0xffff00ff); + final MaterialStateProperty overlayColor = MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.hovered)) { + return hoverColor; + } + if (states.contains(MaterialState.focused)) { + return focusColor; + } + if (states.contains(MaterialState.pressed)) { + return pressedColor; + } + return Colors.transparent; + }); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(navigationBarTheme: NavigationBarThemeData(overlayColor: overlayColor)), + home: Scaffold( + bottomNavigationBar: RepaintBoundary( + child: NavigationBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.ac_unit), + label: 'AC', + ), + NavigationDestination( + icon: Icon(Icons.access_alarm), + label: 'Alarm', + ), + ], + onDestinationSelected: (int i) { }, + ), + ), + ), + )); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + + // Test hovered state. + expect( + inkFeatures, + kIsWeb + ? (paints..rrect()..rrect()..circle(color: hoverColor)) + : (paints..circle(color: hoverColor)), + ); + + await gesture.down(tester.getCenter(find.byType(NavigationIndicator).last)); + await tester.pumpAndSettle(); + + // Test pressed state. + expect( + inkFeatures, + kIsWeb + ? (paints..circle()..circle()..circle(color: pressedColor)) + : (paints..circle()..circle(color: pressedColor)), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Press tab to focus the navigation bar. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Test focused state. + expect( + inkFeatures, + kIsWeb ? (paints..circle()..circle(color: focusColor)) : (paints..circle()..circle(color: focusColor)), + ); + }); } List _destinations() {