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