overscroll_indicator.dart 9.46 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' show Timer;
6
import 'dart:math' as math;
7 8 9 10 11 12 13 14 15

import 'package:flutter/widgets.dart';

import 'theme.dart';

const double _kMinIndicatorExtent = 0.0;
const double _kMaxIndicatorExtent = 64.0;
const double _kMinIndicatorOpacity = 0.0;
const double _kMaxIndicatorOpacity = 0.25;
16

17 18
final Tween<double> _kIndicatorOpacity = new Tween<double>(begin: 0.0, end: 0.3);

19 20 21 22 23 24 25 26 27 28 29
// If an overscroll gesture lasts longer than this the hide timer will
// cause the indicator to fade-out.
const Duration _kTimeoutDuration = const Duration(milliseconds: 500);

// Fade-out duration if the fade-out was triggered by the timer.
const Duration _kTimeoutHideDuration = const Duration(milliseconds: 2000);

// Fade-out duration if the fade-out was triggered by an input gesture.
const Duration _kNormalHideDuration = const Duration(milliseconds: 600);


30 31 32 33
class _Painter extends CustomPainter {
  _Painter({
    this.scrollDirection,
    this.extent, // Indicator width or height, per scrollDirection.
34
    this.dragPosition,
35 36 37 38
    this.isLeading, // Similarly true if the indicator appears at the top/left.
    this.color
  });

39 40 41
  // See EdgeEffect setSize() in https://github.com/android
  static final double _kSizeToRadius = 0.75 / math.sin(math.PI / 6.0);

42 43 44 45
  final Axis scrollDirection;
  final double extent;
  final bool isLeading;
  final Color color;
46
  final Point dragPosition;
47 48

  void paintIndicator(Canvas canvas, Size size) {
49 50 51
    final Paint paint = new Paint()..color = color;
    final double width = size.width;
    final double height = size.height;
52

53
    switch (scrollDirection) {
54
      case Axis.vertical:
55 56 57 58 59 60
        final double radius = width * _kSizeToRadius;
        final double centerX = width / 2.0;
        final double centerY = isLeading ? extent - radius : height - extent + radius;
        final double eventX = dragPosition?.x ?? 0.0;
        final double biasX = (0.5 - (1.0 - eventX / width)) * centerX;
        canvas.drawCircle(new Point(centerX + biasX, centerY), radius, paint);
61 62
        break;
      case Axis.horizontal:
63 64 65 66 67 68
        final double radius = height * _kSizeToRadius;
        final double centerX = isLeading ? extent - radius : width - extent + radius;
        final double centerY = height / 2.0;
        final double eventY = dragPosition?.y ?? 0.0;
        final double biasY = (0.5 - (1.0 - eventY / height)) * centerY;
        canvas.drawCircle(new Point(centerX, centerY + biasY), radius, paint);
69 70 71 72 73 74
        break;
    }
  }

  @override
  void paint(Canvas canvas, Size size) {
75
    if (color.alpha == 0 || size.isEmpty)
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
      return;
    paintIndicator(canvas, size);
  }

  @override
  bool shouldRepaint(_Painter oldPainter) {
    return oldPainter.scrollDirection != scrollDirection
      || oldPainter.extent != extent
      || oldPainter.isLeading != isLeading
      || oldPainter.color != color;
  }
}

/// When the child's Scrollable descendant overscrolls, displays a
/// a translucent arc over the affected edge of the child.
/// If the OverscrollIndicator's child has more than one Scrollable descendant
/// the scrollableKey parameter can be used to identify the one to track.
class OverscrollIndicator extends StatefulWidget {
94 95 96 97 98 99
  /// Creates an overscroll indicator.
  ///
  /// The [child] argument must not be null.
  OverscrollIndicator({
    Key key,
    this.scrollableKey,
100
    this.edge: ScrollableEdge.both,
101 102
    this.child
  }) : super(key: key) {
103
    assert(child != null);
104
    assert(edge != null);
105 106
  }

107 108 109
  /// Identifies the [Scrollable] descendant of child that the overscroll
  /// indicator will track. Can be null if there's only one [Scrollable]
  /// descendant.
110
  final Key scrollableKey;
111

112 113 114
  /// Where the overscroll indicator should appear.
  final ScrollableEdge edge;

115 116 117
  /// The overscroll indicator will be stacked on top of this child. The
  /// indicator will appear when child's [Scrollable] descendant is
  /// over-scrolled.
118 119 120 121 122 123
  final Widget child;

  @override
  _OverscrollIndicatorState createState() => new _OverscrollIndicatorState();
}

124
class _OverscrollIndicatorState extends State<OverscrollIndicator> with SingleTickerProviderStateMixin {
125

126
  AnimationController _extentAnimation;
127
  bool _scrollUnderway = false;
128 129 130 131 132
  Timer _hideTimer;
  Axis _scrollDirection;
  double _scrollOffset;
  double _minScrollOffset;
  double _maxScrollOffset;
133
  Point _dragPosition;
134

135 136 137 138 139 140 141 142 143 144 145
  @override
  void initState() {
    super.initState();
    _extentAnimation = new AnimationController(
      lowerBound: _kMinIndicatorExtent,
      upperBound: _kMaxIndicatorExtent,
      duration: _kNormalHideDuration,
      vsync: this,
    );
  }

146 147
  void _hide([Duration duration=_kTimeoutHideDuration]) {
    _scrollUnderway = false;
148 149
    _hideTimer?.cancel();
    _hideTimer = null;
150
    if (!_extentAnimation.isAnimating) {
151
      _extentAnimation.duration = duration;
152 153
      _extentAnimation.reverse();
    }
154 155 156 157 158 159 160
  }

