Files
Binary-Viewer/lib/widgets/scrollview.dart

2375 lines
86 KiB
Dart

// 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.
/// @docImport 'package:flutter/cupertino.dart';
/// @docImport 'package:flutter/material.dart';
/// @docImport 'package:flutter_test/flutter_test.dart';
///
/// @docImport 'editable_text.dart';
/// @docImport 'list_wheel_scroll_view.dart';
/// @docImport 'nested_scroll_view.dart';
/// @docImport 'page_view.dart';
/// @docImport 'scroll_view.dart';
/// @docImport 'widget_state.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
const double _kMinThumbExtent = 18.0;
const double _kMinInteractiveSize = 48.0;
const double _kScrollbarThickness = 6.0;
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
/// An orientation along either the horizontal or vertical [Axis].
enum ScrollbarOrientation {
/// Place towards the left of the screen.
left,
/// Place towards the right of the screen.
right,
/// Place on top of the screen.
top,
/// Place on the bottom of the screen.
bottom,
}
/// Paints a scrollbar's track and thumb.
///
/// 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.
///
/// If [padding] is an [EdgeInsetsDirectional], a non-null [textDirection] must
/// be provided to properly resolve the padding values.
///
/// 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 this.fadeoutOpacityAnimation,
Color trackColor = const Color(0x00000000),
Color trackBorderColor = const Color(0x00000000),
TextDirection? textDirection,
double thickness = _kScrollbarThickness,
EdgeInsetsGeometry padding = EdgeInsets.zero,
EdgeInsets scrollbarMargin = EdgeInsets.zero,
Offset renderOffset = Offset.zero,
Radius? radius,
Radius? trackRadius,
OutlinedBorder? shape,
double minLength = _kMinThumbExtent,
double? minOverscrollLength,
ScrollbarOrientation? scrollbarOrientation,
bool ignorePointer = false,
}) : assert(radius == null || shape == null),
assert(minLength >= 0),
assert(minOverscrollLength == null || minOverscrollLength <= minLength),
assert(minOverscrollLength == null || minOverscrollLength >= 0),
assert(padding.isNonNegative),
assert(
padding is! EdgeInsetsDirectional || textDirection != null,
'A non-null textDirection must be provided when using EdgeInsetsDirectional for padding.',
),
_color = color,
_textDirection = textDirection,
_thickness = thickness,
_radius = radius,
_shape = shape,
_padding = padding,
_resolvedPadding = padding.resolve(textDirection),
_scrollbarMargin = scrollbarMargin,
_renderOffset = renderOffset,
_minLength = minLength,
_trackColor = trackColor,
_trackBorderColor = trackBorderColor,
_trackRadius = trackRadius,
_scrollbarOrientation = scrollbarOrientation,
_minOverscrollLength = minOverscrollLength ?? minLength,
_ignorePointer = ignorePointer {
fadeoutOpacityAnimation.addListener(notifyListeners);
}
/// [Color] of the thumb. Mustn't be null.
Color get color => _color;
Color _color;
set color(Color value) {
if (color == value) {
return;
}
_color = value;
notifyListeners();
}
/// [Color] of the track. Mustn't be null.
Color get trackColor => _trackColor;
Color _trackColor;
set trackColor(Color value) {
if (trackColor == value) {
return;
}
_trackColor = value;
notifyListeners();
}
/// [Color] of the track border. Mustn't be null.
Color get trackBorderColor => _trackBorderColor;
Color _trackBorderColor;
set trackBorderColor(Color value) {
if (trackBorderColor == value) {
return;
}
_trackBorderColor = value;
notifyListeners();
}
/// [Radius] of corners of the Scrollbar's track.
///
/// Scrollbar's track will be rectangular if [trackRadius] is null.
Radius? get trackRadius => _trackRadius;
Radius? _trackRadius;
set trackRadius(Radius? value) {
if (trackRadius == value) {
return;
}
_trackRadius = value;
notifyListeners();
}
/// [TextDirection] of the [BuildContext] which dictates the side of the
/// screen the scrollbar appears in (the trailing side). Must be set prior to
/// calling paint.
TextDirection? get textDirection => _textDirection;
TextDirection? _textDirection;
set textDirection(TextDirection? value) {
assert(value != null);
if (textDirection == value) {
return;
}
_textDirection = value;
_resolvedPadding = _padding.resolve(_textDirection);
notifyListeners();
}
/// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null.
double get thickness => _thickness;
double _thickness;
set thickness(double value) {
if (thickness == value) {
return;
}
_thickness = value;
notifyListeners();
}
/// 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;
/// The margin around the scrollbar thumb in all directions.
///
/// This provides more flexible control over scrollbar positioning
/// compared to the separate mainAxisMargin and crossAxisMargin.
///
/// The scrollbar track consumes this space.
///
/// Defaults to EdgeInsets.zero.
EdgeInsets get scrollbarMargin => _scrollbarMargin;
EdgeInsets _scrollbarMargin;
set scrollbarMargin(EdgeInsets value) {
if (scrollbarMargin == value) {
return;
}
_scrollbarMargin = value;
notifyListeners();
}
/// Helper getter for mainAxisMargin compatibility - returns leading margin for main axis
double get mainAxisMargin => _isVertical ? _scrollbarMargin.top : _scrollbarMargin.left;
/// Helper getter for crossAxisMargin compatibility - returns leading margin for cross axis
double get crossAxisMargin => _isVertical ? _scrollbarMargin.left : _scrollbarMargin.top;
/// Additional offset to apply to the scrollbar rendering position.
Offset get renderOffset => _renderOffset;
Offset _renderOffset;
set renderOffset(Offset value) {
if (renderOffset == value) {
return;
}
_renderOffset = value;
notifyListeners();
}
/// [Radius] of corners if the scrollbar should have rounded corners.
///
/// Scrollbar will be rectangular if [radius] is null.
Radius? get radius => _radius;
Radius? _radius;
set radius(Radius? value) {
assert(shape == null || value == null);
if (radius == value) {
return;
}
_radius = value;
notifyListeners();
}
/// The [OutlinedBorder] of the scrollbar's thumb.
///
/// Only one of [radius] and [shape] may be specified. For a rounded rectangle,
/// it's simplest to just specify [radius]. By default, the scrollbar thumb's
/// shape is a simple rectangle.
///
/// If [shape] is specified, the thumb will take the shape of the passed
/// [OutlinedBorder] and fill itself with [color] (or grey if it
/// is unspecified).
///
OutlinedBorder? get shape => _shape;
OutlinedBorder? _shape;
set shape(OutlinedBorder? value) {
assert(radius == null || value == null);
if (shape == value) {
return;
}
_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.
///
/// 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]. Offsets from all four directions must be
/// greater than or equal to zero.
///
/// For RTL (right-to-left) support, you can provide [EdgeInsetsDirectional],
/// but you must also provide a non-null [textDirection] to properly resolve
/// the padding values. The scrollbar will automatically adjust the padding
/// based on the text direction.
EdgeInsetsGeometry get padding => _padding;
EdgeInsetsGeometry _padding;
set padding(EdgeInsetsGeometry value) {
if (padding == value) {
return;
}
_padding = value;
_resolvedPadding = _padding.resolve(_textDirection);
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.
///
/// 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
/// [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite.
///
/// Mustn't be null and the value has to be greater or equal to
/// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0.
double get minLength => _minLength;
double _minLength;
set minLength(double value) {
if (minLength == value) {
return;
}
_minLength = value;
notifyListeners();
}
/// The preferred smallest size the scrollbar thumb 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 [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite.
///
/// The value is less than or equal to [minLength] and greater than or equal to 0.
/// When null, it will default to the value of [minLength].
double get minOverscrollLength => _minOverscrollLength;
double _minOverscrollLength;
set minOverscrollLength(double value) {
if (minOverscrollLength == value) {
return;
}
_minOverscrollLength = value;
notifyListeners();
}
/// {@template flutter.widgets.Scrollbar.scrollbarOrientation}
/// Dictates the orientation of the scrollbar.
///
/// [ScrollbarOrientation.top] places the scrollbar on top of the screen.
/// [ScrollbarOrientation.bottom] places the scrollbar on the bottom of the screen.
/// [ScrollbarOrientation.left] places the scrollbar on the left of the screen.
/// [ScrollbarOrientation.right] places the scrollbar on the right of the screen.
///
/// [ScrollbarOrientation.top] and [ScrollbarOrientation.bottom] can only be
/// used with a vertical scroll.
/// [ScrollbarOrientation.left] and [ScrollbarOrientation.right] can only be
/// used with a horizontal scroll.
///
/// For a vertical scroll the orientation defaults to
/// [ScrollbarOrientation.right] for [TextDirection.ltr] and
/// [ScrollbarOrientation.left] for [TextDirection.rtl].
/// For a horizontal scroll the orientation defaults to [ScrollbarOrientation.bottom].
/// {@endtemplate}
ScrollbarOrientation? get scrollbarOrientation => _scrollbarOrientation;
ScrollbarOrientation? _scrollbarOrientation;
set scrollbarOrientation(ScrollbarOrientation? value) {
if (scrollbarOrientation == value) {
return;
}
_scrollbarOrientation = value;
notifyListeners();
}
/// Whether the painter will be ignored during hit testing.
bool get ignorePointer => _ignorePointer;
bool _ignorePointer;
set ignorePointer(bool value) {
// Force interactive scrollbars to always be interactive
if (_forceInteractive && value) {
value = false;
}
if (ignorePointer == value) {
return;
}
_ignorePointer = value;
notifyListeners();
}
bool _forceInteractive = false;
bool get forceInteractive => _forceInteractive;
set forceInteractive(bool value) {
if (_forceInteractive == value) {
return;
}
_forceInteractive = value;
if (value && _ignorePointer) {
_ignorePointer = false;
notifyListeners();
}
}
// - Scrollbar Details
Rect? _trackRect;
EdgeInsets? _resolvedPadding;
// 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 - (_isVertical ? (_scrollbarMargin.top + _scrollbarMargin.bottom) : (_scrollbarMargin.left + _scrollbarMargin.right));
// Track Offsets
// The track is offset by only padding.
double get _totalTrackMainAxisOffsets =>
_isVertical ? _resolvedPadding!.vertical : _resolvedPadding!.horizontal;
double get _leadingTrackMainAxisOffset => switch (_resolvedOrientation) {
ScrollbarOrientation.left || ScrollbarOrientation.right => _resolvedPadding!.top,
ScrollbarOrientation.top || ScrollbarOrientation.bottom => _resolvedPadding!.left,
};
Rect? _thumbRect;
// The current scroll position + _leadingThumbMainAxisOffset
late double _thumbOffset;
// The fraction visible in relation to the traversable length of the track.
late double _thumbExtent;
// Thumb Offsets
// The thumb is offset by padding and margins.
double get _leadingThumbMainAxisOffset => _leadingTrackMainAxisOffset + (_isVertical ? _scrollbarMargin.top : _scrollbarMargin.left);
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(
() {
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.',
);
}
// - Updating
/// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will
/// show and redraw itself based on these new metrics.
///
/// The scrollbar will remain on screen.
void update(ScrollMetrics metrics, AxisDirection axisDirection) {
if (_lastMetrics != null &&
_lastMetrics!.extentBefore == metrics.extentBefore &&
_lastMetrics!.extentInside == metrics.extentInside &&
_lastMetrics!.extentAfter == metrics.extentAfter &&
_lastAxisDirection == axisDirection) {
return;
}
final ScrollMetrics? oldMetrics = _lastMetrics;
_lastMetrics = metrics;
_lastAxisDirection = axisDirection;
if (!_needPaint(oldMetrics) && !_needPaint(metrics)) {
return;
}
notifyListeners();
}
/// Update and redraw with new scrollbar thickness and radius.
void updateThickness(double nextThickness, Radius nextRadius) {
thickness = nextThickness;
radius = nextRadius;
}
// - Painting
Paint get _paintThumb {
return Paint()..color = color.withOpacity(color.opacity * fadeoutOpacityAnimation.value);
}
bool _needPaint(ScrollMetrics? metrics) {
return metrics != null &&
metrics.maxScrollExtent - metrics.minScrollExtent > precisionErrorTolerance;
}
Paint _paintTrack({bool isBorder = false}) {
if (isBorder) {
return Paint()
..color = trackBorderColor.withOpacity(
trackBorderColor.opacity * fadeoutOpacityAnimation.value,
)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
}
return Paint()
..color = trackColor.withOpacity(trackColor.opacity * fadeoutOpacityAnimation.value);
}
void _paintScrollbar(Canvas canvas, Size size) {
assert(
textDirection != null,
'A TextDirection must be provided before a Scrollbar can be painted.',
);
final double x, y;
final Size thumbSize, trackSize;
final Offset trackOffset, borderStart, borderEnd;
_debugAssertIsValidOrientation(_resolvedOrientation);
switch (_resolvedOrientation) {
case ScrollbarOrientation.left:
thumbSize = Size(thickness, _thumbExtent);
trackSize = Size(thickness + _scrollbarMargin.left + _scrollbarMargin.right, _trackExtent);
x = _scrollbarMargin.left + _resolvedPadding!.left;
y = _thumbOffset;
trackOffset = Offset(x - _scrollbarMargin.left, _leadingTrackMainAxisOffset);
borderStart = trackOffset + Offset(trackSize.width, 0.0);
borderEnd = Offset(trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent);
case ScrollbarOrientation.right:
thumbSize = Size(thickness, _thumbExtent);
trackSize = Size(thickness + _scrollbarMargin.left + _scrollbarMargin.right, _trackExtent);
x = size.width - thickness - _scrollbarMargin.right - _resolvedPadding!.right;
y = _thumbOffset;
trackOffset = Offset(x - _scrollbarMargin.left, _leadingTrackMainAxisOffset);
borderStart = trackOffset;
borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent);
case ScrollbarOrientation.top:
thumbSize = Size(_thumbExtent, thickness);
trackSize = Size(_trackExtent, thickness + _scrollbarMargin.top + _scrollbarMargin.bottom);
x = _thumbOffset;
y = _scrollbarMargin.top + _resolvedPadding!.top;
trackOffset = Offset(_leadingTrackMainAxisOffset, y - _scrollbarMargin.top);
borderStart = trackOffset + Offset(0.0, trackSize.height);
borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height);
case ScrollbarOrientation.bottom:
thumbSize = Size(_thumbExtent, thickness);
trackSize = Size(_trackExtent, thickness + _scrollbarMargin.top + _scrollbarMargin.bottom);
x = _thumbOffset;
y = size.height - thickness - _scrollbarMargin.bottom - _resolvedPadding!.bottom;
trackOffset = Offset(_leadingTrackMainAxisOffset, y - _scrollbarMargin.top);
borderStart = trackOffset;
borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy);
}
// Whether we paint or not, calculating these rects allows us to hit test
// when the scrollbar is transparent.
_trackRect = (trackOffset + renderOffset) & trackSize;
_thumbRect = (Offset(x, y) + renderOffset) & thumbSize;
// Paint if the opacity dictates visibility
if (fadeoutOpacityAnimation.value != 0.0) {
// Track
if (trackRadius == null) {
canvas.drawRect(_trackRect!, _paintTrack());
} else {
canvas.drawRRect(RRect.fromRectAndRadius(_trackRect!, trackRadius!), _paintTrack());
}
// Track Border
canvas.drawLine(borderStart + renderOffset, borderEnd + renderOffset, _paintTrack(isBorder: true));
if (radius != null) {
// Rounded rect thumb
canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb);
return;
}
if (shape == null) {
// Square thumb
canvas.drawRect(_thumbRect!, _paintThumb);
return;
}
// Custom-shaped thumb
final Path outerPath = shape!.getOuterPath(_thumbRect!);
canvas.drawPath(outerPath, _paintThumb);
shape!.paint(canvas, _thumbRect!);
}
}
@override
void paint(Canvas canvas, Size size) {
if (_lastAxisDirection == null || !_needPaint(_lastMetrics)) {
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);
}
// - Scroll Position Conversion
/// Convert between a thumb track position and the corresponding scroll
/// position.
///
/// The `thumbOffsetLocal` argument is a position in the thumb track.
double getTrackToScroll(double thumbOffsetLocal) {
final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent;
final double thumbMovableExtent = _traversableTrackExtent - _thumbExtent;
return scrollableExtent * thumbOffsetLocal / thumbMovableExtent;
}
/// The thumb's corresponding scroll offset in the track.
double getThumbScrollOffset() {
assert(_lastMetrics!.maxScrollExtent.isFinite && _lastMetrics!.minScrollExtent.isFinite);
final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent;
final double maxFraction = _lastMetrics!.maxScrollExtent / scrollableExtent;
final double minFraction = _lastMetrics!.minScrollExtent / scrollableExtent;
final double fractionPast = (scrollableExtent > 0)
? clampDouble(_lastMetrics!.pixels / scrollableExtent, minFraction, maxFraction)
: 0;
return fractionPast * (_traversableTrackExtent - _thumbExtent);
}
// 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)
? clampDouble((metrics.pixels - metrics.minScrollExtent) / scrollableExtent, 0.0, 1.0)
: 0;
return (_isReversed ? 1 - fractionPast : fractionPast) *
(_traversableTrackExtent - thumbExtent);
}
// - Hit Testing
@override
bool? hitTest(Offset? position) {
// There is nothing painted to hit.
if (_thumbRect == null) {
return null;
}
// Interaction disabled.
if (ignorePointer
// The thumb is not able to be hit when transparent.
||
fadeoutOpacityAnimation.value == 0.0
// Not scrollable
||
!_lastMetricsAreScrollable) {
return false;
}
return _trackRect!.contains(position!);
}
/// 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.
///
/// The hit test area for hovering with [PointerDeviceKind.mouse] over the
/// scrollbar also uses this extra padding. This is to make it easier to
/// interact with the scrollbar by presenting it to the mouse for interaction
/// based on proximity. When `forHover` is true, the larger hit test area will
/// be used.
bool hitTestInteractive(Offset position, PointerDeviceKind kind, {bool forHover = false}) {
if (_trackRect == null) {
// We have not computed the scrollbar position yet.
return false;
}
if (ignorePointer) {
return false;
}
if (!_lastMetricsAreScrollable) {
return false;
}
final Rect interactiveRect = _trackRect!;
final Rect paddedRect = interactiveRect.expandToInclude(
Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
);
// The scrollbar is not able to be hit when transparent - except when
// hovering with a mouse. This should bring the scrollbar into view so the
// mouse can interact with it.
if (fadeoutOpacityAnimation.value == 0.0) {
if (forHover && kind == PointerDeviceKind.mouse) {
return paddedRect.contains(position);
}
return false;
}
switch (kind) {
case PointerDeviceKind.touch:
case PointerDeviceKind.trackpad:
return paddedRect.contains(position);
case PointerDeviceKind.mouse:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
return interactiveRect.contains(position);
}
}
/// Same as hitTestInteractive, but excludes the track portion of the scrollbar.
/// Used to evaluate interactions with only the scrollbar thumb.
bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) {
if (_thumbRect == null) {
return false;
}
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;
}
switch (kind) {
case PointerDeviceKind.touch:
case PointerDeviceKind.trackpad:
final Rect touchThumbRect = _thumbRect!.expandToInclude(
Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
);
return touchThumbRect.contains(position);
case PointerDeviceKind.mouse:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
return _thumbRect!.contains(position);
}
}
@override
bool shouldRepaint(ScrollbarPainter oldDelegate) {
// Should repaint if any properties changed.
return color != oldDelegate.color ||
trackColor != oldDelegate.trackColor ||
trackBorderColor != oldDelegate.trackBorderColor ||
textDirection != oldDelegate.textDirection ||
thickness != oldDelegate.thickness ||
fadeoutOpacityAnimation != oldDelegate.fadeoutOpacityAnimation ||
mainAxisMargin != oldDelegate.mainAxisMargin ||
crossAxisMargin != oldDelegate.crossAxisMargin ||
radius != oldDelegate.radius ||
trackRadius != oldDelegate.trackRadius ||
shape != oldDelegate.shape ||
padding != oldDelegate.padding ||
minLength != oldDelegate.minLength ||
minOverscrollLength != oldDelegate.minOverscrollLength ||
scrollbarOrientation != oldDelegate.scrollbarOrientation ||
ignorePointer != oldDelegate.ignorePointer;
}
@override
bool shouldRebuildSemantics(CustomPainter oldDelegate) => false;
@override
SemanticsBuilderCallback? get semanticsBuilder => null;
@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.
///
/// To add a scrollbar to a [ScrollView], like a [ListView] or a
/// [CustomScrollView], wrap the scroll view widget in a [RawScrollbar] widget.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=DbkIQSvwnZc}
///
/// {@template flutter.widgets.Scrollbar}
/// A scrollbar thumb indicates which portion of a [ScrollView] is actually
/// visible.
///
/// By default, the thumb will fade in and out as the child scroll view
/// scrolls. When [thumbVisibility] is true, the scrollbar thumb will remain
/// visible without the fade animation. This requires that the [ScrollController]
/// associated with the Scrollable widget is provided to [controller], or that
/// the [PrimaryScrollController] is being used by that Scrollable widget.
///
/// If the scrollbar is wrapped around multiple [ScrollView]s, it only responds to
/// the nearest ScrollView and shows the corresponding scrollbar thumb by default.
/// The [notificationPredicate] allows the ability to customize which
/// [ScrollNotification]s the Scrollbar should listen to.
///
/// If the child [ScrollView] is infinitely long, the [RawScrollbar] will not be
/// painted. In this case, the scrollbar cannot accurately represent the
/// relative location of the visible area, or calculate the accurate delta to
/// apply when dragging on the thumb or tapping on the track.
///
/// ### Interaction
///
/// Scrollbars are interactive and can use the [PrimaryScrollController] if
/// a [controller] is not set. Interactive Scrollbar thumbs can be dragged along
/// the main axis of the [ScrollView] to change the [ScrollPosition]. Tapping
/// along the track exclusive of the thumb will trigger a
/// [ScrollIncrementType.page] based on the relative position to the thumb.
///
/// When using the [PrimaryScrollController], it must not be attached to more
/// than one [ScrollPosition]. [ScrollView]s that have not been provided a
/// [ScrollController] and have a [ScrollView.scrollDirection] of
/// [Axis.vertical] will automatically attach their ScrollPosition to the
/// PrimaryScrollController. Provide a unique ScrollController to each
/// [Scrollable] in this case to prevent having multiple ScrollPositions
/// attached to the PrimaryScrollController.
///
/// {@tool dartpad}
/// This sample shows an app with two scrollables in the same route. Since by
/// default, there is one [PrimaryScrollController] per route, and they both have a
/// scroll direction of [Axis.vertical], they would both try to attach to that
/// controller on mobile platforms. The [Scrollbar] cannot support multiple
/// positions attached to the same controller, so one [ListView], and its
/// [Scrollbar] have been provided a unique [ScrollController]. Desktop
/// platforms do not automatically attach to the PrimaryScrollController,
/// requiring [ScrollView.primary] to be true instead in order to use the
/// PrimaryScrollController.
///
/// Alternatively, a new PrimaryScrollController could be created above one of
/// the [ListView]s.
///
/// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.0.dart **
/// {@end-tool}
///
/// ### Automatic Scrollbars on Desktop Platforms
///
/// Scrollbars are added to most [Scrollable] widgets by default on
/// [TargetPlatformVariant.desktop] platforms. This is done through
/// [ScrollBehavior.buildScrollbar] as part of an app's
/// [ScrollConfiguration]. Scrollables that do not use the
/// [PrimaryScrollController] or have a [ScrollController] provided to them
/// will receive a unique ScrollController for use with the Scrollbar. In this
/// case, only one Scrollable can be using the PrimaryScrollController, unless
/// [interactive] is false. To prevent [Axis.vertical] Scrollables from using
/// the PrimaryScrollController, set [ScrollView.primary] to false. Scrollable
/// widgets that do not have automatically applied Scrollbars include
///
/// * [EditableText]
/// * [ListWheelScrollView]
/// * [PageView]
/// * [NestedScrollView]
/// * [DropdownButton]
///
/// Default Scrollbars can be disabled for the whole app by setting a
/// [ScrollBehavior] with `scrollbars` set to false.
///
/// {@tool snippet}
/// ```dart
/// MaterialApp(
/// scrollBehavior: const MaterialScrollBehavior()
/// .copyWith(scrollbars: false),
/// home: Scaffold(
/// appBar: AppBar(title: const Text('Home')),
/// ),
/// )
/// ```
/// {@end-tool}
///
/// {@tool dartpad}
/// This sample shows how to disable the default Scrollbar for a [Scrollable]
/// widget to avoid duplicate Scrollbars when running on desktop platforms.
///
/// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.desktop.0.dart **
/// {@end-tool}
/// {@endtemplate}
///
/// {@tool dartpad}
/// This sample shows a [RawScrollbar] that executes a fade animation as
/// scrolling occurs. The RawScrollbar will fade into view as the user scrolls,
/// and fade out when scrolling stops. The [GridView] uses the
/// [PrimaryScrollController] since it has an [Axis.vertical] scroll direction
/// and has not been provided a [ScrollController].
///
/// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.1.dart **
/// {@end-tool}
///
/// {@tool dartpad}
/// When `thumbVisibility` is true, the scrollbar thumb will remain visible without
/// the fade animation. This requires that a [ScrollController] is provided to
/// `controller` for both the [RawScrollbar] and the [GridView].
/// Alternatively, the [PrimaryScrollController] can be used automatically so long
/// as it is attached to the singular [ScrollPosition] associated with the GridView.
///
/// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.2.dart **
/// {@end-tool}
///
/// See also:
///
/// * [ListView], which displays a linear, scrollable list of children.
/// * [GridView], which displays a 2 dimensional, scrollable array of children.
class RawScrollbar extends StatefulWidget {
/// Creates a basic raw scrollbar that wraps the given [child].
///
/// The [child], or a descendant of the [child], should be a source of
/// [ScrollNotification] notifications, typically a [Scrollable] widget.
const RawScrollbar({
super.key,
required this.child,
this.controller,
this.thumbVisibility,
this.shape,
this.radius,
this.thickness,
this.thumbColor,
this.minThumbLength = _kMinThumbExtent,
this.minOverscrollLength,
this.trackVisibility,
this.trackRadius,
this.trackColor,
this.trackBorderColor,
this.fadeDuration = _kScrollbarFadeDuration,
this.timeToFade = _kScrollbarTimeToFade,
this.pressDuration = Duration.zero,
this.notificationPredicate = defaultScrollNotificationPredicate,
this.interactive,
this.scrollbarOrientation,
this.scrollbarMargin = EdgeInsets.zero,
this.renderOffset = Offset.zero,
this.forceInteractive = false,
this.padding,
}) : assert(
!(thumbVisibility == false && (trackVisibility ?? false)),
'A scrollbar track cannot be drawn without a scrollbar thumb.',
),
assert(minThumbLength >= 0),
assert(minOverscrollLength == null || minOverscrollLength <= minThumbLength),
assert(minOverscrollLength == null || minOverscrollLength >= 0),
assert(radius == null || shape == null);
/// {@template flutter.widgets.Scrollbar.child}
/// The widget below this widget in the tree.
///
/// The scrollbar will be stacked on top of this child. This child (and its
/// subtree) should include a source of [ScrollNotification] notifications.
/// Typically a [Scrollbar] is created on desktop platforms by a
/// [ScrollBehavior.buildScrollbar] method, in which case the child is usually
/// the one provided as an argument to that method.
///
/// Typically a [ListView] or [CustomScrollView].
/// {@endtemplate}
final Widget child;
/// {@template flutter.widgets.Scrollbar.controller}
/// The [ScrollController] used to implement Scrollbar dragging.
///
/// If nothing is passed to controller, the default behavior is to automatically
/// enable scrollbar dragging on the nearest ScrollController using
/// [PrimaryScrollController.of].
///
/// If a ScrollController is passed, then dragging on the scrollbar thumb will
/// update the [ScrollPosition] attached to the controller. A stateful ancestor
/// of this widget needs to manage the ScrollController and either pass it to
/// a scrollable descendant or use a PrimaryScrollController to share it.
///
/// {@tool snippet}
/// Here is an example of using the [controller] attribute to enable
/// scrollbar dragging for multiple independent ListViews:
///
/// ```dart
/// // (e.g. in a stateful widget)
///
/// final ScrollController controllerOne = ScrollController();
/// final ScrollController controllerTwo = ScrollController();
///
/// @override
/// Widget build(BuildContext context) {
/// return Column(
/// children: <Widget>[
/// SizedBox(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: controllerOne,
/// child: ListView.builder(
/// controller: controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('item $index'),
/// ),
/// ),
/// ),
/// SizedBox(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: controllerTwo,
/// child: ListView.builder(
/// controller: controllerTwo,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('list 2 item $index'),
/// ),
/// ),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
/// {@endtemplate}
final ScrollController? controller;
/// {@template flutter.widgets.Scrollbar.thumbVisibility}
/// Indicates that the scrollbar thumb should be visible, even when a scroll
/// is not underway.
///
/// When false, the scrollbar will be shown during scrolling
/// and will fade out otherwise.
///
/// When true, the scrollbar will always be visible and never fade out. This
/// requires that the Scrollbar can access the [ScrollController] of the
/// associated Scrollable widget. This can either be the provided [controller],
/// or the [PrimaryScrollController] of the current context.
///
/// * When providing a controller, the same ScrollController must also be
/// provided to the associated Scrollable widget.
/// * The [PrimaryScrollController] is used by default for a [ScrollView]
/// that has not been provided a [ScrollController] and that has a
/// [ScrollView.scrollDirection] of [Axis.vertical]. This automatic
/// behavior does not apply to those with [Axis.horizontal]. To explicitly
/// use the PrimaryScrollController, set [ScrollView.primary] to true.
///
/// Defaults to false when null.
///
/// {@tool snippet}
///
/// ```dart
/// // (e.g. in a stateful widget)
///
/// final ScrollController controllerOne = ScrollController();
/// final ScrollController controllerTwo = ScrollController();
///
/// @override
/// Widget build(BuildContext context) {
/// return Column(
/// children: <Widget>[
/// SizedBox(
/// height: 200,
/// child: Scrollbar(
/// thumbVisibility: true,
/// controller: controllerOne,
/// child: ListView.builder(
/// controller: controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) {
/// return Text('item $index');
/// },
/// ),
/// ),
/// ),
/// SizedBox(
/// height: 200,
/// child: CupertinoScrollbar(
/// thumbVisibility: true,
/// controller: controllerTwo,
/// child: SingleChildScrollView(
/// controller: controllerTwo,
/// child: const SizedBox(
/// height: 2000,
/// width: 500,
/// child: Placeholder(),
/// ),
/// ),
/// ),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [RawScrollbarState.showScrollbar], an overridable getter which uses
/// this value to override the default behavior.
/// * [ScrollView.primary], which indicates whether the ScrollView is the primary
/// scroll view associated with the parent [PrimaryScrollController].
/// * [PrimaryScrollController], which associates a [ScrollController] with
/// a subtree.
/// {@endtemplate}
///
/// Subclass [Scrollbar] can hide and show the scrollbar thumb in response to
/// [WidgetState]s by using [ScrollbarThemeData.thumbVisibility].
final bool? thumbVisibility;
/// The [OutlinedBorder] of the scrollbar's thumb.
///
/// Only one of [radius] and [shape] may be specified. For a rounded rectangle,
/// it's simplest to just specify [radius]. By default, the scrollbar thumb's
/// shape is a simple rectangle.
///
/// If [shape] is specified, the thumb will take the shape of the passed
/// [OutlinedBorder] and fill itself with [thumbColor] (or grey if it
/// is unspecified).
///
/// {@tool dartpad}
/// This is an example of using a [StadiumBorder] for drawing the [shape] of the
/// thumb in a [RawScrollbar].
///
/// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.shape.0.dart **
/// {@end-tool}
final OutlinedBorder? shape;
/// The [Radius] of the scrollbar thumb's rounded rectangle corners.
///
/// Scrollbar will be rectangular if [radius] is null, which is the default
/// behavior.
final Radius? radius;
/// The thickness of the scrollbar in the cross axis of the scrollable.
///
/// If null, will default to 6.0 pixels.
final double? thickness;
/// The color of the scrollbar thumb.
///
/// If null, defaults to Color(0x66BCBCBC).
final Color? thumbColor;
/// 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.
///
/// The size of the scrollbar's thumb may shrink to a smaller size than [minThumbLength]
/// to fit in the available paint area (e.g., when [minThumbLength] is greater
/// than [ScrollMetrics.viewportDimension] and [mainAxisMargin] combined).
///
/// Mustn't be null and the value has to be greater or equal to
/// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0.
final double minThumbLength;
/// The preferred smallest size the scrollbar thumb can shrink to when viewport is
/// overscrolled.
///
/// When overscrolling, the size of the scrollbar's thumb may shrink to a smaller size
/// than [minOverscrollLength] to fit in the available paint area (e.g., when
/// [minOverscrollLength] is greater than [ScrollMetrics.viewportDimension] and
/// [mainAxisMargin] combined).
///
/// Overscrolling can be made possible by setting the `physics` property
/// of the `child` Widget to a `BouncingScrollPhysics`, which is a special
/// `ScrollPhysics` that allows overscrolling.
///
/// The value is less than or equal to [minThumbLength] and greater than or equal to 0.
/// When null, it will default to the value of [minThumbLength].
final double? minOverscrollLength;
/// {@template flutter.widgets.Scrollbar.trackVisibility}
/// Indicates that the scrollbar track should be visible.
///
/// When true, the scrollbar track will always be visible so long as the thumb
/// is visible. If the scrollbar thumb is not visible, the track will not be
/// visible either.
///
/// Defaults to false when null.
/// {@endtemplate}
///
/// Subclass [Scrollbar] can hide and show the scrollbar thumb in response to
/// [WidgetState]s by using [ScrollbarThemeData.trackVisibility].
final bool? trackVisibility;
/// The [Radius] of the scrollbar track's rounded rectangle corners.
///
/// Scrollbar's track will be rectangular if [trackRadius] is null, which is
/// the default behavior.
final Radius? trackRadius;
/// The color of the scrollbar track.
///
/// The scrollbar track will only be visible when [trackVisibility] and
/// [thumbVisibility] are true.
///
/// If null, defaults to Color(0x08000000).
final Color? trackColor;
/// The color of the scrollbar track's border.
///
/// The scrollbar track will only be visible when [trackVisibility] and
/// [thumbVisibility] are true.
///
/// If null, defaults to Color(0x1a000000).
final Color? trackBorderColor;
/// The [Duration] of the fade animation.
///
/// Defaults to a [Duration] of 300 milliseconds.
final Duration fadeDuration;
/// The [Duration] of time until the fade animation begins.
///
/// Defaults to a [Duration] of 600 milliseconds.
final Duration timeToFade;
/// The [Duration] of time that a LongPress will trigger the drag gesture of
/// the scrollbar thumb.
///
/// Defaults to [Duration.zero].
final Duration pressDuration;
/// {@template flutter.widgets.Scrollbar.notificationPredicate}
/// A check that specifies whether a [ScrollNotification] should be
/// handled by this widget.
///
/// By default, checks whether `notification.depth == 0`. That means if the
/// scrollbar is wrapped around multiple [ScrollView]s, it only responds to the
/// nearest scrollView and shows the corresponding scrollbar thumb.
/// {@endtemplate}
final ScrollNotificationPredicate notificationPredicate;
/// {@template flutter.widgets.Scrollbar.interactive}
/// Whether the Scrollbar should be interactive and respond to dragging on the
/// thumb, or tapping in the track area.
///
/// Does not apply to the [CupertinoScrollbar], which is always interactive to
/// match native behavior. On Android, the scrollbar is not interactive by
/// default.
///
/// When false, the scrollbar will not respond to gesture or hover events,
/// and will allow to click through it.
///
/// Defaults to true when null, unless on Android, which will default to false
/// when null.
///
/// See also:
///
/// * [RawScrollbarState.enableGestures], an overridable getter which uses
/// this value to override the default behavior.
/// {@endtemplate}
final bool? interactive;
/// {@macro flutter.widgets.Scrollbar.scrollbarOrientation}
final ScrollbarOrientation? scrollbarOrientation;
/// The margin around the scrollbar thumb in all directions.
///
/// This replaces the separate mainAxisMargin and crossAxisMargin parameters
/// with a single EdgeInsets for more flexible control.
///
/// The scrollbar track consumes this space.
///
/// Defaults to EdgeInsets.zero.
final EdgeInsets scrollbarMargin;
/// Additional offset to apply to the scrollbar rendering position.
///
/// This allows fine-tuned positioning of the scrollbar independent
/// of margins and padding.
///
/// Defaults to Offset.zero.
final Offset renderOffset;
/// Forces the scrollbar to be interactive even if it doesn't have a proper
/// scroll controller or other conditions that would normally make it non-interactive.
///
/// This can be useful for horizontal scrollbars that need to remain interactive
/// regardless of their controller state.
///
/// Defaults to false.
final bool forceInteractive;
/// The insets by which the scrollbar thumb and track should be padded.
///
/// When null, the inherited [MediaQueryData.padding] is used.
///
/// Defaults to null.
final EdgeInsetsGeometry? padding;
@override
RawScrollbarState<RawScrollbar> createState() => RawScrollbarState<RawScrollbar>();
}
/// The state for a [RawScrollbar] widget, also shared by the [Scrollbar] and
/// [CupertinoScrollbar] widgets.
///
/// Controls the animation that fades a scrollbar's thumb in and out of view.
///
/// Provides defaults gestures for dragging the scrollbar thumb and tapping on the
/// scrollbar track.
class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProviderStateMixin<T> {
Offset? _startDragScrollbarAxisOffset;
Offset? _lastDragUpdateOffset;
double? _startDragThumbOffset;
ScrollController? _cachedController;
Timer? _fadeoutTimer;
late AnimationController _fadeoutAnimationController;
late CurvedAnimation _fadeoutOpacityAnimation;
final GlobalKey _scrollbarPainterKey = GlobalKey();
bool _hoverIsActive = false;
Drag? _thumbDrag;
bool _maxScrollExtentPermitsScrolling = false;
ScrollHoldController? _thumbHold;
Axis? _axis;
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey =
GlobalKey<RawGestureDetectorState>();
ScrollController? get _effectiveScrollController =>
widget.controller ?? PrimaryScrollController.maybeOf(context);
/// Used to paint the scrollbar.
///
/// Can be customized by subclasses to change scrollbar behavior by overriding
/// [updateScrollbarPainter].
@protected
late final ScrollbarPainter scrollbarPainter;
/// Overridable getter to indicate that the scrollbar should be visible, even
/// when a scroll is not underway.
///
/// Subclasses can override this getter to make its value depend on an inherited
/// theme.
///
/// Defaults to false when [RawScrollbar.thumbVisibility] is null.
@protected
bool get showScrollbar => widget.thumbVisibility ?? false;
bool get _showTrack => showScrollbar && (widget.trackVisibility ?? false);
/// Overridable getter to indicate is gestures should be enabled on the
/// scrollbar.
///
/// When false, the scrollbar will not respond to gesture or hover events,
/// and will allow to click through it.
///
/// Subclasses can override this getter to make its value depend on an inherited
/// theme.
///
/// Defaults to true when [RawScrollbar.interactive] is null.
///
/// See also:
///
/// * [RawScrollbar.interactive], which overrides the default behavior.
@protected
bool get enableGestures => widget.interactive ?? true;
@protected
@override
void initState() {
super.initState();
_fadeoutAnimationController = AnimationController(vsync: this, duration: widget.fadeDuration)
..addStatusListener(_validateInteractions);
_fadeoutOpacityAnimation = CurvedAnimation(
parent: _fadeoutAnimationController,
curve: Curves.fastOutSlowIn,
);
scrollbarPainter = ScrollbarPainter(
color: widget.thumbColor ?? const Color(0x66BCBCBC),
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
thickness: widget.thickness ?? _kScrollbarThickness,
radius: widget.radius,
trackRadius: widget.trackRadius,
scrollbarOrientation: widget.scrollbarOrientation,
scrollbarMargin: widget.scrollbarMargin,
renderOffset: widget.renderOffset,
shape: widget.shape,
minLength: widget.minThumbLength,
minOverscrollLength: widget.minOverscrollLength ?? widget.minThumbLength,
);
scrollbarPainter.forceInteractive = widget.forceInteractive;
}
@protected
@override
void didChangeDependencies() {
super.didChangeDependencies();
assert(_debugScheduleCheckHasValidScrollPosition());
}
bool _debugScheduleCheckHasValidScrollPosition() {
if (!showScrollbar) {
return true;
}
WidgetsBinding.instance.addPostFrameCallback((Duration duration) {
assert(_debugCheckHasValidScrollPosition());
}, debugLabel: 'RawScrollbar.checkScrollPosition');
return true;
}
void _validateInteractions(AnimationStatus status) {
if (status.isDismissed) {
assert(_fadeoutOpacityAnimation.value == 0.0);
// We do not check for a valid scroll position if the scrollbar is not
// visible, because it cannot be interacted with.
} else if (_effectiveScrollController != null && enableGestures) {
// Interactive scrollbars need to be properly configured. If it is visible
// for interaction, ensure we are set up properly.
assert(_debugCheckHasValidScrollPosition());
}
}
bool _debugCheckHasValidScrollPosition() {
if (!mounted) {
return true;
}
final ScrollController? scrollController = _effectiveScrollController;
final bool tryPrimary = widget.controller == null;
final String controllerForError = tryPrimary
? 'PrimaryScrollController'
: 'provided ScrollController';
String when = '';
if (widget.thumbVisibility ?? false) {
when = 'Scrollbar.thumbVisibility is true';
} else if (enableGestures) {
when = 'the scrollbar is interactive';
} else {
when = 'using the Scrollbar';
}
assert(
scrollController != null,
'A ScrollController is required when $when. '
'${tryPrimary ? 'The Scrollbar was not provided a ScrollController, '
'and attempted to use the PrimaryScrollController, but none was found.' : ''}',
);
assert(() {
if (!scrollController!.hasClients) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary("The Scrollbar's ScrollController has no ScrollPosition attached."),
ErrorDescription('A Scrollbar cannot be painted without a ScrollPosition. '),
ErrorHint(
'The Scrollbar attempted to use the $controllerForError. This '
'ScrollController should be associated with the ScrollView that '
'the Scrollbar is being applied to.',
),
if (tryPrimary) ...<ErrorHint>[
ErrorHint(
'If a ScrollController has not been provided, the '
'PrimaryScrollController is used by default on mobile platforms '
'for ScrollViews with an Axis.vertical scroll direction.',
),
ErrorHint(
'To use the PrimaryScrollController explicitly, '
'set ScrollView.primary to true on the Scrollable widget.',
),
] else
ErrorHint(
'When providing your own ScrollController, ensure both the '
'Scrollbar and the Scrollable widget use the same one.',
),
]);
}
return true;
}());
assert(() {
try {
scrollController!.position;
} catch (error) {
if (scrollController == null || scrollController.positions.length <= 1) {
rethrow;
}
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('The $controllerForError is attached to more than one ScrollPosition.'),
ErrorDescription(
'The Scrollbar requires a single ScrollPosition in order to be painted.',
),
ErrorHint(
'When $when, the associated ScrollController must only have one '
'ScrollPosition attached.',
),
if (tryPrimary) ...<ErrorHint>[
ErrorHint(
'If a ScrollController has not been provided, the '
'PrimaryScrollController is used by default on mobile platforms '
'for ScrollViews with an Axis.vertical scroll direction.',
),
ErrorHint(
'More than one ScrollView may have tried to use the '
'PrimaryScrollController of the current context. '
'ScrollView.primary can override this behavior.',
),
] else
ErrorHint(
'The provided ScrollController cannot be shared by multiple '
'ScrollView widgets.',
),
]);
}
return true;
}());
return true;
}
/// This method is responsible for configuring the [scrollbarPainter]
/// according to the [widget]'s properties and any inherited widgets the
/// painter depends on, like [Directionality] and [MediaQuery].
///
/// Subclasses can override to configure the [scrollbarPainter].
@protected
void updateScrollbarPainter() {
final TextDirection textDirection = Directionality.of(context);
scrollbarPainter
..color = widget.thumbColor ?? const Color(0x66BCBCBC)
..trackRadius = widget.trackRadius
..trackColor = _showTrack
? widget.trackColor ?? const Color(0x08000000)
: const Color(0x00000000)
..trackBorderColor = _showTrack
? widget.trackBorderColor ?? const Color(0x1a000000)
: const Color(0x00000000)
..textDirection = textDirection
..thickness = widget.thickness ?? _kScrollbarThickness
..radius = widget.radius
..padding = (widget.padding ?? MediaQuery.paddingOf(context)).resolve(textDirection)
..scrollbarOrientation = widget.scrollbarOrientation
..scrollbarMargin = widget.scrollbarMargin
..renderOffset = widget.renderOffset
..forceInteractive = widget.forceInteractive
..shape = widget.shape
..minLength = widget.minThumbLength
..minOverscrollLength = widget.minOverscrollLength ?? widget.minThumbLength
..ignorePointer = !enableGestures;
}
@protected
@override
void didUpdateWidget(T oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.thumbVisibility != oldWidget.thumbVisibility) {
if (widget.thumbVisibility ?? false) {
assert(_debugScheduleCheckHasValidScrollPosition());
_fadeoutTimer?.cancel();
_fadeoutAnimationController.animateTo(1.0);
} else {
_fadeoutAnimationController.reverse();
}
}
}
void _maybeStartFadeoutTimer() {
if (!showScrollbar) {
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(widget.timeToFade, () {
_fadeoutAnimationController.reverse();
_fadeoutTimer = null;
});
}
}
/// Returns the [Axis] of the child scroll view, or null if the
/// we haven't seen a ScrollMetrics notification yet.
@protected
Axis? getScrollbarDirection() => _axis;
void _disposeThumbDrag() {
_thumbDrag = null;
}
void _disposeThumbHold() {
_thumbHold = null;
}
// Given the drag's localPosition (see handleThumbPressUpdate) compute the
// scroll position delta in the scroll axis direction. Deal with the complications
// arising from scroll metrics changes that have occurred since the last
// drag update and the need to prevent overscrolling on some platforms.
double? _getPrimaryDelta(Offset localPosition) {
assert(_cachedController != null);
assert(_startDragScrollbarAxisOffset != null);
assert(_lastDragUpdateOffset != null);
assert(_startDragThumbOffset != null);
final ScrollPosition position = _cachedController!.position;
late double primaryDeltaFromDragStart;
late double primaryDeltaFromLastDragUpdate;
switch (position.axisDirection) {
case AxisDirection.up:
primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dy - localPosition.dy;
primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dy - localPosition.dy;
case AxisDirection.right:
primaryDeltaFromDragStart = localPosition.dx - _startDragScrollbarAxisOffset!.dx;
primaryDeltaFromLastDragUpdate = localPosition.dx - _lastDragUpdateOffset!.dx;
case AxisDirection.down:
primaryDeltaFromDragStart = localPosition.dy - _startDragScrollbarAxisOffset!.dy;
primaryDeltaFromLastDragUpdate = localPosition.dy - _lastDragUpdateOffset!.dy;
case AxisDirection.left:
primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dx - localPosition.dx;
primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dx - localPosition.dx;
}
// Convert primaryDelta, the amount that the scrollbar moved since the last
// time when drag started or last updated, into the coordinate space of the scroll
// position.
double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll(
_startDragThumbOffset! + primaryDeltaFromDragStart,
);
if (primaryDeltaFromDragStart > 0 && scrollOffsetGlobal < position.pixels ||
primaryDeltaFromDragStart < 0 && scrollOffsetGlobal > position.pixels) {
// Adjust the position value if the scrolling direction conflicts with
// the dragging direction due to scroll metrics shrink.
scrollOffsetGlobal =
position.pixels + scrollbarPainter.getTrackToScroll(primaryDeltaFromLastDragUpdate);
}
if (scrollOffsetGlobal != position.pixels) {
// Ensure we don't drag into overscroll if the physics do not allow it.
final double physicsAdjustment = position.physics.applyBoundaryConditions(
position,
scrollOffsetGlobal,
);
double newPosition = scrollOffsetGlobal - physicsAdjustment;
// The physics may allow overscroll when actually *scrolling*, but
// dragging on the scrollbar does not always allow us to enter overscroll.
switch (ScrollConfiguration.of(context).getPlatform(context)) {
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
newPosition = clampDouble(
newPosition,
position.minScrollExtent,
position.maxScrollExtent,
);
case TargetPlatform.iOS:
case TargetPlatform.android:
// We can only drag the scrollbar into overscroll on mobile
// platforms, and only then if the physics allow it.
}
final bool isReversed = axisDirectionIsReversed(position.axisDirection);
return isReversed ? newPosition - position.pixels : position.pixels - newPosition;
}
return null;
}
/// Handler called when a press on the scrollbar thumb has been recognized.
///
/// Cancels the [Timer] associated with the fade animation of the scrollbar.
@protected
@mustCallSuper
void handleThumbPress() {
assert(_debugCheckHasValidScrollPosition());
_cachedController = _effectiveScrollController;
if (getScrollbarDirection() == null) {
return;
}
_fadeoutTimer?.cancel();
_thumbHold = _cachedController!.position.hold(_disposeThumbHold);
}
/// Handler called when a long press gesture has started.
///
/// Begins the fade out animation and creates the thumb's DragScrollController.
@protected
@mustCallSuper
void handleThumbPressStart(Offset localPosition) {
assert(_debugCheckHasValidScrollPosition());
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
_fadeoutTimer?.cancel();
_fadeoutAnimationController.forward();
assert(_thumbDrag == null);
final ScrollPosition position = _cachedController!.position;
final RenderBox renderBox =
_scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
final DragStartDetails details = DragStartDetails(
localPosition: localPosition,
globalPosition: renderBox.localToGlobal(localPosition),
);
_thumbDrag = position.drag(details, _disposeThumbDrag);
assert(_thumbDrag != null);
assert(_thumbHold == null);
_startDragScrollbarAxisOffset = localPosition;
_lastDragUpdateOffset = localPosition;
_startDragThumbOffset = scrollbarPainter.getThumbScrollOffset();
}
/// Handler called when a currently active long press gesture moves.
///
/// Updates the position of the child scrollable via the _drag ScrollDragController.
@protected
@mustCallSuper
void handleThumbPressUpdate(Offset localPosition) {
assert(_debugCheckHasValidScrollPosition());
if (_lastDragUpdateOffset == localPosition) {
return;
}
final ScrollPosition position = _cachedController!.position;
if (!position.physics.shouldAcceptUserOffset(position)) {
return;
}
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
// _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag.
assert(_thumbHold == null || _thumbDrag == null);
if (_thumbDrag == null) {
return;
}
final double? primaryDelta = _getPrimaryDelta(localPosition);
if (primaryDelta == null) {
return;
}
final Offset delta = switch (direction) {
Axis.horizontal => Offset(primaryDelta, 0),
Axis.vertical => Offset(0, primaryDelta),
};
final RenderBox renderBox =
_scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
final DragUpdateDetails scrollDetails = DragUpdateDetails(
delta: delta,
primaryDelta: primaryDelta,
globalPosition: renderBox.localToGlobal(localPosition),
localPosition: localPosition,
);
_thumbDrag!.update(
scrollDetails,
); // Triggers updates to the ScrollPosition and ScrollbarPainter
_lastDragUpdateOffset = localPosition;
}
/// Handler called when a long press has ended.
@protected
@mustCallSuper
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
assert(_debugCheckHasValidScrollPosition());
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
_maybeStartFadeoutTimer();
_cachedController = null;
_lastDragUpdateOffset = null;
// _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag.
assert(_thumbHold == null || _thumbDrag == null);
if (_thumbDrag == null) {
return;
}
// On mobile platforms flinging the scrollbar thumb causes a ballistic
// scroll, just like it does via a touch drag. Likewise for desktops when
// dragging on the trackpad or with a stylus.
final TargetPlatform platform = ScrollConfiguration.of(context).getPlatform(context);
final Velocity adjustedVelocity = switch (platform) {
TargetPlatform.iOS || TargetPlatform.android => -velocity,
_ => Velocity.zero,
};
final RenderBox renderBox =
_scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
final DragEndDetails details = DragEndDetails(
localPosition: localPosition,
globalPosition: renderBox.localToGlobal(localPosition),
velocity: adjustedVelocity,
primaryVelocity: switch (direction) {
Axis.horizontal => adjustedVelocity.pixelsPerSecond.dx,
Axis.vertical => adjustedVelocity.pixelsPerSecond.dy,
},
);
_thumbDrag?.end(details);
assert(_thumbDrag == null);
_startDragScrollbarAxisOffset = null;
_lastDragUpdateOffset = null;
_startDragThumbOffset = null;
_cachedController = null;
}
/// Handler called when the track is tapped in order to page in the tapped
/// direction.
@protected
@mustCallSuper
void handleTrackTapDown(TapDownDetails details) {
// The Scrollbar should page towards the position of the tap on the track.
assert(_debugCheckHasValidScrollPosition());
_cachedController = _effectiveScrollController;
final ScrollPosition position = _cachedController!.position;
if (!position.physics.shouldAcceptUserOffset(position)) {
return;
}
// Determines the scroll direction.
final AxisDirection scrollDirection;
switch (axisDirectionToAxis(position.axisDirection)) {
case Axis.vertical:
if (details.localPosition.dy > scrollbarPainter._thumbOffset) {
scrollDirection = AxisDirection.down;
} else {
scrollDirection = AxisDirection.up;
}
case Axis.horizontal:
if (details.localPosition.dx > scrollbarPainter._thumbOffset) {
scrollDirection = AxisDirection.right;
} else {
scrollDirection = AxisDirection.left;
}
}
final ScrollableState? state = Scrollable.maybeOf(position.context.notificationContext!);
final ScrollIntent intent = ScrollIntent(
direction: scrollDirection,
type: ScrollIncrementType.page,
);
assert(state != null);
final double scrollIncrement = ScrollAction.getDirectionalIncrement(state!, intent);
_cachedController!.position.moveTo(
_cachedController!.position.pixels + scrollIncrement,
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
);
}
// ScrollController takes precedence over ScrollNotification
bool _shouldUpdatePainter(Axis notificationAxis) {
final ScrollController? scrollController = _effectiveScrollController;
// Only update the painter of this scrollbar if the notification
// metrics do not conflict with the information we have from the scroll
// controller.
// We do not have a scroll controller dictating axis.
if (scrollController == null) {
return true;
}
// Has more than one attached positions.
if (scrollController.positions.length > 1) {
return false;
}
return // The scroll controller is not attached to a position.
!scrollController.hasClients
// The notification matches the scroll controller's axis.
||
scrollController.position.axis == notificationAxis;
}
bool _handleScrollMetricsNotification(ScrollMetricsNotification notification) {
if (!widget.notificationPredicate(notification.asScrollUpdate())) {
return false;
}
if (showScrollbar && !_fadeoutAnimationController.isForwardOrCompleted) {
_fadeoutAnimationController.forward();
}
final ScrollMetrics metrics = notification.metrics;
if (_shouldUpdatePainter(metrics.axis)) {
scrollbarPainter.update(metrics, metrics.axisDirection);
}
if (metrics.axis != _axis) {
setState(() {
_axis = metrics.axis;
});
}
if (_maxScrollExtentPermitsScrolling != notification.metrics.maxScrollExtent > 0.0) {
setState(() {
_maxScrollExtentPermitsScrolling = !_maxScrollExtentPermitsScrolling;
});
}
return false;
}
bool _handleScrollNotification(ScrollNotification notification) {
if (!widget.notificationPredicate(notification)) {
return false;
}
final ScrollMetrics metrics = notification.metrics;
if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
// Hide the bar when the Scrollable widget has no space to scroll.
if (_fadeoutAnimationController.isForwardOrCompleted) {
_fadeoutAnimationController.reverse();
}
if (_shouldUpdatePainter(metrics.axis)) {
scrollbarPainter.update(metrics, metrics.axisDirection);
}
return false;
}
if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
// Any movements always makes the scrollbar start showing up.
if (!_fadeoutAnimationController.isForwardOrCompleted) {
_fadeoutAnimationController.forward();
}
_fadeoutTimer?.cancel();
if (_shouldUpdatePainter(metrics.axis)) {
scrollbarPainter.update(metrics, metrics.axisDirection);
}
} else if (notification is ScrollEndNotification) {
if (_thumbDrag == null) {
_maybeStartFadeoutTimer();
}
}
return false;
}
void _handleThumbDragDown(DragDownDetails details) {
handleThumbPress();
}
// The protected RawScrollbar API methods - handleThumbPressStart,
// handleThumbPressUpdate, handleThumbPressEnd - all depend on a
// localPosition parameter that defines the event's location relative
// to the scrollbar. Ensure that the localPosition is reported consistently,
// even if the source of the event is a trackpad or a stylus.
Offset _globalToScrollbar(Offset offset) {
final RenderBox renderBox =
_scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
return renderBox.globalToLocal(offset);
}
void _handleThumbDragStart(DragStartDetails details) {
handleThumbPressStart(_globalToScrollbar(details.globalPosition));
}
void _handleThumbDragUpdate(DragUpdateDetails details) {
handleThumbPressUpdate(_globalToScrollbar(details.globalPosition));
}
void _handleThumbDragEnd(DragEndDetails details) {
handleThumbPressEnd(_globalToScrollbar(details.globalPosition), details.velocity);
}
void _handleThumbDragCancel() {
if (_gestureDetectorKey.currentContext == null) {
// The cancel was caused by the GestureDetector getting disposed, which
// means we will get disposed momentarily as well and shouldn't do
// any work.
return;
}
// _thumbHold might be null if the drag started.
// _thumbDrag might be null if the drag activity ended and called _disposeThumbDrag.
assert(_thumbHold == null || _thumbDrag == null);
_thumbHold?.cancel();
_thumbDrag?.cancel();
assert(_thumbHold == null);
assert(_thumbDrag == null);
}
void _initThumbDragGestureRecognizer(DragGestureRecognizer instance) {
instance.onDown = _handleThumbDragDown;
instance.onStart = _handleThumbDragStart;
instance.onUpdate = _handleThumbDragUpdate;
instance.onEnd = _handleThumbDragEnd;
instance.onCancel = _handleThumbDragCancel;
instance.gestureSettings = const DeviceGestureSettings(touchSlop: 0);
instance.dragStartBehavior = DragStartBehavior.down;
}
bool _canHandleScrollGestures() {
return enableGestures &&
_effectiveScrollController != null &&
_effectiveScrollController!.positions.length == 1 &&
_effectiveScrollController!.position.hasContentDimensions &&
_effectiveScrollController!.position.maxScrollExtent > 0.0;
}
Map<Type, GestureRecognizerFactory> get _gestures {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
if (!_canHandleScrollGestures()) {
return gestures;
}
switch (_effectiveScrollController!.position.axis) {
case Axis.horizontal:
gestures[_HorizontalThumbDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_HorizontalThumbDragGestureRecognizer>(
() => _HorizontalThumbDragGestureRecognizer(
debugOwner: this,
customPaintKey: _scrollbarPainterKey,
),
_initThumbDragGestureRecognizer,
);
case Axis.vertical:
gestures[_VerticalThumbDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_VerticalThumbDragGestureRecognizer>(
() => _VerticalThumbDragGestureRecognizer(
debugOwner: this,
customPaintKey: _scrollbarPainterKey,
),
_initThumbDragGestureRecognizer,
);
}
gestures[_TrackTapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_TrackTapGestureRecognizer>(
() => _TrackTapGestureRecognizer(debugOwner: this, customPaintKey: _scrollbarPainterKey),
(_TrackTapGestureRecognizer instance) {
instance.onTapDown = handleTrackTapDown;
},
);
return gestures;
}
/// Returns true if the provided [Offset] is located over the track of the
/// [RawScrollbar].
///
/// Excludes the [RawScrollbar] thumb.
@protected
bool isPointerOverTrack(Offset position, PointerDeviceKind kind) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
return scrollbarPainter.hitTestInteractive(localOffset, kind) &&
!scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind);
}
/// Returns true if the provided [Offset] is located over the thumb of the
/// [RawScrollbar].
@protected
bool isPointerOverThumb(Offset position, PointerDeviceKind kind) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
return scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind);
}
/// Returns true if the provided [Offset] is located over the track or thumb
/// of the [RawScrollbar].
///
/// The hit test area for mouse hovering over the scrollbar is larger than
/// regular hit testing. This is to make it easier to interact with the
/// scrollbar and present it to the mouse for interaction based on proximity.
/// When `forHover` is true, the larger hit test area will be used.
@protected
bool isPointerOverScrollbar(Offset position, PointerDeviceKind kind, {bool forHover = false}) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
return scrollbarPainter.hitTestInteractive(localOffset, kind, forHover: true);
}
/// Cancels the fade out animation so the scrollbar will remain visible for
/// interaction.
///
/// Can be overridden by subclasses to respond to a [PointerHoverEvent].
///
/// Helper methods [isPointerOverScrollbar], [isPointerOverThumb], and
/// [isPointerOverTrack] can be used to determine the location of the pointer
/// relative to the painter scrollbar elements.
@protected
@mustCallSuper
void handleHover(PointerHoverEvent event) {
// Check if the position of the pointer falls over the painted scrollbar
if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) {
_hoverIsActive = true;
// Bring the scrollbar back into view if it has faded or started to fade
// away.
_fadeoutAnimationController.forward();
_fadeoutTimer?.cancel();
} else if (_hoverIsActive) {
// Pointer is not over painted scrollbar.
_hoverIsActive = false;
_maybeStartFadeoutTimer();
}
}
/// Initiates the fade out animation.
///
/// Can be overridden by subclasses to respond to a [PointerExitEvent].
@protected
@mustCallSuper
void handleHoverExit(PointerExitEvent event) {
_hoverIsActive = false;
_maybeStartFadeoutTimer();
}
// Returns the delta that should result from applying [event] with axis and
// direction taken into account.
double _pointerSignalEventDelta(PointerScrollEvent event) {
assert(_cachedController != null);
double delta = _cachedController!.position.axis == Axis.horizontal
? event.scrollDelta.dx
: event.scrollDelta.dy;
if (axisDirectionIsReversed(_cachedController!.position.axisDirection)) {
delta *= -1;
}
return delta;
}
// Returns the offset that should result from applying [event] to the current
// position, taking min/max scroll extent into account.
double _targetScrollOffsetForPointerScroll(double delta) {
assert(_cachedController != null);
return math.min(
math.max(
_cachedController!.position.pixels + delta,
_cachedController!.position.minScrollExtent,
),
_cachedController!.position.maxScrollExtent,
);
}
void _handlePointerScroll(PointerEvent event) {
assert(event is PointerScrollEvent);
_cachedController = _effectiveScrollController;
final double delta = _pointerSignalEventDelta(event as PointerScrollEvent);
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
if (delta != 0.0 && targetScrollOffset != _cachedController!.position.pixels) {
_cachedController!.position.pointerScroll(delta);
}
}
void _receivedPointerSignal(PointerSignalEvent event) {
_cachedController = _effectiveScrollController;
// Only try to scroll if the bar absorb the hit test.
if ((scrollbarPainter.hitTest(event.localPosition) ?? false) &&
_cachedController != null &&
_cachedController!.hasClients &&
(_thumbDrag == null || kIsWeb)) {
final ScrollPosition position = _cachedController!.position;
if (event is PointerScrollEvent) {
if (!position.physics.shouldAcceptUserOffset(position)) {
return;
}
final double delta = _pointerSignalEventDelta(event);
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
if (delta != 0.0 && targetScrollOffset != position.pixels) {
GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
}
} else if (event is PointerScrollInertiaCancelEvent) {
position.jumpTo(position.pixels);
// Don't use the pointer signal resolver, all hit-tested scrollables should stop.
}
}
}
@protected
@override
void dispose() {
_fadeoutAnimationController.dispose();
_fadeoutTimer?.cancel();
scrollbarPainter.dispose();
_fadeoutOpacityAnimation.dispose();
super.dispose();
}
@protected
@override
Widget build(BuildContext context) {
updateScrollbarPainter();
return NotificationListener<ScrollMetricsNotification>(
onNotification: _handleScrollMetricsNotification,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: RepaintBoundary(
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
key: _gestureDetectorKey,
gestures: _gestures,
child: MouseRegion(
onExit: (PointerExitEvent event) {
switch (event.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
if (enableGestures) {
handleHoverExit(event);
}
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
case PointerDeviceKind.touch:
break;
}
},
onHover: (PointerHoverEvent event) {
switch (event.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
if (enableGestures) {
handleHover(event);
}
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
case PointerDeviceKind.touch:
break;
}
},
child: CustomPaint(
key: _scrollbarPainterKey,
foregroundPainter: scrollbarPainter,
child: RepaintBoundary(child: widget.child),
),
),
),
),
),
),
);
}
}
Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) {
final RenderBox renderBox = scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
return renderBox.globalToLocal(position);
}
bool _isThumbEvent(GlobalKey customPaintKey, PointerEvent event) {
if (customPaintKey.currentContext == null) {
return false;
}
final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint;
final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
final Offset localOffset = _getLocalOffset(customPaintKey, event.position);
return painter.hitTestOnlyThumbInteractive(localOffset, event.kind);
}
bool _isTrackEvent(GlobalKey customPaintKey, PointerEvent event) {
if (customPaintKey.currentContext == null) {
return false;
}
final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint;
final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
final Offset localOffset = _getLocalOffset(customPaintKey, event.position);
final PointerDeviceKind kind = event.kind;
return painter.hitTestInteractive(localOffset, kind) &&
!painter.hitTestOnlyThumbInteractive(localOffset, kind);
}
class _TrackTapGestureRecognizer extends TapGestureRecognizer {
_TrackTapGestureRecognizer({required super.debugOwner, required GlobalKey customPaintKey})
: _customPaintKey = customPaintKey;
final GlobalKey _customPaintKey;
@override
bool isPointerAllowed(PointerDownEvent event) {
return _isTrackEvent(_customPaintKey, event) && super.isPointerAllowed(event);
}
}
class _VerticalThumbDragGestureRecognizer extends VerticalDragGestureRecognizer {
_VerticalThumbDragGestureRecognizer({
required Object super.debugOwner,
required GlobalKey customPaintKey,
}) : _customPaintKey = customPaintKey;
final GlobalKey _customPaintKey;
@override
bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) {
return false;
}
@override
bool isPointerAllowed(PointerEvent event) {
return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event);
}
}
class _HorizontalThumbDragGestureRecognizer extends HorizontalDragGestureRecognizer {
_HorizontalThumbDragGestureRecognizer({
required Object super.debugOwner,
required GlobalKey customPaintKey,
}) : _customPaintKey = customPaintKey;
final GlobalKey _customPaintKey;
@override
bool isPointerPanZoomAllowed(PointerPanZoomStartEvent event) {
return false;
}
@override
bool isPointerAllowed(PointerEvent event) {
return _isThumbEvent(_customPaintKey, event) && super.isPointerAllowed(event);
}
}