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>();