dismissible.dart 14.9 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 9 10
import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
11 12
import 'ticker_provider.dart';
import 'transitions.dart';
13

Hans Muller's avatar
Hans Muller committed
14 15
const Duration _kDismissDuration = const Duration(milliseconds: 200);
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 87
  }) : assert(key != null),
       assert(secondaryBackground != null ? background != null : true),
       super(key: key);
88

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

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

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

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

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

111 112 113 114 115 116
  /// 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;

117 118 119 120 121 122 123 124
  /// The offset threshold the item has to be dragged in order to be considered dismissed.
  ///
  /// Represented as a fraction, e.g. if it is 0.4, 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. This allows for use cases where item can be
  /// dismissed to end but not to start.
  final Map<DismissDirection, double> dismissThresholds;

125
  @override
126
  _DismissibleState createState() => new _DismissibleState();
127
}
128

129 130
class _DismissibleClipper extends CustomClipper<Rect> {
  _DismissibleClipper({
131 132
    @required this.axis,
    @required this.moveAnimation
133 134 135
  }) : assert(axis != null),
       assert(moveAnimation != null),
       super(reclip: moveAnimation);
136 137

  final Axis axis;
138
  final Animation<Offset> moveAnimation;
139 140 141 142 143 144

  @override
  Rect getClip(Size size) {
    assert(axis != null);
    switch (axis) {
      case Axis.horizontal:
145
        final double offset = moveAnimation.value.dx * size.width;
146 147 148 149
        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:
150
        final double offset = moveAnimation.value.dy * size.height;
151 152 153 154 155 156 157 158 159 160 161
        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
162
  bool shouldReclip(_DismissibleClipper oldClipper) {
163 164 165 166 167
    return oldClipper.axis != axis
        || oldClipper.moveAnimation.value != moveAnimation.value;
  }
}

168
class _DismissibleState extends State<Dismissible> with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
169
  @override
170
  void initState() {
171
    super.initState();
172
    _moveController = new AnimationController(duration: _kDismissDuration, vsync: this)
173 174
      ..addStatusListener(_handleDismissStatusChanged);
    _updateMoveAnimation();
175 176
  }

177
  AnimationController _moveController;
178
  Animation<Offset> _moveAnimation;
179

180
  AnimationController _resizeController;
181
  Animation<double> _resizeAnimation;
182 183 184

  double _dragExtent = 0.0;
  bool _dragUnderway = false;
185
  Size _sizePriorToCollapse;
186

187 188 189
  @override
  bool get wantKeepAlive => _moveController?.isAnimating == true || _resizeController?.isAnimating == true;

190
  @override
191
  void dispose() {
192 193
    _moveController.dispose();
    _resizeController?.dispose();
194 195 196
    super.dispose();
  }

197
  bool get _directionIsXAxis {
198 199 200
    return widget.direction == DismissDirection.horizontal
        || widget.direction == DismissDirection.endToStart
        || widget.direction == DismissDirection.startToEnd;
201 202
  }

203 204
  DismissDirection get _dismissDirection {
    if (_directionIsXAxis)
205
      return  _dragExtent > 0 ? DismissDirection.startToEnd : DismissDirection.endToStart;
206 207 208
    return _dragExtent > 0 ? DismissDirection.down : DismissDirection.up;
  }

209
  double get _dismissThreshold {
210
    return widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold;
211 212
  }

213
  bool get _isActive {
214
    return _dragUnderway || _moveController.isAnimating;
215 216
  }

217
  double get _overallDragAxisExtent {
218
    final Size size = context.size;
219
    return _directionIsXAxis ? size.width : size.height;
220 221
  }

222
  void _handleDragStart(DragStartDetails details) {
223 224
    _dragUnderway = true;
    if (_moveController.isAnimating) {
225
      _dragExtent = _moveController.value * _overallDragAxisExtent * _dragExtent.sign;
226 227 228 229 230
      _moveController.stop();
    } else {
      _dragExtent = 0.0;
      _moveController.value = 0.0;
    }
231
    setState(() {
232
      _updateMoveAnimation();
233
    });
234 235
  }

236
  void _handleDragUpdate(DragUpdateDetails details) {
237
    if (!_isActive || _moveController.isAnimating)
238
      return;
239

240 241
    final double delta = details.primaryDelta;
    final double oldDragExtent = _dragExtent;
242
    switch (widget.direction) {
243 244
      case DismissDirection.horizontal:
      case DismissDirection.vertical:
245
        _dragExtent += delta;
246 247 248
        break;

      case DismissDirection.up:
249
      case DismissDirection.endToStart:
250 251
        if (_dragExtent + delta < 0)
          _dragExtent += delta;
252 253 254
        break;

      case DismissDirection.down:
255
      case DismissDirection.startToEnd:
256 257
        if (_dragExtent + delta > 0)
          _dragExtent += delta;
258 259
        break;
    }
260 261
    if (oldDragExtent.sign != _dragExtent.sign) {
      setState(() {
262
        _updateMoveAnimation();
263 264
      });
    }
265
    if (!_moveController.isAnimating) {
266
      _moveController.value = _dragExtent.abs() / _overallDragAxisExtent;
267 268 269 270
    }
  }

  void _updateMoveAnimation() {
271 272 273 274
    final double end = _dragExtent.sign;
    _moveAnimation = new Tween<Offset>(
      begin: Offset.zero,
      end: _directionIsXAxis ? new Offset(end, 0.0) : new Offset(0.0, end),
275
    ).animate(_moveController);
276 277
  }

278
  bool _isFlingGesture(Velocity velocity) {
279 280 281
    // Cannot fling an item if it cannot be dismissed by drag.
    if (_dismissThreshold >= 1.0)
      return false;
282 283
    final double vx = velocity.pixelsPerSecond.dx;
    final double vy = velocity.pixelsPerSecond.dy;
284
    if (_directionIsXAxis) {
285 286
      if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta)
        return false;
287
      switch (widget.direction) {
288 289
        case DismissDirection.horizontal:
          return vx.abs() > _kMinFlingVelocity;
290
        case DismissDirection.endToStart:
291 292 293 294
          return -vx > _kMinFlingVelocity;
        default:
          return vx > _kMinFlingVelocity;
      }
295 296 297
    } else {
      if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta)
        return false;
298
      switch (widget.direction) {
299 300 301 302 303 304 305
        case DismissDirection.vertical:
          return vy.abs() > _kMinFlingVelocity;
        case DismissDirection.up:
          return -vy > _kMinFlingVelocity;
        default:
          return vy > _kMinFlingVelocity;
      }
306
    }
307 308
  }

