dismissible.dart 17.6 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 16
const Duration _kDismissDuration = const Duration(milliseconds: 200);
const Curve _kResizeTimeCurve = const Interval(0.4, 1.0, curve: Curves.ease);
17 18
const double _kMinFlingVelocity = 700.0;
const double _kMinFlingVelocityDelta = 400.0;
19
const double _kFlingVelocityScale = 1.0 / 300.0;
Hans Muller's avatar
Hans Muller committed
20
const double _kDismissThreshold = 0.4;
21

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

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

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

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

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

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

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

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

90
  /// The widget below this widget in the tree.
91
  final Widget child;
Adam Barth's avatar
Adam Barth committed
92

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

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

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

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

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

135
  @override
136
  _DismissibleState createState() => new _DismissibleState();
137
}
138

139 140
class _DismissibleClipper extends CustomClipper<Rect> {
  _DismissibleClipper({
141 142
    @required this.axis,
    @required this.moveAnimation
143 144 145
  }) : assert(axis != null),
       assert(moveAnimation != null),
       super(reclip: moveAnimation);
146 147

  final Axis axis;
148
  final Animation<Offset> moveAnimation;
149 150 151 152 153 154

  @override
  Rect getClip(Size size) {
    assert(axis != null);
    switch (axis) {
      case Axis.horizontal:
155
        final double offset = moveAnimation.value.dx * size.width;
156 157 158 159
        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:
160
        final double offset = moveAnimation.value.dy * size.height;
161 162 163 164 165 166 167 168 169 170 171
        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
172
  bool shouldReclip(_DismissibleClipper oldClipper) {
173 174 175 176 177
    return oldClipper.axis != axis
        || oldClipper.moveAnimation.value != moveAnimation.value;
  }
}

Ian Hickson's avatar
Ian Hickson committed
178 179
enum _FlingGestureKind { none, forward, reverse }

180
class _DismissibleState extends State<Dismissible> with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
181
  @override
182
  void initState() {
183
    super.initState();
184
    _moveController = new AnimationController(duration: _kDismissDuration, vsync: this)
185 186
      ..addStatusListener(_handleDismissStatusChanged);
    _updateMoveAnimation();
187 188
  }

189
  AnimationController _moveController;
190
  Animation<Offset> _moveAnimation;
191

192
  AnimationController _resizeController;
193
  Animation<double> _resizeAnimation;
194 195 196

  double _dragExtent = 0.0;
  bool _dragUnderway = false;
197
  Size _sizePriorToCollapse;
198

199 200 201
  @override
  bool get wantKeepAlive => _moveController?.isAnimating == true || _resizeController?.isAnimating == true;

202
  @override
203
  void dispose() {
204 205
    _moveController.dispose();
    _resizeController?.dispose();
206 207 208
    super.dispose();
  }

209
  bool get _directionIsXAxis {
210 211 212
    return widget.direction == DismissDirection.horizontal
        || widget.direction == DismissDirection.endToStart
        || widget.direction == DismissDirection.startToEnd;
213 214
  }

Ian Hickson's avatar
Ian Hickson committed
215 216 217 218 219 220 221 222 223 224 225 226 227 228
  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;
229 230
  }

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

233
  bool get _isActive {
234
    return _dragUnderway || _moveController.isAnimating;
235 236
  }

237
  double get _overallDragAxisExtent {
238
    final Size size = context.size;
239
    return _directionIsXAxis ? size.width : size.height;
240 241
  }

242
  void _handleDragStart(DragStartDetails details) {
243 244
    _dragUnderway = true;
    if (_moveController.isAnimating) {
245
      _dragExtent = _moveController.value * _overallDragAxisExtent * _dragExtent.sign;
246 247 248 249 250
      _moveController.stop();
    } else {
      _dragExtent = 0.0;
      _moveController.value = 0.0;
    }
251
    setState(() {
252
      _updateMoveAnimation();
253
    });
254 255
  }

256
  void _handleDragUpdate(DragUpdateDetails details) {
257
    if (!_isActive || _moveController.isAnimating)
258
      return;
259

260 261
    final double delta = details.primaryDelta;
    final double oldDragExtent = _dragExtent;
262
    switch (widget.direction) {
263 264
      case DismissDirection.horizontal:
      case DismissDirection.vertical:
265
        _dragExtent += delta;
266 267 268
        break;

      case DismissDirection.up:
269 270
        if (_dragExtent + delta < 0)
          _dragExtent += delta;
271 272 273
        break;

      case DismissDirection.down:
274 275
        if (_dragExtent + delta > 0)
          _dragExtent += delta;
276
        break;
Ian Hickson's avatar
Ian Hickson committed
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302

      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;
303
    }
304 305
    if (oldDragExtent.sign != _dragExtent.sign) {
      setState(() {
306
        _updateMoveAnimation();
307 308
      });
    }
309
    if (!_moveController.isAnimating) {
310
      _moveController.value = _dragExtent.abs() / _overallDragAxisExtent;
311 312 313 314
    }
  }

  void _updateMoveAnimation() {
315 316 317 318
    final double end = _dragExtent.sign;
    _moveAnimation = new Tween<Offset>(
      begin: Offset.zero,
      end: _directionIsXAxis ? new Offset(end, 0.0) : new Offset(0.0, end),
319
    ).animate(_moveController);
320 321
  }

Ian Hickson's avatar
Ian Hickson committed
322 323 324 325 326 327 328 329 330 331
  _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;
    }
