refresh_indicator.dart 14.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// 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';

import 'package:flutter/widgets.dart';

import 'theme.dart';
import 'progress_indicator.dart';

// 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;

// How far the indicator must be dragged to trigger the refresh callback.
const double _kDragThresholdFactor = 0.75;

// 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);

31 32 33 34 35 36
/// 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.
///
/// Used by [RefreshIndicator.refresh].
37 38 39 40
typedef Future<Null> RefreshCallback();

/// Where the refresh indicator appears: top for over-scrolls at the
/// start of the scrollable, bottom for over-scrolls at the end.
41
enum RefreshIndicatorLocation {
42
  /// The refresh indicator will appear at the top of the scrollable.
43 44
  top,

45
  /// The refresh indicator will appear at the bottom of the scrollable.
46
  bottom,
47 48 49

  /// The refresh indicator will appear at both ends of the scrollable.
  both
50
}
51

52 53
// The state machine moves through these modes only when the scrollable
// identified by scrollableKey has been scrolled to its min or max limit.
54
enum _RefreshIndicatorMode {
55 56 57 58 59
  drag,   // Pointer is down.
  armed,  // Dragged far enough that an up event will run the refresh callback.
  snap,   // Animating to the indicator's final "displacement".
  refresh, // Running the refresh callback.
  dismiss  // Animating the indicator's fade-out.
60 61
}

62 63 64 65 66
enum _DismissTransition {
  shrink, // Refresh callback completed, scale the indicator to 0.
  slide // No refresh, translate the indicator out of view.
}

67 68 69 70 71 72
/// A widget that supports the Material "swipe to refresh" idiom.
///
/// When the child's vertical Scrollable descendant overscrolls, an
/// animated circular 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 refresh callback is called. The callback is
73 74
/// expected to update the scrollable's contents and then complete the Future
/// it returns. The refresh indicator disappears after the callback's
75 76
/// Future has completed.
///
77 78
/// The required [scrollableKey] parameter identifies the scrollable widget
/// whose scrollOffset is monitored by this RefreshIndicator. The same
79
/// scrollableKey must also be set on the scrollable. See [Block.scrollableKey],
80 81
/// [ScrollableList.scrollableKey], etc.
///
82 83
/// See also:
///
84
///  * <https://material.google.com/patterns/swipe-to-refresh.html>
85 86
///  * [RefreshIndicatorState], can be used to programatically show the refresh indicator.
///  * [RefreshProgressIndicator].
87
class RefreshIndicator extends StatefulWidget {
88 89 90 91
  /// Creates a refresh indicator.
  ///
  /// The [refresh] and [child] arguments must be non-null. The default
  /// [displacement] is 40.0 logical pixels.
92 93 94 95 96
  RefreshIndicator({
    Key key,
    this.scrollableKey,
    this.child,
    this.displacement: 40.0,
97
    this.refresh,
98 99 100
    this.location: RefreshIndicatorLocation.top,
    this.color,
    this.backgroundColor
101 102 103
  }) : super(key: key) {
    assert(child != null);
    assert(refresh != null);
104
    assert(location != null);
105 106 107
  }

  /// Identifies the [Scrollable] descendant of child that will cause the
108
  /// refresh indicator to appear.
109
  final GlobalKey<ScrollableState> scrollableKey;
110

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

115 116 117 118 119 120 121
  /// 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.
  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
122
  /// [Future] must complete when the refresh operation is finished.
123 124
  final RefreshCallback refresh;

125
  /// Where the refresh indicator should appear, [RefreshIndicatorLocation.top]
126 127
  /// by default.
  final RefreshIndicatorLocation location;
128

129
  /// The progress indicator's foreground color. The current theme's
130
  /// [ThemeData.accentColor] by default.
131 132 133 134 135 136
  final Color color;

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

137
  @override
138
  RefreshIndicatorState createState() => new RefreshIndicatorState();
139 140
}

141 142
/// Contains the state for a [RefreshIndicator]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
143 144 145
class RefreshIndicatorState extends State<RefreshIndicator> with TickerProviderStateMixin {
  AnimationController _sizeController;
  AnimationController _scaleController;
146 147
  Animation<double> _sizeFactor;
  Animation<double> _scaleFactor;
148
  Animation<double> _value;
149 150
  Animation<Color> _valueColor;

151
  double _dragOffset;
152
  bool _isIndicatorAtTop = true;
153 154
  _RefreshIndicatorMode _mode;
  Future<Null> _pendingRefreshFuture;
155 156 157 158 159

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

160 161 162
    _sizeController = new AnimationController(vsync: this);
    _sizeFactor = new Tween<double>(begin: 0.0, end: _kDragSizeFactorLimit).animate(_sizeController);
    _value = new Tween<double>( // The "value" of the circular progress indicator during a drag.
163 164
      begin: 0.0,
      end: 0.75
165 166 167 168
    ).animate(_sizeController);