  void _updateState(ScrollableState scrollable) {
    if (scrollable.scrollBehavior is! ExtentScrollBehavior)
      return;
    final ExtentScrollBehavior scrollBehavior = scrollable.scrollBehavior;
    _scrollDirection = scrollable.config.scrollDirection;
161
    _scrollOffset = scrollable.virtualScrollOffset;
162 163 164 165 166
    _minScrollOffset = scrollBehavior.minScrollOffset;
    _maxScrollOffset = scrollBehavior.maxScrollOffset;
  }

  void _onScrollStarted(ScrollableState scrollable) {
167 168
    assert(_scrollUnderway == false);
    _scrollUnderway = true;
169 170 171
    _updateState(scrollable);
  }

172 173 174 175
  void _onScrollUpdated(ScrollableState scrollable, DragUpdateDetails details) {
    if (!_scrollUnderway) // The hide timer has run.
      return;

176
    final double value = scrollable.virtualScrollOffset;
177 178 179 180
    if (_isOverscroll(value)) {
      _refreshHideTimer();
      // Hide the indicator as soon as user starts scrolling in the reverse direction of overscroll.
      if (_isReverseScroll(value)) {
181
        _hide(_kNormalHideDuration);
182
      } else if (_isMatchingOverscrollEdge(value)) {
183
        // Changing the animation's value causes an implicit setState().
184
        _dragPosition = details?.globalPosition ?? Point.origin;
185
        _extentAnimation.value = value < _minScrollOffset ? _minScrollOffset - value : value - _maxScrollOffset;
186 187
      } else {
        _hide(_kNormalHideDuration);
188
      }
189 190 191 192 193
    }
    _updateState(scrollable);
  }

  void _onScrollEnded(ScrollableState scrollable) {
194 195 196
    if (!_scrollUnderway) // The hide timer has run.
      return;

197
    _updateState(scrollable);
198
    _hide(_kNormalHideDuration);
199 200
  }

201 202
  void _refreshHideTimer() {
    _hideTimer?.cancel();
203
    _hideTimer = new Timer(_kTimeoutDuration, _hide);
204 205 206 207 208 209 210
  }

  bool _isOverscroll(double scrollOffset) {
    return (scrollOffset < _minScrollOffset || scrollOffset > _maxScrollOffset) &&
      ((scrollOffset - _scrollOffset).abs() > kPixelScrollTolerance.distance);
  }

211 212 213 214 215 216 217 218 219 220 221 222 223 224
  bool _isMatchingOverscrollEdge(double scrollOffset) {
    switch (config.edge) {
      case ScrollableEdge.both:
        return true;
      case ScrollableEdge.leading:
        return scrollOffset < _minScrollOffset;
      case ScrollableEdge.trailing:
        return scrollOffset > _maxScrollOffset;
      case ScrollableEdge.none:
        return false;
    }
    return false;
  }

225 226
  bool _isReverseScroll(double scrollOffset) {
    final double delta = _scrollOffset - scrollOffset;
227
    return scrollOffset < _minScrollOffset ? delta < 0.0 : delta > 0.0;
228 229
  }

230
  bool _handleScrollNotification(ScrollNotification notification) {
231 232 233 234
    if (config.scrollableKey == null) {
        if (notification.depth != 0)
          return false;
    } else if (config.scrollableKey != notification.scrollable.config.key) {
235
      return false;
236 237 238
    }

    final ScrollableState scrollable = notification.scrollable;
239
    switch (notification.kind) {
240 241 242 243
      case ScrollNotificationKind.started:
        _onScrollStarted(scrollable);
        break;
      case ScrollNotificationKind.updated:
244
        _onScrollUpdated(scrollable, notification.dragUpdateDetails);
245 246 247 248
        break;
      case ScrollNotificationKind.ended:
        _onScrollEnded(scrollable);
        break;
249 250 251 252 253 254 255 256
    }
    return false;
  }

  @override
  void dispose() {
    _hideTimer?.cancel();
    _hideTimer = null;
257
    _extentAnimation.dispose();
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273
    super.dispose();
  }

  Color get _indicatorColor {
    final Color accentColor = Theme.of(context).accentColor.withOpacity(0.35);
    final double t = (_extentAnimation.value - _kMinIndicatorExtent) / (_kMaxIndicatorExtent - _kMinIndicatorExtent);
    return accentColor.withOpacity(_kIndicatorOpacity.lerp(Curves.easeIn.transform(t)));
  }

  @override
  Widget build(BuildContext context) {
    return new NotificationListener<ScrollNotification>(
      onNotification: _handleScrollNotification,
      child: new AnimatedBuilder(
        animation: _extentAnimation,
        builder: (BuildContext context, Widget child) {
274 275
          // We keep the same widget hierarchy here, even when we're not
          // painting anything, to avoid rebuilding the children.
276
          return new CustomPaint(
277
            foregroundPainter: _scrollDirection == null ? null : new _Painter(
278 279
              scrollDirection: _scrollDirection,
              extent: _extentAnimation.value,
280
              dragPosition: _dragPosition,
281 282 283 284 285 286
              isLeading: _scrollOffset < _minScrollOffset,
              color: _indicatorColor
            ),
            child: child
          );
        },
287 288 289
        child: new ClampOverscrolls.inherit(
          context: context,
          edge: config.edge,
290 291
          child: config.child,
        )
292 293 294
      )
    );
  }
295
}