blob: 08f28c4acb8055630215623b809e7f19aa06e58a [file] [log] [blame]
// 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/gestures.dart';
import 'automatic_keep_alive.dart';
import 'basic.dart';
import 'debug.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'ticker_provider.dart';
import 'transitions.dart';
const Curve _kResizeTimeCurve = Interval(0.4, 1.0, curve: Curves.ease);
const double _kMinFlingVelocity = 700.0;
const double _kMinFlingVelocityDelta = 400.0;
const double _kFlingVelocityScale = 1.0 / 300.0;
const double _kDismissThreshold = 0.4;
/// Signature used by [Dismissible] to indicate that it has been dismissed in
/// the given `direction`.
///
/// Used by [Dismissible.onDismissed].
typedef DismissDirectionCallback = void Function(DismissDirection direction);
/// Signature used by [Dismissible] to give the application an opportunity to
/// confirm or veto a dismiss gesture.
///
/// Used by [Dismissible.confirmDismiss].
typedef ConfirmDismissCallback = Future<bool?> Function(DismissDirection direction);
/// Signature used by [Dismissible] to indicate that the dismissible has been dragged.
///
/// Used by [Dismissible.onUpdate].
typedef DismissUpdateCallback = void Function(DismissUpdateDetails details);
/// The direction in which a [Dismissible] can be dismissed.
enum DismissDirection {
/// The [Dismissible] can be dismissed by dragging either up or down.
vertical,
/// The [Dismissible] can be dismissed by dragging either left or right.
horizontal,
/// The [Dismissible] can be dismissed by dragging in the reverse of the
/// reading direction (e.g., from right to left in left-to-right languages).
endToStart,
/// The [Dismissible] can be dismissed by dragging in the reading direction
/// (e.g., from left to right in left-to-right languages).
startToEnd,
/// The [Dismissible] can be dismissed by dragging up only.
up,
/// The [Dismissible] can be dismissed by dragging down only.
down,
/// The [Dismissible] cannot be dismissed by dragging.
none
}
/// A widget that can be dismissed by dragging in the indicated [direction].
///
/// Dragging or flinging this widget in the [DismissDirection] causes the child
/// to slide out of view. Following the slide animation, if [resizeDuration] is
/// non-null, the Dismissible widget animates its height (or width, whichever is
/// perpendicular to the dismiss direction) to zero over the [resizeDuration].
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=iEMgjrfuc58}
///
/// {@tool dartpad}
/// This sample shows how you can use the [Dismissible] widget to
/// remove list items using swipe gestures. Swipe any of the list
/// tiles to the left or right to dismiss them from the [ListView].
///
/// ** See code in examples/api/lib/widgets/dismissible/dismissible.0.dart **
/// {@end-tool}
///
/// Backgrounds can be used to implement the "leave-behind" idiom. If a background
/// is specified it is stacked behind the Dismissible's child and is exposed when
/// the child moves.
///
/// The widget calls the [onDismissed] callback either after its size has
/// collapsed to zero (if [resizeDuration] is non-null) or immediately after
/// the slide animation (if [resizeDuration] is null). If the Dismissible is a
/// list item, it must have a key that distinguishes it from the other items and
/// its [onDismissed] callback must remove the item from the list.
class Dismissible extends StatefulWidget {
/// Creates a widget that can be dismissed.
///
/// The [key] argument must not be null because [Dismissible]s are commonly
/// used in lists and removed from the list when dismissed. Without keys, the
/// default behavior is to sync widgets based on their index in the list,
/// which means the item after the dismissed item would be synced with the
/// state of the dismissed item. Using keys causes the widgets to sync
/// according to their keys and avoids this pitfall.
const Dismissible({
required Key key,
required this.child,
this.background,
this.secondaryBackground,
this.confirmDismiss,
this.onResize,
this.onUpdate,
this.onDismissed,
this.direction = DismissDirection.horizontal,
this.resizeDuration = const Duration(milliseconds: 300),
this.dismissThresholds = const <DismissDirection, double>{},
this.movementDuration = const Duration(milliseconds: 200),
this.crossAxisEndOffset = 0.0,
this.dragStartBehavior = DragStartBehavior.start,
this.behavior = HitTestBehavior.opaque,
}) : assert(key != null),
assert(secondaryBackground == null || background != null),
assert(dragStartBehavior != null),
super(key: key);
/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// A widget that is stacked behind the child. If secondaryBackground is also
/// specified then this widget only appears when the child has been dragged
/// down or to the right.
final Widget? background;
/// A widget that is stacked behind the child and is exposed when the child
/// has been dragged up or to the left. It may only be specified when background
/// has also been specified.
final Widget? secondaryBackground;
/// Gives the app an opportunity to confirm or veto a pending dismissal.
///
/// The widget cannot be dragged again until the returned future resolves.
///
/// If the returned Future<bool> completes true, then this widget will be
/// dismissed, otherwise it will be moved back to its original location.
///
/// If the returned Future<bool?> completes to false or null the [onResize]
/// and [onDismissed] callbacks will not run.
final ConfirmDismissCallback? confirmDismiss;
/// Called when the widget changes size (i.e., when contracting before being dismissed).
final VoidCallback? onResize;
/// Called when the widget has been dismissed, after finishing resizing.
final DismissDirectionCallback? onDismissed;
/// The direction in which the widget can be dismissed.
final DismissDirection direction;
/// The amount of time the widget will spend contracting before [onDismissed] is called.
///
/// If null, the widget will not contract and [onDismissed] will be called
/// immediately after the widget is dismissed.
final Duration? resizeDuration;
/// The offset threshold the item has to be dragged in order to be considered
/// dismissed.
///
/// Represented as a fraction, e.g. if it is 0.4 (the default), then the item
/// has to be dragged at least 40% towards one direction to be considered
/// dismissed. Clients can define different thresholds for each dismiss
/// direction.
///
/// Flinging is treated as being equivalent to dragging almost to 1.0, so
/// flinging can dismiss an item past any threshold less than 1.0.
///
/// Setting a threshold of 1.0 (or greater) prevents a drag in the given
/// [DismissDirection] even if it would be allowed by the [direction]
/// property.
///
/// See also:
///
/// * [direction], which controls the directions in which the items can
/// be dismissed.
final Map<DismissDirection, double> dismissThresholds;
/// Defines the duration for card to dismiss or to come back to original position if not dismissed.
final Duration movementDuration;
/// Defines the end offset across the main axis after the card is dismissed.
///
/// If non-zero value is given then widget moves in cross direction depending on whether
/// it is positive or negative.
final double crossAxisEndOffset;
/// Determines the way that drag start behavior is handled.
///
/// If set to [DragStartBehavior.start], the drag gesture used to dismiss a
/// dismissible will begin at the position where the drag gesture won the arena.
/// If set to [DragStartBehavior.down] it will begin at the position where
/// a down event is first detected.
///
/// In general, setting this to [DragStartBehavior.start] will make drag
/// animation smoother and setting it to [DragStartBehavior.down] will make
/// drag behavior feel slightly more reactive.
///
/// By default, the drag start behavior is [DragStartBehavior.start].
///
/// See also:
///
/// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
final DragStartBehavior dragStartBehavior;
/// How to behave during hit tests.
///
/// This defaults to [HitTestBehavior.opaque].
final HitTestBehavior behavior;
/// Called when the dismissible widget has been dragged.
///
/// If [onUpdate] is not null, then it will be invoked for every pointer event
/// to dispatch the latest state of the drag. For example, this callback
/// can be used to for example change the color of the background widget
/// depending on whether the dismiss threshold is currently reached.
final DismissUpdateCallback? onUpdate;
@override
State<Dismissible> createState() => _DismissibleState();
}
/// Details for [DismissUpdateCallback].
///
/// See also:
///
/// * [Dismissible.onUpdate], which receives this information.
class DismissUpdateDetails {
/// Create a new instance of [DismissUpdateDetails].
DismissUpdateDetails({
this.direction = DismissDirection.horizontal,
this.reached = false,
this.previousReached = false,
this.progress = 0.0,
});
/// The direction that the dismissible is being dragged.
final DismissDirection direction;
/// Whether the dismiss threshold is currently reached.
final bool reached;
/// Whether the dismiss threshold was reached the last time this callback was invoked.
///
/// This can be used in conjunction with [DismissUpdateDetails.reached] to catch the moment
/// that the [Dismissible] is dragged across the threshold.
final bool previousReached;
/// The offset ratio of the dismissible in its parent container.
///
/// A value of 0.0 represents the normal position and 1.0 means the child is
/// completely outside its parent.
///
/// This can be used to synchronize other elements to what the dismissible is doing on screen,
/// e.g. using this value to set the opacity thereby fading dismissible as it's dragged offscreen.
final double progress;
}
class _DismissibleClipper extends CustomClipper<Rect> {
_DismissibleClipper({
required this.axis,
required this.moveAnimation,
}) : assert(axis != null),
assert(moveAnimation != null),
super(reclip: moveAnimation);
final Axis axis;
final Animation<Offset> moveAnimation;
@override
Rect getClip(Size size) {
assert(axis != null);
switch (axis) {
case Axis.horizontal:
final double offset = moveAnimation.value.dx * size.width;
if (offset < 0) {
return Rect.fromLTRB(size.width + offset, 0.0, size.width, size.height);
}
return Rect.fromLTRB(0.0, 0.0, offset, size.height);
case Axis.vertical:
final double offset = moveAnimation.value.dy * size.height;
if (offset < 0) {
return Rect.fromLTRB(0.0, size.height + offset, size.width, size.height);
}
return Rect.fromLTRB(0.0, 0.0, size.width, offset);
}
}
@override
Rect getApproximateClipRect(Size size) => getClip(size);
@override
bool shouldReclip(_DismissibleClipper oldClipper) {
return oldClipper.axis != axis
|| oldClipper.moveAnimation.value != moveAnimation.value;
}
}
enum _FlingGestureKind { none, forward, reverse }
class _DismissibleState extends State<Dismissible> with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
@override
void initState() {
super.initState();
_moveController = AnimationController(duration: widget.movementDuration, vsync: this)
..addStatusListener(_handleDismissStatusChanged)
..addListener(_handleDismissUpdateValueChanged);
_updateMoveAnimation();
}
AnimationController? _moveController;
late Animation<Offset> _moveAnimation;
AnimationController? _resizeController;
Animation<double>? _resizeAnimation;
double _dragExtent = 0.0;
bool _confirming = false;
bool _dragUnderway = false;
Size? _sizePriorToCollapse;
bool _dismissThresholdReached = false;
final GlobalKey _contentKey = GlobalKey();
@override
bool get wantKeepAlive => (_moveController?.isAnimating ?? false) || (_resizeController?.isAnimating ?? false);
@override
void dispose() {
_moveController!.dispose();
_resizeController?.dispose();
super.dispose();
}
bool get _directionIsXAxis {
return widget.direction == DismissDirection.horizontal
|| widget.direction == DismissDirection.endToStart
|| widget.direction == DismissDirection.startToEnd;
}
DismissDirection _extentToDirection(double extent) {
if (extent == 0.0) {
return DismissDirection.none;
}
if (_directionIsXAxis) {
switch (Directionality.of(context)) {
case TextDirection.rtl:
return extent < 0 ? DismissDirection.startToEnd : DismissDirection.endToStart;
case TextDirection.ltr:
return extent > 0 ? DismissDirection.startToEnd : DismissDirection.endToStart;
}
}
return extent > 0 ? DismissDirection.down : DismissDirection.up;
}
DismissDirection get _dismissDirection => _extentToDirection(_dragExtent);
bool get _isActive {
return _dragUnderway || _moveController!.isAnimating;
}
double get _overallDragAxisExtent {
final Size size = context.size!;
return _directionIsXAxis ? size.width : size.height;
}
void _handleDragStart(DragStartDetails details) {
if (_confirming) {
return;
}
_dragUnderway = true;
if (_moveController!.isAnimating) {
_dragExtent = _moveController!.value * _overallDragAxisExtent * _dragExtent.sign;
_moveController!.stop();
} else {
_dragExtent = 0.0;
_moveController!.value = 0.0;
}
setState(() {
_updateMoveAnimation();
});
}
void _handleDragUpdate(DragUpdateDetails details) {
if (!_isActive || _moveController!.isAnimating) {
return;
}
final double delta = details.primaryDelta!;
final double oldDragExtent = _dragExtent;
switch (widget.direction) {
case DismissDirection.horizontal:
case DismissDirection.vertical:
_dragExtent += delta;
break;
case DismissDirection.up:
if (_dragExtent + delta < 0) {
_dragExtent += delta;
}
break;
case DismissDirection.down:
if (_dragExtent + delta > 0) {
_dragExtent += delta;
}
break;
case DismissDirection.endToStart:
switch (Directionality.of(context)) {
case TextDirection.rtl:
if (_dragExtent + delta > 0) {
_dragExtent += delta;
}
break;
case TextDirection.ltr:
if (_dragExtent + delta < 0) {
_dragExtent += delta;
}
break;
}
break;
case DismissDirection.startToEnd:
switch (Directionality.of(context)) {
case TextDirection.rtl:
if (_dragExtent + delta < 0) {
_dragExtent += delta;
}
break;
case TextDirection.ltr:
if (_dragExtent + delta > 0) {
_dragExtent += delta;
}
break;
}
break;
case DismissDirection.none:
_dragExtent = 0;
break;
}
if (oldDragExtent.sign != _dragExtent.sign) {
setState(() {
_updateMoveAnimation();
});
}
if (!_moveController!.isAnimating) {
_moveController!.value = _dragExtent.abs() / _overallDragAxisExtent;
}
}
void _handleDismissUpdateValueChanged() {
if(widget.onUpdate != null) {
final bool oldDismissThresholdReached = _dismissThresholdReached;
_dismissThresholdReached = _moveController!.value > (widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold);
final DismissUpdateDetails details = DismissUpdateDetails(
direction: _dismissDirection,
reached: _dismissThresholdReached,
previousReached: oldDismissThresholdReached,
progress: _moveController!.value,
);
widget.onUpdate!(details);
}
}
void _updateMoveAnimation() {
final double end = _dragExtent.sign;
_moveAnimation = _moveController!.drive(
Tween<Offset>(
begin: Offset.zero,
end: _directionIsXAxis
? Offset(end, widget.crossAxisEndOffset)
: Offset(widget.crossAxisEndOffset, end),
),
);
}
_FlingGestureKind _describeFlingGesture(Velocity velocity) {
assert(widget.direction != null);
if (_dragExtent == 0.0) {
// If it was a fling, then it was a fling that was let loose at the exact
// middle of the range (i.e. when there's no displacement). In that case,
// we assume that the user meant to fling it back to the center, as
// opposed to having wanted to drag it out one way, then fling it past the
// center and into and out the other side.
return _FlingGestureKind.none;
}
final double vx = velocity.pixelsPerSecond.dx;
final double vy = velocity.pixelsPerSecond.dy;
DismissDirection flingDirection;
// Verify that the fling is in the generally right direction and fast enough.
if (_directionIsXAxis) {
if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || vx.abs() < _kMinFlingVelocity) {
return _FlingGestureKind.none;
}
assert(vx != 0.0);
flingDirection = _extentToDirection(vx);
} else {
if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta || vy.abs() < _kMinFlingVelocity) {
return _FlingGestureKind.none;
}
assert(vy != 0.0);
flingDirection = _extentToDirection(vy);
}
assert(_dismissDirection != null);
if (flingDirection == _dismissDirection) {
return _FlingGestureKind.forward;
}
return _FlingGestureKind.reverse;
}
void _handleDragEnd(DragEndDetails details) {
if (!_isActive || _moveController!.isAnimating) {
return;
}
_dragUnderway = false;
if (_moveController!.isCompleted) {
_handleMoveCompleted();
return;
}
final double flingVelocity = _directionIsXAxis ? details.velocity.pixelsPerSecond.dx : details.velocity.pixelsPerSecond.dy;
switch (_describeFlingGesture(details.velocity)) {
case _FlingGestureKind.forward:
assert(_dragExtent != 0.0);
assert(!_moveController!.isDismissed);
if ((widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold) >= 1.0) {
_moveController!.reverse();
break;
}
_dragExtent = flingVelocity.sign;
_moveController!.fling(velocity: flingVelocity.abs() * _kFlingVelocityScale);
break;
case _FlingGestureKind.reverse:
assert(_dragExtent != 0.0);
assert(!_moveController!.isDismissed);
_dragExtent = flingVelocity.sign;
_moveController!.fling(velocity: -flingVelocity.abs() * _kFlingVelocityScale);
break;
case _FlingGestureKind.none:
if (!_moveController!.isDismissed) { // we already know it's not completed, we check that above
if (_moveController!.value > (widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold)) {
_moveController!.forward();
} else {
_moveController!.reverse();
}
}
break;
}
}
Future<void> _handleDismissStatusChanged(AnimationStatus status) async {
if (status == AnimationStatus.completed && !_dragUnderway) {
await _handleMoveCompleted();
}
if (mounted) {
updateKeepAlive();
}
}
Future<void> _handleMoveCompleted() async {
if ((widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold) >= 1.0) {
_moveController!.reverse();
return;
}
final bool result = await _confirmStartResizeAnimation();
if (mounted) {
if (result) {
_startResizeAnimation();
} else {
_moveController!.reverse();
}
}
}
Future<bool> _confirmStartResizeAnimation() async {
if (widget.confirmDismiss != null) {
_confirming = true;
final DismissDirection direction = _dismissDirection;
try {
return await widget.confirmDismiss!(direction) ?? false;
} finally {
_confirming = false;
}
}
return true;
}
void _startResizeAnimation() {
assert(_moveController!.isCompleted);
assert(_resizeController == null);
assert(_sizePriorToCollapse == null);
if (widget.resizeDuration == null) {
if (widget.onDismissed != null) {
final DismissDirection direction = _dismissDirection;
widget.onDismissed!(direction);
}
} else {
_resizeController = AnimationController(duration: widget.resizeDuration, vsync: this)
..addListener(_handleResizeProgressChanged)
..addStatusListener((AnimationStatus status) => updateKeepAlive());
_resizeController!.forward();
setState(() {
_sizePriorToCollapse = context.size;
_resizeAnimation = _resizeController!.drive(
CurveTween(
curve: _kResizeTimeCurve,
),
).drive(
Tween<double>(
begin: 1.0,
end: 0.0,
),
);
});
}
}
void _handleResizeProgressChanged() {
if (_resizeController!.isCompleted) {
widget.onDismissed?.call(_dismissDirection);
} else {
widget.onResize?.call();
}
}
@override
Widget build(BuildContext context) {
super.build(context); // See AutomaticKeepAliveClientMixin.
assert(!_directionIsXAxis || debugCheckHasDirectionality(context));
Widget? background = widget.background;
if (widget.secondaryBackground != null) {
final DismissDirection direction = _dismissDirection;
if (direction == DismissDirection.endToStart || direction == DismissDirection.up) {
background = widget.secondaryBackground;
}
}
if (_resizeAnimation != null) {
// we've been dragged aside, and are now resizing.
assert(() {
if (_resizeAnimation!.status != AnimationStatus.forward) {
assert(_resizeAnimation!.status == AnimationStatus.completed);
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('A dismissed Dismissible widget is still part of the tree.'),
ErrorHint(
'Make sure to implement the onDismissed handler and to immediately remove the Dismissible '
'widget from the application once that handler has fired.',
),
]);
}
return true;
}());
return SizeTransition(
sizeFactor: _resizeAnimation!,
axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
child: SizedBox(
width: _sizePriorToCollapse!.width,
height: _sizePriorToCollapse!.height,
child: background,
),
);
}
Widget content = SlideTransition(
position: _moveAnimation,
child: KeyedSubtree(key: _contentKey, child: widget.child),
);
if (background != null) {
content = Stack(children: <Widget>[
if (!_moveAnimation.isDismissed)
Positioned.fill(
child: ClipRect(
clipper: _DismissibleClipper(
axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
moveAnimation: _moveAnimation,
),
child: background,
),
),
content,
]);
}
// If the DismissDirection is none, we do not add drag gestures because the content
// cannot be dragged.
if (widget.direction == DismissDirection.none) {
return content;
}
// We are not resizing but we may be being dragging in widget.direction.
return GestureDetector(
onHorizontalDragStart: _directionIsXAxis ? _handleDragStart : null,
onHorizontalDragUpdate: _directionIsXAxis ? _handleDragUpdate : null,
onHorizontalDragEnd: _directionIsXAxis ? _handleDragEnd : null,
onVerticalDragStart: _directionIsXAxis ? null : _handleDragStart,
onVerticalDragUpdate: _directionIsXAxis ? null : _handleDragUpdate,
onVerticalDragEnd: _directionIsXAxis ? null : _handleDragEnd,
behavior: widget.behavior,
dragStartBehavior: widget.dragStartBehavior,
child: content,
);
}
}