309
  void _handleDragEnd(DragEndDetails details) {
310
    if (!_isActive || _moveController.isAnimating)
311
      return;
312 313 314
    _dragUnderway = false;
    if (_moveController.isCompleted) {
      _startResizeAnimation();
315
    } else if (_isFlingGesture(details.velocity)) {
316
      final double flingVelocity = _directionIsXAxis ? details.velocity.pixelsPerSecond.dx : details.velocity.pixelsPerSecond.dy;
317 318
      _dragExtent = flingVelocity.sign;
      _moveController.fling(velocity: flingVelocity.abs() * _kFlingVelocityScale);
319
    } else if (_moveController.value > _dismissThreshold) {
320 321 322 323
      _moveController.forward();
    } else {
      _moveController.reverse();
    }
324 325
  }

326 327 328
  void _handleDismissStatusChanged(AnimationStatus status) {
    if (status == AnimationStatus.completed && !_dragUnderway)
      _startResizeAnimation();
329
    updateKeepAlive();
330 331
  }

332 333 334 335
  void _startResizeAnimation() {
    assert(_moveController != null);
    assert(_moveController.isCompleted);
    assert(_resizeController == null);
336
    assert(_sizePriorToCollapse == null);
337 338 339
    if (widget.resizeDuration == null) {
      if (widget.onDismissed != null)
        widget.onDismissed(_dismissDirection);
340
    } else {
341
      _resizeController = new AnimationController(duration: widget.resizeDuration, vsync: this)
342 343
        ..addListener(_handleResizeProgressChanged)
        ..addStatusListener((AnimationStatus status) => updateKeepAlive());
344 345
      _resizeController.forward();
      setState(() {
346
        _sizePriorToCollapse = context.size;
347 348 349 350 351 352 353 354 355
        _resizeAnimation = new Tween<double>(
          begin: 1.0,
          end: 0.0
        ).animate(new CurvedAnimation(
          parent: _resizeController,
          curve: _kResizeTimeCurve
        ));
      });
    }
356
  }
357

358 359
  void _handleResizeProgressChanged() {
    if (_resizeController.isCompleted) {
360 361
      if (widget.onDismissed != null)
        widget.onDismissed(_dismissDirection);
362
    } else {
363 364
      if (widget.onResize != null)
        widget.onResize();
365 366 367
    }
  }

368
  @override
369
  Widget build(BuildContext context) {
370
    super.build(context); // See AutomaticKeepAliveClientMixin.
371 372
    Widget background = widget.background;
    if (widget.secondaryBackground != null) {
373
      final DismissDirection direction = _dismissDirection;
374
      if (direction == DismissDirection.endToStart || direction == DismissDirection.up)
375
        background = widget.secondaryBackground;
376 377
    }

378 379 380 381 382
    if (_resizeAnimation != null) {
      // we've been dragged aside, and are now resizing.
      assert(() {
        if (_resizeAnimation.status != AnimationStatus.forward) {
          assert(_resizeAnimation.status == AnimationStatus.completed);
383
          throw new FlutterError(
384 385
            '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'
386 387 388 389
            'widget from the application once that handler has fired.'
          );
        }
        return true;
390
      }());
391

Hans Muller's avatar
Hans Muller committed
392 393
      return new SizeTransition(
        sizeFactor: _resizeAnimation,
394 395 396 397 398 399
        axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
        child: new SizedBox(
          width: _sizePriorToCollapse.width,
          height: _sizePriorToCollapse.height,
          child: background
        )
400
      );
Adam Barth's avatar
Adam Barth committed
401
    }
402

403
    Widget content = new SlideTransition(
404
      position: _moveAnimation,
405
      child: widget.child
406
    );
407

408
    if (background != null) {
409
      final List<Widget> children = <Widget>[];
410 411 412 413

      if (!_moveAnimation.isDismissed) {
        children.add(new Positioned.fill(
          child: new ClipRect(
414
            clipper: new _DismissibleClipper(
415 416 417 418 419 420 421 422 423 424
              axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
              moveAnimation: _moveAnimation,
            ),
            child: background
          )
        ));
      }

      children.add(content);
      content = new Stack(children: children);
425 426
    }

427
    // We are not resizing but we may be being dragging in widget.direction.
428
    return new GestureDetector(
429 430 431 432 433 434
      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
435
      behavior: HitTestBehavior.opaque,
436
      child: content
437 438 439
    );
  }
}