Skip to content

Commit a4e6831

Browse files
Automatically find the scrollbar's thumb position.
1 parent 713f27e commit a4e6831

File tree

1 file changed

+227
-8
lines changed

1 file changed

+227
-8
lines changed

lib/src/scrollbar.dart

Lines changed: 227 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,245 @@
1-
import 'package:flutter/gestures.dart';
1+
import 'dart:math' as math;
2+
import 'dart:ui';
3+
4+
import 'package:flutter/material.dart';
25
import 'package:flutter_test/flutter_test.dart';
36

47
/// Simulates the user interacting with a Scrollbar.
58
extension ScrollbarInteractions on WidgetTester {
6-
/// Press a scrollbar thumb at [thumbLocation] and drag it vertically by [delta] pixels.
7-
Future<void> dragScrollbar(Offset thumbLocation, double delta) async {
8-
//Hover to make the thumb visible with a duration long enough to run the fade in animation.
9+
/// Drag the scrollbar by [delta] pixels.
10+
///
11+
/// A positive [delta] scrolls down or right, depending on the scrollbar's orientation,
12+
/// and a negative [delta] scrolls up or left.
13+
///
14+
/// By default, this method expects a single [Scrollbar] in the widget tree and
15+
/// finds it `byType`. To specify one [Scrollbar] among many, pass a [finder].
16+
Future<void> dragScrollbar(double delta, [Finder? finder]) async {
17+
// Find where the scrollbar's thumb sits.
18+
final thumbRect = _findThumbRect(finder ?? find.byType(Scrollbar));
19+
final thumbOffset = thumbRect.center;
20+
921
final testPointer = TestPointer(1, PointerDeviceKind.mouse);
1022

11-
await sendEventToBinding(testPointer.hover(thumbLocation, timeStamp: const Duration(seconds: 1)));
23+
// Hover to make the thumb visible with a duration long enough to run the fade in animation.
24+
await sendEventToBinding(testPointer.hover(thumbOffset, timeStamp: const Duration(seconds: 1)));
1225
await pumpAndSettle();
1326

1427
// Press the thumb.
15-
await sendEventToBinding(testPointer.down(thumbLocation));
28+
await sendEventToBinding(testPointer.down(thumbOffset));
1629
await pump(const Duration(milliseconds: 40));
1730

18-
// Move the thumb down.
19-
await sendEventToBinding(testPointer.move(thumbLocation + Offset(0, delta)));
31+
// Move the thumb.
32+
await sendEventToBinding(testPointer.move(thumbOffset + Offset(0, delta)));
2033
await pump();
2134

2235
// Release the pointer.
2336
await sendEventToBinding(testPointer.up());
2437
await pump();
2538
}
39+
40+
/// Finds the thumb's rect, in global coordinates.
41+
///
42+
/// Adapted from ScrollbarPainter._paintScrollbar.
43+
Rect _findThumbRect(Finder scrollbarFinder) {
44+
// Find the Scrollbar's Scrollable.
45+
final scrollState = state<ScrollableState>(find.descendant(
46+
of: scrollbarFinder,
47+
matching: find.byType(Scrollable),
48+
));
49+
50+
// Find the Scrollbar's ScrollbarPainter, which is used to paint the thumb.
51+
// The ScrollbarPainter is used to gather information necessary to compute the thumb's
52+
// position and size.
53+
final scrollbarPainter = widget<CustomPaint>(
54+
find.descendant(
55+
of: scrollbarFinder,
56+
matching: find.byWidgetPredicate(
57+
(widget) => widget is CustomPaint && widget.foregroundPainter is ScrollbarPainter,
58+
),
59+
),
60+
).foregroundPainter as ScrollbarPainter;
61+
62+
final scrollPosition = scrollState.position;
63+
final isVertical = scrollPosition.axisDirection == AxisDirection.down || //
64+
scrollPosition.axisDirection == AxisDirection.up;
65+
66+
final orientation = _resolvedOrientation(scrollbarPainter, isVertical);
67+
final leadingTrackMainAxisOffset =
68+
orientation == ScrollbarOrientation.left || orientation == ScrollbarOrientation.right //
69+
? scrollbarPainter.padding.top
70+
: scrollbarPainter.padding.left;
71+
72+
final leadingThumbMainAxisOffset = leadingTrackMainAxisOffset + scrollbarPainter.mainAxisMargin;
73+
74+
final traversableTrackExtent = _findTraversableTrackExtent(
75+
scrollbarPainter: scrollbarPainter,
76+
scrollPosition: scrollPosition,
77+
);
78+
final thumbExtent = _findThumbExtent(
79+
scrollbarPainter: scrollbarPainter,
80+
scrollPosition: scrollPosition,
81+
traversableTrackExtent: traversableTrackExtent,
82+
);
83+
final thumbOffset = _getScrollToTrack(
84+
scrollbarPainter: scrollbarPainter,
85+
scrollPosition: scrollPosition,
86+
thumbExtent: thumbExtent,
87+
) +
88+
leadingThumbMainAxisOffset;
89+
90+
late double thumbX, thumbY;
91+
late Size thumbSize;
92+
93+
final scrollableSize = (scrollState.context.findRenderObject() as RenderBox).size;
94+
switch (orientation) {
95+
case ScrollbarOrientation.left:
96+
thumbSize = Size(scrollbarPainter.thickness, thumbExtent);
97+
thumbX = scrollbarPainter.crossAxisMargin + scrollbarPainter.padding.left;
98+
thumbY = thumbOffset;
99+
break;
100+
case ScrollbarOrientation.right:
101+
thumbSize = Size(scrollbarPainter.thickness, thumbExtent);
102+
thumbX = scrollableSize.width -
103+
scrollbarPainter.thickness -
104+
scrollbarPainter.crossAxisMargin -
105+
scrollbarPainter.padding.right;
106+
thumbY = thumbOffset;
107+
break;
108+
case ScrollbarOrientation.top:
109+
thumbSize = Size(thumbExtent, scrollbarPainter.thickness);
110+
thumbX = thumbOffset;
111+
thumbY = scrollbarPainter.crossAxisMargin + scrollbarPainter.padding.top;
112+
break;
113+
case ScrollbarOrientation.bottom:
114+
thumbSize = Size(thumbExtent, scrollbarPainter.thickness);
115+
thumbX = thumbOffset;
116+
thumbY = scrollableSize.height -
117+
scrollbarPainter.thickness -
118+
scrollbarPainter.crossAxisMargin -
119+
scrollbarPainter.padding.bottom;
120+
break;
121+
}
122+
123+
final scrollbarRenderBox = element(scrollbarFinder).findRenderObject() as RenderBox;
124+
return scrollbarRenderBox.localToGlobal(Offset(thumbX, thumbY)) & thumbSize;
125+
}
126+
127+
/// Converts between a scroll position and the corresponding position in the
128+
/// thumb track, in ScrollBar's coordinates.
129+
///
130+
/// Copied and adapted from ScrollbarPainter._getScrollToTrack.
131+
double _getScrollToTrack({
132+
required ScrollbarPainter scrollbarPainter,
133+
required ScrollPosition scrollPosition,
134+
required double thumbExtent,
135+
}) {
136+
final scrollableExtent = scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent;
137+
final axisDirection = scrollPosition.axisDirection;
138+
139+
final fractionPast = (scrollableExtent > 0)
140+
? clampDouble((scrollPosition.pixels - scrollPosition.minScrollExtent) / scrollableExtent, 0.0, 1.0)
141+
: 0.0;
142+
143+
final isReversed = scrollPosition.axisDirection == AxisDirection.up || //
144+
scrollPosition.axisDirection == AxisDirection.left;
145+
146+
final isVertical = axisDirection == AxisDirection.down || //
147+
axisDirection == AxisDirection.up;
148+
final totalTrackMainAxisOffset =
149+
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal;
150+
final trackExtent = scrollPosition.viewportDimension - totalTrackMainAxisOffset;
151+
final traversableTrackExtent = trackExtent - (2 * scrollbarPainter.mainAxisMargin);
152+
153+
return (isReversed ? 1 - fractionPast : fractionPast) * (traversableTrackExtent - thumbExtent);
154+
}
155+
156+
/// Returns the position where the Scrollbar is painted.
157+
///
158+
/// The scrollbar can be painted in the left or right edge when it's vertical,
159+
/// or at the bottom when it's horizontal.
160+
///
161+
/// Copied from ScrollbarPainter._resolvedOrientation.
162+
ScrollbarOrientation _resolvedOrientation(ScrollbarPainter scrollbarPainter, bool isVertical) {
163+
if (scrollbarPainter.scrollbarOrientation == null) {
164+
if (isVertical) {
165+
return scrollbarPainter.textDirection == TextDirection.ltr
166+
? ScrollbarOrientation.right
167+
: ScrollbarOrientation.left;
168+
}
169+
return ScrollbarOrientation.bottom;
170+
}
171+
return scrollbarPainter.scrollbarOrientation!;
172+
}
173+
174+
// Copied from ScrollbarPainter._setThumbExtent.
175+
double _findThumbExtent({
176+
required ScrollbarPainter scrollbarPainter,
177+
required ScrollPosition scrollPosition,
178+
required double traversableTrackExtent,
179+
}) {
180+
final isVertical = scrollPosition.axisDirection == AxisDirection.down || //
181+
scrollPosition.axisDirection == AxisDirection.up;
182+
final totalTrackMainAxisOffsets =
183+
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal;
184+
final totalContentExtent =
185+
scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent + scrollPosition.viewportDimension;
186+
187+
// Thumb extent reflects fraction of content visible, as long as this
188+
// isn't less than the absolute minimum size.
189+
// _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0
190+
final fractionVisible = clampDouble(
191+
(scrollPosition.extentInside - totalTrackMainAxisOffsets) / (totalContentExtent - totalTrackMainAxisOffsets),
192+
0.0,
193+
1.0,
194+
);
195+
196+
final thumbExtent = math.max(
197+
math.min(traversableTrackExtent, scrollbarPainter.minOverscrollLength),
198+
traversableTrackExtent * fractionVisible,
199+
);
200+
201+
final fractionOverscrolled = 1.0 - scrollPosition.extentInside / scrollPosition.viewportDimension;
202+
final safeMinLength = math.min(scrollbarPainter.minLength, traversableTrackExtent);
203+
204+
final isReversed = scrollPosition.axisDirection == AxisDirection.up || //
205+
scrollPosition.axisDirection == AxisDirection.left;
206+
final beforeExtent = isReversed ? scrollPosition.extentAfter : scrollPosition.extentBefore;
207+
final afterExtent = isReversed ? scrollPosition.extentBefore : scrollPosition.extentAfter;
208+
final newMinLength = (beforeExtent > 0 && afterExtent > 0)
209+
// Thumb extent is no smaller than minLength if scrolling normally.
210+
? safeMinLength
211+
// User is overscrolling. Thumb extent can be less than minLength
212+
// but no smaller than minOverscrollLength. We can't use the
213+
// fractionVisible to produce intermediate values between minLength and
214+
// minOverscrollLength when the user is transitioning from regular
215+
// scrolling to overscrolling, so we instead use the percentage of the
216+
// content that is still in the viewport to determine the size of the
217+
// thumb. iOS behavior appears to have the thumb reach its minimum size
218+
// with ~20% of overscroll. We map the percentage of minLength from
219+
// [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce
220+
// values for the thumb that range between minLength and the smallest
221+
// possible value, minOverscrollLength.
222+
: safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2);
223+
224+
// The `thumbExtent` should be no greater than `trackSize`, otherwise
225+
// the scrollbar may scroll towards the wrong direction.
226+
return clampDouble(thumbExtent, newMinLength, traversableTrackExtent);
227+
}
228+
229+
/// The full length of the track that the thumb can travel.
230+
///
231+
/// Copied from ScrollbarPainter._traversableTrackExtent.
232+
double _findTraversableTrackExtent({
233+
required ScrollbarPainter scrollbarPainter,
234+
required ScrollPosition scrollPosition,
235+
}) {
236+
final isVertical = scrollPosition.axisDirection == AxisDirection.down || //
237+
scrollPosition.axisDirection == AxisDirection.up;
238+
239+
final totalTrackMainAxisOffset =
240+
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal;
241+
final trackExtent = scrollPosition.viewportDimension - totalTrackMainAxisOffset;
242+
243+
return trackExtent - (2 * scrollbarPainter.mainAxisMargin);
244+
}
26245
}

0 commit comments

Comments
 (0)