refresh_indicator.dart 14.6 KB
Newer Older
1 2 3 4 5
// Copyright 2016 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.

import 'dart:async';
6
import 'dart:math' as math;
7

8
import 'package:flutter/foundation.dart';
9 10 11
import 'package:flutter/widgets.dart';

import 'progress_indicator.dart';
12
import 'theme.dart';
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29

// The over-scroll distance that moves the indicator to its maximum
// displacement, as a percentage of the scrollable's container extent.
const double _kDragContainerExtentPercentage = 0.25;

// How much the scroll's drag gesture can overshoot the RefreshIndicator's
// displacement; max displacement = _kDragSizeFactorLimit * displacement.
const double _kDragSizeFactorLimit = 1.5;

// When the scroll ends, the duration of the refresh indicator's animation
// to the RefreshIndicator's displacment.
const Duration _kIndicatorSnapDuration = const Duration(milliseconds: 150);

// The duration of the ScaleTransition that starts when the refresh action
// has completed.
const Duration _kIndicatorScaleDuration = const Duration(milliseconds: 200);

30 31 32 33 34
/// The signature for a function that's called when the user has dragged a
/// [RefreshIndicator] far enough to demonstrate that they want the app to
/// refresh. The returned [Future] must complete when the refresh operation is
/// finished.
///
35
/// Used by [RefreshIndicator.onRefresh].
36 37
typedef Future<Null> RefreshCallback();

38 39
// The state machine moves through these modes only when the scrollable
// identified by scrollableKey has been scrolled to its min or max limit.
40
enum _RefreshIndicatorMode {
41 42 43 44 45 46
  drag,     // Pointer is down.
  armed,    // Dragged far enough that an up event will run the onRefresh callback.
  snap,     // Animating to the indicator's final "displacement".
  refresh,  // Running the refresh callback.
  done,     // Animating the indicator's fade-out after refreshing.
  canceled, // Animating the indicator's fade-out after not arming.
47 48
}

49 50
/// A widget that supports the Material "swipe to refresh" idiom.
///
Adam Barth's avatar
Adam Barth committed
51
/// When the child's [Scrollable] descendant overscrolls, an animated circular
52 53 54 55 56 57
/// progress indicator is faded into view. When the scroll ends, if the
/// indicator has been dragged far enough for it to become completely opaque,
/// the [onRefresh] callback is called. The callback is expected to update the
/// scrollable's contents and then complete the [Future] it returns. The refresh
/// indicator disappears after the callback's [Future] has completed.
///
58 59 60 61 62 63 64 65 66 67 68 69 70 71
/// If the [Scrollable] might not have enough content to overscroll, consider
/// settings its `physics` property to [AlwaysScrollableScrollPhysics]:
///
/// ```dart
/// new ListView(
///   physics: const AlwaysScrollableScrollPhysics(),
///   children: ...
//  )
/// ```
///
/// Using [AlwaysScrollableScrollPhysics] will ensure that the scroll view is
/// always scrollable and, therefore, can trigger the [RefreshIndicator].
///
/// A [RefreshIndicator] can only be used with a vertical scroll view.
72 73 74
///
/// See also:
///
75
///  * <https://material.google.com/patterns/swipe-to-refresh.html>
76 77
///  * [RefreshIndicatorState], can be used to programatically show the refresh indicator.
///  * [RefreshProgressIndicator].
78
class RefreshIndicator extends StatefulWidget {
79 80
  /// Creates a refresh indicator.
  ///
81
  /// The [onRefresh] and [child] arguments must be non-null. The default
82
  /// [displacement] is 40.0 logical pixels.
83
  const RefreshIndicator({
84
    Key key,
85
    @required this.child,
86
    this.displacement: 40.0,
87
    @required this.onRefresh,
88 89
    this.color,
    this.backgroundColor
90 91 92
  }) : assert(child != null),
       assert(onRefresh != null),
       super(key: key);
93

94 95 96 97
  /// The refresh indicator will be stacked on top of this child. The indicator
  /// will appear when child's Scrollable descendant is over-scrolled.
  final Widget child;

98 99 100
  /// The distance from the child's top or bottom edge to where the refresh
  /// indicator will settle. During the drag that exposes the refresh indicator,
  /// its actual displacement may significantly exceed this value.
101 102 103 104
  final double displacement;

  /// A function that's called when the user has dragged the refresh indicator
  /// far enough to demonstrate that they want the app to refresh. The returned
105
  /// [Future] must complete when the refresh operation is finished.
106
  final RefreshCallback onRefresh;
107

108
  /// The progress indicator's foreground color. The current theme's
109
  /// [ThemeData.accentColor] by default.
110 111 112 113 114 115
  final Color color;

  /// The progress indicator's background color. The current theme's
  /// [ThemeData.canvasColor] by default.
  final Color backgroundColor;

116
  @override
117
  RefreshIndicatorState createState() => new RefreshIndicatorState();
118 119
}

120 121
/// Contains the state for a [RefreshIndicator]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
122
class RefreshIndicatorState extends State<RefreshIndicator> with TickerProviderStateMixin {
123
  AnimationController _positionController;
124
  AnimationController _scaleController;
125
  Animation<double> _positionFactor;
126
  Animation<double> _scaleFactor;
127
  Animation<double> _value;
128 129
  Animation<Color> _valueColor;

130 131
  _RefreshIndicatorMode _mode;
  Future<Null> _pendingRefreshFuture;
132 133
  bool _isIndicatorAtTop;
  double _dragOffset;
134 135 136 137 138

  @override
  void initState() {
    super.initState();

139 140 141 142 143
    _positionController = new AnimationController(vsync: this);
    _positionFactor = new Tween<double>(
      begin: 0.0,
      end: _kDragSizeFactorLimit,
    ).animate(_positionController);
144
    _value = new Tween<double>( // The "value" of the circular progress indicator during a drag.
145
      begin: 0.0,
146 147
      end: 0.75,
    ).animate(_positionController);
148 149

    _scaleController = new AnimationController(vsync: this);
150 151 152 153 154 155 156
    _scaleFactor = new Tween<double>(
      begin: 1.0,
      end: 0.0,
    ).animate(_scaleController);
  }

  @override
157
  void didChangeDependencies() {
158 159
    final ThemeData theme = Theme.of(context);
    _valueColor = new ColorTween(
160 161
      begin: (widget.color ?? theme.accentColor).withOpacity(0.0),
      end: (widget.color ?? theme.accentColor).withOpacity(1.0)
162 163 164 165
    ).animate(new CurvedAnimation(
      parent: _positionController,
      curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit)
    ));
166
    super.didChangeDependencies();
167 168 169 170
  }

  @override
  void dispose() {
171
    _positionController.dispose();
172 173 174 175
    _scaleController.dispose();
    super.dispose();
  }

Adam Barth's avatar
Adam Barth committed
176
  bool _handleScrollNotification(ScrollNotification notification) {
177 178 179
    if (notification.depth != 0)
      return false;
    if (notification is ScrollStartNotification && notification.metrics.extentBefore == 0.0 &&
180
        _mode == null && _start(notification.metrics.axisDirection)) {
181 182 183
      setState(() {
        _mode = _RefreshIndicatorMode.drag;
      });
184
      return false;
185 186
    }
    bool indicatorAtTopNow;
187
    switch (notification.metrics.axisDirection) {
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227
      case AxisDirection.down:
        indicatorAtTopNow = true;
        break;
      case AxisDirection.up:
        indicatorAtTopNow = false;
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        indicatorAtTopNow = null;
        break;
    }
    if (indicatorAtTopNow != _isIndicatorAtTop) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed)
        _dismiss(_RefreshIndicatorMode.canceled);
    } else if (notification is ScrollUpdateNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
        if (notification.metrics.extentBefore > 0.0) {
          _dismiss(_RefreshIndicatorMode.canceled);
        } else {
          _dragOffset -= notification.scrollDelta;
          _checkDragOffset(notification.metrics.viewportDimension);
        }
      }
    } else if (notification is OverscrollNotification) {
      if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) {
        _dragOffset -= notification.overscroll / 2.0;
        _checkDragOffset(notification.metrics.viewportDimension);
      }
    } else if (notification is ScrollEndNotification) {
      switch (_mode) {
        case _RefreshIndicatorMode.armed:
          _show();
          break;
        case _RefreshIndicatorMode.drag:
          _dismiss(_RefreshIndicatorMode.canceled);
          break;
        default:
          // do nothing
          break;
      }
228 229
    }
    return false;
230 231
  }

232 233 234 235 236 237
  bool _handleGlowNotification(OverscrollIndicatorNotification notification) {
    if (notification.depth != 0 || !notification.leading)
      return false;
    if (_mode == _RefreshIndicatorMode.drag) {
      notification.disallowGlow();
      return true;
238
    }
239
    return false;
240 241
  }

242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258
  bool _start(AxisDirection direction) {
    assert(_mode == null);
    assert(_isIndicatorAtTop == null);
    assert(_dragOffset == null);
    switch (direction) {
      case AxisDirection.down:
        _isIndicatorAtTop = true;
        break;
      case AxisDirection.up:
        _isIndicatorAtTop = false;
        break;
      case AxisDirection.left:
      case AxisDirection.right:
        _isIndicatorAtTop = null;
        // we do not support horizontal scroll views.
        return false;
    }
259 260
    _dragOffset = 0.0;
    _scaleController.value = 0.0;
261 262
    _positionController.value = 0.0;
    return true;
263 264
  }

265 266 267 268 269 270 271 272
  void _checkDragOffset(double containerExtent) {
    assert(_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed);
    double newValue = _dragOffset / (containerExtent * _kDragContainerExtentPercentage);
    if (_mode == _RefreshIndicatorMode.armed)
      newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit);
    _positionController.value = newValue.clamp(0.0, 1.0); // this triggers various rebuilds
    if (_mode == _RefreshIndicatorMode.drag && _valueColor.value.alpha == 0xFF)
      _mode = _RefreshIndicatorMode.armed;
273 274
  }

