diff --git a/packages/flutter/lib/src/animation/curves.dart b/packages/flutter/lib/src/animation/curves.dart index 4cf28138ac5..b258d206362 100644 --- a/packages/flutter/lib/src/animation/curves.dart +++ b/packages/flutter/lib/src/animation/curves.dart @@ -1657,6 +1657,10 @@ class Curves { /// 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 53eafd33625..5159a591f8d 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 'package:flutter/widgets.dart'; 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 @@ class BottomSheet extends StatefulWidget { Key key, this.animationController, this.enableDrag = true, + this.onDragStart, + this.onDragEnd, this.backgroundColor, this.elevation, this.shape, @@ -95,6 +107,21 @@ class BottomSheet extends StatefulWidget { /// 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 @@ class BottomSheet extends StatefulWidget { /// 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 @@ class _BottomSheetState extends State { 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 @@ class _BottomSheetState extends State { 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 @@ class _BottomSheetState extends State { ), ); return !widget.enableDrag ? bottomSheet : GestureDetector( + onVerticalDragStart: _handleDragStart, onVerticalDragUpdate: _handleDragUpdate, onVerticalDragEnd: _handleDragEnd, child: bottomSheet, @@ -283,6 +330,8 @@ class _ModalBottomSheet extends StatefulWidget { } class _ModalBottomSheetState extends State<_ModalBottomSheet> { + ParametricCurve animationCurve = _modalBottomSheetCurve; + String _getRouteLabel(MaterialLocalizations localizations) { switch (Theme.of(context).platform) { case TargetPlatform.iOS: @@ -295,6 +344,19 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> { 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 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> { 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 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> { shape: widget.shape, clipBehavior: widget.clipBehavior, enableDrag: widget.enableDrag, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, ), ), ), @@ -370,7 +436,10 @@ class _ModalBottomSheetRoute extends PopupRoute { 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 @@ class _ModalBottomSheetRoute extends PopupRoute { AnimationController _animationController; - @override AnimationController createAnimationController() { assert(_animationController == null); @@ -415,6 +483,63 @@ class _ModalBottomSheetRoute extends PopupRoute { } } +// 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 { + /// 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 @@ class _ModalBottomSheetRoute extends PopupRoute { /// 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 00000000000..eb5762178f1 --- /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: +/// * +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: +/// * +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: +/// * +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 3d594cb5cb9..732b07f8d60 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 'app_bar.dart'; 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 @@ import 'theme_data.dart'; 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 @@ class ScaffoldFeatureController { 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 { + /// 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 _StandardBottomSheet extends StatefulWidget { } class _StandardBottomSheetState extends State<_StandardBottomSheet> { + ParametricCurve animationCurve = _standardBottomSheetCurve; + @override void initState() { super.initState(); @@ -2617,6 +2679,19 @@ class _StandardBottomSheetState extends State<_StandardBottomSheet> { 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 @@ class _StandardBottomSheetState extends State<_StandardBottomSheet> { 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 @@ class _StandardBottomSheetState extends State<_StandardBottomSheet> { 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 43f2e732f8d..8b33bf54e45 100644 --- a/packages/flutter/test/material/bottom_sheet_test.dart +++ b/packages/flutter/test/material/bottom_sheet_test.dart @@ -10,6 +10,21 @@ import 'package:flutter/gestures.dart'; import '../widgets/semantics_tester.dart'; void main() { + // Pumps and ensures that the BottomSheet animates non-linearly. + Future _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 @@ void main() { 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( + 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 39bee154e02..b56704774a0 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_test/flutter_test.dart'; import 'package:flutter/material.dart'; void main() { + // Pumps and ensures that the BottomSheet animates non-linearly. + Future _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 scaffoldKey = GlobalKey(); PersistentBottomSheetController bottomSheet; @@ -97,6 +112,41 @@ void main() { expect(find.text('Two'), findsNothing); }); + testWidgets('Verify that a BottomSheet animates non-linearly', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + key: scaffoldKey, + body: const Center(child: Text('body')), + ), + )); + + scaffoldKey.currentState.showBottomSheet((BuildContext context) { + return ListView( + shrinkWrap: true, + primary: false, + children: [ + 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 scaffoldKey = GlobalKey();