    _scaleController = new AnimationController(vsync: this);
    _scaleFactor = new Tween<double>(begin: 1.0, end: 0.0).animate(_scaleController);
169 170 171 172 173 174 175 176 177
  }

  @override
  void dispose() {
    _sizeController.dispose();
    _scaleController.dispose();
    super.dispose();
  }

178 179 180
  bool _isValidScrollable(ScrollableState scrollable) {
    if (scrollable == null)
      return false;
181
    final Axis axis = scrollable.config.scrollDirection;
182
    return axis == Axis.vertical && scrollable.scrollBehavior is ExtentScrollBehavior;
183 184
  }

185 186 187 188 189 190 191 192 193 194 195 196 197
  bool _isScrolledToLimit(ScrollableState scrollable) {
    final double minScrollOffset = scrollable.scrollBehavior.minScrollOffset;
    final double maxScrollOffset = scrollable.scrollBehavior.maxScrollOffset;
    final double scrollOffset = scrollable.scrollOffset;
    switch (config.location) {
      case RefreshIndicatorLocation.top:
        return scrollOffset <= minScrollOffset;
      case RefreshIndicatorLocation.bottom:
        return scrollOffset >= maxScrollOffset;
      case RefreshIndicatorLocation.both:
        return scrollOffset <= minScrollOffset || scrollOffset >= maxScrollOffset;
    }
    return false;
198 199
  }

200 201 202 203
  double _overscrollDistance(ScrollableState scrollable) {
    final double minScrollOffset = scrollable.scrollBehavior.minScrollOffset;
    final double maxScrollOffset = scrollable.scrollBehavior.maxScrollOffset;
    final double scrollOffset = scrollable.scrollOffset;
204 205
    switch (config.location) {
      case RefreshIndicatorLocation.top:
206
        return  scrollOffset <= minScrollOffset ? -_dragOffset : 0.0;
207
      case RefreshIndicatorLocation.bottom:
208
        return scrollOffset >= maxScrollOffset ? _dragOffset : 0.0;
209
      case RefreshIndicatorLocation.both: {
210 211 212 213
        if (scrollOffset <= minScrollOffset)
          return -_dragOffset;
        else if (scrollOffset >= maxScrollOffset)
          return _dragOffset;
214 215 216 217 218 219 220
        else
          return 0.0;
      }
    }
    return 0.0;
  }

221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
  void _handlePointerDown(PointerDownEvent event) {
    if (_mode != null)
      return;

    final ScrollableState scrollable = config.scrollableKey.currentState;
    if (!_isValidScrollable(scrollable) || !_isScrolledToLimit(scrollable))
      return;

    _dragOffset = 0.0;
    _scaleController.value = 0.0;
    _sizeController.value = 0.0;
    setState(() {
      _mode = _RefreshIndicatorMode.drag;
    });
  }

237
  void _handlePointerMove(PointerMoveEvent event) {
238 239 240 241 242 243 244 245 246 247 248 249 250 251
    if (_mode != _RefreshIndicatorMode.drag && _mode != _RefreshIndicatorMode.armed)
      return;

    final ScrollableState scrollable = config.scrollableKey?.currentState;
    if (!_isValidScrollable(scrollable))
      return;

    final double dragOffsetDelta = scrollable.pixelOffsetToScrollOffset(event.delta.dy);
    _dragOffset += dragOffsetDelta / 2.0;
    if (_dragOffset.abs() < kPixelScrollTolerance.distance)
      return;

    final double containerExtent = scrollable.scrollBehavior.containerExtent;
    final double overscroll = _overscrollDistance(scrollable);
252
    if (overscroll > 0.0) {
253
      final double newValue = overscroll / (containerExtent * _kDragContainerExtentPercentage);
254
      _sizeController.value = newValue.clamp(0.0, 1.0);
255

256
      final bool newIsAtTop = _dragOffset < 0;
257
      if (_isIndicatorAtTop != newIsAtTop) {
258
        setState(() {
259
          _isIndicatorAtTop = newIsAtTop;
260
        });
261 262
      }
    }
263
    // No setState() here because this doesn't cause a visual change.
264
    _mode = _valueColor.value.alpha == 0xFF ? _RefreshIndicatorMode.armed : _RefreshIndicatorMode.drag;
265 266 267
  }

  // Stop showing the refresh indicator
268
  Future<Null> _dismiss(_DismissTransition transition) async {
269 270 271
    setState(() {
      _mode = _RefreshIndicatorMode.dismiss;
    });
272 273 274 275 276 277 278 279
    switch(transition) {
      case _DismissTransition.shrink:
        await _sizeController.animateTo(0.0, duration: _kIndicatorScaleDuration);
        break;
      case _DismissTransition.slide:
        await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration);
        break;
    }
