dismissible.dart 20.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'package:flutter/gestures.dart';

7
import 'automatic_keep_alive.dart';
8
import 'basic.dart';
Ian Hickson's avatar
Ian Hickson committed
9
import 'debug.dart';
10 11
import 'framework.dart';
import 'gesture_detector.dart';
12 13
import 'ticker_provider.dart';
import 'transitions.dart';
14

15
const Curve _kResizeTimeCurve = Interval(0.4, 1.0, curve: Curves.ease);
16 17
const double _kMinFlingVelocity = 700.0;
const double _kMinFlingVelocityDelta = 400.0;
18
const double _kFlingVelocityScale = 1.0 / 300.0;
Hans Muller's avatar
Hans Muller committed
19
const double _kDismissThreshold = 0.4;
20

21
/// Signature used by [Dismissible] to indicate that it has been dismissed in
22 23
/// the given `direction`.
///
24
/// Used by [Dismissible.onDismissed].
25
typedef DismissDirectionCallback = void Function(DismissDirection direction);
26

27 28 29 30 31 32
/// 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);

33
/// The direction in which a [Dismissible] can be dismissed.
34
enum DismissDirection {
35
  /// The [Dismissible] can be dismissed by dragging either up or down.
36
  vertical,
Adam Barth's avatar
Adam Barth committed
37

38
  /// The [Dismissible] can be dismissed by dragging either left or right.
39
  horizontal,
Adam Barth's avatar
Adam Barth committed
40

41
  /// The [Dismissible] can be dismissed by dragging in the reverse of the
42 43
  /// reading direction (e.g., from right to left in left-to-right languages).
  endToStart,
Adam Barth's avatar
Adam Barth committed
44

45
  /// The [Dismissible] can be dismissed by dragging in the reading direction
46 47
  /// (e.g., from left to right in left-to-right languages).
  startToEnd,
Adam Barth's avatar
Adam Barth committed
48

49
  /// The [Dismissible] can be dismissed by dragging up only.
50
  up,
Adam Barth's avatar
Adam Barth committed
51

52
  /// The [Dismissible] can be dismissed by dragging down only.
53 54 55
  down
}

56
/// A widget that can be dismissed by dragging in the indicated [direction].
Adam Barth's avatar
Adam Barth committed
57
///
58
/// Dragging or flinging this widget in the [DismissDirection] causes the child
59
/// to slide out of view. Following the slide animation, if [resizeDuration] is
60
/// non-null, the Dismissible widget animates its height (or width, whichever is
61
/// perpendicular to the dismiss direction) to zero over the [resizeDuration].
62
///
63 64
/// {@youtube 560 315 https://www.youtube.com/watch?v=iEMgjrfuc58}
///
65
/// Backgrounds can be used to implement the "leave-behind" idiom. If a background
66
/// is specified it is stacked behind the Dismissible's child and is exposed when
67 68
/// the child moves.
///
69
/// The widget calls the [onDismissed] callback either after its size has
70
/// collapsed to zero (if [resizeDuration] is non-null) or immediately after
71
/// the slide animation (if [resizeDuration] is null). If the Dismissible is a
72 73
/// 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.
74
class Dismissible extends StatefulWidget {
75 76
  /// Creates a widget that can be dismissed.
  ///
77
  /// The [key] argument must not be null because [Dismissible]s are commonly
78 79 80 81 82
  /// 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.
83
  const Dismissible({
84
    @required Key key,
85
    @required this.child,
86 87
    this.background,
    this.secondaryBackground,
88
    this.confirmDismiss,
89
    this.onResize,
90
    this.onDismissed,
91 92 93 94 95
    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,
96
    this.dragStartBehavior = DragStartBehavior.start,
97
  }) : assert(key != null),
98
       assert(secondaryBackground == null || background != null),
99
       assert(dragStartBehavior != null),
100
       super(key: key);
101

102
  /// The widget below this widget in the tree.
103 104
  ///
  /// {@macro flutter.widgets.child}
105
  final Widget child;
Adam Barth's avatar
Adam Barth committed
106

107 108 109
  /// 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.
110
  final Widget background;
111 112 113 114 115 116

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

117 118 119 120 121 122 123 124 125
  /// Gives the app an opportunity to confirm or veto a pending dismissal.
  ///
  /// 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;

126
  /// Called when the widget changes size (i.e., when contracting before being dismissed).
127
  final VoidCallback onResize;
Adam Barth's avatar
Adam Barth committed
128

129
  /// Called when the widget has been dismissed, after finishing resizing.
130
  final DismissDirectionCallback onDismissed;
Adam Barth's avatar
Adam Barth committed
131 132

