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:
462 _directionIsXAxis
463 ? Offset(end, widget.crossAxisEndOffset)
464 : Offset(widget.crossAxisEndOffset, end),
465 ),
466 );
467 }
468
469 _FlingGestureKind _describeFlingGesture(Velocity velocity) {
470 if (_dragExtent == 0.0) {
471 // If it was a fling, then it was a fling that was let loose at the exact
472 // middle of the range (i.e. when there's no displacement). In that case,
473 // we assume that the user meant to fling it back to the center, as
474 // opposed to having wanted to drag it out one way, then fling it past the
475 // center and into and out the other side.
476 return _FlingGestureKind.none;
477 }
478 final double vx = velocity.pixelsPerSecond.dx;
479 final double vy = velocity.pixelsPerSecond.dy;
480 DismissDirection flingDirection;
481 // Verify that the fling is in the generally right direction and fast enough.
482 if (_directionIsXAxis) {
483 if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || vx.abs() < _kMinFlingVelocity) {
484 return _FlingGestureKind.none;
485 }
486 assert(vx != 0.0);
487 flingDirection = _extentToDirection(vx);
488 } else {
489 if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta || vy.abs() < _kMinFlingVelocity) {
490 return _FlingGestureKind.none;
491 }
492 assert(vy != 0.0);
493 flingDirection = _extentToDirection(vy);
494 }
495 if (flingDirection == _dismissDirection) {
496 return _FlingGestureKind.forward;
497 }
498 return _FlingGestureKind.reverse;
499 }
500
501 void _handleDragEnd(DragEndDetails details) {
502 if (!_dragUnderway || _moveController.isAnimating) {
503 return;
504 }
505 _dragUnderway = false;
506 if (_moveController.isCompleted) {
507 _handleMoveCompleted();
508 return;
509 }
510 final double flingVelocity =
511 _directionIsXAxis
512 ? details.velocity.pixelsPerSecond.dx
513 : details.velocity.pixelsPerSecond.dy;
514 switch (_describeFlingGesture(details.velocity)) {
515 case _FlingGestureKind.forward:
516 assert(_dragExtent != 0.0);
517 assert(!_moveController.isDismissed);
518 if (_dismissThreshold >= 1.0) {
519 _moveController.reverse();
520 break;
521 }
522 _dragExtent = flingVelocity.sign;
523 _moveController.fling(velocity: flingVelocity.abs() * _kFlingVelocityScale);
524 case _FlingGestureKind.reverse:
525 assert(_dragExtent != 0.0);
526 assert(!_moveController.isDismissed);
527 _dragExtent = flingVelocity.sign;
528 _moveController.fling(velocity: -flingVelocity.abs() * _kFlingVelocityScale);
529 case _FlingGestureKind.none:
530 if (!_moveController.isDismissed) {
531 // we already know it's not completed, we check that above
532 if (_moveController.value > _dismissThreshold) {
533 _moveController.forward();
534 } else {
535 _moveController.reverse();
536 }
537 }
538 }
539 }
540
541 Future<void> _handleDismissStatusChanged(AnimationStatus status) async {
542 if (status.isCompleted && !_dragUnderway) {
543 await _handleMoveCompleted();
544 }
545 if (mounted) {
546 updateKeepAlive();
547 }
548 }
549
550 Future<void> _handleMoveCompleted() async {
551 if (_dismissThreshold >= 1.0) {
552 _moveController.reverse();
553 return;
554 }
555 final bool result = await _confirmStartResizeAnimation();
556 if (mounted) {
557 if (result) {
558 _startResizeAnimation();
559 } else {
560 _moveController.reverse();
561 }
562 }
563 }
564
565 Future<bool> _confirmStartResizeAnimation() async {
566 if (widget.confirmDismiss != null) {
567 _confirming = true;
568 final DismissDirection direction = _dismissDirection;
569 try {
570 return await widget.confirmDismiss!(direction) ?? false;
571 } finally {
572 _confirming = false;
573 }
574 }
575 return true;
576 }
577
578 void _startResizeAnimation() {
579 assert(_moveController.isCompleted);
580 assert(_resizeController == null);
581 assert(_sizePriorToCollapse == null);
582 if (widget.resizeDuration == null) {
583 if (widget.onDismissed != null) {
584 final DismissDirection direction = _dismissDirection;
585 widget.onDismissed!(direction);
586 }
587 } else {
588 _resizeController =
589 AnimationController(duration: widget.resizeDuration, vsync: this)
590 ..addListener(_handleResizeProgressChanged)
591 ..addStatusListener((AnimationStatus status) => updateKeepAlive());
592 _resizeController!.forward();
593 setState(() {
594 _sizePriorToCollapse = context.size;
595 _resizeAnimation = _resizeController!
596 .drive(CurveTween(curve: _kResizeTimeCurve))
597 .drive(Tween<double>(begin: 1.0, end: 0.0));
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!.isCompleted);
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(
658 children: <Widget>[
659 if (!_moveAnimation.isDismissed)
660 Positioned.fill(
661 child: ClipRect(
662 clipper: _DismissibleClipper(
663 axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
664 moveAnimation: _moveAnimation,
665 ),
666 child: background,
667 ),
668 ),
669 content,
670 ],
671 );
672 }
673
674 // If the DismissDirection is none, we do not add drag gestures because the content
675 // cannot be dragged.
676 if (widget.direction == DismissDirection.none) {
677 return content;
678 }
679
680 // We are not resizing but we may be being dragging in widget.direction.
681 return GestureDetector(
682 onHorizontalDragStart: _directionIsXAxis ? _handleDragStart : null,
683 onHorizontalDragUpdate: _directionIsXAxis ? _handleDragUpdate : null,
684 onHorizontalDragEnd: _directionIsXAxis ? _handleDragEnd : null,
685 onVerticalDragStart: _directionIsXAxis ? null : _handleDragStart,
686 onVerticalDragUpdate: _directionIsXAxis ? null : _handleDragUpdate,
687 onVerticalDragEnd: _directionIsXAxis ? null : _handleDragEnd,
688 behavior: widget.behavior,
689 dragStartBehavior: widget.dragStartBehavior,
690 child: content,
691 );
692 }
693}
694

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com