diff --git a/packages/flutter/lib/src/material/navigation_rail.dart b/packages/flutter/lib/src/material/navigation_rail.dart index 4b4a44d12fdd..731b470a9583 100644 --- a/packages/flutter/lib/src/material/navigation_rail.dart +++ b/packages/flutter/lib/src/material/navigation_rail.dart @@ -346,7 +346,7 @@ class _NavigationRailState extends State with TickerProviderStat late List _destinationControllers; late List> _destinationAnimations; late AnimationController _extendedController; - late Animation _extendedAnimation; + late CurvedAnimation _extendedAnimation; @override void initState() { @@ -488,6 +488,8 @@ class _NavigationRailState extends State with TickerProviderStat controller.dispose(); } _extendedController.dispose(); + _extendedAnimation.dispose(); + } void _initControllers() { @@ -528,8 +530,8 @@ class _NavigationRailState extends State with TickerProviderStat } } -class _RailDestination extends StatelessWidget { - _RailDestination({ +class _RailDestination extends StatefulWidget { + const _RailDestination({ required this.minWidth, required this.minExtendedWidth, required this.icon, @@ -547,11 +549,7 @@ class _RailDestination extends StatelessWidget { this.indicatorColor, this.indicatorShape, this.disabled = false, - }) : _positionAnimation = CurvedAnimation( - parent: ReverseAnimation(destinationAnimation), - curve: Curves.easeInOut, - reverseCurve: Curves.easeInOut.flipped, - ); + }); final double minWidth; final double minExtendedWidth; @@ -571,33 +569,70 @@ class _RailDestination extends StatelessWidget { final ShapeBorder? indicatorShape; final bool disabled; - final Animation _positionAnimation; + + @override + State<_RailDestination> createState() => _RailDestinationState(); +} + +class _RailDestinationState extends State<_RailDestination> { + late CurvedAnimation _positionAnimation; + + @override + void initState() { + super.initState(); + _setPositionAnimation(); + } + + @override + void didUpdateWidget(_RailDestination oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.destinationAnimation != oldWidget.destinationAnimation) { + _positionAnimation.dispose(); + _setPositionAnimation(); + } + } + + void _setPositionAnimation() { + _positionAnimation = CurvedAnimation( + parent: ReverseAnimation(widget.destinationAnimation), + curve: Curves.easeInOut, + reverseCurve: Curves.easeInOut.flipped, + ); + } + + @override + void dispose() { + _positionAnimation.dispose(); + super.dispose(); + } + + @override Widget build(BuildContext context) { assert( - useIndicator || indicatorColor == null, + widget.useIndicator || widget.indicatorColor == null, '[NavigationRail.indicatorColor] does not have an effect when [NavigationRail.useIndicator] is false', ); final ThemeData theme = Theme.of(context); final TextDirection textDirection = Directionality.of(context); final bool material3 = theme.useMaterial3; - final EdgeInsets destinationPadding = (padding ?? EdgeInsets.zero).resolve(textDirection); + final EdgeInsets destinationPadding = (widget.padding ?? EdgeInsets.zero).resolve(textDirection); Offset indicatorOffset; bool applyXOffset = false; final Widget themedIcon = IconTheme( - data: disabled - ? iconTheme.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) - : iconTheme, - child: icon, + data: widget.disabled + ? widget.iconTheme.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) + : widget.iconTheme, + child: widget.icon, ); final Widget styledLabel = DefaultTextStyle( - style: disabled - ? labelTextStyle.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) - : labelTextStyle, - child: label, + style: widget.disabled + ? widget.labelTextStyle.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) + : widget.labelTextStyle, + child: widget.label, ); Widget content; @@ -605,30 +640,30 @@ class _RailDestination extends StatelessWidget { // The indicator height is fixed and equal to _kIndicatorHeight. // When the icon height is larger than the indicator height the indicator // vertical offset is used to vertically center the indicator. - final bool isLargeIconSize = iconTheme.size != null && iconTheme.size! > _kIndicatorHeight; - final double indicatorVerticalOffset = isLargeIconSize ? (iconTheme.size! - _kIndicatorHeight) / 2 : 0; + final bool isLargeIconSize = widget.iconTheme.size != null && widget.iconTheme.size! > _kIndicatorHeight; + final double indicatorVerticalOffset = isLargeIconSize ? (widget.iconTheme.size! - _kIndicatorHeight) / 2 : 0; - switch (labelType) { + switch (widget.labelType) { case NavigationRailLabelType.none: // Split the destination spacing across the top and bottom to keep the icon centered. final Widget? spacing = material3 ? const SizedBox(height: _verticalDestinationSpacingM3 / 2) : null; indicatorOffset = Offset( - minWidth / 2 + destinationPadding.left, + widget.minWidth / 2 + destinationPadding.left, _verticalDestinationSpacingM3 / 2 + destinationPadding.top + indicatorVerticalOffset, ); final Widget iconPart = Column( children: [ if (spacing != null) spacing, SizedBox( - width: minWidth, - height: material3 ? null : minWidth, + width: widget.minWidth, + height: material3 ? null : widget.minWidth, child: Center( child: _AddIndicator( - addIndicator: useIndicator, - indicatorColor: indicatorColor, - indicatorShape: indicatorShape, + addIndicator: widget.useIndicator, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, isCircular: !material3, - indicatorAnimation: destinationAnimation, + indicatorAnimation: widget.destinationAnimation, child: themedIcon, ), ), @@ -636,9 +671,9 @@ class _RailDestination extends StatelessWidget { if (spacing != null) spacing, ], ); - if (extendedTransitionAnimation.value == 0) { + if (widget.extendedTransitionAnimation.value == 0) { content = Padding( - padding: padding ?? EdgeInsets.zero, + padding: widget.padding ?? EdgeInsets.zero, child: Stack( children: [ iconPart, @@ -646,20 +681,20 @@ class _RailDestination extends StatelessWidget { SizedBox.shrink( child: Visibility.maintain( visible: false, - child: label, + child: widget.label, ), ), ], ), ); } else { - final Animation labelFadeAnimation = extendedTransitionAnimation.drive(CurveTween(curve: const Interval(0.0, 0.25))); + final Animation labelFadeAnimation = widget.extendedTransitionAnimation.drive(CurveTween(curve: const Interval(0.0, 0.25))); applyXOffset = true; content = Padding( - padding: padding ?? EdgeInsets.zero, + padding: widget.padding ?? EdgeInsets.zero, child: ConstrainedBox( constraints: BoxConstraints( - minWidth: lerpDouble(minWidth, minExtendedWidth, extendedTransitionAnimation.value)!, + minWidth: lerpDouble(widget.minWidth, widget.minExtendedWidth, widget.extendedTransitionAnimation.value)!, ), child: ClipRect( child: Row( @@ -667,7 +702,7 @@ class _RailDestination extends StatelessWidget { iconPart, Align( heightFactor: 1.0, - widthFactor: extendedTransitionAnimation.value, + widthFactor: widget.extendedTransitionAnimation.value, alignment: AlignmentDirectional.centerStart, child: FadeTransition( alwaysIncludeSemantics: true, @@ -675,7 +710,7 @@ class _RailDestination extends StatelessWidget { child: styledLabel, ), ), - SizedBox(width: _horizontalDestinationPadding * extendedTransitionAnimation.value), + SizedBox(width: _horizontalDestinationPadding * widget.extendedTransitionAnimation.value), ], ), ), @@ -685,30 +720,30 @@ class _RailDestination extends StatelessWidget { case NavigationRailLabelType.selected: final double appearingAnimationValue = 1 - _positionAnimation.value; 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 labelFadeAnimation = destinationAnimation.drive(CurveTween(curve: interval)); - final double minHeight = material3 ? 0 : minWidth; + final Interval interval = widget.selected ? const Interval(0.25, 0.75) : const Interval(0.75, 1.0); + final Animation labelFadeAnimation = widget.destinationAnimation.drive(CurveTween(curve: interval)); + final double minHeight = material3 ? 0 : widget.minWidth; final Widget topSpacing = SizedBox(height: material3 ? 0 : verticalPadding); final Widget labelSpacing = SizedBox(height: material3 ? lerpDouble(0, _verticalIconLabelSpacingM3, appearingAnimationValue)! : 0); final Widget bottomSpacing = SizedBox(height: material3 ? _verticalDestinationSpacingM3 : verticalPadding); final double indicatorHorizontalPadding = (destinationPadding.left / 2) - (destinationPadding.right / 2); final double indicatorVerticalPadding = destinationPadding.top; indicatorOffset = Offset( - minWidth / 2 + indicatorHorizontalPadding, + widget.minWidth / 2 + indicatorHorizontalPadding, indicatorVerticalPadding + indicatorVerticalOffset, ); - if (minWidth < _NavigationRailDefaultsM2(context).minWidth!) { + if (widget.minWidth < _NavigationRailDefaultsM2(context).minWidth!) { indicatorOffset = Offset( - minWidth / 2 + _horizontalDestinationSpacingM3, + widget.minWidth / 2 + _horizontalDestinationSpacingM3, indicatorVerticalPadding + indicatorVerticalOffset, ); } content = Container( constraints: BoxConstraints( - minWidth: minWidth, + minWidth: widget.minWidth, minHeight: minHeight, ), - padding: padding ?? const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), + padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), child: ClipRect( child: Column( mainAxisSize: MainAxisSize.min, @@ -716,11 +751,11 @@ class _RailDestination extends StatelessWidget { children: [ topSpacing, _AddIndicator( - addIndicator: useIndicator, - indicatorColor: indicatorColor, - indicatorShape: indicatorShape, + addIndicator: widget.useIndicator, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, isCircular: false, - indicatorAnimation: destinationAnimation, + indicatorAnimation: widget.destinationAnimation, child: themedIcon, ), labelSpacing, @@ -740,37 +775,37 @@ class _RailDestination extends StatelessWidget { ), ); case NavigationRailLabelType.all: - final double minHeight = material3 ? 0 : minWidth; + final double minHeight = material3 ? 0 : widget.minWidth; final Widget topSpacing = SizedBox(height: material3 ? 0 : _verticalDestinationPaddingWithLabel); final Widget labelSpacing = SizedBox(height: material3 ? _verticalIconLabelSpacingM3 : 0); final Widget bottomSpacing = SizedBox(height: material3 ? _verticalDestinationSpacingM3 : _verticalDestinationPaddingWithLabel); final double indicatorHorizontalPadding = (destinationPadding.left / 2) - (destinationPadding.right / 2); final double indicatorVerticalPadding = destinationPadding.top; indicatorOffset = Offset( - minWidth / 2 + indicatorHorizontalPadding, + widget.minWidth / 2 + indicatorHorizontalPadding, indicatorVerticalPadding + indicatorVerticalOffset, ); - if (minWidth < _NavigationRailDefaultsM2(context).minWidth!) { + if (widget.minWidth < _NavigationRailDefaultsM2(context).minWidth!) { indicatorOffset = Offset( - minWidth / 2 + _horizontalDestinationSpacingM3, + widget.minWidth / 2 + _horizontalDestinationSpacingM3, indicatorVerticalPadding + indicatorVerticalOffset, ); } content = Container( constraints: BoxConstraints( - minWidth: minWidth, + minWidth: widget.minWidth, minHeight: minHeight, ), - padding: padding ?? const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), + padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: _horizontalDestinationPadding), child: Column( children: [ topSpacing, _AddIndicator( - addIndicator: useIndicator, - indicatorColor: indicatorColor, - indicatorShape: indicatorShape, + addIndicator: widget.useIndicator, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, isCircular: false, - indicatorAnimation: destinationAnimation, + indicatorAnimation: widget.destinationAnimation, child: themedIcon, ), labelSpacing, @@ -791,15 +826,15 @@ class _RailDestination extends StatelessWidget { : colors.primary.withOpacity(0.04); return Semantics( container: true, - selected: selected, + selected: widget.selected, child: Stack( children: [ Material( type: MaterialType.transparency, child: _IndicatorInkWell( - onTap: disabled ? null : onTap, - borderRadius: BorderRadius.all(Radius.circular(minWidth / 2.0)), - customBorder: indicatorShape, + onTap: widget.disabled ? null : widget.onTap, + borderRadius: BorderRadius.all(Radius.circular(widget.minWidth / 2.0)), + customBorder: widget.indicatorShape, splashColor: effectiveSplashColor, hoverColor: effectiveHoverColor, useMaterial3: material3, @@ -810,7 +845,7 @@ class _RailDestination extends StatelessWidget { ), ), Semantics( - label: indexLabel, + label: widget.indexLabel, ), ], ), diff --git a/packages/flutter/test/material/navigation_rail_theme_test.dart b/packages/flutter/test/material/navigation_rail_theme_test.dart index 7a56586ca264..56265abbe85b 100644 --- a/packages/flutter/test/material/navigation_rail_theme_test.dart +++ b/packages/flutter/test/material/navigation_rail_theme_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; void main() { test('copyWith, ==, hashCode basics', () { @@ -145,7 +146,10 @@ void main() { expect(_indicatorDecoration(tester)?.shape, indicatorShape); }); - testWidgets('NavigationRail values take priority over NavigationRailThemeData values when both properties are specified', (WidgetTester tester) async { + testWidgets('NavigationRail values take priority over NavigationRailThemeData values when both properties are specified', + // TODO(polina-c): remove when fixed https://github.com/flutter/flutter/issues/145600 [leak-tracking-opt-in] + experimentalLeakTesting: LeakTesting.settings.withTracked(classes: const ['CurvedAnimation']), + (WidgetTester tester) async { const Color backgroundColor = Color(0x00000001); const double elevation = 7.0; const double selectedIconSize = 25.0;