  /// The direction in which the widget can be dismissed.
133
  final DismissDirection direction;
134

135 136 137
  /// 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
138
  /// immediately after the widget is dismissed.
139 140
  final Duration resizeDuration;

Ian Hickson's avatar
Ian Hickson committed
141 142
  /// The offset threshold the item has to be dragged in order to be considered
  /// dismissed.
143
  ///
Ian Hickson's avatar
Ian Hickson committed
144 145 146 147 148 149 150 151
  /// 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.
  ///
152 153 154 155 156 157 158 159
  /// 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.
160 161
  final Map<DismissDirection, double> dismissThresholds;

162 163 164 165 166 167 168 169 170
  /// 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;

171 172 173 174 175 176 177 178 179 180
  /// Determines the way that drag start behavior is handled.
  ///
  /// If set to [DragStartBehavior.start], the drag gesture used to dismiss a
  /// dismissible will begin upon the detection of a drag gesture. If set to
  /// [DragStartBehavior.down] it will begin when 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.
  ///
181
  /// By default, the drag start behavior is [DragStartBehavior.start].
182 183 184 185 186 187
  ///
  /// See also:
  ///
  ///  * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
  final DragStartBehavior dragStartBehavior;

188
  @override
189
  _DismissibleState createState() => _DismissibleState();
190
}
191

192 193
class _DismissibleClipper extends CustomClipper<Rect> {
  _DismissibleClipper({
194
    @required this.axis,
195
    @required this.moveAnimation,
196 197 198
  }) : assert(axis != null),
       assert(moveAnimation != null),
       super(reclip: moveAnimation);
199 200

  final Axis axis;
201
  final Animation<Offset> moveAnimation;
202 203 204 205 206 207

  @override
  Rect getClip(Size size) {
    assert(axis != null);
    switch (axis) {
      case Axis.horizontal:
208
        final double offset = moveAnimation.value.dx * size.width;
209
        if (offset < 0)
210 211
          return Rect.fromLTRB(size.width + offset, 0.0, size.width, size.height);
        return Rect.fromLTRB(0.0, 0.0, offset, size.height);
212
      case Axis.vertical:
213
        final double offset = moveAnimation.value.dy * size.height;
214
        if (offset < 0)
215 216
          return Rect.fromLTRB(0.0, size.height + offset, size.width, size.height);
        return Rect.fromLTRB(0.0, 0.0, size.width, offset);
217 218 219 220 221 222 223 224
    }
    return null;
  }

  @override
  Rect getApproximateClipRect(Size size) => getClip(size);

  @override
225
  bool shouldReclip(_DismissibleClipper oldClipper) {
226 227 228 229 230
    return oldClipper.axis != axis
        || oldClipper.moveAnimation.value != moveAnimation.value;
  }
}

Ian Hickson's avatar
Ian Hickson committed
231 232
enum _FlingGestureKind { none, forward, reverse }

233
class _DismissibleState extends State<Dismissible> with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
234
  @override
235
  void initState() {
236
    super.initState();
237
    _moveController = AnimationController(duration: widget.movementDuration, vsync: this)
238 239
      ..addStatusListener(_handleDismissStatusChanged);
    _updateMoveAnimation();
240 241
  }

242
  AnimationController _moveController;
243
  Animation<Offset> _moveAnimation;
244

245
  AnimationController _resizeController;
246
  Animation<double> _resizeAnimation;
247 248 249

  double _dragExtent = 0.0;
  bool _dragUnderway = false;
250
  Size _sizePriorToCollapse;
251

252 253 254
  @override
  bool get wantKeepAlive => _moveController?.isAnimating == true || _resizeController?.isAnimating == true;

255
  @override
256
  void dispose() {
257 258
    _moveController.dispose();
    _resizeController?.dispose();
259 260 261
    super.dispose();
  }

262
  bool get _directionIsXAxis {
263 264 265
    return widget.direction == DismissDirection.horizontal
        || widget.direction == DismissDirection.endToStart
        || widget.direction == DismissDirection.startToEnd;
266 267
  }

Ian Hickson's avatar
Ian Hickson committed
268 269 270 271 272 273 274 275 276 277 278 279 280 281
  DismissDirection _extentToDirection(double extent) {
    if (extent == 0.0)
      return null;
    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;
      }
      assert(false);
      return null;
    }
    return extent > 0 ? DismissDirection.down : DismissDirection.up;
282 283
  }

Ian Hickson's avatar
Ian Hickson committed
284
  DismissDirection get _dismissDirection => _extentToDirection(_dragExtent);
285

286
  bool get _isActive {
287
    return _dragUnderway || _moveController.isAnimating;
288 289
  }

290
  double get _overallDragAxisExtent {
291
    final Size size = context.size;
292
    return _directionIsXAxis ? size.width : size.height;
293 294
  }

295
  void _handleDragStart(DragStartDetails details) {
296 297
    _dragUnderway = true;
    if (_moveController.isAnimating) {
298
      _dragExtent = _moveController.value * _overallDragAxisExtent * _dragExtent.sign;
299 300 301 302 303
      _moveController.stop();
    } else {
      _dragExtent = 0.0;
      _moveController.value = 0.0;
    }
304
    setState(() {
305
      _updateMoveAnimation();
306
    });
307 308
  }

309
  void _handleDragUpdate(DragUpdateDetails details) {
310
    if (!_isActive || _moveController.isAnimating)
311
      return;
312

313 314
    final double delta = details.primaryDelta;
    final double oldDragExtent = _dragExtent;
315
    switch (widget.direction) {
316 317
      case DismissDirection.horizontal:
      case DismissDirection.vertical:
318
        _dragExtent += delta;
319 320 321
        break;

      case DismissDirection.up:
322 323
        if (_dragExtent + delta < 0)
          _dragExtent += delta;
324 325 326
        break;

      case DismissDirection.down:
327 328
        if (_dragExtent + delta > 0)
          _dragExtent += delta;
329
        break;
Ian Hickson's avatar
Ian Hickson committed
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355

      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;
356
    }
357 358
    if (oldDragExtent.sign != _dragExtent.sign) {
      setState(() {
359
        _updateMoveAnimation();
360 361
      });
    }
362
    if (!_moveController.isAnimating) {
363
      _moveController.value = _dragExtent.abs() / _overallDragAxisExtent;
364 365 366 367
    }
  }

  void _updateMoveAnimation() {
368
    final double end = _dragExtent.sign;
369 370 371 372
    _moveAnimation = _moveController.drive(
      Tween<Offset>(
        begin: Offset.zero,
        end: _directionIsXAxis
373 374
            ? Offset(end, widget.crossAxisEndOffset)
            : Offset(widget.crossAxisEndOffset, end),
375 376
      ),
    );
377 378
  }

Ian Hickson's avatar
Ian Hickson committed
379 380 381 382 383 384 385 386 387 388
  _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;
    }
389 390
    final double vx = velocity.pixelsPerSecond.dx;
    final double vy = velocity.pixelsPerSecond.dy;
Ian Hickson's avatar
Ian Hickson committed
391 392
    DismissDirection flingDirection;
    // Verify that the fling is in the generally right direction and fast enough.
393
    if (_directionIsXAxis) {
Ian Hickson's avatar
Ian Hickson committed
394 395 396 397
      if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || vx.abs() < _kMinFlingVelocity)
        return _FlingGestureKind.none;
      assert(vx != 0.0);
      flingDirection = _extentToDirection(vx);
398
    } else {
Ian Hickson's avatar
Ian Hickson committed
399 400 401 402
      if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta || vy.abs() < _kMinFlingVelocity)
        return _FlingGestureKind.none;
      assert(vy != 0.0);
      flingDirection = _extentToDirection(vy);
403
    }
Ian Hickson's avatar
Ian Hickson committed
404 405 406 407
    assert(_dismissDirection != null);
    if (flingDirection == _dismissDirection)
      return _FlingGestureKind.forward;
    return _FlingGestureKind.reverse;
408 409
  }

410
  Future<void> _handleDragEnd(DragEndDetails details) async {
411
    if (!_isActive || _moveController.isAnimating)
412
      return;
413
    _dragUnderway = false;
414
    if (_moveController.isCompleted && await _confirmStartResizeAnimation() == true) {
415
      _startResizeAnimation();
Ian Hickson's avatar
Ian Hickson committed
416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444
      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;
445
    }
446 447
  }

448 449 450 451 452 453 454
  Future<void> _handleDismissStatusChanged(AnimationStatus status) async {
    if (status == AnimationStatus.completed && !_dragUnderway) {
      if (await _confirmStartResizeAnimation() == true)
        _startResizeAnimation();
      else
        _moveController.reverse();
    }
455
    updateKeepAlive();
456 457
  }

458 459 460 461 462 463 464 465 466
  Future<bool> _confirmStartResizeAnimation() async {
    if (widget.confirmDismiss != null) {
      final DismissDirection direction = _dismissDirection;
      assert(direction != null);
      return widget.confirmDismiss(direction);
    }
    return true;
  }

467 468 469 470
  void _startResizeAnimation() {
    assert(_moveController != null);
    assert(_moveController.isCompleted);
    assert(_resizeController == null);
471
    assert(_sizePriorToCollapse == null);
472
    if (widget.resizeDuration == null) {
Ian Hickson's avatar
Ian Hickson committed
473 474 475 476 477
      if (widget.onDismissed != null) {
        final DismissDirection direction = _dismissDirection;
        assert(direction != null);
        widget.onDismissed(direction);
      }
478
    } else {
479
      _resizeController = AnimationController(duration: widget.resizeDuration, vsync: this)
480 481
        ..addListener(_handleResizeProgressChanged)
        ..addStatusListener((AnimationStatus status) => updateKeepAlive());
482 483
      _resizeController.forward();
      setState(() {
484
        _sizePriorToCollapse = context.size;
485 486 487 488 489 490 491
        _resizeAnimation = _resizeController.drive(
          CurveTween(
            curve: _kResizeTimeCurve
          ),
        ).drive(
          Tween<double>(
            begin: 1.0,
492
            end: 0.0,
493 494
          ),
        );
495 496
      });
    }
497
  }
498

499 500
  void _handleResizeProgressChanged() {
    if (_resizeController.isCompleted) {
Ian Hickson's avatar
Ian Hickson committed
501 502 503 504 505
      if (widget.onDismissed != null) {
        final DismissDirection direction = _dismissDirection;
        assert(direction != null);
        widget.onDismissed(direction);
      }
506
    } else {
507 508
      if (widget.onResize != null)
        widget.onResize();
509 510 511
    }
  }

512
  @override
513
  Widget build(BuildContext context) {
514
    super.build(context); // See AutomaticKeepAliveClientMixin.
Ian Hickson's avatar
Ian Hickson committed
515 516 517

    assert(!_directionIsXAxis || debugCheckHasDirectionality(context));

518 519
    Widget background = widget.background;
    if (widget.secondaryBackground != null) {
520
      final DismissDirection direction = _dismissDirection;
521
      if (direction == DismissDirection.endToStart || direction == DismissDirection.up)
522
        background = widget.secondaryBackground;
523 524
    }

525 526 527 528 529
    if (_resizeAnimation != null) {
      // we've been dragged aside, and are now resizing.
      assert(() {
        if (_resizeAnimation.status != AnimationStatus.forward) {
          assert(_resizeAnimation.status == AnimationStatus.completed);
530 531 532 533 534 535 536
          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.'
            )
          ]);
537 538
        }
        return true;
539
      }());
540

541
      return SizeTransition(
Hans Muller's avatar
Hans Muller committed
542
        sizeFactor: _resizeAnimation,
543
        axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
544
        child: SizedBox(
545 546
          width: _sizePriorToCollapse.width,
          height: _sizePriorToCollapse.height,
547 548
          child: background,
        ),
549
      );
Adam Barth's avatar
Adam Barth committed
550
    }
551

552
    Widget content = SlideTransition(
553
      position: _moveAnimation,
554
      child: widget.child,
555
    );
556

557
    if (background != null) {
558 559 560 561 562 563 564 565 566
      content = Stack(children: <Widget>[
        if (!_moveAnimation.isDismissed)
          Positioned.fill(
            child: ClipRect(
              clipper: _DismissibleClipper(
                axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
                moveAnimation: _moveAnimation,
              ),
              child: background,
567
            ),
568
          ),
569 570
        content,
      ]);
571
    }
572
    // We are not resizing but we may be being dragging in widget.direction.
573
    return GestureDetector(
574 575 576 577 578 579
      onHorizontalDragStart: _directionIsXAxis ? _handleDragStart : null,
      onHorizontalDragUpdate: _directionIsXAxis ? _handleDragUpdate : null,
      onHorizontalDragEnd: _directionIsXAxis ? _handleDragEnd : null,
      onVerticalDragStart: _directionIsXAxis ? null : _handleDragStart,
      onVerticalDragUpdate: _directionIsXAxis ? null : _handleDragUpdate,
      onVerticalDragEnd: _directionIsXAxis ? null : _handleDragEnd,
Hixie's avatar
Hixie committed
580
      behavior: HitTestBehavior.opaque,
581 582
      child: content,
      dragStartBehavior: widget.dragStartBehavior,
583 584 585
    );
  }
}