dismissible.dart 20.1 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// 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 152 153 154 155
  /// 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.
  ///
  /// See also [direction], which controls the directions in which the items can
  /// be dismissed. 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.
156 157
  final Map<DismissDirection, double> dismissThresholds;

158 159 160 161 162 163 164 165 166
  /// 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;

167 168 169 170 171 172 173 174 175 176
  /// 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.
  ///
177
  /// By default, the drag start behavior is [DragStartBehavior.start].
178 179 180 181 182 183
  ///
  /// See also:
  ///
  ///  * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
  final DragStartBehavior dragStartBehavior;

184
  @override
185
  _DismissibleState createState() => _DismissibleState();
186
}
187

188 189
class _DismissibleClipper extends CustomClipper<Rect> {
  _DismissibleClipper({
190
    @required this.axis,
191
    @required this.moveAnimation,
192 193 194
  }) : assert(axis != null),
       assert(moveAnimation != null),
       super(reclip: moveAnimation);
195 196

  final Axis axis;
197
  final Animation<Offset> moveAnimation;
198 199 200 201 202 203

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

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

  @override
221
  bool shouldReclip(_DismissibleClipper oldClipper) {
222 223 224 225 226
    return oldClipper.axis != axis
        || oldClipper.moveAnimation.value != moveAnimation.value;
  }
}

Ian Hickson's avatar
Ian Hickson committed
227 228
enum _FlingGestureKind { none, forward, reverse }

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

238
  AnimationController _moveController;
239
  Animation<Offset> _moveAnimation;
240

241
  AnimationController _resizeController;
242
  Animation<double> _resizeAnimation;
243 244 245

  double _dragExtent = 0.0;
  bool _dragUnderway = false;
246
  Size _sizePriorToCollapse;
247

248 249 250
  @override
  bool get wantKeepAlive => _moveController?.isAnimating == true || _resizeController?.isAnimating == true;

251
  @override
252
  void dispose() {
253 254
    _moveController.dispose();
    _resizeController?.dispose();
255 256 257
    super.dispose();
  }

258
  bool get _directionIsXAxis {
259 260 261
    return widget.direction == DismissDirection.horizontal
        || widget.direction == DismissDirection.endToStart
        || widget.direction == DismissDirection.startToEnd;
262 263
  }

Ian Hickson's avatar
Ian Hickson committed
264 265 266 267 268 269 270 271 272 273 274 275 276 277
  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;
278 279
  }

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

282
  bool get _isActive {
283
    return _dragUnderway || _moveController.isAnimating;
284 285
  }

286
  double get _overallDragAxisExtent {
287
    final Size size = context.size;
288
    return _directionIsXAxis ? size.width : size.height;
289 290
  }

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

305
  void _handleDragUpdate(DragUpdateDetails details) {
306
    if (!_isActive || _moveController.isAnimating)
307
      return;
308

309 310
    final double delta = details.primaryDelta;
    final double oldDragExtent = _dragExtent;
311
    switch (widget.direction) {
312 313
      case DismissDirection.horizontal:
      case DismissDirection.vertical:
314
        _dragExtent += delta;
315 316 317
        break;

      case DismissDirection.up:
318 319
        if (_dragExtent + delta < 0)
          _dragExtent += delta;
320 321 322
        break;

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

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

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

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

406
  Future<void> _handleDragEnd(DragEndDetails details) async {
407
    if (!_isActive || _moveController.isAnimating)
408
      return;
409
    _dragUnderway = false;
410
    if (_moveController.isCompleted && await _confirmStartResizeAnimation() == true) {
411
      _startResizeAnimation();
Ian Hickson's avatar
Ian Hickson committed
412 413 414 415 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
      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;
441
    }
442 443
  }

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

454 455 456 457 458 459 460 461 462
  Future<bool> _confirmStartResizeAnimation() async {
    if (widget.confirmDismiss != null) {
      final DismissDirection direction = _dismissDirection;
      assert(direction != null);
      return widget.confirmDismiss(direction);
    }
    return true;
  }

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

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

508
  @override
509
  Widget build(BuildContext context) {
510
    super.build(context); // See AutomaticKeepAliveClientMixin.
Ian Hickson's avatar
Ian Hickson committed
511 512 513

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

514 515
    Widget background = widget.background;
    if (widget.secondaryBackground != null) {
516
      final DismissDirection direction = _dismissDirection;
517
      if (direction == DismissDirection.endToStart || direction == DismissDirection.up)
518
        background = widget.secondaryBackground;
519 520
    }

521 522 523 524 525
    if (_resizeAnimation != null) {
      // we've been dragged aside, and are now resizing.
      assert(() {
        if (_resizeAnimation.status != AnimationStatus.forward) {
          assert(_resizeAnimation.status == AnimationStatus.completed);
526
          throw FlutterError(
527 528
            'A dismissed Dismissible widget is still part of the tree.\n'
            'Make sure to implement the onDismissed handler and to immediately remove the Dismissible\n'
529 530 531 532
            'widget from the application once that handler has fired.'
          );
        }
        return true;
533
      }());
534

535
      return SizeTransition(
Hans Muller's avatar
Hans Muller committed
536
        sizeFactor: _resizeAnimation,
537
        axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
538
        child: SizedBox(
539 540
          width: _sizePriorToCollapse.width,
          height: _sizePriorToCollapse.height,
541 542
          child: background,
        ),
543
      );
Adam Barth's avatar
Adam Barth committed
544
    }
545

546
    Widget content = SlideTransition(
547
      position: _moveAnimation,
548
      child: widget.child,
549
    );
550

551
    if (background != null) {
552 553 554 555 556 557 558 559 560
      content = Stack(children: <Widget>[
        if (!_moveAnimation.isDismissed)
          Positioned.fill(
            child: ClipRect(
              clipper: _DismissibleClipper(
                axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
                moveAnimation: _moveAnimation,
              ),
              child: background,
561
            ),
562
          ),
563 564
        content,
      ]);
565
    }
566
    // We are not resizing but we may be being dragging in widget.direction.
567
    return GestureDetector(
568 569 570 571 572 573
      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
574
      behavior: HitTestBehavior.opaque,
575 576
      child: content,
      dragStartBehavior: widget.dragStartBehavior,
577 578 579
    );
  }
}