Unverified Commit 397fd25b authored by Taha Tesser's avatar Taha Tesser Committed by GitHub

Add ability to customize `NavigationBar` indicator overlay and fix indicator...

Add ability to customize `NavigationBar` indicator overlay and fix indicator 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)

### 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

![Screenshot 2023-11-22 at 18 22 48](https://github.com/flutter/flutter/assets/48603081/06f54335-71ee-4882-afb0-53b614933c38)

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

![Screenshot 2023-11-22 at 15 29 52](https://github.com/flutter/flutter/assets/48603081/913e0f77-48f4-4c6e-87f3-52c81b78f3d9)

### After

#### Can use `NavigationBar.overlayColor` or `NavigationBarThemeData.NavigationBar` to override default indicator overlay

`overlayColor: MaterialStatePropertyAll<Color>(Colors.red.withOpacity(0.33)),`

![Screenshot 2023-11-22 at 18 22 08](https://github.com/flutter/flutter/assets/48603081/28badae4-a7c7-4bf0-8bcc-278a1f84729d)

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

![Screenshot 2023-11-22 at 18 22 25](https://github.com/flutter/flutter/assets/48603081/674b48b1-f66a-4d91-9f10-ad307416ac32)

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

![Screenshot 2023-11-22 at 15 30 36](https://github.com/flutter/flutter/assets/48603081/ae9a3627-787e-45ac-9319-2ea8ea1e6ae6)
parent 7b80797d
......@@ -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<Color?>? 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: <Widget>[
......@@ -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<Color?>? overlayColor;
/// The callback that should be called when this destination is tapped.
///
/// This is computed by calling [NavigationBar.onDestinationSelected]
......
......@@ -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<Color?>? 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<TextStyle?>? labelTextStyle,
MaterialStateProperty<IconThemeData?>? iconTheme,
NavigationDestinationLabelBehavior? labelBehavior,
MaterialStateProperty<Color?>? 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<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 @@ 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<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));
}
}
......
......@@ -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 <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 @@ 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<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
......
......@@ -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 @@ void main() {
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 @@ 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<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() {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment