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 'package:flutter/foundation.dart';
6

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

Hans Muller's avatar
Hans Muller committed
15
const Curve _kResizeTimeCurve = const 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 26
typedef void DismissDirectionCallback(DismissDirection direction);

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

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

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

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

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

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

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

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

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

  /// 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).
107
  final VoidCallback onResize;
Adam Barth's avatar
Adam Barth committed
108

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

  /// The direction in which the widget can be dismissed.
113
  final DismissDirection direction;
114

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

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

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

147
  @override
148
  _DismissibleState createState() => new _DismissibleState();
149
}
150

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

  final Axis axis;
160
  final Animation<Offset> moveAnimation;
161 162 163 164 165 166

  @override
  Rect getClip(Size size) {
    assert(axis != null);
    switch (axis) {
      case Axis.horizontal:
167
        final double offset = moveAnimation.value.dx * size.width;
168 169 170 171
        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:
172
        final double offset = moveAnimation.value.dy * size.height;
173 174 175 176 177 178 179 180 181 182 183
        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
184
  bool shouldReclip(_DismissibleClipper oldClipper) {
185 186 187 188 189
    return oldClipper.axis != axis
        || oldClipper.moveAnimation.value != moveAnimation.value;
  }
}

Ian Hickson's avatar
Ian Hickson committed
190 191
enum _FlingGestureKind { none, forward, reverse }

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

201
  AnimationController _moveController;
202
  Animation<Offset> _moveAnimation;
203

204
  AnimationController _resizeController;
205
  Animation<double> _resizeAnimation;
206 207 208

  double _dragExtent = 0.0;
  bool _dragUnderway = false;
209
  Size _sizePriorToCollapse;
210

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

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

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

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

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

245
  bool get _isActive {
246
    return _dragUnderway || _moveController.isAnimating;
247 248
  }

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

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

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

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

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

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

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

  void _updateMoveAnimation() {
327 328 329
    final double end = _dragExtent.sign;
    _moveAnimation = new Tween<Offset>(
      begin: Offset.zero,
330 331 332
      end: _directionIsXAxis
          ? new Offset(end, widget.crossAxisEndOffset)
          : new Offset(widget.crossAxisEndOffset, end),
333
    ).animate(_moveController);
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 = new 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
        _resizeAnimation = new Tween<double>(
          begin: 1.0,
          end: 0.0
        ).animate(new CurvedAnimation(
          parent: _resizeController,
          curve: _kResizeTimeCurve
        ));
      });
    }
438
  }
439

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

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

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

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

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

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

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

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

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

      children.add(content);
      content = new Stack(children: children);
513 514
    }

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