280 281 282 283 284
    if (mounted && _mode == _RefreshIndicatorMode.dismiss) {
      setState(() {
        _mode = null;
      });
    }
285 286
  }

287 288 289 290 291 292 293 294
  Future<Null> _show() async {
    _mode = _RefreshIndicatorMode.snap;
    await _sizeController.animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration);
    if (mounted && _mode == _RefreshIndicatorMode.snap) {
      assert(config.refresh != null);
      setState(() {
        _mode = _RefreshIndicatorMode.refresh; // Show the indeterminate progress indicator.
      });
295

296 297 298 299 300 301 302 303 304 305 306
      // Only one refresh callback is allowed to run at a time. If the user
      // attempts to start a refresh while one is still running ("pending") we
      // just continue to wait on the pending refresh.
      if (_pendingRefreshFuture == null)
        _pendingRefreshFuture = config.refresh();
      await _pendingRefreshFuture;
      bool completed = _pendingRefreshFuture != null;
      _pendingRefreshFuture = null;

      if (mounted && completed && _mode == _RefreshIndicatorMode.refresh)
        _dismiss(_DismissTransition.slide);
307
    }
308 309
  }

310 311 312 313 314 315 316
  Future<Null> _doHandlePointerUp(PointerUpEvent event) async {
    if (_mode == _RefreshIndicatorMode.armed)
      _show();
    else if (_mode == _RefreshIndicatorMode.drag)
      _dismiss(_DismissTransition.shrink);
  }

317 318
  void _handlePointerUp(PointerEvent event) {
    _doHandlePointerUp(event);
319 320
  }

321 322 323 324
  /// 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.
  ///
325 326
  /// Creating the RefreshIndicator with a [GlobalKey<RefreshIndicatorState>]
  /// makes it possible to refer to the [RefreshIndicatorState].
327 328 329 330 331 332 333 334
  Future<Null> show() async {
    if (_mode != _RefreshIndicatorMode.refresh) {
      _sizeController.value = 0.0;
      _scaleController.value = 0.0;
      await _show();
    }
  }

335 336 337 338 339 340 341 342 343 344 345 346
  ScrollableEdge get _clampOverscrollsEdge {
    switch (config.location) {
      case RefreshIndicatorLocation.top:
        return ScrollableEdge.leading;
      case RefreshIndicatorLocation.bottom:
        return ScrollableEdge.trailing;
      case RefreshIndicatorLocation.both:
        return ScrollableEdge.both;
    }
    return ScrollableEdge.none;
  }

347 348
  @override
  Widget build(BuildContext context) {
349
    final ThemeData theme = Theme.of(context);
350 351
    final bool showIndeterminateIndicator =
      _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.dismiss;
352 353 354 355 356 357 358 359

    // Fully opaque when we've reached config.displacement.
    _valueColor = new ColorTween(
      begin: (config.color ?? theme.accentColor).withOpacity(0.0),
      end: (config.color ?? theme.accentColor).withOpacity(1.0)
    )
    .animate(new CurvedAnimation(
      parent: _sizeController,
360
      curve: const Interval(0.0, 1.0 / _kDragSizeFactorLimit)
361 362
    ));

363 364 365 366
    return new Listener(
      onPointerDown: _handlePointerDown,
      onPointerMove: _handlePointerMove,
      onPointerUp: _handlePointerUp,
367 368
      child: new Stack(
        children: <Widget>[
369 370 371
          new ClampOverscrolls.inherit(
            context: context,
            edge: _clampOverscrollsEdge,
372 373 374
            child: config.child,
          ),
          new Positioned(
375 376
            top: _isIndicatorAtTop ? 0.0 : null,
            bottom: _isIndicatorAtTop ? null : 0.0,
377 378 379
            left: 0.0,
            right: 0.0,
            child: new SizeTransition(
380
              axisAlignment: _isIndicatorAtTop ? 1.0 : 0.0,
381 382
              sizeFactor: _sizeFactor,
              child: new Container(
383
                padding: _isIndicatorAtTop
384 385
                  ? new EdgeInsets.only(top: config.displacement)
                  : new EdgeInsets.only(bottom: config.displacement),
386
                alignment: _isIndicatorAtTop
387 388 389 390 391 392 393 394 395 396 397 398 399
                  ? FractionalOffset.bottomCenter
                  : FractionalOffset.topCenter,
                child: new ScaleTransition(
                  scale: _scaleFactor,
                  child: new AnimatedBuilder(
                    animation: _sizeController,
                    builder: (BuildContext context, Widget child) {
                      return new RefreshProgressIndicator(
                        value: showIndeterminateIndicator ? null : _value.value,
                        valueColor: _valueColor,
                        backgroundColor: config.backgroundColor
                      );
                    }
400 401 402 403 404 405 406 407 408 409
                  )
                )
              )
            )
          )
        ]
      )
    );
  }
}