blob: 6a324b884fdc2076e8d321dfd00de0f0b9cd562e [file] [log] [blame]
// Copyright 2014 The Flutter 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:math' as math;
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'scroll_metrics.dart';
const double _kMinThumbExtent = 18.0;
const double _kMinInteractiveSize = 48.0;
/// A [CustomPainter] for painting scrollbars.
///
/// The size of the scrollbar along its scroll direction is typically
/// proportional to the percentage of content completely visible on screen,
/// as long as its size isn't less than [minLength] and it isn't overscrolling.
///
/// Unlike [CustomPainter]s that subclasses [CustomPainter] and only repaint
/// when [shouldRepaint] returns true (which requires this [CustomPainter] to
/// be rebuilt), this painter has the added optimization of repainting and not
/// rebuilding when:
///
/// * the scroll position changes; and
/// * when the scrollbar fades away.
///
/// Calling [update] with the new [ScrollMetrics] will repaint the new scrollbar
/// position.
///
/// Updating the value on the provided [fadeoutOpacityAnimation] will repaint
/// with the new opacity.
///
/// You must call [dispose] on this [ScrollbarPainter] when it's no longer used.
///
/// See also:
///
/// * [Scrollbar] for a widget showing a scrollbar around a [Scrollable] in the
/// Material Design style.
/// * [CupertinoScrollbar] for a widget showing a scrollbar around a
/// [Scrollable] in the iOS style.
class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
/// Creates a scrollbar with customizations given by construction arguments.
ScrollbarPainter({
@required Color color,
@required TextDirection textDirection,
@required this.thickness,
@required this.fadeoutOpacityAnimation,
EdgeInsets padding = EdgeInsets.zero,
this.mainAxisMargin = 0.0,
this.crossAxisMargin = 0.0,
this.radius,
this.minLength = _kMinThumbExtent,
double minOverscrollLength,
}) : assert(color != null),
assert(textDirection != null),
assert(thickness != null),
assert(fadeoutOpacityAnimation != null),
assert(mainAxisMargin != null),
assert(crossAxisMargin != null),
assert(minLength != null),
assert(minLength >= 0),
assert(minOverscrollLength == null || minOverscrollLength <= minLength),
assert(minOverscrollLength == null || minOverscrollLength >= 0),
assert(padding != null),
assert(padding.isNonNegative),
_color = color,
_textDirection = textDirection,
_padding = padding,
minOverscrollLength = minOverscrollLength ?? minLength {
fadeoutOpacityAnimation.addListener(notifyListeners);
}
/// [Color] of the thumb. Mustn't be null.
Color get color => _color;
Color _color;
set color(Color value) {
assert(value != null);
if (color == value)
return;
_color = value;
notifyListeners();
}
/// [TextDirection] of the [BuildContext] which dictates the side of the
/// screen the scrollbar appears in (the trailing side). Mustn't be null.
TextDirection get textDirection => _textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
assert(value != null);
if (textDirection == value)
return;
_textDirection = value;
notifyListeners();
}
/// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null.
double thickness;
/// An opacity [Animation] that dictates the opacity of the thumb.
/// Changes in value of this [Listenable] will automatically trigger repaints.
/// Mustn't be null.
final Animation<double> fadeoutOpacityAnimation;
/// Distance from the scrollbar's start and end to the edge of the viewport
/// in logical pixels. It affects the amount of available paint area.
///
/// Mustn't be null and defaults to 0.
final double mainAxisMargin;
/// Distance from the scrollbar's side to the nearest edge in logical pixels.
///
/// Must not be null and defaults to 0.
final double crossAxisMargin;
/// [Radius] of corners if the scrollbar should have rounded corners.
///
/// Scrollbar will be rectangular if [radius] is null.
Radius radius;
/// 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.
///
/// This is typically set to the current [MediaQueryData.padding] to avoid
/// partial obstructions such as display notches. If you only want additional
/// margins around the scrollbar, see [mainAxisMargin].
///
/// Defaults to [EdgeInsets.zero]. Must not be null and offsets from all four
/// directions must be greater than or equal to zero.
EdgeInsets get padding => _padding;
EdgeInsets _padding;
set padding(EdgeInsets value) {
assert(value != null);
if (padding == value)
return;
_padding = value;
notifyListeners();
}
/// The preferred smallest size the scrollbar can shrink to when the total
/// scrollable extent is large, the current visible viewport is small, and the
/// viewport is not overscrolled.
///
/// The size of the scrollbar may shrink to a smaller size than [minLength]
/// to fit in the available paint area. E.g., when [minLength] is
/// `double.infinity`, it will not be respected if [viewportDimension] and
/// [mainAxisMargin] are finite.
///
/// Mustn't be null and the value has to be within the range of 0 to
/// [minOverscrollLength], inclusive. Defaults to 18.0.
final double minLength;
/// The preferred smallest size the scrollbar can shrink to when viewport is
/// overscrolled.
///
/// When overscrolling, the size of the scrollbar may shrink to a smaller size
/// than [minOverscrollLength] to fit in the available paint area. E.g., when
/// [minOverscrollLength] is `double.infinity`, it will not be respected if
/// the [viewportDimension] and [mainAxisMargin] are finite.
///
/// The value is less than or equal to [minLength] and greater than or equal to 0.
/// If unspecified or set to null, it will defaults to the value of [minLength].
final double minOverscrollLength;
ScrollMetrics _lastMetrics;
AxisDirection _lastAxisDirection;
Rect _thumbRect;
/// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself
/// based on these new metrics.
///
/// The scrollbar will remain on screen.
void update(
ScrollMetrics metrics,
AxisDirection axisDirection,
) {
_lastMetrics = metrics;
_lastAxisDirection = axisDirection;
notifyListeners();
}
/// Update and redraw with new scrollbar thickness and radius.
void updateThickness(double nextThickness, Radius nextRadius) {
thickness = nextThickness;
radius = nextRadius;
notifyListeners();
}
Paint get _paint {
return Paint()
..color = color.withOpacity(color.opacity * fadeoutOpacityAnimation.value);
}
void _paintThumbCrossAxis(Canvas canvas, Size size, double thumbOffset, double thumbExtent, AxisDirection direction) {
double x, y;
Size thumbSize;
switch (direction) {
case AxisDirection.down:
thumbSize = Size(thickness, thumbExtent);
x = textDirection == TextDirection.rtl
? crossAxisMargin + padding.left
: size.width - thickness - crossAxisMargin - padding.right;
y = thumbOffset;
break;
case AxisDirection.up:
thumbSize = Size(thickness, thumbExtent);
x = textDirection == TextDirection.rtl
? crossAxisMargin + padding.left
: size.width - thickness - crossAxisMargin - padding.right;
y = thumbOffset;
break;
case AxisDirection.left:
thumbSize = Size(thumbExtent, thickness);
x = thumbOffset;
y = size.height - thickness - crossAxisMargin - padding.bottom;
break;
case AxisDirection.right:
thumbSize = Size(thumbExtent, thickness);
x = thumbOffset;
y = size.height - thickness - crossAxisMargin - padding.bottom;
break;
}
_thumbRect = Offset(x, y) & thumbSize;
if (radius == null)
canvas.drawRect(_thumbRect, _paint);
else
canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect, radius), _paint);
}
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 = ((_lastMetrics.extentInside - _mainAxisPadding) / (_totalContentExtent - _mainAxisPadding))
.clamp(0.0, 1.0) as double;
final double thumbExtent = math.max(
math.min(_trackExtent, minOverscrollLength),
_trackExtent * fractionVisible,
);
final double fractionOverscrolled = 1.0 - _lastMetrics.extentInside / _lastMetrics.viewportDimension;
final double safeMinLength = math.min(minLength, _trackExtent);
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 - fractionOverscrolled.clamp(0.0, 0.2) / 0.2);
// The `thumbExtent` should be no greater than `trackSize`, otherwise
// the scrollbar may scroll towards the wrong direction.
return thumbExtent.clamp(newMinLength, _trackExtent) as double;
}
@override
void dispose() {
fadeoutOpacityAnimation.removeListener(notifyListeners);
super.dispose();
}
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 size of the thumb track.
double get _trackExtent => _lastMetrics.viewportDimension - 2 * mainAxisMargin - _mainAxisPadding;
// The total size of the scrollable content.
double get _totalContentExtent {
return _lastMetrics.maxScrollExtent
- _lastMetrics.minScrollExtent
+ _lastMetrics.viewportDimension;
}
/// Convert between a thumb track position and the corresponding scroll
/// position.
///
/// thumbOffsetLocal is a position in the thumb track. Cannot be null.
double getTrackToScroll(double thumbOffsetLocal) {
assert(thumbOffsetLocal != null);
final double scrollableExtent = _lastMetrics.maxScrollExtent - _lastMetrics.minScrollExtent;
final double thumbMovableExtent = _trackExtent - _thumbExtent();
return scrollableExtent * thumbOffsetLocal / thumbMovableExtent;
}
// Converts between a scroll position and the corresponding position in the
// thumb track.
double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) {
final double scrollableExtent = metrics.maxScrollExtent - metrics.minScrollExtent;
final double fractionPast = (scrollableExtent > 0)
? ((metrics.pixels - metrics.minScrollExtent) / scrollableExtent).clamp(0.0, 1.0) as double
: 0;
return (_isReversed ? 1 - fractionPast : fractionPast) * (_trackExtent - thumbExtent);
}
@override
void paint(Canvas canvas, Size size) {
if (_lastAxisDirection == null
|| _lastMetrics == null
|| fadeoutOpacityAnimation.value == 0.0)
return;
// Skip painting if there's not enough space.
if (_lastMetrics.viewportDimension <= _mainAxisPadding || _trackExtent <= 0) {
return;
}
final double beforePadding = _isVertical ? padding.top : padding.left;
final double thumbExtent = _thumbExtent();
final double thumbOffsetLocal = _getScrollToTrack(_lastMetrics, thumbExtent);
final double thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding;
return _paintThumbCrossAxis(canvas, size, thumbOffset, thumbExtent, _lastAxisDirection);
}
/// Same as hitTest, but includes some padding to make sure that the region
/// isn't too small to be interacted with by the user.
bool hitTestInteractive(Offset position) {
if (_thumbRect == null) {
return false;
}
// The thumb is not able to be hit when transparent.
if (fadeoutOpacityAnimation.value == 0.0) {
return false;
}
final Rect interactiveThumbRect = _thumbRect.expandToInclude(
Rect.fromCircle(center: _thumbRect.center, radius: _kMinInteractiveSize / 2),
);
return interactiveThumbRect.contains(position);
}
// Scrollbars can be interactive in Cupertino.
@override
bool hitTest(Offset position) {
if (_thumbRect == null) {
return null;
}
// The thumb is not able to be hit when transparent.
if (fadeoutOpacityAnimation.value == 0.0) {
return false;
}
return _thumbRect.contains(position);
}
@override
bool shouldRepaint(ScrollbarPainter old) {
// Should repaint if any properties changed.
return color != old.color
|| textDirection != old.textDirection
|| thickness != old.thickness
|| fadeoutOpacityAnimation != old.fadeoutOpacityAnimation
|| mainAxisMargin != old.mainAxisMargin
|| crossAxisMargin != old.crossAxisMargin
|| radius != old.radius
|| minLength != old.minLength
|| padding != old.padding;
}
@override
bool shouldRebuildSemantics(CustomPainter oldDelegate) => false;
@override
SemanticsBuilderCallback get semanticsBuilder => null;
}