Clean up ScrollbarPainter (#107179)
diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 67d28bc..971d9ee 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart
@@ -283,6 +283,7 @@ _shape = value; notifyListeners(); } + /// The amount of space by which to inset the scrollbar's start and end, as /// well as its side to the nearest edge, in logical pixels. /// @@ -304,7 +305,6 @@ notifyListeners(); } - /// The preferred smallest size the scrollbar thumb can shrink to when the total /// scrollable extent is large, the current visible viewport is small, and the /// viewport is not overscrolled. @@ -391,23 +391,129 @@ notifyListeners(); } + // - Scrollbar Details + + Rect? _trackRect; + // The full painted length of the track + double get _trackExtent => _lastMetrics!.viewportDimension - _totalTrackMainAxisOffsets; + // The full length of the track that the thumb can travel + double get _traversableTrackExtent => _trackExtent - (2 * mainAxisMargin); + // Track Offsets + // The track is offset by only padding. + double get _totalTrackMainAxisOffsets => _isVertical ? padding.vertical : padding.horizontal; + double get _leadingTrackMainAxisOffset { + switch(_resolvedOrientation) { + case ScrollbarOrientation.left: + case ScrollbarOrientation.right: + return padding.top; + case ScrollbarOrientation.top: + case ScrollbarOrientation.bottom: + return padding.left; + } + } + + Rect? _thumbRect; + // The current scroll position + _leadingThumbMainAxisOffset + late double _thumbOffset; + // The fraction visible in relation to the trversable length of the track. + late double _thumbExtent; + // Thumb Offsets + // The thumb is offset by padding and margins. + double get _leadingThumbMainAxisOffset { + switch(_resolvedOrientation) { + case ScrollbarOrientation.left: + case ScrollbarOrientation.right: + return padding.top + mainAxisMargin; + case ScrollbarOrientation.top: + case ScrollbarOrientation.bottom: + return padding.left + mainAxisMargin; + } + } + void _setThumbExtent() { + // Thumb extent reflects fraction of content visible, as long as this + // isn't less than the absolute minimum size. + // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 + final double fractionVisible = clampDouble( + (_lastMetrics!.extentInside - _totalTrackMainAxisOffsets) + / (_totalContentExtent - _totalTrackMainAxisOffsets), + 0.0, + 1.0, + ); + + final double thumbExtent = math.max( + math.min(_traversableTrackExtent, minOverscrollLength), + _traversableTrackExtent * fractionVisible, + ); + + final double fractionOverscrolled = 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; + final double safeMinLength = math.min(minLength, _traversableTrackExtent); + final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) + // Thumb extent is no smaller than minLength if scrolling normally. + ? safeMinLength + // User is overscrolling. Thumb extent can be less than minLength + // but no smaller than minOverscrollLength. We can't use the + // fractionVisible to produce intermediate values between minLength and + // minOverscrollLength when the user is transitioning from regular + // scrolling to overscrolling, so we instead use the percentage of the + // content that is still in the viewport to determine the size of the + // thumb. iOS behavior appears to have the thumb reach its minimum size + // with ~20% of overscroll. We map the percentage of minLength from + // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce + // values for the thumb that range between minLength and the smallest + // possible value, minOverscrollLength. + : safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2); + + // The `thumbExtent` should be no greater than `trackSize`, otherwise + // the scrollbar may scroll towards the wrong direction. + _thumbExtent = clampDouble(thumbExtent, newMinLength, _traversableTrackExtent); + } + + // - Scrollable Details + + ScrollMetrics? _lastMetrics; + bool get _lastMetricsAreScrollable => _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; + AxisDirection? _lastAxisDirection; + + bool get _isVertical => _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up; + bool get _isReversed => _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left; + // The amount of scroll distance before and after the current position. + double get _beforeExtent => _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; + double get _afterExtent => _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; + + // The total size of the scrollable content. + double get _totalContentExtent { + return _lastMetrics!.maxScrollExtent + - _lastMetrics!.minScrollExtent + + _lastMetrics!.viewportDimension; + } + + ScrollbarOrientation get _resolvedOrientation { + if (scrollbarOrientation == null) { + if (_isVertical) { + return textDirection == TextDirection.ltr + ? ScrollbarOrientation.right + : ScrollbarOrientation.left; + } + return ScrollbarOrientation.bottom; + } + return scrollbarOrientation!; + } + void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { assert( - (_isVertical && _isVerticalOrientation(orientation)) || (!_isVertical && !_isVerticalOrientation(orientation)), - 'The given ScrollbarOrientation: $orientation is incompatible with the current AxisDirection: $_lastAxisDirection.' + () { + bool isVerticalOrientation(ScrollbarOrientation orientation) => + orientation == ScrollbarOrientation.left + || orientation == ScrollbarOrientation.right; + return (_isVertical && isVerticalOrientation(orientation)) + || (!_isVertical && !isVerticalOrientation(orientation)); + }(), + 'The given ScrollbarOrientation: $orientation is incompatible with the ' + 'current AxisDirection: $_lastAxisDirection.' ); } - /// Check whether given scrollbar orientation is vertical - bool _isVerticalOrientation(ScrollbarOrientation orientation) => - orientation == ScrollbarOrientation.left - || orientation == ScrollbarOrientation.right; - - ScrollMetrics? _lastMetrics; - AxisDirection? _lastAxisDirection; - Rect? _thumbRect; - Rect? _trackRect; - late double _thumbOffset; + // - Updating /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will /// show and redraw itself based on these new metrics. @@ -433,7 +539,6 @@ if (!needPaint(oldMetrics) && !needPaint(metrics)) { return; } - notifyListeners(); } @@ -443,6 +548,8 @@ radius = nextRadius; } + // - Painting + Paint get _paintThumb { return Paint() ..color = color.withOpacity(color.opacity * fadeoutOpacityAnimation.value); @@ -459,67 +566,50 @@ ..color = trackColor.withOpacity(trackColor.opacity * fadeoutOpacityAnimation.value); } - void _paintScrollbar(Canvas canvas, Size size, double thumbExtent, AxisDirection direction) { + void _paintScrollbar(Canvas canvas, Size size) { assert( textDirection != null, 'A TextDirection must be provided before a Scrollbar can be painted.', ); - final ScrollbarOrientation resolvedOrientation; - - if (scrollbarOrientation == null) { - if (_isVertical) { - resolvedOrientation = textDirection == TextDirection.ltr - ? ScrollbarOrientation.right - : ScrollbarOrientation.left; - } else { - resolvedOrientation = ScrollbarOrientation.bottom; - } - } - else { - resolvedOrientation = scrollbarOrientation!; - } - final double x, y; final Size thumbSize, trackSize; final Offset trackOffset, borderStart, borderEnd; - - _debugAssertIsValidOrientation(resolvedOrientation); - - switch(resolvedOrientation) { + _debugAssertIsValidOrientation(_resolvedOrientation); + switch(_resolvedOrientation) { case ScrollbarOrientation.left: - thumbSize = Size(thickness, thumbExtent); + thumbSize = Size(thickness, _thumbExtent); trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); x = crossAxisMargin + padding.left; y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, padding.top); + trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); borderStart = trackOffset + Offset(trackSize.width, 0.0); borderEnd = Offset(trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); break; case ScrollbarOrientation.right: - thumbSize = Size(thickness, thumbExtent); + thumbSize = Size(thickness, _thumbExtent); trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); x = size.width - thickness - crossAxisMargin - padding.right; y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, padding.top); + trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); borderStart = trackOffset; borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); break; case ScrollbarOrientation.top: - thumbSize = Size(thumbExtent, thickness); + thumbSize = Size(_thumbExtent, thickness); trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); x = _thumbOffset; y = crossAxisMargin + padding.top; - trackOffset = Offset(padding.left, y - crossAxisMargin); + trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); borderStart = trackOffset + Offset(0.0, trackSize.height); borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); break; case ScrollbarOrientation.bottom: - thumbSize = Size(thumbExtent, thickness); + thumbSize = Size(_thumbExtent, thickness); trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); x = _thumbOffset; y = size.height - thickness - crossAxisMargin - padding.bottom; - trackOffset = Offset(padding.left, y - crossAxisMargin); + trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); borderStart = trackOffset; borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); break; @@ -557,69 +647,32 @@ } } - double _thumbExtent() { - // Thumb extent reflects fraction of content visible, as long as this - // isn't less than the absolute minimum size. - // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 - final double fractionVisible = clampDouble( - (_lastMetrics!.extentInside - _mainAxisPadding) / - (_totalContentExtent - _mainAxisPadding), - 0.0, - 1.0); - - final double thumbExtent = math.max( - math.min(_traversableTrackExtent, minOverscrollLength), - _traversableTrackExtent * fractionVisible, - ); - - final double fractionOverscrolled = 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; - final double safeMinLength = math.min(minLength, _traversableTrackExtent); - final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) - // Thumb extent is no smaller than minLength if scrolling normally. - ? safeMinLength - // User is overscrolling. Thumb extent can be less than minLength - // but no smaller than minOverscrollLength. We can't use the - // fractionVisible to produce intermediate values between minLength and - // minOverscrollLength when the user is transitioning from regular - // scrolling to overscrolling, so we instead use the percentage of the - // content that is still in the viewport to determine the size of the - // thumb. iOS behavior appears to have the thumb reach its minimum size - // with ~20% of overscroll. We map the percentage of minLength from - // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce - // values for the thumb that range between minLength and the smallest - // possible value, minOverscrollLength. - : safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2); - - // The `thumbExtent` should be no greater than `trackSize`, otherwise - // the scrollbar may scroll towards the wrong direction. - final double extent = clampDouble(thumbExtent, newMinLength, _traversableTrackExtent); - return extent; - } - @override - void dispose() { - fadeoutOpacityAnimation.removeListener(notifyListeners); - super.dispose(); + void paint(Canvas canvas, Size size) { + if (_lastAxisDirection == null + || _lastMetrics == null + || _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { + return; + } + // Skip painting if there's not enough space. + if (_traversableTrackExtent <= 0) { + return; + } + // Do not paint a scrollbar if the scroll view is infinitely long. + // TODO(Piinks): Special handling for infinite scroll views, + // https://github.com/flutter/flutter/issues/41434 + if (_lastMetrics!.maxScrollExtent.isInfinite) { + return; + } + + _setThumbExtent(); + final double thumbPositionOffset = _getScrollToTrack(_lastMetrics!, _thumbExtent); + _thumbOffset = thumbPositionOffset + _leadingThumbMainAxisOffset; + + return _paintScrollbar(canvas, size); } - bool get _isVertical => _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up; - bool get _isReversed => _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left; - // The amount of scroll distance before and after the current position. - double get _beforeExtent => _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; - double get _afterExtent => _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; - // Padding of the thumb track. - double get _mainAxisPadding => _isVertical ? padding.vertical : padding.horizontal; - // The length of the painted track. - double get _trackExtent => _lastMetrics!.viewportDimension - _mainAxisPadding; - // The length of the track that is traversable by the thumb. - double get _traversableTrackExtent => _trackExtent - (2 * mainAxisMargin); - - // The total size of the scrollable content. - double get _totalContentExtent { - return _lastMetrics!.maxScrollExtent - - _lastMetrics!.minScrollExtent - + _lastMetrics!.viewportDimension; - } + // - Scroll Position Conversion /// Convert between a thumb track position and the corresponding scroll /// position. @@ -628,7 +681,7 @@ double getTrackToScroll(double thumbOffsetLocal) { assert(thumbOffsetLocal != null); final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; - final double thumbMovableExtent = _traversableTrackExtent - _thumbExtent(); + final double thumbMovableExtent = _traversableTrackExtent - _thumbExtent; return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; } @@ -645,35 +698,27 @@ return (_isReversed ? 1 - fractionPast : fractionPast) * (_traversableTrackExtent - thumbExtent); } + // - Hit Testing + @override - void paint(Canvas canvas, Size size) { - if (_lastAxisDirection == null - || _lastMetrics == null - || _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { - return; + bool? hitTest(Offset? position) { + // There is nothing painted to hit. + if (_thumbRect == null) { + return null; } - // Skip painting if there's not enough space. - if (_lastMetrics!.viewportDimension <= _mainAxisPadding || _traversableTrackExtent <= 0) { - return; + // Interaction disabled. + if (ignorePointer + // The thumb is not able to be hit when transparent. + || fadeoutOpacityAnimation.value == 0.0 + // Not scrollable + || !_lastMetricsAreScrollable) { + return false; } - final double beforePadding = _isVertical ? padding.top : padding.left; - final double thumbExtent = _thumbExtent(); - final double thumbOffsetLocal = _getScrollToTrack(_lastMetrics!, thumbExtent); - _thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding; - - // Do not paint a scrollbar if the scroll view is infinitely long. - // TODO(Piinks): Special handling for infinite scroll views, https://github.com/flutter/flutter/issues/41434 - if (_lastMetrics!.maxScrollExtent.isInfinite) { - return; - } - - return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!); + return _trackRect!.contains(position!); } - bool get _lastMetricsAreScrollable => _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; - /// Same as hitTest, but includes some padding when the [PointerEvent] is /// caused by [PointerDeviceKind.touch] to make sure that the region /// isn't too small to be interacted with by the user. @@ -756,28 +801,6 @@ } } - // Scrollbars are interactive. - @override - bool? hitTest(Offset? position) { - if (_thumbRect == null) { - return null; - } - if (ignorePointer) { - return false; - } - - // The thumb is not able to be hit when transparent. - if (fadeoutOpacityAnimation.value == 0.0) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - return _trackRect!.contains(position!); - } - @override bool shouldRepaint(ScrollbarPainter oldDelegate) { // Should repaint if any properties changed. @@ -807,6 +830,12 @@ @override String toString() => describeIdentity(this); + + @override + void dispose() { + fadeoutOpacityAnimation.removeListener(notifyListeners); + super.dispose(); + } } /// An extendable base class for building scrollbars that fade in and out. @@ -1669,7 +1698,7 @@ case TargetPlatform.iOS: case TargetPlatform.android: // We can only drag the scrollbar into overscroll on mobile - // platforms, and only if the physics allow it. + // platforms, and only then if the physics allow it. break; } position.jumpTo(newPosition); @@ -1908,7 +1937,7 @@ () => _ThumbPressGestureRecognizer( debugOwner: this, customPaintKey: _scrollbarPainterKey, - pressDuration: widget.pressDuration, + duration: widget.pressDuration, ), (_ThumbPressGestureRecognizer instance) { instance.onLongPress = handleThumbPress; @@ -2074,11 +2103,8 @@ _ThumbPressGestureRecognizer({ required Object super.debugOwner, required GlobalKey customPaintKey, - required Duration pressDuration, - }) : _customPaintKey = customPaintKey, - super( - duration: pressDuration, - ); + required super.duration, + }) : _customPaintKey = customPaintKey; final GlobalKey _customPaintKey; @@ -2105,10 +2131,9 @@ // track and ignores everything else, including the thumb. class _TrackTapGestureRecognizer extends TapGestureRecognizer { _TrackTapGestureRecognizer({ - required Object debugOwner, + required super.debugOwner, required GlobalKey customPaintKey, - }) : _customPaintKey = customPaintKey, - super(debugOwner: debugOwner); + }) : _customPaintKey = customPaintKey; final GlobalKey _customPaintKey;