From 257df5ebfc591b6ef610f621590647eda779e107 Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Mon, 29 Apr 2024 11:26:19 +0300 Subject: [PATCH] Add ability to disable `FloatingActionButton` scale and rotation animations using `FloatingActionButtonAnimator.noAnimation` (#146126) fixes [[Proposal] Allow disabling the scaling animation of the FloatingActionButton](https://github.com/flutter/flutter/issues/145585) ### Using default `FloatingActionButton` animations ![ScreenRecording2024-04-02at16 19 03-ezgif com-video-to-gif-converter](https://github.com/flutter/flutter/assets/48603081/627ea564-7f60-4eb4-bed9-95c053ae2f56) ### Using `FloatingActionButtonAnimator.noAnimation` ![ScreenRecording2024-04-02at16 17 20-ezgif com-video-to-gif-converter](https://github.com/flutter/flutter/assets/48603081/d0a936ea-9e16-4225-8dc4-40a11ee8a975) --- ...old.floating_action_button_animator.0.dart | 120 +++++++++++++++ ...loating_action_button_animator.0_test.dart | 60 ++++++++ .../floating_action_button_location.dart | 31 ++++ .../flutter/lib/src/material/scaffold.dart | 18 ++- .../flutter/test/material/scaffold_test.dart | 139 ++++++++++++++++++ 5 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 examples/api/lib/material/scaffold/scaffold.floating_action_button_animator.0.dart create mode 100644 examples/api/test/material/scaffold/scaffold.floating_action_button_animator.0_test.dart diff --git a/examples/api/lib/material/scaffold/scaffold.floating_action_button_animator.0.dart b/examples/api/lib/material/scaffold/scaffold.floating_action_button_animator.0.dart new file mode 100644 index 0000000000..4ef8ae3df6 --- /dev/null +++ b/examples/api/lib/material/scaffold/scaffold.floating_action_button_animator.0.dart @@ -0,0 +1,120 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [Scaffold.floatingActionButtonAnimator]. + +void main() => runApp(const ScaffoldFloatingActionButtonAnimatorApp()); + +class ScaffoldFloatingActionButtonAnimatorApp extends StatelessWidget { + const ScaffoldFloatingActionButtonAnimatorApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: ScaffoldFloatingActionButtonAnimatorExample(), + ); + } +} + +enum FabAnimator { defaultStyle, none } +const List<(FabAnimator, String)> fabAnimatoregments = <(FabAnimator, String)>[ + (FabAnimator.defaultStyle, 'Default'), + (FabAnimator.none, 'None'), +]; + +enum FabLocation { centerFloat, endFloat, endTop } +const List<(FabLocation, String)> fabLocationegments = <(FabLocation, String)>[ + (FabLocation.centerFloat, 'centerFloat'), + (FabLocation.endFloat, 'endFloat'), + (FabLocation.endTop, 'endTop'), +]; + +class ScaffoldFloatingActionButtonAnimatorExample extends StatefulWidget { + const ScaffoldFloatingActionButtonAnimatorExample({super.key}); + + @override + State createState() => _ScaffoldFloatingActionButtonAnimatorExampleState(); +} + +class _ScaffoldFloatingActionButtonAnimatorExampleState extends State { + Set _selectedFabAnimator = {FabAnimator.defaultStyle}; + Set _selectedFabLocation = {FabLocation.endFloat}; + FloatingActionButtonAnimator? _floatingActionButtonAnimator; + FloatingActionButtonLocation? _floatingActionButtonLocation; + bool _showFab = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButtonLocation: _floatingActionButtonLocation, + floatingActionButtonAnimator: _floatingActionButtonAnimator, + appBar: AppBar(title: const Text('FloatingActionButtonAnimator Sample')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SegmentedButton( + selected: _selectedFabAnimator, + onSelectionChanged: (Set styles) { + setState(() { + _floatingActionButtonAnimator = switch (styles.first) { + FabAnimator.defaultStyle => null, + FabAnimator.none => FloatingActionButtonAnimator.noAnimation, + }; + _selectedFabAnimator = styles; + }); + }, + segments: fabAnimatoregments + .map>(((FabAnimator, String) fabAnimator) { + final FabAnimator animator = fabAnimator.$1; + final String label = fabAnimator.$2; + return ButtonSegment(value: animator, label: Text(label)); + }) + .toList(), + ), + const SizedBox(height: 10), + SegmentedButton( + selected: _selectedFabLocation, + onSelectionChanged: (Set styles) { + setState(() { + _floatingActionButtonLocation = switch (styles.first) { + FabLocation.centerFloat => FloatingActionButtonLocation.centerFloat, + FabLocation.endFloat => FloatingActionButtonLocation.endFloat, + FabLocation.endTop => FloatingActionButtonLocation.endTop, + }; + _selectedFabLocation = styles; + }); + }, + segments: fabLocationegments + .map>(((FabLocation, String) fabLocation) { + final FabLocation location = fabLocation.$1; + final String label = fabLocation.$2; + return ButtonSegment(value: location, label: Text(label)); + }) + .toList(), + ), + const SizedBox(height: 10), + FilledButton.icon( + onPressed: () { + setState(() { + _showFab = !_showFab; + }); + }, + icon: Icon(_showFab ? Icons.visibility_off : Icons.visibility), + label: const Text('Toggle FAB'), + ), + ], + ), + ), + floatingActionButton: !_showFab + ? null + : FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/examples/api/test/material/scaffold/scaffold.floating_action_button_animator.0_test.dart b/examples/api/test/material/scaffold/scaffold.floating_action_button_animator.0_test.dart new file mode 100644 index 0000000000..ca9be38492 --- /dev/null +++ b/examples/api/test/material/scaffold/scaffold.floating_action_button_animator.0_test.dart @@ -0,0 +1,60 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/scaffold/scaffold.floating_action_button_animator.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('FloatingActionButton animation can be customized', (WidgetTester tester) async { + await tester.pumpWidget( + const example.ScaffoldFloatingActionButtonAnimatorApp(), + ); + + expect(find.byType(FloatingActionButton), findsNothing); + + // Test default FloatingActionButtonAnimator. + // Tap the toggle button to show the FAB. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance animation by 100ms. + // FAB is partially animated in. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, closeTo(743.8, 0.1)); + + await tester.pump(const Duration(milliseconds: 100)); // Advance animation by 100ms. + // FAB is fully animated in. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the toggle button to hide the FAB. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance animation by 100ms. + // FAB is partially animated out. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, closeTo(747.1, 0.1)); + + await tester.pump(const Duration(milliseconds: 100)); // Advance animation by 100ms. + // FAB is fully animated out. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(756.0)); + + await tester.pump(const Duration(milliseconds: 50)); // Advance animation by 50ms. + // FAB is hidden. + expect(find.byType(FloatingActionButton), findsNothing); + + // Select 'None' to disable animation. + await tester.tap(find.text('None')); + await tester.pump(); + + // Test no animation FloatingActionButtonAnimator. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + // FAB is immediately shown. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the toggle button to hide the FAB. + await tester.tap(find.text('Toggle FAB')); + await tester.pump(); + // FAB is immediately hidden. + expect(find.byType(FloatingActionButton), findsNothing); + }); +} diff --git a/packages/flutter/lib/src/material/floating_action_button_location.dart b/packages/flutter/lib/src/material/floating_action_button_location.dart index fba009fb6f..69ee0a12ba 100644 --- a/packages/flutter/lib/src/material/floating_action_button_location.dart +++ b/packages/flutter/lib/src/material/floating_action_button_location.dart @@ -937,6 +937,18 @@ abstract class FloatingActionButtonAnimator { /// the animation from the beginning, regardless of the original state of the animation. double getAnimationRestart(double previousValue) => 0.0; + /// Creates an instance of [FloatingActionButtonAnimator] where the [FloatingActionButton] + /// does not animate on entrance and exit when [FloatingActionButtonLocation] is shown + /// or hidden and when transitioning between [FloatingActionButtonLocation]s. + /// + /// {@tool dartpad} + /// This sample showcases how to override [FloatingActionButton] entrance and exit animations + /// using [FloatingActionButtonAnimator.noAnimation] in [Scaffold.floatingActionButtonAnimator]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold.floating_action_button_animator.0.dart ** + /// {@end-tool} + static const FloatingActionButtonAnimator noAnimation = _NoAnimationFabMotionAnimator(); + @override String toString() => objectRuntimeType(this, 'FloatingActionButtonAnimator'); } @@ -993,6 +1005,25 @@ class _ScalingFabMotionAnimator extends FloatingActionButtonAnimator { double getAnimationRestart(double previousValue) => math.min(1.0 - previousValue, previousValue); } +class _NoAnimationFabMotionAnimator extends FloatingActionButtonAnimator { + const _NoAnimationFabMotionAnimator(); + + @override + Offset getOffset({required Offset begin, required Offset end, required double progress}) { + return end; + } + + @override + Animation getRotationAnimation({required Animation parent}) { + return const AlwaysStoppedAnimation(1.0); + } + + @override + Animation getScaleAnimation({required Animation parent}) { + return const AlwaysStoppedAnimation(1.0); + } +} + /// An animation that swaps from one animation to the next when the [parent] passes [swapThreshold]. /// /// The [value] of this animation is the value of [first] when [parent.value] < [swapThreshold] diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index b69fd5c469..23a512c74f 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -1436,13 +1436,19 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr final Animation moveRotationAnimation = widget.fabMotionAnimator.getRotationAnimation(parent: widget.fabMoveAnimation); // Aggregate the animations. - _previousScaleAnimation = AnimationMin(moveScaleAnimation, _previousExitScaleAnimation!); - _currentScaleAnimation = AnimationMin(moveScaleAnimation, _currentEntranceScaleAnimation!); + if (widget.fabMotionAnimator == FloatingActionButtonAnimator.noAnimation) { + _previousScaleAnimation = moveScaleAnimation; + _currentScaleAnimation = moveScaleAnimation; + _previousRotationAnimation = TrainHoppingAnimation(moveRotationAnimation, null); + _currentRotationAnimation = TrainHoppingAnimation(moveRotationAnimation, null); + } else { + _previousScaleAnimation = AnimationMin(moveScaleAnimation, _previousExitScaleAnimation!); + _currentScaleAnimation = AnimationMin(moveScaleAnimation, _currentEntranceScaleAnimation!); + _previousRotationAnimation = TrainHoppingAnimation(previousExitRotationAnimation, moveRotationAnimation); + _currentRotationAnimation = TrainHoppingAnimation(currentEntranceRotationAnimation, moveRotationAnimation); + } + _extendedCurrentScaleAnimation = _currentScaleAnimation.drive(CurveTween(curve: const Interval(0.0, 0.1))); - - _previousRotationAnimation = TrainHoppingAnimation(previousExitRotationAnimation, moveRotationAnimation); - _currentRotationAnimation = TrainHoppingAnimation(currentEntranceRotationAnimation, moveRotationAnimation); - _currentScaleAnimation.addListener(_onProgressChanged); _previousScaleAnimation.addListener(_onProgressChanged); } diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 9ce8f3f577..82fcbc32e7 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -3256,6 +3256,145 @@ void main() { // The bottom sheet is dismissed. expect(find.byKey(sheetKey), findsNothing); }); + + // This is a regression test for https://github.com/flutter/flutter/issues/145585. + testWidgets('FAB default entrance and exit animations', (WidgetTester tester) async { + bool showFab = false; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + body: ElevatedButton( + onPressed: () { + setState(() { + showFab = !showFab; + }); + }, + child: const Text('Toggle FAB'), + ), + floatingActionButton: !showFab + ? null + : FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ); + }, + ), + ), + ); + + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + + // Tap the button to show the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is partially animated in. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, closeTo(743.8, 0.1)); + + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is fully animated in. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the button to hide the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is partially animated out. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, closeTo(747.1, 0.1)); + + await tester.pump(const Duration(milliseconds: 100)); // Advance the animation by 100ms. + // FAB is fully animated out. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(756.0)); + + await tester.pump(const Duration(milliseconds: 50)); // Advance the animation by 50ms. + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/145585. + testWidgets('FAB default entrance and exit animations can be disabled', (WidgetTester tester) async { + bool showFab = false; + FloatingActionButtonLocation fabLocation = FloatingActionButtonLocation.endFloat; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + // Disable FAB animations. + floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation, + floatingActionButtonLocation: fabLocation, + body: Column( + children: [ + ElevatedButton( + onPressed: () { + setState(() { + showFab = !showFab; + }); + }, + child: const Text('Toggle FAB'), + ), + ElevatedButton( + onPressed: () { + setState(() { + fabLocation = FloatingActionButtonLocation.centerFloat; + }); + }, + child: const Text('Update FAB Location'), + ), + ], + ), + floatingActionButton: !showFab + ? null + : FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), + ); + }, + ), + ), + ); + + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + + // Tap the button to show the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is visible. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the button to hide the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + + // Tap the button to show the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is visible. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(728.0)); + + // Tap the update location button. + await tester.tap(find.widgetWithText(ElevatedButton, 'Update FAB Location')); + await tester.pump(); + + // FAB is visible at the new location. + expect(tester.getTopLeft(find.byType(FloatingActionButton)).dx, equals(372.0)); + + // Tap the button to hide the FAB. + await tester.tap(find.widgetWithText(ElevatedButton, 'Toggle FAB')); + await tester.pump(); + // FAB is not visible. + expect(find.byType(FloatingActionButton), findsNothing); + }); } class _GeometryListener extends StatefulWidget {