dismissible.dart 18.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
import 'automatic_keep_alive.dart';
6
import 'basic.dart';
Ian Hickson's avatar
Ian Hickson committed
7
import 'debug.dart';
8 9
import 'framework.dart';
import 'gesture_detector.dart';
10 11
import 'ticker_provider.dart';
import 'transitions.dart';
12

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

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

25
/// The direction in which a [Dismissible] can be dismissed.
26
enum DismissDirection {
27
  /// The [Dismissible] can be dismissed by dragging either up or down.
28
  vertical,
Adam Barth's avatar
Adam Barth committed
29

30
  /// The [Dismissible] can be dismissed by dragging either left or right.
31
  horizontal,
Adam Barth's avatar
Adam Barth committed
32

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

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

41
  /// The [Dismissible] can be dismissed by dragging up only.
42
  up,
Adam Barth's avatar
Adam Barth committed
43

44
  /// The [Dismissible] can be dismissed by dragging down only.
45 46 47
  down
}

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

89
  /// The widget below this widget in the tree.
90 91
  ///
  /// {@macro flutter.widgets.child}
92
  final Widget child;
Adam Barth's avatar
Adam Barth committed
93

94 95 96
  /// 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.
97
  final Widget background;
98 99 100 101 102 103 104

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

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

107
  /// Called when the widget has been dismissed, after finishing resizing.
108
  final DismissDirectionCallback onDismissed;
Adam Barth's avatar
Adam Barth committed
109 110

  /// The direction in which the widget can be dismissed.
111
  final DismissDirection direction;
112

113 114 115
  /// 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
116
  /// immediately after the widget is dismissed.
117 118
  final Duration resizeDuration;

Ian Hickson's avatar
Ian Hickson committed
119 120
  /// The offset threshold the item has to be dragged in order to be considered
  /// dismissed.
121
  ///
Ian Hickson's avatar
Ian Hickson committed
122 123 124 125 126 127 128 129 130 131 132 133
  /// 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.
134 135
  final Map<DismissDirection, double> dismissThresholds;

136 137 138 139 140 141 142 143 144
  /// 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;

145
  @override
146
  _DismissibleState createState() => _DismissibleState();
147
}
148

149 150
class _DismissibleClipper extends CustomClipper<Rect> {
  _DismissibleClipper({
151 152
    @required this.axis,
    @required this.moveAnimation
153 154 155
  }) : assert(axis != null),
       assert(moveAnimation != null),
       super(reclip: moveAnimation);
156 157

  final Axis axis;
158
  final Animation<Offset> moveAnimation;
159 160 161 162 163 164

  @override
  Rect getClip(Size size) {
    assert(axis != null);
    switch (axis) {
      case Axis.horizontal:
165
        final double offset = moveAnimation.value.dx * size.width;
166
        if (offset < 0)
167 168
          return Rect.fromLTRB(size.width + offset, 0.0, size.width, size.height);
        return Rect.fromLTRB(0.0, 0.0, offset, size.height);
169
      case Axis.vertical:
170
        final double offset = moveAnimation.value.dy * size.height;
171
        if (offset < 0)
172 173
          return Rect.fromLTRB(0.0, size.height + offset, size.width, size.height);
        return Rect.fromLTRB(0.0, 0.0, size.width, offset);
174 175 176 177 178 179 180 181
    }
    return null;
  }

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

  @override
182
  bool shouldReclip(_DismissibleClipper oldClipper) {
183 184 185 186 187
    return oldClipper.axis != axis
        || oldClipper.moveAnimation.value != moveAnimation.value;
  }
}

Ian Hickson's avatar
Ian Hickson committed
188 189
enum _FlingGestureKind { none, forward, reverse }

190
class _DismissibleState extends State<Dismissible> with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { // ignore: MIXIN_INFERENCE_INCONSISTENT_MATCHING_CLASSES
191
  @override
192
  void initState() {
193
    super.initState();
194
    _moveController = AnimationController(duration: widget.movementDuration, vsync: this)
195 196
      ..addStatusListener(_handleDismissStatusChanged);
    _updateMoveAnimation();
197 198
  }

199
  AnimationController _moveController;
200
  Animation<Offset> _moveAnimation;
201

202
  AnimationController _resizeController;
203
  Animation<double> _resizeAnimation;
204 205 206

