[CP] Add ability to customize `NavigationBar` indicator overlay and fix in… (#139162)
â¦dicator shape for the overlay (#138901)
fixes [Provide ability to override `NavigationBar` indicator ink response overlay](https://github.com/flutter/flutter/issues/138850) fixes [`NavigationBar.indicatorShape` is ignored, `NavigationBarThemeData.indicatorShape` is applied to the indicator inkwell](https://github.com/flutter/flutter/issues/138900)
---
Cherry pick fixes https://github.com/flutter/flutter/issues/139159
---
### 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 const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
bottomNavigationBar: NavigationBarExample(),
),
);
}
}
class NavigationBarExample extends StatefulWidget {
const NavigationBarExample({super.key});
@override
State<NavigationBarExample> createState() => _NavigationBarExampleState();
}
class _NavigationBarExampleState extends State<NavigationBarExample> {
int index = 0;
@override
Widget build(BuildContext context) {
return NavigationBar(
elevation: 0,
overlayColor: const MaterialStatePropertyAll<Color>(Colors.transparent),
// indicatorShape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(4.0),
// ),
indicatorColor: Colors.transparent,
selectedIndex: index,
onDestinationSelected: (int index) {
setState(() {
this.index = index;
});
},
destinations: const <Widget>[
NavigationDestination(
selectedIcon: Icon(Icons.home_filled),
icon: Icon(Icons.home_outlined),
label: 'Home',
),
NavigationDestination(
selectedIcon: Icon(Icons.favorite),
icon: Icon(Icons.favorite_outline),
label: 'Favorites',
),
],
);
}
}
```
</details>
### Before
#### Cannot override `NavigationBar` Indicator ink well overlay

#### Indicator shape is ignored for the indicator overlay

### After
#### Can use `NavigationBar.overlayColor` or `NavigationBarThemeData.NavigationBar` to override default indicator overlay
`overlayColor: MaterialStatePropertyAll<Color>(Colors.red.withOpacity(0.33)),`

`overlayColor: MaterialStatePropertyAll<Color>(Colors.transparent),`

#### Indicator shape is respected for the indicator overlay

*Replace this paragraph with a description of what this PR is changing or adding, and why. Consider including before/after screenshots.*
*List which issues are fixed by this PR. You must list at least one issue. An issue is not required if the PR fixes something trivial like a typo.*
*If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
diff --git a/packages/flutter/lib/src/material/navigation_bar.dart b/packages/flutter/lib/src/material/navigation_bar.dart
index b209ffb..e40c624 100644
--- a/packages/flutter/lib/src/material/navigation_bar.dart
+++ b/packages/flutter/lib/src/material/navigation_bar.dart
@@ -98,6 +98,7 @@
this.indicatorShape,
this.height,
this.labelBehavior,
+ this.overlayColor,
}) : assert(destinations.length >= 2),
assert(0 <= selectedIndex && selectedIndex < destinations.length);
@@ -201,6 +202,10 @@
/// [NavigationDestinationLabelBehavior.alwaysShow].
final NavigationDestinationLabelBehavior? labelBehavior;
+ /// The highlight color that's typically used to indicate that
+ /// the [NavigationDestination] is focused, hovered, or pressed.
+ final MaterialStateProperty<Color?>? overlayColor;
+
VoidCallback _handleTap(int index) {
return onDestinationSelected != null
? () => onDestinationSelected!(index)
@@ -243,6 +248,7 @@
labelBehavior: effectiveLabelBehavior,
indicatorColor: indicatorColor,
indicatorShape: indicatorShape,
+ overlayColor: overlayColor,
onTap: _handleTap(i),
child: destinations[i],
);
@@ -503,7 +509,8 @@
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: <Widget>[
@@ -526,6 +533,7 @@
const _IndicatorInkWell({
required this.iconKey,
required this.labelBehavior,
+ super.overlayColor,
super.customBorder,
super.onTap,
super.child,
@@ -563,6 +571,7 @@
required this.labelBehavior,
required this.indicatorColor,
required this.indicatorShape,
+ required this.overlayColor,
required this.onTap,
required super.child,
});
@@ -629,6 +638,12 @@
/// 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<Color?>? 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 2de555f..adb9e39 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 @@
this.labelTextStyle,
this.iconTheme,
this.labelBehavior,
+ this.overlayColor,
});
/// Overrides the default value of [NavigationBar.height].
@@ -91,6 +92,9 @@
/// Overrides the default value of [NavigationBar.labelBehavior].
final NavigationDestinationLabelBehavior? labelBehavior;
+ /// Overrides the default value of [NavigationBar.overlayColor].
+ final MaterialStateProperty<Color?>? overlayColor;
+
/// Creates a copy of this object with the given fields replaced with the
/// new values.
NavigationBarThemeData copyWith({
@@ -104,6 +108,7 @@
MaterialStateProperty<TextStyle?>? labelTextStyle,
MaterialStateProperty<IconThemeData?>? iconTheme,
NavigationDestinationLabelBehavior? labelBehavior,
+ MaterialStateProperty<Color?>? overlayColor,
}) {
return NavigationBarThemeData(
height: height ?? this.height,
@@ -116,6 +121,7 @@
labelTextStyle: labelTextStyle ?? this.labelTextStyle,
iconTheme: iconTheme ?? this.iconTheme,
labelBehavior: labelBehavior ?? this.labelBehavior,
+ overlayColor: overlayColor ?? this.overlayColor,
);
}
@@ -139,6 +145,7 @@
labelTextStyle: MaterialStateProperty.lerp<TextStyle?>(a?.labelTextStyle, b?.labelTextStyle, t, TextStyle.lerp),
iconTheme: MaterialStateProperty.lerp<IconThemeData?>(a?.iconTheme, b?.iconTheme, t, IconThemeData.lerp),
labelBehavior: t < 0.5 ? a?.labelBehavior : b?.labelBehavior,
+ overlayColor: MaterialStateProperty.lerp<Color?>(a?.overlayColor, b?.overlayColor, t, Color.lerp),
);
}
@@ -154,6 +161,7 @@
labelTextStyle,
iconTheme,
labelBehavior,
+ overlayColor,
);
@override
@@ -165,16 +173,17 @@
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 @@
properties.add(DiagnosticsProperty<MaterialStateProperty<TextStyle?>>('labelTextStyle', labelTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<MaterialStateProperty<IconThemeData?>>('iconTheme', iconTheme, defaultValue: null));
properties.add(DiagnosticsProperty<NavigationDestinationLabelBehavior>('labelBehavior', labelBehavior, defaultValue: null));
+ properties.add(DiagnosticsProperty<MaterialStateProperty<Color?>>('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 598b9a3..9e01060 100644
--- a/packages/flutter/test/material/navigation_bar_test.dart
+++ b/packages/flutter/test/material/navigation_bar_test.dart
@@ -12,6 +12,7 @@
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 @@
});
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 @@
return MaterialApp(
theme: theme,
home: Scaffold(
- bottomNavigationBar: NavigationBar(
- indicatorColor: indicatorColor,
- indicatorShape: indicatorShape,
- destinations: const <Widget>[
- 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 <Widget>[
+ NavigationDestination(
+ icon: Icon(Icons.ac_unit),
+ label: 'AC',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.access_alarm),
+ label: 'Alarm',
+ ),
+ ],
+ onDestinationSelected: (int i) { },
+ ),
),
),
);
@@ -970,11 +973,22 @@
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 @@
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<Color?> overlayColor = MaterialStateProperty.resolveWith<Color>(
+ (Set<MaterialState> 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 <Widget>[
+ 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 71d27ba..028c0ff 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(<String>['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 @@
labelTextStyle: MaterialStatePropertyAll<TextStyle>(TextStyle(fontSize: 7.0)),
iconTheme: MaterialStatePropertyAll<IconThemeData>(IconThemeData(color: Color(0x00000097))),
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
+ overlayColor: MaterialStatePropertyAll<Color>(Color(0x00000096)),
).debugFillProperties(builder);
final List<String> description = builder.properties
@@ -61,12 +64,11 @@
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 @@
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<Color?> overlayColor = MaterialStateProperty.resolveWith<Color>(
+ (Set<MaterialState> 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 <Widget>[
+ 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<NavigationDestination> _destinations() {