275 276 277 278 279 280
  // Stop showing the refresh indicator.
  Future<Null> _dismiss(_RefreshIndicatorMode newMode) async {
    // This can only be called from _show() when refreshing and
    // _handleScrollNotification in response to a ScrollEndNotification or
    // direction change.
    assert(newMode == _RefreshIndicatorMode.canceled || newMode == _RefreshIndicatorMode.done);
281
    setState(() {
282
      _mode = newMode;
283
    });
284 285
    switch (_mode) {
      case _RefreshIndicatorMode.done:
286 287
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
        break;
288 289 290 291 292
      case _RefreshIndicatorMode.canceled:
        await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration);
        break;
      default:
        assert(false);
293
    }
294 295 296
    if (mounted && _mode == newMode) {
      _dragOffset = null;
      _isIndicatorAtTop = null;
297 298 299 300
      setState(() {
        _mode = null;
      });
    }
301 302
  }

303 304 305
  void _show() {
    assert(_mode != _RefreshIndicatorMode.refresh);
    assert(_mode != _RefreshIndicatorMode.snap);
306
    final Completer<Null> completer = new Completer<Null>();
307
    _pendingRefreshFuture = completer.future;
308
    _mode = _RefreshIndicatorMode.snap;
309
    _positionController
310
      .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration)
311
      .then((Null value) {
312
        if (mounted && _mode == _RefreshIndicatorMode.snap) {
313
          assert(widget.onRefresh != null);
314 315 316 317 318
          setState(() {
            // Show the indeterminate progress indicator.
            _mode = _RefreshIndicatorMode.refresh;
          });

319
          widget.onRefresh().whenComplete(() {
320 321
            if (mounted && _mode == _RefreshIndicatorMode.refresh) {
              completer.complete();
322
              _dismiss(_RefreshIndicatorMode.done);
323 324 325
            }
          });
        }
326 327 328 329 330 331 332
      });
  }

  /// Show the refresh indicator and run the refresh callback as if it had
  /// been started interactively. If this method is called while the refresh
  /// callback is running, it quietly does nothing.
  ///
333
  /// Creating the [RefreshIndicator] with a [GlobalKey<RefreshIndicatorState>]
334
  /// makes it possible to refer to the [RefreshIndicatorState].
335 336 337 338 339 340 341 342 343 344 345
  ///
  /// The future returned from this method completes when the [onRefresh]
  /// callback's future completes.
  ///
  /// If you await the future returned by this function from a [State], you
  /// should check that the state is still [mounted] before calling [setState].
  ///
  /// When initiated in this manner, the refresh indicator is independent of any
  /// actual scroll view. It defaults to showing the indicator at the top. To
  /// show it at the bottom, set `atTop` to false.
  Future<Null> show({ bool atTop: true }) {
346 347
    if (_mode != _RefreshIndicatorMode.refresh &&
        _mode != _RefreshIndicatorMode.snap) {
348 349
      if (_mode == null)
        _start(atTop ? AxisDirection.down : AxisDirection.up);
350
      _show();
351
    }
352
    return _pendingRefreshFuture;
353 354
  }

355
  final GlobalKey _key = new GlobalKey();
356

357 358
  @override
  Widget build(BuildContext context) {
359
    final Widget child = new NotificationListener<ScrollNotification>(
360 361 362 363
      key: _key,
      onNotification: _handleScrollNotification,
      child: new NotificationListener<OverscrollIndicatorNotification>(
        onNotification: _handleGlowNotification,
364
        child: widget.child,
365 366 367 368 369 370 371 372 373
      ),
    );
    if (_mode == null) {
      assert(_dragOffset == null);
      assert(_isIndicatorAtTop == null);
      return child;
    }
    assert(_dragOffset != null);
    assert(_isIndicatorAtTop != null);
374

375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390
    final bool showIndeterminateIndicator =
      _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.done;

    return new Stack(
      children: <Widget>[
        child,
        new Positioned(
          top: _isIndicatorAtTop ? 0.0 : null,
          bottom: !_isIndicatorAtTop ? 0.0 : null,
          left: 0.0,
          right: 0.0,
          child: new SizeTransition(
            axisAlignment: _isIndicatorAtTop ? 1.0 : 0.0,
            sizeFactor: _positionFactor, // this is what brings it down
            child: new Container(
              padding: _isIndicatorAtTop
391 392
                ? new EdgeInsets.only(top: widget.displacement)
                : new EdgeInsets.only(bottom: widget.displacement),
393 394 395 396 397 398 399 400 401 402 403
              alignment: _isIndicatorAtTop
                ? FractionalOffset.topCenter
                : FractionalOffset.bottomCenter,
              child: new ScaleTransition(
                scale: _scaleFactor,
                child: new AnimatedBuilder(
                  animation: _positionController,
                  builder: (BuildContext context, Widget child) {
                    return new RefreshProgressIndicator(
                      value: showIndeterminateIndicator ? null : _value.value,
                      valueColor: _valueColor,
404
                      backgroundColor: widget.backgroundColor,
405 406 407 408 409
                    );
                  },
                ),
              ),
            ),
410
          ),
411 412
        ),
      ],
413 414 415
    );
  }
}