332 333
    final double vx = velocity.pixelsPerSecond.dx;
    final double vy = velocity.pixelsPerSecond.dy;
Ian Hickson's avatar
Ian Hickson committed
334 335
    DismissDirection flingDirection;
    // Verify that the fling is in the generally right direction and fast enough.
336
    if (_directionIsXAxis) {
Ian Hickson's avatar
Ian Hickson committed
337 338 339 340
      if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || vx.abs() < _kMinFlingVelocity)
        return _FlingGestureKind.none;
      assert(vx != 0.0);
      flingDirection = _extentToDirection(vx);
341
    } else {
Ian Hickson's avatar
Ian Hickson committed
342 343 344 345
      if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta || vy.abs() < _kMinFlingVelocity)
        return _FlingGestureKind.none;
      assert(vy != 0.0);
      flingDirection = _extentToDirection(vy);
346
    }
Ian Hickson's avatar
Ian Hickson committed
347 348 349 350
    assert(_dismissDirection != null);
    if (flingDirection == _dismissDirection)
      return _FlingGestureKind.forward;
    return _FlingGestureKind.reverse;
351 352
  }

353
  void _handleDragEnd(DragEndDetails details) {
354
    if (!_isActive || _moveController.isAnimating)
355
      return;
356 357 358
    _dragUnderway = false;
    if (_moveController.isCompleted) {
      _startResizeAnimation();
Ian Hickson's avatar
Ian Hickson committed
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387
      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;
388
    }
389 390
  }

391 392 393
  void _handleDismissStatusChanged(AnimationStatus status) {
    if (status == AnimationStatus.completed && !_dragUnderway)
      _startResizeAnimation();
394
    updateKeepAlive();
395 396
  }

397 398 399 400
  void _startResizeAnimation() {
    assert(_moveController != null);
    assert(_moveController.isCompleted);
    assert(_resizeController == null);
401
    assert(_sizePriorToCollapse == null);
402
    if (widget.resizeDuration == null) {
Ian Hickson's avatar
Ian Hickson committed
403 404 405 406 407
      if (widget.onDismissed != null) {
        final DismissDirection direction = _dismissDirection;
        assert(direction != null);
        widget.onDismissed(direction);
      }
408
    } else {
409
      _resizeController = new AnimationController(duration: widget.resizeDuration, vsync: this)
410 411
        ..addListener(_handleResizeProgressChanged)
        ..addStatusListener((AnimationStatus status) => updateKeepAlive());
412 413
      _resizeController.forward();
      setState(() {
414
        _sizePriorToCollapse = context.size;
415 416 417 418 419 420 421 422 423
        _resizeAnimation = new Tween<double>(
          begin: 1.0,
          end: 0.0
        ).animate(new CurvedAnimation(
          parent: _resizeController,
          curve: _kResizeTimeCurve
        ));
      });
    }
424
  }
425

426 427
  void _handleResizeProgressChanged() {
    if (_resizeController.isCompleted) {
Ian Hickson's avatar
Ian Hickson committed
428 429 430 431 432
      if (widget.onDismissed != null) {
        final DismissDirection direction = _dismissDirection;
        assert(direction != null);
        widget.onDismissed(direction);
      }
433
    } else {
434 435
      if (widget.onResize != null)
        widget.onResize();
436 437 438
    }
  }

439
  @override
440
  Widget build(BuildContext context) {
441
    super.build(context); // See AutomaticKeepAliveClientMixin.
Ian Hickson's avatar
Ian Hickson committed
442 443 444

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

445 446
    Widget background = widget.background;
    if (widget.secondaryBackground != null) {
447
      final DismissDirection direction = _dismissDirection;
448
      if (direction == DismissDirection.endToStart || direction == DismissDirection.up)
449
        background = widget.secondaryBackground;
450 451
    }

452 453 454 455 456
    if (_resizeAnimation != null) {
      // we've been dragged aside, and are now resizing.
      assert(() {
        if (_resizeAnimation.status != AnimationStatus.forward) {
          assert(_resizeAnimation.status == AnimationStatus.completed);
457
          throw new FlutterError(
458 459
            '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'
460 461 462 463
            'widget from the application once that handler has fired.'
          );
        }
        return true;
464
      }());
465

Hans Muller's avatar
Hans Muller committed
466 467
      return new SizeTransition(
        sizeFactor: _resizeAnimation,
468 469 470 471 472 473
        axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
        child: new SizedBox(
          width: _sizePriorToCollapse.width,
          height: _sizePriorToCollapse.height,
          child: background
        )
474
      );
Adam Barth's avatar
Adam Barth committed
475
    }
476

477
    Widget content = new SlideTransition(
478
      position: _moveAnimation,
479
      child: widget.child
480
    );
481

482
    if (background != null) {
483
      final List<Widget> children = <Widget>[];
484 485 486 487

      if (!_moveAnimation.isDismissed) {
        children.add(new Positioned.fill(
          child: new ClipRect(
488
            clipper: new _DismissibleClipper(
489 490 491 492 493 494 495 496 497 498
              axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
              moveAnimation: _moveAnimation,
            ),
            child: background
          )
        ));
      }

      children.add(content);
      content = new Stack(children: children);
499 500
    }

501
    // We are not resizing but we may be being dragging in widget.direction.
502
    return new GestureDetector(
503 504 505 506 507 508
      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
509
      behavior: HitTestBehavior.opaque,
510
      child: content
511 512 513
    );
  }
}