-
Notifications
You must be signed in to change notification settings - Fork 3
Create a WidgetTester extension to drag scroll bars (Resolves #22) #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
matthew-carroll
merged 3 commits into
Flutter-Bounty-Hunters:main
from
angelosilvestre:22_scrollbar_extension
Aug 10, 2025
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,245 @@ | ||
import 'dart:math' as math; | ||
import 'dart:ui'; | ||
|
||
import 'package:flutter/material.dart'; | ||
import 'package:flutter_test/flutter_test.dart'; | ||
|
||
/// Simulates the user interacting with a Scrollbar. | ||
extension ScrollbarInteractions on WidgetTester { | ||
/// Drag the scrollbar by [delta] pixels. | ||
/// | ||
/// A positive [delta] scrolls down or right, depending on the scrollbar's orientation, | ||
/// and a negative [delta] scrolls up or left. | ||
/// | ||
/// By default, this method expects a single [Scrollbar] in the widget tree and | ||
/// finds it `byType`. To specify one [Scrollbar] among many, pass a [finder]. | ||
Future<void> dragScrollbar(double delta, [Finder? finder]) async { | ||
// Find where the scrollbar's thumb sits. | ||
final thumbRect = _findThumbRect(finder ?? find.byType(Scrollbar)); | ||
final thumbOffset = thumbRect.center; | ||
|
||
final testPointer = TestPointer(1, PointerDeviceKind.mouse); | ||
|
||
// Hover to make the thumb visible with a duration long enough to run the fade in animation. | ||
await sendEventToBinding(testPointer.hover(thumbOffset, timeStamp: const Duration(seconds: 1))); | ||
await pumpAndSettle(); | ||
|
||
// Press the thumb. | ||
await sendEventToBinding(testPointer.down(thumbOffset)); | ||
await pump(const Duration(milliseconds: 40)); | ||
|
||
// Move the thumb. | ||
await sendEventToBinding(testPointer.move(thumbOffset + Offset(0, delta))); | ||
await pump(); | ||
|
||
// Release the pointer. | ||
await sendEventToBinding(testPointer.up()); | ||
await pump(); | ||
} | ||
|
||
/// Finds the thumb's rect, in global coordinates. | ||
/// | ||
/// Adapted from ScrollbarPainter._paintScrollbar. | ||
Rect _findThumbRect(Finder scrollbarFinder) { | ||
// Find the Scrollbar's Scrollable. | ||
final scrollState = state<ScrollableState>(find.descendant( | ||
of: scrollbarFinder, | ||
matching: find.byType(Scrollable), | ||
)); | ||
|
||
// Find the Scrollbar's ScrollbarPainter, which is used to paint the thumb. | ||
// The ScrollbarPainter is used to gather information necessary to compute the thumb's | ||
// position and size. | ||
final scrollbarPainter = widget<CustomPaint>( | ||
find.descendant( | ||
of: scrollbarFinder, | ||
matching: find.byWidgetPredicate( | ||
(widget) => widget is CustomPaint && widget.foregroundPainter is ScrollbarPainter, | ||
), | ||
), | ||
).foregroundPainter as ScrollbarPainter; | ||
|
||
final scrollPosition = scrollState.position; | ||
final isVertical = scrollPosition.axisDirection == AxisDirection.down || // | ||
scrollPosition.axisDirection == AxisDirection.up; | ||
|
||
final orientation = _resolvedOrientation(scrollbarPainter, isVertical); | ||
final leadingTrackMainAxisOffset = | ||
orientation == ScrollbarOrientation.left || orientation == ScrollbarOrientation.right // | ||
? scrollbarPainter.padding.top | ||
: scrollbarPainter.padding.left; | ||
|
||
final leadingThumbMainAxisOffset = leadingTrackMainAxisOffset + scrollbarPainter.mainAxisMargin; | ||
|
||
final traversableTrackExtent = _findTraversableTrackExtent( | ||
scrollbarPainter: scrollbarPainter, | ||
scrollPosition: scrollPosition, | ||
); | ||
final thumbExtent = _findThumbExtent( | ||
scrollbarPainter: scrollbarPainter, | ||
scrollPosition: scrollPosition, | ||
traversableTrackExtent: traversableTrackExtent, | ||
); | ||
final thumbOffset = _getScrollToTrack( | ||
scrollbarPainter: scrollbarPainter, | ||
scrollPosition: scrollPosition, | ||
thumbExtent: thumbExtent, | ||
) + | ||
leadingThumbMainAxisOffset; | ||
|
||
late double thumbX, thumbY; | ||
late Size thumbSize; | ||
|
||
final scrollableSize = (scrollState.context.findRenderObject() as RenderBox).size; | ||
switch (orientation) { | ||
case ScrollbarOrientation.left: | ||
thumbSize = Size(scrollbarPainter.thickness, thumbExtent); | ||
thumbX = scrollbarPainter.crossAxisMargin + scrollbarPainter.padding.left; | ||
thumbY = thumbOffset; | ||
break; | ||
case ScrollbarOrientation.right: | ||
thumbSize = Size(scrollbarPainter.thickness, thumbExtent); | ||
thumbX = scrollableSize.width - | ||
scrollbarPainter.thickness - | ||
scrollbarPainter.crossAxisMargin - | ||
scrollbarPainter.padding.right; | ||
thumbY = thumbOffset; | ||
break; | ||
case ScrollbarOrientation.top: | ||
thumbSize = Size(thumbExtent, scrollbarPainter.thickness); | ||
thumbX = thumbOffset; | ||
thumbY = scrollbarPainter.crossAxisMargin + scrollbarPainter.padding.top; | ||
break; | ||
case ScrollbarOrientation.bottom: | ||
thumbSize = Size(thumbExtent, scrollbarPainter.thickness); | ||
thumbX = thumbOffset; | ||
thumbY = scrollableSize.height - | ||
scrollbarPainter.thickness - | ||
scrollbarPainter.crossAxisMargin - | ||
scrollbarPainter.padding.bottom; | ||
break; | ||
} | ||
|
||
final scrollbarRenderBox = element(scrollbarFinder).findRenderObject() as RenderBox; | ||
return scrollbarRenderBox.localToGlobal(Offset(thumbX, thumbY)) & thumbSize; | ||
} | ||
|
||
/// Converts between a scroll position and the corresponding position in the | ||
/// thumb track, in ScrollBar's coordinates. | ||
/// | ||
/// Copied and adapted from ScrollbarPainter._getScrollToTrack. | ||
double _getScrollToTrack({ | ||
required ScrollbarPainter scrollbarPainter, | ||
required ScrollPosition scrollPosition, | ||
required double thumbExtent, | ||
}) { | ||
final scrollableExtent = scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent; | ||
final axisDirection = scrollPosition.axisDirection; | ||
|
||
final fractionPast = (scrollableExtent > 0) | ||
? clampDouble((scrollPosition.pixels - scrollPosition.minScrollExtent) / scrollableExtent, 0.0, 1.0) | ||
: 0.0; | ||
|
||
final isReversed = scrollPosition.axisDirection == AxisDirection.up || // | ||
scrollPosition.axisDirection == AxisDirection.left; | ||
|
||
final isVertical = axisDirection == AxisDirection.down || // | ||
axisDirection == AxisDirection.up; | ||
final totalTrackMainAxisOffset = | ||
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal; | ||
final trackExtent = scrollPosition.viewportDimension - totalTrackMainAxisOffset; | ||
final traversableTrackExtent = trackExtent - (2 * scrollbarPainter.mainAxisMargin); | ||
|
||
return (isReversed ? 1 - fractionPast : fractionPast) * (traversableTrackExtent - thumbExtent); | ||
} | ||
|
||
/// Returns the position where the Scrollbar is painted. | ||
/// | ||
/// The scrollbar can be painted in the left or right edge when it's vertical, | ||
/// or at the bottom when it's horizontal. | ||
/// | ||
/// Copied from ScrollbarPainter._resolvedOrientation. | ||
ScrollbarOrientation _resolvedOrientation(ScrollbarPainter scrollbarPainter, bool isVertical) { | ||
if (scrollbarPainter.scrollbarOrientation == null) { | ||
if (isVertical) { | ||
return scrollbarPainter.textDirection == TextDirection.ltr | ||
? ScrollbarOrientation.right | ||
: ScrollbarOrientation.left; | ||
} | ||
return ScrollbarOrientation.bottom; | ||
} | ||
return scrollbarPainter.scrollbarOrientation!; | ||
} | ||
|
||
// Copied from ScrollbarPainter._setThumbExtent. | ||
double _findThumbExtent({ | ||
required ScrollbarPainter scrollbarPainter, | ||
required ScrollPosition scrollPosition, | ||
required double traversableTrackExtent, | ||
}) { | ||
final isVertical = scrollPosition.axisDirection == AxisDirection.down || // | ||
scrollPosition.axisDirection == AxisDirection.up; | ||
final totalTrackMainAxisOffsets = | ||
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal; | ||
final totalContentExtent = | ||
scrollPosition.maxScrollExtent - scrollPosition.minScrollExtent + scrollPosition.viewportDimension; | ||
|
||
// 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 fractionVisible = clampDouble( | ||
(scrollPosition.extentInside - totalTrackMainAxisOffsets) / (totalContentExtent - totalTrackMainAxisOffsets), | ||
0.0, | ||
1.0, | ||
); | ||
|
||
final thumbExtent = math.max( | ||
math.min(traversableTrackExtent, scrollbarPainter.minOverscrollLength), | ||
traversableTrackExtent * fractionVisible, | ||
); | ||
|
||
final fractionOverscrolled = 1.0 - scrollPosition.extentInside / scrollPosition.viewportDimension; | ||
final safeMinLength = math.min(scrollbarPainter.minLength, traversableTrackExtent); | ||
|
||
final isReversed = scrollPosition.axisDirection == AxisDirection.up || // | ||
scrollPosition.axisDirection == AxisDirection.left; | ||
final beforeExtent = isReversed ? scrollPosition.extentAfter : scrollPosition.extentBefore; | ||
final afterExtent = isReversed ? scrollPosition.extentBefore : scrollPosition.extentAfter; | ||
final 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. | ||
return clampDouble(thumbExtent, newMinLength, traversableTrackExtent); | ||
} | ||
|
||
/// The full length of the track that the thumb can travel. | ||
/// | ||
/// Copied from ScrollbarPainter._traversableTrackExtent. | ||
double _findTraversableTrackExtent({ | ||
required ScrollbarPainter scrollbarPainter, | ||
required ScrollPosition scrollPosition, | ||
}) { | ||
final isVertical = scrollPosition.axisDirection == AxisDirection.down || // | ||
scrollPosition.axisDirection == AxisDirection.up; | ||
|
||
final totalTrackMainAxisOffset = | ||
isVertical ? scrollbarPainter.padding.vertical : scrollbarPainter.padding.horizontal; | ||
final trackExtent = scrollPosition.viewportDimension - totalTrackMainAxisOffset; | ||
|
||
return trackExtent - (2 * scrollbarPainter.mainAxisMargin); | ||
} | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should make this action even more clear by adding the direction to the method name, e.g.,
dragScrollbarDown(300)
anddragScrollbarUp(300)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated.