Unverified Commit bd77118e authored by MH Johnson's avatar MH Johnson Committed by GitHub

[Material 3] Add optional indicator to Navigation Rail. (#92930)

parent 7ef52267
......@@ -300,7 +300,7 @@ class NavigationDestination extends StatelessWidget {
return Stack(
alignment: Alignment.center,
children: <Widget>[
_NavigationIndicator(
NavigationIndicator(
animation: animation,
color: navigationBarTheme.indicatorColor,
),
......@@ -507,20 +507,24 @@ class _NavigationDestinationInfo extends InheritedWidget {
}
}
/// Selection Indicator for the Material 3 Navigation Bar component.
/// Selection Indicator for the Material 3 [NavigationBar] and [NavigationRail]
/// components.
///
/// When [animation] is 0, the indicator is not present. As [animation] grows
/// from 0 to 1, the indicator scales in on the x axis.
///
/// Useful in a [Stack] widget behind the icons in the Material 3 Navigation Bar
/// Used in a [Stack] widget behind the icons in the Material 3 Navigation Bar
/// to illuminate the selected destination.
class _NavigationIndicator extends StatelessWidget {
class NavigationIndicator extends StatelessWidget {
/// Builds an indicator, usually used in a stack behind the icon of a
/// navigation bar destination.
const _NavigationIndicator({
const NavigationIndicator({
Key? key,
required this.animation,
this.color,
this.width = 64,
this.height = 32,
this.borderRadius = const BorderRadius.all(Radius.circular(16)),
}) : super(key: key);
/// Determines the scale of the indicator.
......@@ -534,6 +538,21 @@ class _NavigationIndicator extends StatelessWidget {
/// If null, defaults to [ColorScheme.secondary].
final Color? color;
/// The width of the container that holds in the indicator.
///
/// Defaults to `64`.
final double width;
/// The height of the container that holds in the indicator.
///
/// Defaults to `32`.
final double height;
/// The radius of the container that holds in the indicator.
///
/// Defaults to `BorderRadius.circular(16)`.
final BorderRadius borderRadius;
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
......@@ -573,10 +592,10 @@ class _NavigationIndicator extends StatelessWidget {
return FadeTransition(
opacity: fadeAnimation,
child: Container(
width: 64.0,
height: 32.0,
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
borderRadius: borderRadius,
color: color ?? colorScheme.secondary.withOpacity(.24),
),
),
......
......@@ -10,6 +10,7 @@ import 'color_scheme.dart';
import 'ink_well.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'navigation_bar.dart';
import 'navigation_rail_theme.dart';
import 'theme.dart';
......@@ -94,6 +95,8 @@ class NavigationRail extends StatefulWidget {
this.selectedIconTheme,
this.minWidth,
this.minExtendedWidth,
this.useIndicator,
this.indicatorColor,
}) : assert(destinations != null && destinations.length >= 2),
assert(selectedIndex != null),
assert(0 <= selectedIndex && selectedIndex < destinations.length),
......@@ -279,6 +282,21 @@ class NavigationRail extends StatefulWidget {
/// The default value is 256.
final double? minExtendedWidth;
/// If `true`, adds a rounded [NavigationIndicator] behind the selected
/// destination's icon.
///
/// The indicator's shape will be circular if [labelType] is
/// [NavigationRailLabelType.none], or a [StadiumBorder] if [labelType] is
/// [NavigationRailLabelType.all] or [NavigationRailLabelType.selected].
///
/// If `null`, defaults to [NavigationRailThemeData.useIndicator]. If that is
/// `null`, defaults to [ThemeData.useMaterial3].
final bool? useIndicator;
/// Overrides the default value of [NavigationRail]'s selection indicator color,
/// when [useIndicator] is true.
final Color? indicatorColor;
/// Returns the animation that controls the [NavigationRail.extended] state.
///
/// This can be used to synchronize animations in the [leading] or [trailing]
......@@ -413,6 +431,8 @@ class _NavigationRailState extends State<NavigationRail> with TickerProviderStat
iconTheme: widget.selectedIndex == i ? selectedIconTheme : unselectedIconTheme,
labelTextStyle: widget.selectedIndex == i ? selectedLabelTextStyle : unselectedLabelTextStyle,
padding: widget.destinations[i].padding,
useIndicator: widget.useIndicator ?? navigationRailTheme.useIndicator ?? theme.useMaterial3,
indicatorColor: widget.indicatorColor ?? navigationRailTheme.indicatorColor,
onTap: () {
if (widget.onDestinationSelected != null)
widget.onDestinationSelected!(i);
......@@ -498,6 +518,8 @@ class _RailDestination extends StatelessWidget {
required this.onTap,
required this.indexLabel,
this.padding,
required this.useIndicator,
this.indicatorColor,
}) : assert(minWidth != null),
assert(minExtendedWidth != null),
assert(icon != null),
......@@ -529,11 +551,18 @@ class _RailDestination extends StatelessWidget {
final VoidCallback onTap;
final String indexLabel;
final EdgeInsetsGeometry? padding;
final bool useIndicator;
final Color? indicatorColor;
final Animation<double> _positionAnimation;
@override
Widget build(BuildContext context) {
assert(
useIndicator || indicatorColor == null,
'[NavigationRail.indicatorColor] does not have an effect when [NavigationRail.useIndicator] is false',
);
final Widget themedIcon = IconTheme(
data: iconTheme,
child: icon,
......@@ -542,13 +571,19 @@ class _RailDestination extends StatelessWidget {
style: labelTextStyle,
child: label,
);
final Widget content;
switch (labelType) {
case NavigationRailLabelType.none:
final Widget iconPart = SizedBox(
width: minWidth,
height: minWidth,
child: Align(
child: _AddIndicator(
addIndicator: useIndicator,
indicatorColor: indicatorColor,
isCircular: true,
indicatorAnimation: destinationAnimation,
child: themedIcon,
),
);
......@@ -606,6 +641,7 @@ class _RailDestination extends StatelessWidget {
final double verticalPadding = lerpDouble(_verticalDestinationPaddingNoLabel, _verticalDestinationPaddingWithLabel, appearingAnimationValue)!;
final Interval interval = selected ? const Interval(0.25, 0.75) : const Interval(0.75, 1.0);
final Animation<double> labelFadeAnimation = destinationAnimation.drive(CurveTween(curve: interval));
content = Container(
constraints: BoxConstraints(
minWidth: minWidth,
......@@ -618,7 +654,13 @@ class _RailDestination extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(height: verticalPadding),
themedIcon,
_AddIndicator(
addIndicator: useIndicator,
indicatorColor: indicatorColor,
isCircular: false,
indicatorAnimation: destinationAnimation,
child: themedIcon,
),
Align(
alignment: Alignment.topCenter,
heightFactor: appearingAnimationValue,
......@@ -645,7 +687,13 @@ class _RailDestination extends StatelessWidget {
child: Column(
children: <Widget>[
const SizedBox(height: _verticalDestinationPaddingWithLabel),
themedIcon,
_AddIndicator(
addIndicator: useIndicator,
indicatorColor: indicatorColor,
isCircular: false,
indicatorAnimation: destinationAnimation,
child: themedIcon,
),
styledLabel,
const SizedBox(height: _verticalDestinationPaddingWithLabel),
],
......@@ -682,6 +730,62 @@ class _RailDestination extends StatelessWidget {
}
}
/// When [addIndicator] is `true`, puts [child] center aligned in a [Stack] with
/// a [NavigationIndicator] behind it, otherwise returns [child].
///
/// When [isCircular] is true, the indicator will be a circle, otherwise the
/// indicator will be a stadium shape.
class _AddIndicator extends StatelessWidget {
const _AddIndicator({
Key? key,
required this.addIndicator,
required this.isCircular,
required this.indicatorColor,
required this.indicatorAnimation,
required this.child,
}) : super(key: key);
final bool addIndicator;
final bool isCircular;
final Color? indicatorColor;
final Animation<double> indicatorAnimation;
final Widget child;
@override
Widget build(BuildContext context) {
if (!addIndicator) {
return child;
}
late final Widget indicator;
if (isCircular) {
const double circularIndicatorDiameter = 56;
indicator = NavigationIndicator(
animation: indicatorAnimation,
height: circularIndicatorDiameter,
width: circularIndicatorDiameter,
borderRadius: BorderRadius.circular(circularIndicatorDiameter / 2),
color: indicatorColor,
);
} else {
indicator = NavigationIndicator(
animation: indicatorAnimation,
width: 56,
borderRadius: BorderRadius.circular(16),
color: indicatorColor,
);
}
return Stack(
alignment: Alignment.center,
children: <Widget>[
indicator,
child,
],
);
}
}
/// Defines the behavior of the labels of a [NavigationRail].
///
/// See also:
......
......@@ -44,6 +44,8 @@ class NavigationRailThemeData with Diagnosticable {
this.selectedIconTheme,
this.groupAlignment,
this.labelType,
this.useIndicator,
this.indicatorColor,
});
/// Color to be used for the [NavigationRail]'s background.
......@@ -76,6 +78,14 @@ class NavigationRailThemeData with Diagnosticable {
/// [NavigationRail].
final NavigationRailLabelType? labelType;
/// Whether or not the selected [NavigationRailDestination] should include a
/// [NavigationIndicator].
final bool? useIndicator;
/// Overrides the default value of [NavigationRail]'s selection indicator color,
/// when [useIndicator] is true.
final Color? indicatorColor;
/// Creates a copy of this object with the given fields replaced with the
/// new values.
NavigationRailThemeData copyWith({
......@@ -87,6 +97,8 @@ class NavigationRailThemeData with Diagnosticable {
IconThemeData? selectedIconTheme,
double? groupAlignment,
NavigationRailLabelType? labelType,
bool? useIndicator,
Color? indicatorColor,
}) {
return NavigationRailThemeData(
backgroundColor: backgroundColor ?? this.backgroundColor,
......@@ -97,6 +109,8 @@ class NavigationRailThemeData with Diagnosticable {
selectedIconTheme: selectedIconTheme ?? this.selectedIconTheme,
groupAlignment: groupAlignment ?? this.groupAlignment,
labelType: labelType ?? this.labelType,
useIndicator: useIndicator ?? this.useIndicator,
indicatorColor: indicatorColor ?? this.indicatorColor,
);
}
......@@ -118,6 +132,8 @@ class NavigationRailThemeData with Diagnosticable {
selectedIconTheme: IconThemeData.lerp(a?.selectedIconTheme, b?.selectedIconTheme, t),
groupAlignment: lerpDouble(a?.groupAlignment, b?.groupAlignment, t),
labelType: t < 0.5 ? a?.labelType : b?.labelType,
useIndicator: t < 0.5 ? a?.useIndicator : b?.useIndicator,
indicatorColor: Color.lerp(a?.indicatorColor, b?.indicatorColor, t),
);
}
......@@ -132,6 +148,8 @@ class NavigationRailThemeData with Diagnosticable {
selectedIconTheme,
groupAlignment,
labelType,
useIndicator,
indicatorColor,
);
}
......@@ -149,7 +167,9 @@ class NavigationRailThemeData with Diagnosticable {
&& other.unselectedIconTheme == unselectedIconTheme
&& other.selectedIconTheme == selectedIconTheme
&& other.groupAlignment == groupAlignment
&& other.labelType == labelType;
&& other.labelType == labelType
&& other.useIndicator == useIndicator
&& other.indicatorColor == indicatorColor;
}
@override
......@@ -165,6 +185,8 @@ class NavigationRailThemeData with Diagnosticable {
properties.add(DiagnosticsProperty<IconThemeData>('selectedIconTheme', selectedIconTheme, defaultValue: defaultData.selectedIconTheme));
properties.add(DoubleProperty('groupAlignment', groupAlignment, defaultValue: defaultData.groupAlignment));
properties.add(DiagnosticsProperty<NavigationRailLabelType>('labelType', labelType, defaultValue: defaultData.labelType));
properties.add(DiagnosticsProperty<bool>('useIndicator', useIndicator, defaultValue: defaultData.useIndicator));
properties.add(ColorProperty('indicatorColor', indicatorColor, defaultValue: defaultData.indicatorColor));
}
}
......
......@@ -2160,6 +2160,195 @@ void main() {
expect(secondItem.padding, secondItemPadding);
expect(thirdItem.padding, thirdItemPadding);
});
testWidgets('NavigationRailDestination adds indicator by default when ThemeData.useMaterial3 is true', (WidgetTester tester) async {
await _pumpNavigationRail(
tester,
theme: ThemeData(useMaterial3: true),
navigationRail: NavigationRail(
labelType: NavigationRailLabelType.selected,
selectedIndex: 0,
destinations: const <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
label: Text('Abc'),
),
NavigationRailDestination(
icon: Icon(Icons.bookmark_border),
selectedIcon: Icon(Icons.bookmark),
label: Text('Def'),
),
NavigationRailDestination(
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
label: Text('Ghi'),
),
],
),
);
expect(find.byType(NavigationIndicator), findsWidgets);
});
testWidgets('NavigationRailDestination adds indicator when useIndicator is true', (WidgetTester tester) async {
await _pumpNavigationRail(
tester,
navigationRail: NavigationRail(
useIndicator: true,
labelType: NavigationRailLabelType.selected,
selectedIndex: 0,
destinations: const <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
label: Text('Abc'),
),
NavigationRailDestination(
icon: Icon(Icons.bookmark_border),
selectedIcon: Icon(Icons.bookmark),
label: Text('Def'),
),
NavigationRailDestination(
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
label: Text('Ghi'),
),
],
),
);
expect(find.byType(NavigationIndicator), findsWidgets);
});
testWidgets('NavigationRailDestination does not add indicator when useIndicator is false', (WidgetTester tester) async {
await _pumpNavigationRail(
tester,
navigationRail: NavigationRail(
useIndicator: false,
labelType: NavigationRailLabelType.selected,
selectedIndex: 0,
destinations: const <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
label: Text('Abc'),
),
NavigationRailDestination(
icon: Icon(Icons.bookmark_border),
selectedIcon: Icon(Icons.bookmark),
label: Text('Def'),
),
NavigationRailDestination(
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
label: Text('Ghi'),
),
],
),
);
expect(find.byType(NavigationIndicator), findsNothing);
});
testWidgets('NavigationRailDestination adds circular indicator when no labels are present', (WidgetTester tester) async {
await _pumpNavigationRail(
tester,
navigationRail: NavigationRail(
useIndicator: true,
labelType: NavigationRailLabelType.none,
selectedIndex: 0,
destinations: const <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
label: Text('Abc'),
),
NavigationRailDestination(
icon: Icon(Icons.bookmark_border),
selectedIcon: Icon(Icons.bookmark),
label: Text('Def'),
),
NavigationRailDestination(
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
label: Text('Ghi'),
),
],
),
);
final NavigationIndicator indicator = tester.widget<NavigationIndicator>(find.byType(NavigationIndicator).first);
expect(indicator.width, 56);
expect(indicator.height, 56);
});
testWidgets('NavigationRailDestination adds circular indicator when selected labels are present', (WidgetTester tester) async {
await _pumpNavigationRail(
tester,
navigationRail: NavigationRail(
useIndicator: true,
labelType: NavigationRailLabelType.selected,
selectedIndex: 0,
destinations: const <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
label: Text('Abc'),
),
NavigationRailDestination(
icon: Icon(Icons.bookmark_border),
selectedIcon: Icon(Icons.bookmark),
label: Text('Def'),
),
NavigationRailDestination(
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
label: Text('Ghi'),
),
],
),
);
final NavigationIndicator indicator = tester.widget<NavigationIndicator>(find.byType(NavigationIndicator).first);
expect(indicator.width, 56);
expect(indicator.height, 32);
});
testWidgets('NavigationRailDestination adds circular indicator when all labels are present', (WidgetTester tester) async {
await _pumpNavigationRail(
tester,
navigationRail: NavigationRail(
useIndicator: true,
labelType: NavigationRailLabelType.all,
selectedIndex: 0,
destinations: const <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.favorite_border),
selectedIcon: Icon(Icons.favorite),
label: Text('Abc'),
),
NavigationRailDestination(
icon: Icon(Icons.bookmark_border),
selectedIcon: Icon(Icons.bookmark),
label: Text('Def'),
),
NavigationRailDestination(
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
label: Text('Ghi'),
),
],
),
);
final NavigationIndicator indicator = tester.widget<NavigationIndicator>(find.byType(NavigationIndicator).first);
expect(indicator.width, 56);
expect(indicator.height, 32);
});
}
TestSemantics _expectedSemantics() {
......@@ -2243,9 +2432,11 @@ Future<void> _pumpNavigationRail(
WidgetTester tester, {
double textScaleFactor = 1.0,
required NavigationRail navigationRail,
ThemeData? theme,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Builder(
builder: (BuildContext context) {
return MediaQuery(
......
......@@ -36,6 +36,7 @@ void main() {
expect(_unselectedLabelStyle(tester).fontSize, 14.0);
expect(_destinationsAlign(tester).alignment, Alignment.topCenter);
expect(_labelType(tester), NavigationRailLabelType.none);
expect(find.byType(NavigationIndicator), findsNothing);
});
testWidgets('NavigationRailThemeData values are used when no NavigationRail properties are specified', (WidgetTester tester) async {
......@@ -51,6 +52,8 @@ void main() {
const double unselectedLabelFontSize = 11.0;
const double groupAlignment = 0.0;
const NavigationRailLabelType labelType = NavigationRailLabelType.all;
const bool useIndicator = true;
const Color indicatorColor = Color(0x00000004);
await tester.pumpWidget(
MaterialApp(
......@@ -73,6 +76,8 @@ void main() {
unselectedLabelTextStyle: TextStyle(fontSize: unselectedLabelFontSize),
groupAlignment: groupAlignment,
labelType: labelType,
useIndicator: useIndicator,
indicatorColor: indicatorColor,
),
child: NavigationRail(
selectedIndex: 0,
......@@ -95,6 +100,8 @@ void main() {
expect(_unselectedLabelStyle(tester).fontSize, unselectedLabelFontSize);
expect(_destinationsAlign(tester).alignment, Alignment.center);
expect(_labelType(tester), labelType);
expect(find.byType(NavigationIndicator), findsWidgets);
expect(_indicatorDecoration(tester)?.color, indicatorColor);
});
testWidgets('NavigationRail values take priority over NavigationRailThemeData values when both properties are specified', (WidgetTester tester) async {
......@@ -110,6 +117,8 @@ void main() {
const double unselectedLabelFontSize = 11.0;
const double groupAlignment = 0.0;
const NavigationRailLabelType labelType = NavigationRailLabelType.all;
const bool useIndicator = true;
const Color indicatorColor = Color(0x00000004);
await tester.pumpWidget(
MaterialApp(
......@@ -132,6 +141,8 @@ void main() {
unselectedLabelTextStyle: TextStyle(fontSize: 7.0),
groupAlignment: 1.0,
labelType: NavigationRailLabelType.selected,
useIndicator: false,
indicatorColor: Color(0x00000096),
),
child: NavigationRail(
selectedIndex: 0,
......@@ -152,6 +163,8 @@ void main() {
unselectedLabelTextStyle: const TextStyle(fontSize: unselectedLabelFontSize),
groupAlignment: groupAlignment,
labelType: labelType,
useIndicator: useIndicator,
indicatorColor: indicatorColor,
),
),
),
......@@ -170,6 +183,8 @@ void main() {
expect(_unselectedLabelStyle(tester).fontSize, unselectedLabelFontSize);
expect(_destinationsAlign(tester).alignment, Alignment.center);
expect(_labelType(tester), labelType);
expect(find.byType(NavigationIndicator), findsWidgets);
expect(_indicatorDecoration(tester)?.color, indicatorColor);
});
testWidgets('Default debugFillProperties', (WidgetTester tester) async {
......@@ -195,6 +210,8 @@ void main() {
unselectedLabelTextStyle: TextStyle(fontSize: 7.0),
groupAlignment: 1.0,
labelType: NavigationRailLabelType.selected,
useIndicator: true,
indicatorColor: Color(0x00000096),
).debugFillProperties(builder);
final List<String> description = builder.properties
......@@ -215,7 +232,8 @@ void main() {
expect(description[6], 'groupAlignment: 1.0');
expect(description[7], 'labelType: NavigationRailLabelType.selected');
expect(description[8], 'useIndicator: true');
expect(description[9], 'indicatorColor: Color(0x00000096)');
});
}
......@@ -244,6 +262,16 @@ Material _railMaterial(WidgetTester tester) {
);
}
BoxDecoration? _indicatorDecoration(WidgetTester tester) {
return tester.firstWidget<Container>(
find.descendant(
of: find.byType(NavigationIndicator),
matching: find.byType(Container),
),
).decoration as BoxDecoration?;
}
IconThemeData _selectedIconTheme(WidgetTester tester) {
return _iconTheme(tester, Icons.favorite);
}
......
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