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
///
/// Backgrounds can be used to implement the "leave-behind" idiom. If a background
64
/// is specified it is stacked behind the Dismissible's child and is exposed when
65 66
/// the child moves.
///
67
/// The widget calls the [onDismissed] callback either after its size has
68
/// collapsed to zero (if [resizeDuration] is non-null) or immediately after
69
/// the slide animation (if [resizeDuration] is null). If the Dismissible is a
70 71
/// 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.
72
class Dismissible extends StatefulWidget {
73 74
  /// Creates a widget that can be dismissed.
  ///
75
  /// The [key] argument must not be null because [Dismissible]s are commonly
76 77 78 79 80
  /// 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.
81
  const Dismissible({
82
    @required Key key,
83
    @required this.child,
84 85
    this.background,
    this.secondaryBackground,
86
    this.confirmDismiss,
87
    this.onResize,
88
    this.onDismissed,
89 90 91 92 93
    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,
94
    this.dragStartBehavior = DragStartBehavior.start,
95 96
  }) : assert(key != null),
       assert(secondaryBackground != null ? background != null : true),
97
       assert(dragStartBehavior != null),
98
       super(key: key);
99

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

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

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

115 116 117 118 119 120 121 122 123
  /// 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;

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

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

  /// The direction in which the widget can be dismissed.
131
  final DismissDirection direction;
132

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

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

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

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

182
  @override
183
  _DismissibleState createState() => _DismissibleState();
184
}
185

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

  final Axis axis;
195
  final Animation<Offset> moveAnimation;
196 197 198 199 200 201

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

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

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

Ian Hickson's avatar
Ian Hickson committed
225 226
enum _FlingGestureKind { none, forward, reverse }

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

236
  AnimationController _moveController;
237
  Animation<Offset> _moveAnimation;
238

239
  AnimationController _resizeController;
240
  Animation<double> _resizeAnimation;
241 242 243

  double _dragExtent = 0.0;
  bool _dragUnderway = false;
244
  Size _sizePriorToCollapse;
245

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

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

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

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

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

280
  bool get _isActive {
281
    return _dragUnderway || _moveController.isAnimating;
282 283
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

519 520 521 522 523
    if (_resizeAnimation != null) {
      // we've been dragged aside, and are now resizing.
      assert(() {
        if (_resizeAnimation.status != AnimationStatus.forward) {
          assert(_resizeAnimation.status == AnimationStatus.completed);
524
          throw FlutterError(
525 526
            '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'
527 528 529 530
            'widget from the application once that handler has fired.'
          );
        }
        return true;
531
      }());
532

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

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

549
    if (background != null) {
550
      final List<Widget> children = <Widget>[];
551 552

      if (!_moveAnimation.isDismissed) {
553 554 555
        children.add(Positioned.fill(
          child: ClipRect(
            clipper: _DismissibleClipper(
556 557 558
              axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
              moveAnimation: _moveAnimation,
            ),
559 560
            child: background,
          ),
561 562 563 564
        ));
      }

      children.add(content);
565
      content = Stack(children: children);
566
    }
567
    // We are not resizing but we may be being dragging in widget.direction.
568
    return GestureDetector(
569 570 571 572 573 574
      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
575
      behavior: HitTestBehavior.opaque,
576 577
      child: content,
      dragStartBehavior: widget.dragStartBehavior,
578 579 580
    );
  }
}