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 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161

  final Axis axis;
  final Animation<FractionalOffset> moveAnimation;

  @override
  Rect getClip(Size size) {
    assert(axis != null);
    switch (axis) {
      case Axis.horizontal:
        final double offset = moveAnimation.value.dx * size.width;
        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:
        final double offset = moveAnimation.value.dy * size.height;
        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 178 179
  AnimationController _moveController;
  Animation<FractionalOffset> _moveAnimation;

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
    _moveAnimation = new FractionalOffsetTween(
272
      begin: FractionalOffset.topLeft,
273 274 275 276
      end: _directionIsXAxis ?
             new FractionalOffset(_dragExtent.sign, 0.0) :
             new FractionalOffset(0.0, _dragExtent.sign)
    ).animate(_moveController);
277 278
  }

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

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

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

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

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

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

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

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

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

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

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

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

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