Material Bottom Sheet Reveal/Dismiss animation uses a curved animation (#51122)
diff --git a/packages/flutter/lib/src/animation/curves.dart b/packages/flutter/lib/src/animation/curves.dart
index 4cf2813..b258d20 100644
--- a/packages/flutter/lib/src/animation/curves.dart
+++ b/packages/flutter/lib/src/animation/curves.dart
@@ -1657,6 +1657,10 @@
/// animation to finish, and the negative effects of motion are minimized.
///
/// {@animation 464 192 https://flutter.github.io/assets-for-api-docs/assets/animation/curve_fast_out_slow_in.mp4}
+ ///
+ /// See also:
+ ///
+ /// * [standardEasing], the name for this curve in the Material specification.
static const Cubic fastOutSlowIn = Cubic(0.4, 0.0, 0.2, 1.0);
/// A cubic animation curve that starts quickly, slows down, and then ends
diff --git a/packages/flutter/lib/src/material/bottom_sheet.dart b/packages/flutter/lib/src/material/bottom_sheet.dart
index 53eafd3..5159a59 100644
--- a/packages/flutter/lib/src/material/bottom_sheet.dart
+++ b/packages/flutter/lib/src/material/bottom_sheet.dart
@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:async';
+import 'dart:ui' show lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
@@ -10,16 +11,25 @@
import 'bottom_sheet_theme.dart';
import 'colors.dart';
+import 'curves.dart';
import 'debug.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'scaffold.dart';
import 'theme.dart';
-const Duration _bottomSheetDuration = Duration(milliseconds: 200);
+const Duration _bottomSheetEnterDuration = Duration(milliseconds: 250);
+const Duration _bottomSheetExitDuration = Duration(milliseconds: 200);
+const Curve _modalBottomSheetCurve = decelerateEasing;
const double _minFlingVelocity = 700.0;
const double _closeProgressThreshold = 0.5;
+typedef BottomSheetDragStartHandler = void Function(DragStartDetails details);
+typedef BottomSheetDragEndHandler = void Function(
+ DragEndDetails details, {
+ bool isClosing,
+});
+
/// A material design bottom sheet.
///
/// There are two kinds of bottom sheets in material design:
@@ -57,6 +67,8 @@
Key key,
this.animationController,
this.enableDrag = true,
+ this.onDragStart,
+ this.onDragEnd,
this.backgroundColor,
this.elevation,
this.shape,
@@ -95,6 +107,21 @@
/// Default is true.
final bool enableDrag;
+ /// Called when the user begins dragging the bottom sheet vertically, if
+ /// [enableDrag] is true.
+ ///
+ /// Would typically be used to change the bottom sheet animation curve so
+ /// that it tracks the user's finger accurately.
+ final BottomSheetDragStartHandler onDragStart;
+
+ /// Called when the user stops dragging the bottom sheet, if [enableDrag]
+ /// is true.
+ ///
+ /// Would typically be used to reset the bottom sheet animation curve, so
+ /// that it animates non-linearly. Called before [onClosing] if the bottom
+ /// sheet is closing.
+ final BottomSheetDragEndHandler onDragEnd;
+
/// The bottom sheet's background color.
///
/// Defines the bottom sheet's [Material.color].
@@ -140,7 +167,8 @@
/// animation controller could be provided.
static AnimationController createAnimationController(TickerProvider vsync) {
return AnimationController(
- duration: _bottomSheetDuration,
+ duration: _bottomSheetEnterDuration,
+ reverseDuration: _bottomSheetExitDuration,
debugLabel: 'BottomSheet',
vsync: vsync,
);
@@ -158,6 +186,12 @@
bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse;
+ void _handleDragStart(DragStartDetails details) {
+ if (widget.onDragStart != null) {
+ widget.onDragStart(details);
+ }
+ }
+
void _handleDragUpdate(DragUpdateDetails details) {
assert(widget.enableDrag);
if (_dismissUnderway)
@@ -169,21 +203,33 @@
assert(widget.enableDrag);
if (_dismissUnderway)
return;
+ bool isClosing = false;
if (details.velocity.pixelsPerSecond.dy > _minFlingVelocity) {
final double flingVelocity = -details.velocity.pixelsPerSecond.dy / _childHeight;
if (widget.animationController.value > 0.0) {
widget.animationController.fling(velocity: flingVelocity);
}
if (flingVelocity < 0.0) {
- widget.onClosing();
+ isClosing = true;
}
} else if (widget.animationController.value < _closeProgressThreshold) {
if (widget.animationController.value > 0.0)
widget.animationController.fling(velocity: -1.0);
- widget.onClosing();
+ isClosing = true;
} else {
widget.animationController.forward();
- }
+ }
+
+ if (widget.onDragEnd != null) {
+ widget.onDragEnd(
+ details,
+ isClosing: isClosing,
+ );
+ }
+
+ if (isClosing) {
+ widget.onClosing();
+ }
}
bool extentChanged(DraggableScrollableNotification notification) {
@@ -213,6 +259,7 @@
),
);
return !widget.enableDrag ? bottomSheet : GestureDetector(
+ onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
child: bottomSheet,
@@ -283,6 +330,8 @@
}
class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
+ ParametricCurve<double> animationCurve = _modalBottomSheetCurve;
+
String _getRouteLabel(MaterialLocalizations localizations) {
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
@@ -295,6 +344,19 @@
return null;
}
+ void handleDragStart(DragStartDetails details) {
+ // Allow the bottom sheet to track the user's finger accurately.
+ animationCurve = Curves.linear;
+ }
+
+ void handleDragEnd(DragEndDetails details, {bool isClosing}) {
+ // Allow the bottom sheet to animate smoothly from its current position.
+ animationCurve = _BottomSheetSuspendedCurve(
+ widget.route.animation.value,
+ curve: _modalBottomSheetCurve,
+ );
+ }
+
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
@@ -308,7 +370,9 @@
builder: (BuildContext context, Widget child) {
// Disable the initial animation when accessible navigation is on so
// that the semantics are added to the tree at the correct time.
- final double animationValue = mediaQuery.accessibleNavigation ? 1.0 : widget.route.animation.value;
+ final double animationValue = animationCurve.transform(
+ mediaQuery.accessibleNavigation ? 1.0 : widget.route.animation.value
+ );
return Semantics(
scopesRoute: true,
namesRoute: true,
@@ -330,6 +394,8 @@
shape: widget.shape,
clipBehavior: widget.clipBehavior,
enableDrag: widget.enableDrag,
+ onDragStart: handleDragStart,
+ onDragEnd: handleDragEnd,
),
),
),
@@ -370,7 +436,10 @@
final bool enableDrag;
@override
- Duration get transitionDuration => _bottomSheetDuration;
+ Duration get transitionDuration => _bottomSheetEnterDuration;
+
+ @override
+ Duration get reverseTransitionDuration => _bottomSheetExitDuration;
@override
bool get barrierDismissible => isDismissible;
@@ -383,7 +452,6 @@
AnimationController _animationController;
-
@override
AnimationController createAnimationController() {
assert(_animationController == null);
@@ -415,6 +483,63 @@
}
}
+// TODO(guidezpl): Look into making this public. A copy of this class is in scaffold.dart, for now.
+/// A curve that progresses linearly until a specified [startingPoint], at which
+/// point [curve] will begin. Unlike [Interval], [curve] will not start at zero,
+/// but will use [startingPoint] as the Y position.
+///
+/// For example, if [startingPoint] is set to `0.5`, and [curve] is set to
+/// [Curves.easeOut], then the bottom-left quarter of the curve will be a
+/// straight line, and the top-right quarter will contain the entire contents of
+/// [Curves.easeOut].
+///
+/// This is useful in situations where a widget must track the user's finger
+/// (which requires a linear animation), and afterwards can be flung using a
+/// curve specified with the [curve] argument, after the finger is released. In
+/// such a case, the value of [startingPoint] would be the progress of the
+/// animation at the time when the finger was released.
+///
+/// The [startingPoint] and [curve] arguments must not be null.
+class _BottomSheetSuspendedCurve extends ParametricCurve<double> {
+ /// Creates a suspended curve.
+ const _BottomSheetSuspendedCurve(
+ this.startingPoint, {
+ this.curve = Curves.easeOutCubic,
+ }) : assert(startingPoint != null),
+ assert(curve != null);
+
+ /// The progress value at which [curve] should begin.
+ ///
+ /// This defaults to [Curves.easeOutCubic].
+ final double startingPoint;
+
+ /// The curve to use when [startingPoint] is reached.
+ final Curve curve;
+
+ @override
+ double transform(double t) {
+ assert(t >= 0.0 && t <= 1.0);
+ assert(startingPoint >= 0.0 && startingPoint <= 1.0);
+
+ if (t < startingPoint) {
+ return t;
+ }
+
+ if (t == 1.0) {
+ return t;
+ }
+
+ final double curveProgress = (t - startingPoint) / (1 - startingPoint);
+ final double transformed = curve.transform(curveProgress);
+ return lerpDouble(startingPoint, 1, transformed);
+ }
+
+ @override
+ String toString() {
+ return '${describeIdentity(this)}($startingPoint, $curve)';
+ }
+}
+
/// Shows a modal material design bottom sheet.
///
/// A modal bottom sheet is an alternative to a menu or a dialog and prevents
@@ -446,7 +571,7 @@
/// dismissed when user taps on the scrim.
///
/// The [enableDrag] parameter specifies whether the bottom sheet can be
-/// dragged up and down and dismissed by swiping downards.
+/// dragged up and down and dismissed by swiping downwards.
///
/// The optional [backgroundColor], [elevation], [shape], and [clipBehavior]
/// parameters can be passed in to customize the appearance and behavior of
diff --git a/packages/flutter/lib/src/material/curves.dart b/packages/flutter/lib/src/material/curves.dart
new file mode 100644
index 0000000..eb57621
--- /dev/null
+++ b/packages/flutter/lib/src/material/curves.dart
@@ -0,0 +1,36 @@
+// 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/animation.dart';
+
+// The easing curves of the Material Library
+
+/// The standard easing curve in the Material specification.
+///
+/// Elements that begin and end at rest use standard easing.
+/// They speed up quickly and slow down gradually, in order
+/// to emphasize the end of the transition.
+///
+/// See also:
+/// * <https://material.io/design/motion/speed.html#easing>
+const Curve standardEasing = Curves.fastOutSlowIn;
+
+/// The accelerate easing curve in the Material specification.
+///
+/// Elements exiting a screen use acceleration easing,
+/// where they start at rest and end at peak velocity.
+///
+/// See also:
+/// * <https://material.io/design/motion/speed.html#easing>
+const Curve accelerateEasing = Cubic(0.4, 0.0, 1.0, 1.0);
+
+/// The decelerate easing curve in the Material specification.
+///
+/// Incoming elements are animated using deceleration easing,
+/// which starts a transition at peak velocity (the fastest
+/// point of an element’s movement) and ends at rest.
+///
+/// See also:
+/// * <https://material.io/design/motion/speed.html#easing>
+const Curve decelerateEasing = Cubic(0.0, 0.0, 0.2, 1.0);
diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart
index 3d594cb..732b07f 100644
--- a/packages/flutter/lib/src/material/scaffold.dart
+++ b/packages/flutter/lib/src/material/scaffold.dart
@@ -9,6 +9,7 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
+import 'dart:ui' show lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
@@ -19,6 +20,7 @@
import 'bottom_sheet.dart';
import 'button_bar.dart';
import 'colors.dart';
+import 'curves.dart';
import 'divider.dart';
import 'drawer.dart';
import 'flexible_space_bar.dart';
@@ -40,6 +42,7 @@
const FloatingActionButtonLocation _kDefaultFloatingActionButtonLocation = FloatingActionButtonLocation.endFloat;
const FloatingActionButtonAnimator _kDefaultFloatingActionButtonAnimator = FloatingActionButtonAnimator.scaling;
+const Curve _standardBottomSheetCurve = standardEasing;
// When the top of the BottomSheet crosses this threshold, it will start to
// shrink the FAB and show a scrim.
const double _kBottomSheetDominatesPercentage = 0.3;
@@ -2562,6 +2565,63 @@
final StateSetter setState;
}
+// TODO(guidezpl): Look into making this public. A copy of this class is in bottom_sheet.dart, for now.
+/// A curve that progresses linearly until a specified [startingPoint], at which
+/// point [curve] will begin. Unlike [Interval], [curve] will not start at zero,
+/// but will use [startingPoint] as the Y position.
+///
+/// For example, if [startingPoint] is set to `0.5`, and [curve] is set to
+/// [Curves.easeOut], then the bottom-left quarter of the curve will be a
+/// straight line, and the top-right quarter will contain the entire contents of
+/// [Curves.easeOut].
+///
+/// This is useful in situations where a widget must track the user's finger
+/// (which requires a linear animation), and afterwards can be flung using a
+/// curve specified with the [curve] argument, after the finger is released. In
+/// such a case, the value of [startingPoint] would be the progress of the
+/// animation at the time when the finger was released.
+///
+/// The [startingPoint] and [curve] arguments must not be null.
+class _BottomSheetSuspendedCurve extends ParametricCurve<double> {
+ /// Creates a suspended curve.
+ const _BottomSheetSuspendedCurve(
+ this.startingPoint, {
+ this.curve = Curves.easeOutCubic,
+ }) : assert(startingPoint != null),
+ assert(curve != null);
+
+ /// The progress value at which [curve] should begin.
+ ///
+ /// This defaults to [Curves.easeOutCubic].
+ final double startingPoint;
+
+ /// The curve to use when [startingPoint] is reached.
+ final Curve curve;
+
+ @override
+ double transform(double t) {
+ assert(t >= 0.0 && t <= 1.0);
+ assert(startingPoint >= 0.0 && startingPoint <= 1.0);
+
+ if (t < startingPoint) {
+ return t;
+ }
+
+ if (t == 1.0) {
+ return t;
+ }
+
+ final double curveProgress = (t - startingPoint) / (1 - startingPoint);
+ final double transformed = curve.transform(curveProgress);
+ return lerpDouble(startingPoint, 1, transformed);
+ }
+
+ @override
+ String toString() {
+ return '${describeIdentity(this)}($startingPoint, $curve)';
+ }
+}
+
class _StandardBottomSheet extends StatefulWidget {
const _StandardBottomSheet({
Key key,
@@ -2593,6 +2653,8 @@
}
class _StandardBottomSheetState extends State<_StandardBottomSheet> {
+ ParametricCurve<double> animationCurve = _standardBottomSheetCurve;
+
@override
void initState() {
super.initState();
@@ -2617,6 +2679,19 @@
return null;
}
+ void _handleDragStart(DragStartDetails details) {
+ // Allow the bottom sheet to track the user's finger accurately.
+ animationCurve = Curves.linear;
+ }
+
+ void _handleDragEnd(DragEndDetails details, { bool isClosing }) {
+ // Allow the bottom sheet to animate smoothly from its current position.
+ animationCurve = _BottomSheetSuspendedCurve(
+ widget.animationController.value,
+ curve: _standardBottomSheetCurve,
+ );
+ }
+
void _handleStatusChange(AnimationStatus status) {
if (status == AnimationStatus.dismissed && widget.onDismissed != null) {
widget.onDismissed();
@@ -2662,7 +2737,7 @@
builder: (BuildContext context, Widget child) {
return Align(
alignment: AlignmentDirectional.topStart,
- heightFactor: widget.animationController.value,
+ heightFactor: animationCurve.transform(widget.animationController.value),
child: child,
);
},
@@ -2670,6 +2745,8 @@
BottomSheet(
animationController: widget.animationController,
enableDrag: widget.enableDrag,
+ onDragStart: _handleDragStart,
+ onDragEnd: _handleDragEnd,
onClosing: widget.onClosing,
builder: widget.builder,
backgroundColor: widget.backgroundColor,
diff --git a/packages/flutter/test/material/bottom_sheet_test.dart b/packages/flutter/test/material/bottom_sheet_test.dart
index 43f2e73..8b33bf5 100644
--- a/packages/flutter/test/material/bottom_sheet_test.dart
+++ b/packages/flutter/test/material/bottom_sheet_test.dart
@@ -10,6 +10,21 @@
import '../widgets/semantics_tester.dart';
void main() {
+ // Pumps and ensures that the BottomSheet animates non-linearly.
+ Future<void> _checkNonLinearAnimation(WidgetTester tester) async {
+ final Offset firstPosition = tester.getCenter(find.text('BottomSheet'));
+ await tester.pump(const Duration(milliseconds: 30));
+ final Offset secondPosition = tester.getCenter(find.text('BottomSheet'));
+ await tester.pump(const Duration(milliseconds: 30));
+ final Offset thirdPosition = tester.getCenter(find.text('BottomSheet'));
+
+ final double dyDelta1 = secondPosition.dy - firstPosition.dy;
+ final double dyDelta2 = thirdPosition.dy - secondPosition.dy;
+
+ // If the animation were linear, these two values would be the same.
+ expect(dyDelta1, isNot(closeTo(dyDelta2, 0.1)));
+ }
+
testWidgets('Tapping on a modal BottomSheet should not dismiss it', (WidgetTester tester) async {
BuildContext savedContext;
@@ -115,6 +130,38 @@
expect(find.text('BottomSheet'), findsNothing);
});
+ testWidgets('Verify that the BottomSheet animates non-linearly', (WidgetTester tester) async {
+ BuildContext savedContext;
+
+ await tester.pumpWidget(MaterialApp(
+ home: Builder(
+ builder: (BuildContext context) {
+ savedContext = context;
+ return Container();
+ },
+ ),
+ ));
+
+ await tester.pump();
+ expect(find.text('BottomSheet'), findsNothing);
+
+ showModalBottomSheet<void>(
+ context: savedContext,
+ builder: (BuildContext context) => const Text('BottomSheet'),
+ );
+ await tester.pump();
+
+ await _checkNonLinearAnimation(tester);
+ await tester.pumpAndSettle();
+
+ // Tap above the bottom sheet to dismiss it.
+ await tester.tapAt(const Offset(20.0, 20.0));
+ await tester.pump();
+ await _checkNonLinearAnimation(tester);
+ await tester.pumpAndSettle(); // Bottom sheet dismiss animation.
+ expect(find.text('BottomSheet'), findsNothing);
+ });
+
testWidgets('Tapping outside a modal BottomSheet should not dismiss it when isDismissible=false', (WidgetTester tester) async {
BuildContext savedContext;
diff --git a/packages/flutter/test/material/persistent_bottom_sheet_test.dart b/packages/flutter/test/material/persistent_bottom_sheet_test.dart
index 39bee15..b567047 100644
--- a/packages/flutter/test/material/persistent_bottom_sheet_test.dart
+++ b/packages/flutter/test/material/persistent_bottom_sheet_test.dart
@@ -6,6 +6,21 @@
import 'package:flutter/material.dart';
void main() {
+ // Pumps and ensures that the BottomSheet animates non-linearly.
+ Future<void> _checkNonLinearAnimation(WidgetTester tester) async {
+ final Offset firstPosition = tester.getCenter(find.text('One'));
+ await tester.pump(const Duration(milliseconds: 30));
+ final Offset secondPosition = tester.getCenter(find.text('One'));
+ await tester.pump(const Duration(milliseconds: 30));
+ final Offset thirdPosition = tester.getCenter(find.text('One'));
+
+ final double dyDelta1 = secondPosition.dy - firstPosition.dy;
+ final double dyDelta2 = thirdPosition.dy - secondPosition.dy;
+
+ // If the animation were linear, these two values would be the same.
+ expect(dyDelta1, isNot(closeTo(dyDelta2, 0.1)));
+ }
+
testWidgets('Verify that a BottomSheet can be rebuilt with ScaffoldFeatureController.setState()', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
PersistentBottomSheetController<void> bottomSheet;
@@ -97,6 +112,41 @@
expect(find.text('Two'), findsNothing);
});
+ testWidgets('Verify that a BottomSheet animates non-linearly', (WidgetTester tester) async {
+ final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
+
+ await tester.pumpWidget(MaterialApp(
+ home: Scaffold(
+ key: scaffoldKey,
+ body: const Center(child: Text('body')),
+ ),
+ ));
+
+ scaffoldKey.currentState.showBottomSheet<void>((BuildContext context) {
+ return ListView(
+ shrinkWrap: true,
+ primary: false,
+ children: <Widget>[
+ Container(height: 100.0, child: const Text('One')),
+ Container(height: 100.0, child: const Text('Two')),
+ Container(height: 100.0, child: const Text('Three')),
+ ],
+ );
+ });
+ await tester.pump();
+ await _checkNonLinearAnimation(tester);
+
+ await tester.pumpAndSettle();
+
+ expect(find.text('Two'), findsOneWidget);
+
+ await tester.drag(find.text('Two'), const Offset(0.0, 200.0));
+ await _checkNonLinearAnimation(tester);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Two'), findsNothing);
+ });
+
testWidgets('Verify that a scrollControlled BottomSheet can be dismissed', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();