  double _dragExtent = 0.0;
  bool _dragUnderway = false;
207
  Size _sizePriorToCollapse;
208

209 210 211
  @override
  bool get wantKeepAlive => _moveController?.isAnimating == true || _resizeController?.isAnimating == true;

212
  @override
213
  void dispose() {
214 215
    _moveController.dispose();
    _resizeController?.dispose();
216 217 218
    super.dispose();
  }

219
  bool get _directionIsXAxis {
220 221 222
    return widget.direction == DismissDirection.horizontal
        || widget.direction == DismissDirection.endToStart
        || widget.direction == DismissDirection.startToEnd;
223 224
  }

Ian Hickson's avatar
Ian Hickson committed
225 226 227 228 229 230 231 232 233 234 235 236 237 238
  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;
239 240
  }

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

243
  bool get _isActive {
244
    return _dragUnderway || _moveController.isAnimating;
245 246
  }

247
  double get _overallDragAxisExtent {
248
    final Size size = context.size;
249
    return _directionIsXAxis ? size.width : size.height;
250 251
  }

252
  void _handleDragStart(DragStartDetails details) {
253 254
    _dragUnderway = true;
    if (_moveController.isAnimating) {
255
      _dragExtent = _moveController.value * _overallDragAxisExtent * _dragExtent.sign;
256 257 258 259 260
      _moveController.stop();
    } else {
      _dragExtent = 0.0;
      _moveController.value = 0.0;
    }
261
    setState(() {
262
      _updateMoveAnimation();
263
    });
264 265
  }

266
  void _handleDragUpdate(DragUpdateDetails details) {
267
    if (!_isActive || _moveController.isAnimating)
268
      return;
269

270 271
    final double delta = details.primaryDelta;
    final double oldDragExtent = _dragExtent;
272
    switch (widget.direction) {
273 274
      case DismissDirection.horizontal:
      case DismissDirection.vertical:
275
        _dragExtent += delta;
276 277 278
        break;

      case DismissDirection.up:
279 280
        if (_dragExtent + delta < 0)
          _dragExtent += delta;
281 282 283
        break;

      case DismissDirection.down:
284 285
        if (_dragExtent + delta > 0)
          _dragExtent += delta;
286
        break;
Ian Hickson's avatar
Ian Hickson committed
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312

      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;
313
    }
314 315
    if (oldDragExtent.sign != _dragExtent.sign) {
      setState(() {
316
        _updateMoveAnimation();
317 318
      });
    }
319
    if (!_moveController.isAnimating) {
320
      _moveController.value = _dragExtent.abs() / _overallDragAxisExtent;
321 322 323 324
    }
  }

  void _updateMoveAnimation() {
325
    final double end = _dragExtent.sign;
326 327 328 329 330 331 332 333
    _moveAnimation = _moveController.drive(
      Tween<Offset>(
        begin: Offset.zero,
        end: _directionIsXAxis
               ? Offset(end, widget.crossAxisEndOffset)
               : Offset(widget.crossAxisEndOffset, end),
      ),
    );
334 335
  }

Ian Hickson's avatar
Ian Hickson committed
336 337 338 339 340 341 342 343 344 345
  _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;
    }
346 347
    final double vx = velocity.pixelsPerSecond.dx;
    final double vy = velocity.pixelsPerSecond.dy;
Ian Hickson's avatar
Ian Hickson committed
348 349
    DismissDirection flingDirection;
    // Verify that the fling is in the generally right direction and fast enough.
350
    if (_directionIsXAxis) {
Ian Hickson's avatar
Ian Hickson committed
351 352 353 354
      if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || vx.abs() < _kMinFlingVelocity)
        return _FlingGestureKind.none;
      assert(vx != 0.0);
      flingDirection = _extentToDirection(vx);
355
    } else {
Ian Hickson's avatar
Ian Hickson committed
356 357 358 359
      if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta || vy.abs() < _kMinFlingVelocity)
        return _FlingGestureKind.none;
      assert(vy != 0.0);
      flingDirection = _extentToDirection(vy);
360
    }
Ian Hickson's avatar
Ian Hickson committed
361 362 363 364
    assert(_dismissDirection != null);
    if (flingDirection == _dismissDirection)
      return _FlingGestureKind.forward;
    return _FlingGestureKind.reverse;
365 366
  }

