Fix NavigatorBar lacks visual feedback (#175182)
## Description
This PR fixes NavigationBar lacking visual feedback on the active
destination indicator.
This is a reland of https://github.com/flutter/flutter/pull/164484 which
was reverted in https://github.com/flutter/flutter/pull/169497.
After investigation, I narrowed down the regression introduced in
https://github.com/flutter/flutter/pull/164484 to the usage of a
RepaintBoundary.
I added one test to verify that the regression which led to the revert
of https://github.com/flutter/flutter/pull/164484 is no more
reproducible.
### Before:
The navigation indicator does not change color when hovered or focused:
https://github.com/user-attachments/assets/a1e67dee-4a38-4711-ba90-bdcd9bed3226
### After:
The navigation indicator color changes (slightly darker):
https://github.com/user-attachments/assets/1b1cc335-2cf4-4c41-9c53-696537707c72
## Related Issue
Fixes [NavigationBar lacks visual feedback when focused or
hovered](https://github.com/flutter/flutter/issues/163871)
## Tests
- Updates several helper functions which are used by several tests.
- Updates several test: adding an Ink widget changes the coordinates
used in several tests because these coordinates are now relative to the
Ink offset.
- Add one test to check that active destination moves to the correct
destination (that was not the case after
https://github.com/flutter/flutter/pull/164484). This new test is a
golden test.
diff --git a/packages/flutter/lib/src/material/navigation_bar.dart b/packages/flutter/lib/src/material/navigation_bar.dart
index 213b088..6499dc6 100644
--- a/packages/flutter/lib/src/material/navigation_bar.dart
+++ b/packages/flutter/lib/src/material/navigation_bar.dart
@@ -16,6 +16,7 @@
import 'color_scheme.dart';
import 'colors.dart';
import 'elevation_overlay.dart';
+import 'ink_decoration.dart';
import 'ink_well.dart';
import 'material.dart';
import 'material_localizations.dart';
@@ -866,7 +867,7 @@
builder: (BuildContext context, Animation<double> fadeAnimation) {
return FadeTransition(
opacity: fadeAnimation,
- child: Container(
+ child: Ink(
width: width,
height: height,
decoration: ShapeDecoration(
@@ -916,8 +917,6 @@
/// See [NavigationDestination.label].
final Widget label;
- static final Key _labelKey = UniqueKey();
-
@override
Widget build(BuildContext context) {
return _DestinationLayoutAnimationBuilder(
@@ -927,15 +926,12 @@
children: <Widget>[
LayoutId(
id: _NavigationDestinationLayoutDelegate.iconId,
- child: RepaintBoundary(key: iconKey, child: icon),
+ // The key is used by the _IndicatorInkWell to query the icon position.
+ child: KeyedSubtree(key: iconKey, child: icon),
),
LayoutId(
id: _NavigationDestinationLayoutDelegate.labelId,
- child: FadeTransition(
- alwaysIncludeSemantics: true,
- opacity: animation,
- child: RepaintBoundary(key: _labelKey, child: label),
- ),
+ child: FadeTransition(alwaysIncludeSemantics: true, opacity: animation, child: label),
),
],
);
diff --git a/packages/flutter/test/material/navigation_bar_test.dart b/packages/flutter/test/material/navigation_bar_test.dart
index 67d1e2f..dcbbdf6 100644
--- a/packages/flutter/test/material/navigation_bar_test.dart
+++ b/packages/flutter/test/material/navigation_bar_test.dart
@@ -953,6 +953,47 @@
);
});
+ // Regression test for https://github.com/flutter/flutter/issues/169249.
+ testWidgets('Material3 - Navigation indicator moves to selected item', (
+ WidgetTester tester,
+ ) async {
+ final ThemeData theme = ThemeData();
+ int index = 0;
+
+ Widget buildNavigationBar({Color? indicatorColor, ShapeBorder? indicatorShape}) {
+ return MaterialApp(
+ theme: theme,
+ home: Scaffold(
+ bottomNavigationBar: RepaintBoundary(
+ child: NavigationBar(
+ indicatorColor: indicatorColor,
+ indicatorShape: indicatorShape,
+ selectedIndex: index,
+ destinations: const <Widget>[
+ NavigationDestination(icon: Icon(Icons.ac_unit), label: 'AC'),
+ NavigationDestination(icon: Icon(Icons.access_alarm), label: 'Alarm'),
+ ],
+ onDestinationSelected: (int i) {},
+ ),
+ ),
+ ),
+ );
+ }
+
+ await tester.pumpWidget(buildNavigationBar());
+
+ // Move the selection to the second destination.
+ index = 1;
+ await tester.pumpWidget(buildNavigationBar());
+ await tester.pumpAndSettle();
+
+ // The navigation indicator should be on the second item.
+ await expectLater(
+ find.byType(NavigationBar),
+ matchesGoldenFile('m3.navigation_bar.indicator.ink.position.png'),
+ );
+ });
+
testWidgets('Navigation indicator scale transform', (WidgetTester tester) async {
int selectedIndex = 0;
@@ -1718,8 +1759,8 @@
ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) {
return tester
- .firstWidget<Container>(
- find.descendant(of: find.byType(FadeTransition), matching: find.byType(Container)),
+ .firstWidget<Ink>(
+ find.descendant(of: find.byType(FadeTransition), matching: find.byType(Ink)),
)
.decoration
as ShapeDecoration?;
diff --git a/packages/flutter/test/material/navigation_bar_theme_test.dart b/packages/flutter/test/material/navigation_bar_theme_test.dart
index e932e7d..8506b13 100644
--- a/packages/flutter/test/material/navigation_bar_theme_test.dart
+++ b/packages/flutter/test/material/navigation_bar_theme_test.dart
@@ -365,8 +365,8 @@
ShapeDecoration? _indicator(WidgetTester tester) {
return tester
- .firstWidget<Container>(
- find.descendant(of: find.byType(FadeTransition), matching: find.byType(Container)),
+ .firstWidget<Ink>(
+ find.descendant(of: find.byType(FadeTransition), matching: find.byType(Ink)),
)
.decoration
as ShapeDecoration?;
diff --git a/packages/flutter/test/material/navigation_drawer_test.dart b/packages/flutter/test/material/navigation_drawer_test.dart
index 96ca683..585c87f 100644
--- a/packages/flutter/test/material/navigation_drawer_test.dart
+++ b/packages/flutter/test/material/navigation_drawer_test.dart
@@ -123,14 +123,15 @@
await tester.tap(find.text('AC'));
await tester.pump();
- expect(findDestinationInk('AC'), findsNothing);
+ // When no background color is set, only the non-visible indicator Ink is expected.
+ expect(findDestinationInk('AC'), findsOne);
// Destination with a custom background color.
await tester.tap(find.byIcon(Icons.access_alarm));
await tester.pump();
// A Material is added with the custom color.
- expect(findDestinationInk('Alarm'), findsOne);
+ expect(findDestinationInk('Alarm'), findsNWidgets(2));
final BoxDecoration destinationDecoration =
tester.firstWidget<Ink>(findDestinationInk('Alarm')).decoration! as BoxDecoration;
expect(destinationDecoration.color, color);
@@ -549,8 +550,8 @@
ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) {
return tester
- .firstWidget<Container>(
- find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Container)),
+ .firstWidget<Ink>(
+ find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Ink)),
)
.decoration
as ShapeDecoration?;
diff --git a/packages/flutter/test/material/navigation_drawer_theme_test.dart b/packages/flutter/test/material/navigation_drawer_theme_test.dart
index 3dcf1a9..b81d82c 100644
--- a/packages/flutter/test/material/navigation_drawer_theme_test.dart
+++ b/packages/flutter/test/material/navigation_drawer_theme_test.dart
@@ -283,8 +283,8 @@
ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) {
return tester
- .firstWidget<Container>(
- find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Container)),
+ .firstWidget<Ink>(
+ find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Ink)),
)
.decoration
as ShapeDecoration?;
diff --git a/packages/flutter/test/material/navigation_rail_test.dart b/packages/flutter/test/material/navigation_rail_test.dart
index 8405893..faf488b 100644
--- a/packages/flutter/test/material/navigation_rail_test.dart
+++ b/packages/flutter/test/material/navigation_rail_test.dart
@@ -3123,7 +3123,7 @@
)
..rect(rect: indicatorRect, color: const Color(0x0a6750a4))
..rrect(
- rrect: RRect.fromLTRBR(12.0, 72.0, 68.0, 104.0, const Radius.circular(16)),
+ rrect: RRect.fromLTRBR(12.0, 0.0, 68.0, 32.0, const Radius.circular(16)),
color: const Color(0xffe8def8),
),
);
@@ -3185,7 +3185,7 @@
)
..rect(rect: indicatorRect, color: const Color(0x0a6750a4))
..rrect(
- rrect: RRect.fromLTRBR(12.0, 58.0, 68.0, 90.0, const Radius.circular(16)),
+ rrect: RRect.fromLTRBR(12.0, 6.0, 68.0, 38.0, const Radius.circular(16)),
color: const Color(0xffe8def8),
),
);
@@ -3251,7 +3251,7 @@
)
..rect(rect: indicatorRect, color: const Color(0x0a6750a4))
..rrect(
- rrect: RRect.fromLTRBR(30.0, 96.0, 86.0, 128.0, const Radius.circular(16)),
+ rrect: RRect.fromLTRBR(30.0, 24.0, 86.0, 56.0, const Radius.circular(16)),
color: const Color(0xffe8def8),
),
);
@@ -3316,7 +3316,7 @@
)
..rect(rect: indicatorRect, color: const Color(0x0a6750a4))
..rrect(
- rrect: RRect.fromLTRBR(0.0, 58.0, 50.0, 90.0, const Radius.circular(16)),
+ rrect: RRect.fromLTRBR(0.0, 6.0, 50.0, 38.0, const Radius.circular(16)),
color: const Color(0xffe8def8),
),
);
@@ -3383,7 +3383,7 @@
)
..rect(rect: indicatorRect, color: const Color(0x0a6750a4))
..rrect(
- rrect: RRect.fromLTRBR(140.0, 96.0, 196.0, 128.0, const Radius.circular(16)),
+ rrect: RRect.fromLTRBR(140.0, 24.0, 196.0, 56.0, const Radius.circular(16)),
color: const Color(0xffe8def8),
),
);
@@ -3423,13 +3423,11 @@
);
// Default values from M3 specification.
+ const double railMinWidth = 80.0;
const double indicatorHeight = 32.0;
const double destinationWidth = 72.0;
const double destinationHorizontalPadding = 8.0;
const double indicatorWidth = destinationWidth - 2 * destinationHorizontalPadding; // 56.0
- const double verticalSpacer = 8.0;
- const double verticalIconLabelSpacing = 4.0;
- const double verticalDestinationSpacing = 12.0;
// The navigation rail width is larger than default because of the first destination long label.
final double railWidth = tester.getSize(find.byType(NavigationRail)).width;
@@ -3440,13 +3438,7 @@
final Rect indicatorRect = Rect.fromLTRB(indicatorLeft, 0.0, indicatorRight, indicatorHeight);
final Rect includedRect = indicatorRect;
final Rect excludedRect = includedRect.inflate(10);
-
- // Compute the vertical position for the selected destination (the one with 'bookmark' icon).
- const double labelHeight = 16; // fontSize is 12 and height is 1.3.
- const double destinationHeight =
- indicatorHeight + verticalIconLabelSpacing + labelHeight + verticalDestinationSpacing;
- const double secondDestinationVerticalOffset = verticalSpacer + destinationHeight;
- const double secondIndicatorVerticalOffset = secondDestinationVerticalOffset;
+ const double indicatorHorizontalPadding = (railMinWidth - indicatorWidth) / 2; // 12.0
expect(
inkFeatures,
@@ -3470,10 +3462,10 @@
..rect(rect: indicatorRect, color: const Color(0x0a6750a4))
..rrect(
rrect: RRect.fromLTRBR(
- indicatorLeft,
- secondIndicatorVerticalOffset,
- indicatorRight,
- secondIndicatorVerticalOffset + indicatorHeight,
+ indicatorHorizontalPadding,
+ 0.0,
+ indicatorHorizontalPadding + indicatorWidth,
+ indicatorHeight,
const Radius.circular(16),
),
color: const Color(0xffe8def8),
@@ -3526,9 +3518,6 @@
const double destinationWidth = 72.0;
const double destinationHorizontalPadding = 8.0;
const double indicatorWidth = destinationWidth - 2 * destinationHorizontalPadding; // 56.0
- const double verticalSpacer = 8.0;
- const double verticalIconLabelSpacing = 4.0;
- const double verticalDestinationSpacing = 12.0;
// The navigation rail width is the default one because labels are short.
final double railWidth = tester.getSize(find.byType(NavigationRail)).width;
@@ -3548,13 +3537,8 @@
final Rect includedRect = indicatorRect;
final Rect excludedRect = includedRect.inflate(10);
- // Compute the vertical position for the selected destination (the one with 'bookmark' icon).
- const double labelHeight = 16; // fontSize is 12 and height is 1.3.
- const double destinationHeight =
- iconSize + verticalIconLabelSpacing + labelHeight + verticalDestinationSpacing;
- const double secondDestinationVerticalOffset = verticalSpacer + destinationHeight;
- const double indicatorOffset = (iconSize - indicatorHeight) / 2;
- const double secondIndicatorVerticalOffset = secondDestinationVerticalOffset + indicatorOffset;
+ // Icon height is greater than indicator height so the indicator has a vertical offset.
+ const double secondIndicatorVerticalOffset = (iconSize - indicatorHeight) / 2;
expect(
inkFeatures,
@@ -3633,7 +3617,6 @@
const double destinationWidth = 72.0;
const double destinationHorizontalPadding = 8.0;
const double indicatorWidth = destinationWidth - 2 * destinationHorizontalPadding; // 56.0
- const double verticalSpacer = 8.0;
const double verticalDestinationSpacingM3 = 12.0;
// The navigation rail width is the default one because labels are short.
@@ -3653,11 +3636,7 @@
final Rect excludedRect = includedRect.inflate(10);
// Compute the vertical position for the selected destination (the one with 'bookmark' icon).
- const double destinationHeight = indicatorHeight + verticalDestinationSpacingM3;
- const double secondDestinationVerticalOffset = verticalSpacer + destinationHeight;
- const double secondIndicatorVerticalOffset =
- secondDestinationVerticalOffset + verticalDestinationSpacingM3 / 2;
- const double secondDestinationHorizontalOffset = 800 - railMinExtendedWidth; // RTL.
+ const double secondIndicatorVerticalOffset = verticalDestinationSpacingM3 / 2;
expect(
inkFeatures,
@@ -3683,9 +3662,9 @@
// Indicator for the selected destination (the one with 'bookmark' icon).
..rrect(
rrect: RRect.fromLTRBR(
- secondDestinationHorizontalOffset + indicatorLeft,
+ indicatorLeft,
secondIndicatorVerticalOffset,
- secondDestinationHorizontalOffset + indicatorRight,
+ indicatorRight,
secondIndicatorVerticalOffset + indicatorHeight,
const Radius.circular(16),
),
@@ -6369,8 +6348,8 @@
ShapeDecoration? _getIndicatorDecoration(WidgetTester tester) {
return tester
- .firstWidget<Container>(
- find.descendant(of: find.byType(FadeTransition), matching: find.byType(Container)),
+ .firstWidget<Ink>(
+ find.descendant(of: find.byType(FadeTransition), matching: find.byType(Ink)),
)
.decoration
as ShapeDecoration?;
diff --git a/packages/flutter/test/material/navigation_rail_theme_test.dart b/packages/flutter/test/material/navigation_rail_theme_test.dart
index 52a055e..d8c96f0 100644
--- a/packages/flutter/test/material/navigation_rail_theme_test.dart
+++ b/packages/flutter/test/material/navigation_rail_theme_test.dart
@@ -324,8 +324,8 @@
ShapeDecoration? _indicatorDecoration(WidgetTester tester) {
return tester
- .firstWidget<Container>(
- find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Container)),
+ .firstWidget<Ink>(
+ find.descendant(of: find.byType(NavigationIndicator), matching: find.byType(Ink)),
)
.decoration
as ShapeDecoration?;