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

Hans Muller's avatar
Hans Muller committed
13
const Curve _kResizeTimeCurve = const 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 void DismissDirectionCallback(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() => new _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 167 168 169
        if (offset < 0)
          return new Rect.fromLTRB(size.width + offset, 0.0, size.width, size.height);
        return new Rect.fromLTRB(0.0, 0.0, offset, size.height);
      case Axis.vertical:
170
        final double offset = moveAnimation.value.dy * size.height;
171 172 173 174 175 176 177 178 179 180 181
        if (offset < 0)
          return new Rect.fromLTRB(0.0, size.height + offset, size.width, size.height);
        return new Rect.fromLTRB(0.0, 0.0, size.width, offset);
    }
    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 = new 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 326 327
    final double end = _dragExtent.sign;
    _moveAnimation = new Tween<Offset>(
      begin: Offset.zero,
328 329 330
      end: _directionIsXAxis
          ? new Offset(end, widget.crossAxisEndOffset)
          : new Offset(widget.crossAxisEndOffset, end),
331
    ).animate(_moveController);
332 333
  }

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

365
  void _handleDragEnd(DragEndDetails details) {
366
    if (!_isActive || _moveController.isAnimating)
367
      return;
368 369 370
    _dragUnderway = false;
    if (_moveController.isCompleted) {
      _startResizeAnimation();
Ian Hickson's avatar
Ian Hickson committed
371 372 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
      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;
400
    }
401 402
  }

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

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

438 439
  void _handleResizeProgressChanged() {
    if (_resizeController.isCompleted) {
Ian Hickson's avatar
Ian Hickson committed
440 441 442 443 444
      if (widget.onDismissed != null) {
        final DismissDirection direction = _dismissDirection;
        assert(direction != null);
        widget.onDismissed(direction);
      }
445
    } else {
446 447
      if (widget.onResize != null)
        widget.onResize();
448 449 450
    }
  }

451
  @override
452
  Widget build(BuildContext context) {
453
    super.build(context); // See AutomaticKeepAliveClientMixin.
Ian Hickson's avatar
Ian Hickson committed
454 455 456

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

457 458
    Widget background = widget.background;
    if (widget.secondaryBackground != null) {
459
      final DismissDirection direction = _dismissDirection;
460
      if (direction == DismissDirection.endToStart || direction == DismissDirection.up)
461
        background = widget.secondaryBackground;
462 463
    }

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

Hans Muller's avatar
Hans Muller committed
478 479
      return new SizeTransition(
        sizeFactor: _resizeAnimation,
480 481 482 483 484 485
        axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
        child: new SizedBox(
          width: _sizePriorToCollapse.width,
          height: _sizePriorToCollapse.height,
          child: background
        )
486
      );
Adam Barth's avatar
Adam Barth committed
487
    }
488

489
    Widget content = new SlideTransition(
490
      position: _moveAnimation,
491
      child: widget.child
492
    );
493

494
    if (background != null) {
495
      final List<Widget> children = <Widget>[];
496 497 498 499

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

      children.add(content);
      content = new Stack(children: children);
511 512
    }

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