1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:flutter/gestures.dart';
6
7import 'automatic_keep_alive.dart';
8import 'basic.dart';
9import 'debug.dart';
10import 'framework.dart';
11import 'gesture_detector.dart';
12import 'ticker_provider.dart';
13import 'transitions.dart';
14
15const Curve _kResizeTimeCurve = Interval(0.4, 1.0, curve: Curves.ease);
16const double _kMinFlingVelocity = 700.0;
17const double _kMinFlingVelocityDelta = 400.0;
18const double _kFlingVelocityScale = 1.0 / 300.0;
19const double _kDismissThreshold = 0.4;
20
21/// Signature used by [Dismissible] to indicate that it has been dismissed in
22/// the given `direction`.
23///
24/// Used by [Dismissible.onDismissed].
25typedef DismissDirectionCallback = void Function(DismissDirection direction);
26
27/// Signature used by [Dismissible] to give the application an opportunity to
28/// confirm or veto a dismiss gesture.
29///
30/// Used by [Dismissible.confirmDismiss].
31typedef ConfirmDismissCallback = Future<bool?> Function(DismissDirection direction);
32
33/// Signature used by [Dismissible] to indicate that the dismissible has been dragged.
34///
35/// Used by [Dismissible.onUpdate].
36typedef DismissUpdateCallback = void Function(DismissUpdateDetails details);
37
38/// The direction in which a [Dismissible] can be dismissed.
39enum DismissDirection {
40 /// The [Dismissible] can be dismissed by dragging either up or down.
41 vertical,
42
43 /// The [Dismissible] can be dismissed by dragging either left or right.
44 horizontal,
45
46 /// The [Dismissible] can be dismissed by dragging in the reverse of the
47 /// reading direction (e.g., from right to left in left-to-right languages).
48 endToStart,
49
50 /// The [Dismissible] can be dismissed by dragging in the reading direction
51 /// (e.g., from left to right in left-to-right languages).
52 startToEnd,
53
54 /// The [Dismissible] can be dismissed by dragging up only.
55 up,
56
57 /// The [Dismissible] can be dismissed by dragging down only.
58 down,
59
60 /// The [Dismissible] cannot be dismissed by dragging.
61 none
62}
63
64/// A widget that can be dismissed by dragging in the indicated [direction].
65///
66/// Dragging or flinging this widget in the [DismissDirection] causes the child
67/// to slide out of view. Following the slide animation, if [resizeDuration] is
68/// non-null, the Dismissible widget animates its height (or width, whichever is
69/// perpendicular to the dismiss direction) to zero over the [resizeDuration].
70///
71/// {@youtube 560 315 https://www.youtube.com/watch?v=iEMgjrfuc58}
72///
73/// {@tool dartpad}
74/// This sample shows how you can use the [Dismissible] widget to
75/// remove list items using swipe gestures. Swipe any of the list
76/// tiles to the left or right to dismiss them from the [ListView].
77///
78/// ** See code in examples/api/lib/widgets/dismissible/dismissible.0.dart **
79/// {@end-tool}
80///
81/// Backgrounds can be used to implement the "leave-behind" idiom. If a background
82/// is specified it is stacked behind the Dismissible's child and is exposed when
83/// the child moves.
84///
85/// The widget calls the [onDismissed] callback either after its size has
86/// collapsed to zero (if [resizeDuration] is non-null) or immediately after
87/// the slide animation (if [resizeDuration] is null). If the Dismissible is a
88/// list item, it must have a key that distinguishes it from the other items and
89/// its [onDismissed] callback must remove the item from the list.
90class Dismissible extends StatefulWidget {
91 /// Creates a widget that can be dismissed.
92 ///
93 /// The [key] argument is required because [Dismissible]s are commonly used in
94 /// lists and removed from the list when dismissed. Without keys, the default
95 /// behavior is to sync widgets based on their index in the list, which means
96 /// the item after the dismissed item would be synced with the state of the
97 /// dismissed item. Using keys causes the widgets to sync according to their
98 /// keys and avoids this pitfall.
99 const Dismissible({
100 required Key key,
101 required this.child,
102 this.background,
103 this.secondaryBackground,
104 this.confirmDismiss,
105 this.onResize,
106 this.onUpdate,
107 this.onDismissed,
108 this.direction = DismissDirection.horizontal,
109 this.resizeDuration = const Duration(milliseconds: 300),
110 this.dismissThresholds = const <DismissDirection, double>{},
111 this.movementDuration = const Duration(milliseconds: 200),
112 this.crossAxisEndOffset = 0.0,
113 this.dragStartBehavior = DragStartBehavior.start,
114 this.behavior = HitTestBehavior.opaque,
115 }) : assert(secondaryBackground == null || background != null),
116 super(key: key);
117
118 /// The widget below this widget in the tree.
119 ///
120 /// {@macro flutter.widgets.ProxyWidget.child}
121 final Widget child;
122
123 /// A widget that is stacked behind the child. If secondaryBackground is also
124 /// specified then this widget only appears when the child has been dragged
125 /// down or to the right.
126 final Widget? background;
127
128 /// A widget that is stacked behind the child and is exposed when the child
129 /// has been dragged up or to the left. It may only be specified when background
130 /// has also been specified.
131 final Widget? secondaryBackground;
132
133 /// Gives the app an opportunity to confirm or veto a pending dismissal.
134 ///
135 /// The widget cannot be dragged again until the returned future resolves.
136 ///
137 /// If the returned Future<bool> completes true, then this widget will be
138 /// dismissed, otherwise it will be moved back to its original location.
139 ///
140 /// If the returned Future<bool?> completes to false or null the [onResize]
141 /// and [onDismissed] callbacks will not run.
142 final ConfirmDismissCallback? confirmDismiss;
143
144 /// Called when the widget changes size (i.e., when contracting before being dismissed).
145 final VoidCallback? onResize;
146
147 /// Called when the widget has been dismissed, after finishing resizing.
148 final DismissDirectionCallback? onDismissed;
149
150 /// The direction in which the widget can be dismissed.
151 final DismissDirection direction;
152
153 /// The amount of time the widget will spend contracting before [onDismissed] is called.
154 ///
155 /// If null, the widget will not contract and [onDismissed] will be called
156 /// immediately after the widget is dismissed.
157 final Duration? resizeDuration;
158
159 /// The offset threshold the item has to be dragged in order to be considered
160 /// dismissed.
161 ///
162 /// Represented as a fraction, e.g. if it is 0.4 (the default), then the item
163 /// has to be dragged at least 40% towards one direction to be considered
164 /// dismissed. Clients can define different thresholds for each dismiss
165 /// direction.
166 ///
167 /// Flinging is treated as being equivalent to dragging almost to 1.0, so
168 /// flinging can dismiss an item past any threshold less than 1.0.
169 ///
170 /// Setting a threshold of 1.0 (or greater) prevents a drag in the given
171 /// [DismissDirection] even if it would be allowed by the [direction]
172 /// property.
173 ///
174 /// See also:
175 ///
176 /// * [direction], which controls the directions in which the items can
177 /// be dismissed.
178 final Map<DismissDirection, double> dismissThresholds;
179
180 /// Defines the duration for card to dismiss or to come back to original position if not dismissed.
181 final Duration movementDuration;
182
183 /// Defines the end offset across the main axis after the card is dismissed.
184 ///
185 /// If non-zero value is given then widget moves in cross direction depending on whether
186 /// it is positive or negative.
187 final double crossAxisEndOffset;
188
189 /// Determines the way that drag start behavior is handled.
190 ///
191 /// If set to [DragStartBehavior.start], the drag gesture used to dismiss a
192 /// dismissible will begin at the position where the drag gesture won the arena.
193 /// If set to [DragStartBehavior.down] it will begin at the position where
194 /// a down event is first detected.
195 ///
196 /// In general, setting this to [DragStartBehavior.start] will make drag
197 /// animation smoother and setting it to [DragStartBehavior.down] will make
198 /// drag behavior feel slightly more reactive.
199 ///
200 /// By default, the drag start behavior is [DragStartBehavior.start].
201 ///
202 /// See also:
203 ///
204 /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
205 final DragStartBehavior dragStartBehavior;
206
207 /// How to behave during hit tests.
208 ///
209 /// This defaults to [HitTestBehavior.opaque].
210 final HitTestBehavior behavior;
211
212 /// Called when the dismissible widget has been dragged.
213 ///
214 /// If [onUpdate] is not null, then it will be invoked for every pointer event
215 /// to dispatch the latest state of the drag. For example, this callback
216 /// can be used to for example change the color of the background widget
217 /// depending on whether the dismiss threshold is currently reached.
218 final DismissUpdateCallback? onUpdate;
219
220 @override
221 State<Dismissible> createState() => _DismissibleState();
222}
223
224/// Details for [DismissUpdateCallback].
225///
226/// See also:
227///
228/// * [Dismissible.onUpdate], which receives this information.
229class DismissUpdateDetails {
230 /// Create a new instance of [DismissUpdateDetails].
231 DismissUpdateDetails({
232 this.direction = DismissDirection.horizontal,
233 this.reached = false,
234 this.previousReached = false,
235 this.progress = 0.0,
236 });
237
238 /// The direction that the dismissible is being dragged.
239 final DismissDirection direction;
240
241 /// Whether the dismiss threshold is currently reached.
242 final bool reached;
243
244 /// Whether the dismiss threshold was reached the last time this callback was invoked.
245 ///
246 /// This can be used in conjunction with [DismissUpdateDetails.reached] to catch the moment
247 /// that the [Dismissible] is dragged across the threshold.
248 final bool previousReached;
249
250 /// The offset ratio of the dismissible in its parent container.
251 ///
252 /// A value of 0.0 represents the normal position and 1.0 means the child is
253 /// completely outside its parent.
254 ///
255 /// This can be used to synchronize other elements to what the dismissible is doing on screen,
256 /// e.g. using this value to set the opacity thereby fading dismissible as it's dragged offscreen.
257 final double progress;
258}
259
260class _DismissibleClipper extends CustomClipper<Rect> {
261 _DismissibleClipper({
262 required this.axis,
263 required this.moveAnimation,
264 }) : super(reclip: moveAnimation);
265
266 final Axis axis;
267 final Animation<Offset> moveAnimation;
268
269 @override
270 Rect getClip(Size size) {
271 switch (axis) {
272 case Axis.horizontal:
273 final double offset = moveAnimation.value.dx * size.width;
274 if (offset < 0) {
275 return Rect.fromLTRB(size.width + offset, 0.0, size.width, size.height);
276 }
277 return Rect.fromLTRB(0.0, 0.0, offset, size.height);
278 case Axis.vertical:
279 final double offset = moveAnimation.value.dy * size.height;
280 if (offset < 0) {
281 return Rect.fromLTRB(0.0, size.height + offset, size.width, size.height);
282 }
283 return Rect.fromLTRB(0.0, 0.0, size.width, offset);
284 }
285 }
286
287 @override
288 Rect getApproximateClipRect(Size size) => getClip(size);
289
290 @override
291 bool shouldReclip(_DismissibleClipper oldClipper) {
292 return oldClipper.axis != axis
293 || oldClipper.moveAnimation.value != moveAnimation.value;
294 }
295}
296
297enum _FlingGestureKind { none, forward, reverse }
298
299class _DismissibleState extends State<Dismissible> with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
300 @override
301 void initState() {
302 super.initState();
303 _moveController = AnimationController(duration: widget.movementDuration, vsync: this)
304 ..addStatusListener(_handleDismissStatusChanged)
305 ..addListener(_handleDismissUpdateValueChanged);
306 _updateMoveAnimation();
307 }
308
309 AnimationController? _moveController;
310 late Animation<Offset> _moveAnimation;
311
312 AnimationController? _resizeController;
313 Animation<double>? _resizeAnimation;
314
315 double _dragExtent = 0.0;
316 bool _confirming = false;
317 bool _dragUnderway = false;
318 Size? _sizePriorToCollapse;
319 bool _dismissThresholdReached = false;
320
321 final GlobalKey _contentKey = GlobalKey();
322
323 @override
324 bool get wantKeepAlive => (_moveController?.isAnimating ?? false) || (_resizeController?.isAnimating ?? false);
325
326 @override
327 void dispose() {
328 _moveController!.dispose();
329 _resizeController?.dispose();
330 super.dispose();
331 }
332
333 bool get _directionIsXAxis {
334 return widget.direction == DismissDirection.horizontal
335 || widget.direction == DismissDirection.endToStart
336 || widget.direction == DismissDirection.startToEnd;
337 }
338
339 DismissDirection _extentToDirection(double extent) {
340 if (extent == 0.0) {
341 return DismissDirection.none;
342 }
343 if (_directionIsXAxis) {
344 switch (Directionality.of(context)) {
345 case TextDirection.rtl:
346 return extent < 0 ? DismissDirection.startToEnd : DismissDirection.endToStart;
347 case TextDirection.ltr:
348 return extent > 0 ? DismissDirection.startToEnd : DismissDirection.endToStart;
349 }
350 }
351 return extent > 0 ? DismissDirection.down : DismissDirection.up;
352 }
353
354 DismissDirection get _dismissDirection => _extentToDirection(_dragExtent);
355
356 bool get _isActive {
357 return _dragUnderway || _moveController!.isAnimating;
358 }
359
360 double get _overallDragAxisExtent {
361 final Size size = context.size!;
362 return _directionIsXAxis ? size.width : size.height;
363 }
364
365 void _handleDragStart(DragStartDetails details) {
366 if (_confirming) {
367 return;
368 }
369 _dragUnderway = true;
370 if (_moveController!.isAnimating) {
371 _dragExtent = _moveController!.value * _overallDragAxisExtent * _dragExtent.sign;
372 _moveController!.stop();
373 } else {
374 _dragExtent = 0.0;
375 _moveController!.value = 0.0;
376 }
377 setState(() {
378 _updateMoveAnimation();
379 });
380 }
381
382 void _handleDragUpdate(DragUpdateDetails details) {
383 if (!_isActive || _moveController!.isAnimating) {
384 return;
385 }
386
387 final double delta = details.primaryDelta!;
388 final double oldDragExtent = _dragExtent;
389 switch (widget.direction) {
390 case DismissDirection.horizontal:
391 case DismissDirection.vertical:
392 _dragExtent += delta;
393
394 case DismissDirection.up:
395 if (_dragExtent + delta < 0) {
396 _dragExtent += delta;
397 }
398
399 case DismissDirection.down:
400 if (_dragExtent + delta > 0) {
401 _dragExtent += delta;
402 }
403
404 case DismissDirection.endToStart:
405 switch (Directionality.of(context)) {
406 case TextDirection.rtl:
407 if (_dragExtent + delta > 0) {
408 _dragExtent += delta;
409 }
410 case TextDirection.ltr:
411 if (_dragExtent + delta < 0) {
412 _dragExtent += delta;
413 }
414 }
415
416 case DismissDirection.startToEnd:
417 switch (Directionality.of(context)) {
418 case TextDirection.rtl:
419 if (_dragExtent + delta < 0) {
420 _dragExtent += delta;
421 }
422 case TextDirection.ltr:
423 if (_dragExtent + delta > 0) {
424 _dragExtent += delta;
425 }
426 }
427
428 case DismissDirection.none:
429 _dragExtent = 0;
430 }
431 if (oldDragExtent.sign != _dragExtent.sign) {
432 setState(() {
433 _updateMoveAnimation();
434 });
435 }
436 if (!_moveController!.isAnimating) {
437 _moveController!.value = _dragExtent.abs() / _overallDragAxisExtent;
438 }
439 }
440
441 void _handleDismissUpdateValueChanged() {
442 if (widget.onUpdate != null) {
443 final bool oldDismissThresholdReached = _dismissThresholdReached;
444 _dismissThresholdReached = _moveController!.value > (widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold);
445 final DismissUpdateDetails details = DismissUpdateDetails(
446 direction: _dismissDirection,
447 reached: _dismissThresholdReached,
448 previousReached: oldDismissThresholdReached,
449 progress: _moveController!.value,
450 );
451 widget.onUpdate!(details);
452 }
453 }
454
455 void _updateMoveAnimation() {
456 final double end = _dragExtent.sign;
457 _moveAnimation = _moveController!.drive(
458 Tween<Offset>(
459 begin: Offset.zero,
460 end: _directionIsXAxis
461 ? Offset(end, widget.crossAxisEndOffset)
462 : Offset(widget.crossAxisEndOffset, end),
463 ),
464 );
465 }
466
467 _FlingGestureKind _describeFlingGesture(Velocity velocity) {
468 if (_dragExtent == 0.0) {
469 // If it was a fling, then it was a fling that was let loose at the exact
470 // middle of the range (i.e. when there's no displacement). In that case,
471 // we assume that the user meant to fling it back to the center, as
472 // opposed to having wanted to drag it out one way, then fling it past the
473 // center and into and out the other side.
474 return _FlingGestureKind.none;
475 }
476 final double vx = velocity.pixelsPerSecond.dx;
477 final double vy = velocity.pixelsPerSecond.dy;
478 DismissDirection flingDirection;
479 // Verify that the fling is in the generally right direction and fast enough.
480 if (_directionIsXAxis) {
481 if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || vx.abs() < _kMinFlingVelocity) {
482 return _FlingGestureKind.none;
483 }
484 assert(vx != 0.0);
485 flingDirection = _extentToDirection(vx);
486 } else {
487 if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta || vy.abs() < _kMinFlingVelocity) {
488 return _FlingGestureKind.none;
489 }
490 assert(vy != 0.0);
491 flingDirection = _extentToDirection(vy);
492 }
493 if (flingDirection == _dismissDirection) {
494 return _FlingGestureKind.forward;
495 }
496 return _FlingGestureKind.reverse;
497 }
498
499 void _handleDragEnd(DragEndDetails details) {
500 if (!_isActive || _moveController!.isAnimating) {
501 return;
502 }
503 _dragUnderway = false;
504 if (_moveController!.isCompleted) {
505 _handleMoveCompleted();
506 return;
507 }
508 final double flingVelocity = _directionIsXAxis ? details.velocity.pixelsPerSecond.dx : details.velocity.pixelsPerSecond.dy;
509 switch (_describeFlingGesture(details.velocity)) {
510 case _FlingGestureKind.forward:
511 assert(_dragExtent != 0.0);
512 assert(!_moveController!.isDismissed);
513 if ((widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold) >= 1.0) {
514 _moveController!.reverse();
515 break;
516 }
517 _dragExtent = flingVelocity.sign;
518 _moveController!.fling(velocity: flingVelocity.abs() * _kFlingVelocityScale);
519 case _FlingGestureKind.reverse:
520 assert(_dragExtent != 0.0);
521 assert(!_moveController!.isDismissed);
522 _dragExtent = flingVelocity.sign;
523 _moveController!.fling(velocity: -flingVelocity.abs() * _kFlingVelocityScale);
524 case _FlingGestureKind.none:
525 if (!_moveController!.isDismissed) { // we already know it's not completed, we check that above
526 if (_moveController!.value > (widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold)) {
527 _moveController!.forward();
528 } else {
529 _moveController!.reverse();
530 }
531 }
532 }
533 }
534
535 Future<void> _handleDismissStatusChanged(AnimationStatus status) async {
536 if (status == AnimationStatus.completed && !_dragUnderway) {
537 await _handleMoveCompleted();
538 }
539 if (mounted) {
540 updateKeepAlive();
541 }
542 }
543
544 Future<void> _handleMoveCompleted() async {
545 if ((widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold) >= 1.0) {
546 _moveController!.reverse();
547 return;
548 }
549 final bool result = await _confirmStartResizeAnimation();
550 if (mounted) {
551 if (result) {
552 _startResizeAnimation();
553 } else {
554 _moveController!.reverse();
555 }
556 }
557 }
558
559 Future<bool> _confirmStartResizeAnimation() async {
560 if (widget.confirmDismiss != null) {
561 _confirming = true;
562 final DismissDirection direction = _dismissDirection;
563 try {
564 return await widget.confirmDismiss!(direction) ?? false;
565 } finally {
566 _confirming = false;
567 }
568 }
569 return true;
570 }
571
572 void _startResizeAnimation() {
573 assert(_moveController!.isCompleted);
574 assert(_resizeController == null);
575 assert(_sizePriorToCollapse == null);
576 if (widget.resizeDuration == null) {
577 if (widget.onDismissed != null) {
578 final DismissDirection direction = _dismissDirection;
579 widget.onDismissed!(direction);
580 }
581 } else {
582 _resizeController = AnimationController(duration: widget.resizeDuration, vsync: this)
583 ..addListener(_handleResizeProgressChanged)
584 ..addStatusListener((AnimationStatus status) => updateKeepAlive());
585 _resizeController!.forward();
586 setState(() {
587 _sizePriorToCollapse = context.size;
588 _resizeAnimation = _resizeController!.drive(
589 CurveTween(
590 curve: _kResizeTimeCurve,
591 ),
592 ).drive(
593 Tween<double>(
594 begin: 1.0,
595 end: 0.0,
596 ),
597 );
598 });
599 }
600 }
601
602 void _handleResizeProgressChanged() {
603 if (_resizeController!.isCompleted) {
604 widget.onDismissed?.call(_dismissDirection);
605 } else {
606 widget.onResize?.call();
607 }
608 }
609
610 @override
611 Widget build(BuildContext context) {
612 super.build(context); // See AutomaticKeepAliveClientMixin.
613
614 assert(!_directionIsXAxis || debugCheckHasDirectionality(context));
615
616 Widget? background = widget.background;
617 if (widget.secondaryBackground != null) {
618 final DismissDirection direction = _dismissDirection;
619 if (direction == DismissDirection.endToStart || direction == DismissDirection.up) {
620 background = widget.secondaryBackground;
621 }
622 }
623
624 if (_resizeAnimation != null) {
625 // we've been dragged aside, and are now resizing.
626 assert(() {
627 if (_resizeAnimation!.status != AnimationStatus.forward) {
628 assert(_resizeAnimation!.status == AnimationStatus.completed);
629 throw FlutterError.fromParts(<DiagnosticsNode>[
630 ErrorSummary('A dismissed Dismissible widget is still part of the tree.'),
631 ErrorHint(
632 'Make sure to implement the onDismissed handler and to immediately remove the Dismissible '
633 'widget from the application once that handler has fired.',
634 ),
635 ]);
636 }
637 return true;
638 }());
639
640 return SizeTransition(
641 sizeFactor: _resizeAnimation!,
642 axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
643 child: SizedBox(
644 width: _sizePriorToCollapse!.width,
645 height: _sizePriorToCollapse!.height,
646 child: background,
647 ),
648 );
649 }
650
651 Widget content = SlideTransition(
652 position: _moveAnimation,
653 child: KeyedSubtree(key: _contentKey, child: widget.child),
654 );
655
656 if (background != null) {
657 content = Stack(children: <Widget>[
658 if (!_moveAnimation.isDismissed)
659 Positioned.fill(
660 child: ClipRect(
661 clipper: _DismissibleClipper(
662 axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
663 moveAnimation: _moveAnimation,
664 ),
665 child: background,
666 ),
667 ),
668 content,
669 ]);
670 }
671
672 // If the DismissDirection is none, we do not add drag gestures because the content
673 // cannot be dragged.
674 if (widget.direction == DismissDirection.none) {
675 return content;
676 }
677
678 // We are not resizing but we may be being dragging in widget.direction.
679 return GestureDetector(
680 onHorizontalDragStart: _directionIsXAxis ? _handleDragStart : null,
681 onHorizontalDragUpdate: _directionIsXAxis ? _handleDragUpdate : null,
682 onHorizontalDragEnd: _directionIsXAxis ? _handleDragEnd : null,
683 onVerticalDragStart: _directionIsXAxis ? null : _handleDragStart,
684 onVerticalDragUpdate: _directionIsXAxis ? null : _handleDragUpdate,
685 onVerticalDragEnd: _directionIsXAxis ? null : _handleDragEnd,
686 behavior: widget.behavior,
687 dragStartBehavior: widget.dragStartBehavior,
688 child: content,
689 );
690 }
691}
692