367
  void _handleDragEnd(DragEndDetails details) {
368
    if (!_isActive || _moveController.isAnimating)
369
      return;
370 371 372
    _dragUnderway = false;
    if (_moveController.isCompleted) {
      _startResizeAnimation();
Ian Hickson's avatar
Ian Hickson committed
373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401
      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;
402
    }
403 404
  }

405 406 407
  void _handleDismissStatusChanged(AnimationStatus status) {
    if (status == AnimationStatus.completed && !_dragUnderway)
      _startResizeAnimation();
408
    updateKeepAlive();
409 410
  }

411 412 413 414
  void _startResizeAnimation() {
    assert(_moveController != null);
    assert(_moveController.isCompleted);
    assert(_resizeController == null);
415
    assert(_sizePriorToCollapse == null);
416
    if (widget.resizeDuration == null) {
Ian Hickson's avatar
Ian Hickson committed
417 418 419 420 421
      if (widget.onDismissed != null) {
        final DismissDirection direction = _dismissDirection;
        assert(direction != null);
        widget.onDismissed(direction);
      }
422
    } else {
423
      _resizeController = AnimationController(duration: widget.resizeDuration, vsync: this)
424 425
        ..addListener(_handleResizeProgressChanged)
        ..addStatusListener((AnimationStatus status) => updateKeepAlive());
426 427
      _resizeController.forward();
      setState(() {
428
        _sizePriorToCollapse = context.size;
429 430 431 432 433 434 435 436 437 438
        _resizeAnimation = _resizeController.drive(
          CurveTween(
            curve: _kResizeTimeCurve
          ),
        ).drive(
          Tween<double>(
            begin: 1.0,
            end: 0.0
          ),
        );
439 440
      });
    }
441
  }
442

443 444
  void _handleResizeProgressChanged() {
    if (_resizeController.isCompleted) {
Ian Hickson's avatar
Ian Hickson committed
445 446 447 448 449
      if (widget.onDismissed != null) {
        final DismissDirection direction = _dismissDirection;
        assert(direction != null);
        widget.onDismissed(direction);
      }
450
    } else {
451 452
      if (widget.onResize != null)
        widget.onResize();
453 454 455
    }
  }

456
  @override
457
  Widget build(BuildContext context) {
458
    super.build(context); // See AutomaticKeepAliveClientMixin.
Ian Hickson's avatar
Ian Hickson committed
459 460 461

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

462 463
    Widget background = widget.background;
    if (widget.secondaryBackground != null) {
464
      final DismissDirection direction = _dismissDirection;
465
      if (direction == DismissDirection.endToStart || direction == DismissDirection.up)
466
        background = widget.secondaryBackground;
467 468
    }

469 470 471 472 473
    if (_resizeAnimation != null) {
      // we've been dragged aside, and are now resizing.
      assert(() {
        if (_resizeAnimation.status != AnimationStatus.forward) {
          assert(_resizeAnimation.status == AnimationStatus.completed);
474
          throw FlutterError(
475 476
            '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'
477 478 479 480
            'widget from the application once that handler has fired.'
          );
        }
        return true;
481
      }());
482

483
      return SizeTransition(
Hans Muller's avatar
Hans Muller committed
484
        sizeFactor: _resizeAnimation,
485
        axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
486
        child: SizedBox(
487 488 489 490
          width: _sizePriorToCollapse.width,
          height: _sizePriorToCollapse.height,
          child: background
        )
491
      );
Adam Barth's avatar
Adam Barth committed
492
    }
493

494
    Widget content = SlideTransition(
495
      position: _moveAnimation,
496
      child: widget.child
497
    );
498

499
    if (background != null) {
500
      final List<Widget> children = <Widget>[];
501 502

      if (!_moveAnimation.isDismissed) {
503 504 505
        children.add(Positioned.fill(
          child: ClipRect(
            clipper: _DismissibleClipper(
506 507 508 509 510 511 512 513 514
              axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
              moveAnimation: _moveAnimation,
            ),
            child: background
          )
        ));
      }

      children.add(content);
515
      content = Stack(children: children);
516 517
    }

518
    // We are not resizing but we may be being dragging in widget.direction.
519
    return GestureDetector(
520 521 522 523 524 525
      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
526
      behavior: HitTestBehavior.opaque,
527
      child: content
528 529 530
    );
  }
}
531