Material Bottom Sheet Reveal/Dismiss animation uses a curved animation (#51122)

This commit is contained in:
Pierre-Louis 2020-03-02 18:49:03 +01:00 committed by GitHub
parent f3018c378a
commit ec64f93fdd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 349 additions and 10 deletions

View file

@ -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

View file

@ -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<BottomSheet> {
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<BottomSheet> {
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<BottomSheet> {
),
);
return !widget.enableDrag ? bottomSheet : GestureDetector(
onVerticalDragStart: _handleDragStart,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
child: bottomSheet,
@ -283,6 +330,8 @@ class _ModalBottomSheet<T> extends StatefulWidget {
}
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 @@ class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
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<T> extends State<_ModalBottomSheet<T>> {
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<T> extends State<_ModalBottomSheet<T>> {
shape: widget.shape,
clipBehavior: widget.clipBehavior,
enableDrag: widget.enableDrag,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
),
),
),
@ -370,7 +436,10 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
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<T> extends PopupRoute<T> {
AnimationController _animationController;
@override
AnimationController createAnimationController() {
assert(_animationController == null);
@ -415,6 +483,63 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
}
}
// 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 @@ class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
/// 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

View file

@ -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 elements 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);

View file

@ -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<T extends Widget, U> {
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 _StandardBottomSheet extends StatefulWidget {
}
class _StandardBottomSheetState extends State<_StandardBottomSheet> {
ParametricCurve<double> 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,

View file

@ -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<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 @@ 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<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;

View file

@ -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<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 @@ void main() {
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>();