Skip to content

Commit 71689c0

Browse files
Create a WidgetTester extension to drag scroll bars (Resolves #22) (#23)
1 parent 776c490 commit 71689c0

File tree

2 files changed

+262
-0
lines changed

2 files changed

+262
-0
lines changed

lib/flutter_test_robots.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ library flutter_test_robots;
33
export 'src/clipboard.dart';
44
export 'src/input_method_engine.dart';
55
export 'src/keyboard.dart';
6+
export 'src/scrollbar.dart';

lib/src/scrollbar.dart

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import 'dart:math' as math;
2+
import 'dart:ui';
3+
4+
import 'package:flutter/material.dart';
5+
import 'package:flutter_test/flutter_test.dart';
6+
7+
/// Simulates the user interacting with a Scrollbar.
8+
extension ScrollbarInteractions on WidgetTester {
9+
/// Drag the scrollbar down by [delta] pixels.
10+
///
11+
/// By default, this method expects a single [Scrollbar] in the widget tree and
12+
/// finds it `byType`. To specify one [Scrollbar] among many, pass a [finder].
13+
Future<void> dragScrollbarDown(double delta, [Finder? finder]) async {
14+
await _dragScrollbar(delta, finder);
15+
}
16+
17+
/// Drag the scrollbar up by [delta] pixels.
18+
///
19+
/// By default, this method expects a single [Scrollbar] in the widget tree and
20+
/// finds it `byType`. To specify one [Scrollbar] among many, pass a [finder].
21+
Future<void> dragScrollbarUp(double delta, [Finder? finder]) async {
22+
await _dragScrollbar(-delta, finder);
23+
}
24+
25+
/// Drag the scrollbar by [delta] pixels.
26+
///
27+
/// A positive [delta] scrolls down or right, depending on the scrollbar's orientation,
28+
/// and a negative [delta] scrolls up or left.
29+
///
30+
/// By default, this method expects a single [Scrollbar] in the widget tree and
31+
/// finds it `byType`. To specify one [Scrollbar] among many, pass a [finder].
32+
Future<void> _dragScrollbar(double delta, [Finder? finder]) async {
33+
// Find where the scrollbar's thumb sits.
34+
final thumbRect = _findThumbRect(finder ?? find.byType(Scrollbar));
35+
final thumbOffset = thumbRect.center;
36+
37+
final testPointer = TestPointer(1, PointerDeviceKind.mouse);
38+
39+
// Hover to make the thumb visible with a duration long enough to run the fade in animation.
40+
await sendEventToBinding(testPointer.hover(thumbOffset, timeStamp: const Duration(seconds: 1)));
41+
await pumpAndSettle();
42+
43+
// Press the thumb.
44+
await sendEventToBinding(testPointer.down(thumbOffset));
45+
await pump(const Duration(milliseconds: 40));
46+
47+
// Move the thumb.
48+
await sendEventToBinding(testPointer.move(thumbOffset + Offset(0, delta)));
49+
await pump();
50+
51+
// Release the pointer.
52+
await sendEventToBinding(testPointer.up());
53+
await pump();
54+
}
55+
56+
/// Finds the thumb's rect, in global coordinates.
57+
///
58+
/// Adapted from ScrollbarPainter._paintScrollbar.
59+
Rect _findThumbRect(Finder scrollbarFinder) {
60+
// Find the Scrollbar's Scrollable.
61+
final scrollState = state<ScrollableState>(find.descendant(
62+
of: scrollbarFinder,
63+
matching: find.byType(Scrollable),
64+
));
65+
66+
// Find the Scrollbar's ScrollbarPainter, which is used to paint the thumb.
67+
// The ScrollbarPainter is used to gather information necessary to compute the thumb's
68+
// position and size.
69+
final scrollbarPainter = widget<CustomPaint>(
70+
find.descendant(
71+
of: scrollbarFinder,
72+
matching: find.byWidgetPredicate(
73+
(widget) => widget is CustomPaint && widget.foregroundPainter is ScrollbarPainter,
74+
),
75+
),
76+
).foregroundPainter as ScrollbarPainter;
77+
78+
final scrollPosition = scrollState.position;
79+
final isVertical = scrollPosition.axisDirection == AxisDirection.down || //
80+
scrollPosition.axisDirection == AxisDirection.up;
81+
82+
final orientation = _resolvedOrientation(scrollbarPainter, isVertical);
83+
final leadingTrackMainAxisOffset =
84+
orientation == ScrollbarOrientation.left || orientation == ScrollbarOrientation.right //
85+
? scrollbarPainter.padding.top
86+
: scrollbarPainter.padding.left;
87+
88+
final leadingThumbMainAxisOffset = leadingTrackMainAxisOffset + scrollbarPainter.mainAxisMargin;
89+
90+
final traversableTrackExtent = _findTraversableTrackExtent(
91+
scrollbarPainter: scrollbarPainter,
92+
scrollPosition: scrollPosition,
93+
);
94+
final thumbExtent = _findThumbExtent(
95+
scrollbarPainter: scrollbarPainter,
96+
scrollPosition: scrollPosition,
97+
traversableTrackExtent: traversableTrackExtent,
98+
);
99+
final thumbOffset = _getScrollToTrack(
100+
scrollbarPainter: scrollbarPainter,
101+
scrollPosition: scrollPosition,
102+
thumbExtent: thumbExtent,
103+
) +
104+
leadingThumbMainAxisOffset;
105+
106+
late double thumbX, thumbY;
107+
late Size thumbSize;
108+
109+
final scrollableSize = (scrollState.context.findRenderObject() as RenderBox).size;
110+
switch (orientation) {
111+
case ScrollbarOrientation.left:
112+
thumbSize = Size(scrollbarPainter.thickness, thumbExtent);
113+
thumbX = scrollbarPainter.crossAxisMargin + scrollbarPainter.padding.left;
114+
thumbY = thumbOffset;
115+
break;
116+
case ScrollbarOrientation.right:
117+
thumbSize = Size(scrollbarPainter.thickness, thumbExtent);
118+
thumbX = scrollableSize.width -
119+
scrollbarPainter.thickness -
120+
scrollbarPainter.crossAxisMargin -
121+
scrollbarPainter.padding.right;
122+
thumbY = thumbOffset;
123+
break;
124+
case ScrollbarOrientation.top:
125+
thumbSize = Size(thumbExtent, scrollbarPainter.thickness);
126+
thumbX = thumbOffset;
127+
thumbY = scrollbarPainter.crossAxisMargin + scrollbarPainter.padding.top;
128+
break;
129+
case ScrollbarOrientation.bottom:
130+
thumbSize = Size(thumbExtent, scrollbarPainter.thickness);
131+
thumbX = thumbOffset;
132+
thumbY = scrollableSize.height -
133+
scrollbarPainter.thickness -
134+
scrollbarPainter.crossAxisMargin -
135+
scrollbarPainter.padding.bottom;
136+
break;
137+
}
138+
139+
final scrollbarRenderBox = element(scrollbarFinder).findRenderObject() as RenderBox;
140+
return scrollbarRenderBox.localToGlobal(Offset(thumbX, thumbY)) & thumbSize;
141+
}
142+
143+
/// Converts between a scroll position and the corresponding position in the
144+
/// thumb track, in ScrollBar's coordinates.
145+
///
146+
/// Copied and adapted from ScrollbarPainter._getScrollToTrack.
147+
double _getScrollToTrack({
148+
required ScrollbarPainter scrollbarPainter,
149+
required ScrollPosition scrollPosition,
150+
required double thumbExtent,
151+
}) {
152+
final scrollableExtent = scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent;
153+
final axisDirection = scrollPosition.axisDirection;
154+
155+
final fractionPast = (scrollableExtent > 0)
156+
? clampDouble((scrollPosition.pixels - scrollPosition.minScrollExtent) / scrollableExtent, 0.0, 1.0)
157+
: 0.0;
158+
159+
final isReversed = scrollPosition.axisDirection == AxisDirection.up || //
160+
scrollPosition.axisDirection == AxisDirection.left;
161+
162+
final isVertical = axisDirection == AxisDirection.down || //
163+
axisDirection == AxisDirection.up;
164+
final totalTrackMainAxisOffset =
165+
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal;
166+
final trackExtent = scrollPosition.viewportDimension - totalTrackMainAxisOffset;
167+
final traversableTrackExtent = trackExtent - (2 * scrollbarPainter.mainAxisMargin);
168+
169+
return (isReversed ? 1 - fractionPast : fractionPast) * (traversableTrackExtent - thumbExtent);
170+
}
171+
172+
/// Returns the position where the Scrollbar is painted.
173+
///
174+
/// The scrollbar can be painted in the left or right edge when it's vertical,
175+
/// or at the bottom when it's horizontal.
176+
///
177+
/// Copied from ScrollbarPainter._resolvedOrientation.
178+
ScrollbarOrientation _resolvedOrientation(ScrollbarPainter scrollbarPainter, bool isVertical) {
179+
if (scrollbarPainter.scrollbarOrientation == null) {
180+
if (isVertical) {
181+
return scrollbarPainter.textDirection == TextDirection.ltr
182+
? ScrollbarOrientation.right
183+
: ScrollbarOrientation.left;
184+
}
185+
return ScrollbarOrientation.bottom;
186+
}
187+
return scrollbarPainter.scrollbarOrientation!;
188+
}
189+
190+
// Copied from ScrollbarPainter._setThumbExtent.
191+
double _findThumbExtent({
192+
required ScrollbarPainter scrollbarPainter,
193+
required ScrollPosition scrollPosition,
194+
required double traversableTrackExtent,
195+
}) {
196+
final isVertical = scrollPosition.axisDirection == AxisDirection.down || //
197+
scrollPosition.axisDirection == AxisDirection.up;
198+
final totalTrackMainAxisOffsets =
199+
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal;
200+
final totalContentExtent =
201+
scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent + scrollPosition.viewportDimension;
202+
203+
// Thumb extent reflects fraction of content visible, as long as this
204+
// isn't less than the absolute minimum size.
205+
// _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0
206+
final fractionVisible = clampDouble(
207+
(scrollPosition.extentInside - totalTrackMainAxisOffsets) / (totalContentExtent - totalTrackMainAxisOffsets),
208+
0.0,
209+
1.0,
210+
);
211+
212+
final thumbExtent = math.max(
213+
math.min(traversableTrackExtent, scrollbarPainter.minOverscrollLength),
214+
traversableTrackExtent * fractionVisible,
215+
);
216+
217+
final fractionOverscrolled = 1.0 - scrollPosition.extentInside / scrollPosition.viewportDimension;
218+
final safeMinLength = math.min(scrollbarPainter.minLength, traversableTrackExtent);
219+
220+
final isReversed = scrollPosition.axisDirection == AxisDirection.up || //
221+
scrollPosition.axisDirection == AxisDirection.left;
222+
final beforeExtent = isReversed ? scrollPosition.extentAfter : scrollPosition.extentBefore;
223+
final afterExtent = isReversed ? scrollPosition.extentBefore : scrollPosition.extentAfter;
224+
final newMinLength = (beforeExtent > 0 && afterExtent > 0)
225+
// Thumb extent is no smaller than minLength if scrolling normally.
226+
? safeMinLength
227+
// User is overscrolling. Thumb extent can be less than minLength
228+
// but no smaller than minOverscrollLength. We can't use the
229+
// fractionVisible to produce intermediate values between minLength and
230+
// minOverscrollLength when the user is transitioning from regular
231+
// scrolling to overscrolling, so we instead use the percentage of the
232+
// content that is still in the viewport to determine the size of the
233+
// thumb. iOS behavior appears to have the thumb reach its minimum size
234+
// with ~20% of overscroll. We map the percentage of minLength from
235+
// [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce
236+
// values for the thumb that range between minLength and the smallest
237+
// possible value, minOverscrollLength.
238+
: safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2);
239+
240+
// The `thumbExtent` should be no greater than `trackSize`, otherwise
241+
// the scrollbar may scroll towards the wrong direction.
242+
return clampDouble(thumbExtent, newMinLength, traversableTrackExtent);
243+
}
244+
245+
/// The full length of the track that the thumb can travel.
246+
///
247+
/// Copied from ScrollbarPainter._traversableTrackExtent.
248+
double _findTraversableTrackExtent({
249+
required ScrollbarPainter scrollbarPainter,
250+
required ScrollPosition scrollPosition,
251+
}) {
252+
final isVertical = scrollPosition.axisDirection == AxisDirection.down || //
253+
scrollPosition.axisDirection == AxisDirection.up;
254+
255+
final totalTrackMainAxisOffset =
256+
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal;
257+
final trackExtent = scrollPosition.viewportDimension - totalTrackMainAxisOffset;
258+
259+
return trackExtent - (2 * scrollbarPainter.mainAxisMargin);
260+
}
261+
}

0 commit comments

Comments
 (0)