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

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

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

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

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

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

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

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

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

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

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

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

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

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

110 111 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
  /// immediately after the the widget is dismissed.
  final Duration resizeDuration;

116 117 118 119 120 121 122 123
  /// 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;

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

128 129
class _DismissibleClipper extends CustomClipper<Rect> {
  _DismissibleClipper({
130 131
    @required this.axis,
    @required this.moveAnimation
132
  }) : super(reclip: moveAnimation) {
133 134 135 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
    assert(axis != null);
    assert(moveAnimation != null);
  }

  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 {
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
  @override
188
  void dispose() {
189 190
    _moveController.dispose();
    _resizeController?.dispose();
191 192 193
    super.dispose();
  }

194
  bool get _directionIsXAxis {
195 196 197
    return widget.direction == DismissDirection.horizontal
        || widget.direction == DismissDirection.endToStart
        || widget.direction == DismissDirection.startToEnd;
198 199
  }

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

206
  double get _dismissThreshold {
207
    return widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold;
208 209
  }

210
  bool get _isActive {
211
    return _dragUnderway || _moveController.isAnimating;
212 213
  }

214
  double get _overallDragAxisExtent {
215
    final Size size = context.size;
216
    return _directionIsXAxis ? size.width : size.height;
217 218
  }

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

233
  void _handleDragUpdate(DragUpdateDetails details) {
234
    if (!_isActive || _moveController.isAnimating)
235
      return;
236

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

      case DismissDirection.up:
246
      case DismissDirection.endToStart:
247 248
        if (_dragExtent + delta < 0)
          _dragExtent += delta;
249 250 251
        break;

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

  void _updateMoveAnimation() {
268
    _moveAnimation = new FractionalOffsetTween(
269
      begin: FractionalOffset.topLeft,
270 271 272 273
      end: _directionIsXAxis ?
             new FractionalOffset(_dragExtent.sign, 0.0) :
             new FractionalOffset(0.0, _dragExtent.sign)
    ).animate(_moveController);
274 275
  }

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

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

324 325 326
  void _handleDismissStatusChanged(AnimationStatus status) {
    if (status == AnimationStatus.completed && !_dragUnderway)
      _startResizeAnimation();
327 328
  }

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

354 355
  void _handleResizeProgressChanged() {
    if (_resizeController.isCompleted) {
356 357
      if (widget.onDismissed != null)
        widget.onDismissed(_dismissDirection);
358
    } else {
359 360
      if (widget.onResize != null)
        widget.onResize();
361 362 363
    }
  }

364
  @override
365
  Widget build(BuildContext context) {
366 367
    Widget background = widget.background;
    if (widget.secondaryBackground != null) {
368
      final DismissDirection direction = _dismissDirection;
369
      if (direction == DismissDirection.endToStart || direction == DismissDirection.up)
370
        background = widget.secondaryBackground;
371 372
    }

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

Hans Muller's avatar
Hans Muller committed
387 388
      return new SizeTransition(
        sizeFactor: _resizeAnimation,
389 390 391 392 393 394
        axis: _directionIsXAxis ? Axis.vertical : Axis.horizontal,
        child: new SizedBox(
          width: _sizePriorToCollapse.width,
          height: _sizePriorToCollapse.height,
          child: background
        )
395
      );
Adam Barth's avatar
Adam Barth committed
396
    }
397

398
    Widget content = new SlideTransition(
399
      position: _moveAnimation,
400
      child: widget.child
401
    );
402

403
    if (background != null) {
404
      final List<Widget> children = <Widget>[];
405 406 407 408

      if (!_moveAnimation.isDismissed) {
        children.add(new Positioned.fill(
          child: new ClipRect(
409
            clipper: new _DismissibleClipper(
410 411 412 413 414 415 416 417 418 419
              axis: _directionIsXAxis ? Axis.horizontal : Axis.vertical,
              moveAnimation: _moveAnimation,
            ),
            child: background
          )
        ));
      }

      children.add(content);
      content = new Stack(children: children);
420 421
    }

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