Fix scrollbar hit testing based on pointer device kind (#77755)
diff --git a/packages/flutter/lib/src/material/scrollbar.dart b/packages/flutter/lib/src/material/scrollbar.dart
index 58353d7..d7c1e1e 100644
--- a/packages/flutter/lib/src/material/scrollbar.dart
+++ b/packages/flutter/lib/src/material/scrollbar.dart
@@ -352,7 +352,7 @@
void handleHover(PointerHoverEvent event) {
super.handleHover(event);
// Check if the position of the pointer falls over the painted scrollbar
- if (isPointerOverScrollbar(event.position)) {
+ if (isPointerOverScrollbar(event.position, event.kind)) {
// Pointer is hovering over the scrollbar
setState(() { _hoverIsActive = true; });
_hoverAnimationController.forward();
diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart
index 41e127c..e6fbe10 100644
--- a/packages/flutter/lib/src/widgets/scrollbar.dart
+++ b/packages/flutter/lib/src/widgets/scrollbar.dart
@@ -478,9 +478,10 @@
return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!);
}
- /// Same as hitTest, but includes some padding to make sure that the region
+ /// 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.
- bool hitTestInteractive(Offset position) {
+ bool hitTestInteractive(Offset position, PointerDeviceKind kind) {
if (_thumbRect == null) {
return false;
}
@@ -488,19 +489,25 @@
if (fadeoutOpacityAnimation.value == 0.0) {
return false;
}
- final Rect interactiveScrollbarRect = _trackRect == null
- ? _thumbRect!.expandToInclude(
- Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
- )
- : _trackRect!.expandToInclude(
+
+ final Rect interactiveRect = _trackRect ?? _thumbRect!;
+ switch (kind) {
+ case PointerDeviceKind.touch:
+ final Rect touchScrollbarRect = interactiveRect.expandToInclude(
Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
);
- return interactiveScrollbarRect.contains(position);
+ return touchScrollbarRect.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) {
+ bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) {
if (_thumbRect == null) {
return false;
}
@@ -508,10 +515,19 @@
if (fadeoutOpacityAnimation.value == 0.0) {
return false;
}
- final Rect interactiveThumbRect = _thumbRect!.expandToInclude(
- Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
- );
- return interactiveThumbRect.contains(position);
+
+ switch (kind) {
+ case PointerDeviceKind.touch:
+ 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);
+ }
}
// Scrollbars are interactive.
@@ -1153,33 +1169,33 @@
///
/// Excludes the [RawScrollbar] thumb.
@protected
- bool isPointerOverTrack(Offset position) {
+ bool isPointerOverTrack(Offset position, PointerDeviceKind kind) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
- return scrollbarPainter.hitTestInteractive(localOffset)
- && !scrollbarPainter.hitTestOnlyThumbInteractive(localOffset);
+ 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) {
+ bool isPointerOverThumb(Offset position, PointerDeviceKind kind) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
- return scrollbarPainter.hitTestOnlyThumbInteractive(localOffset);
+ return scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind);
}
/// Returns true if the provided [Offset] is located over the track or thumb
/// of the [RawScrollbar].
@protected
- bool isPointerOverScrollbar(Offset position) {
+ bool isPointerOverScrollbar(Offset position, PointerDeviceKind kind) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
- return scrollbarPainter.hitTestInteractive(localOffset);
+ return scrollbarPainter.hitTestInteractive(localOffset, kind);
}
/// Cancels the fade out animation so the scrollbar will remain visible for
@@ -1194,7 +1210,7 @@
@mustCallSuper
void handleHover(PointerHoverEvent event) {
// Check if the position of the pointer falls over the painted scrollbar
- if (isPointerOverScrollbar(event.position)) {
+ if (isPointerOverScrollbar(event.position, event.kind)) {
_hoverIsActive = true;
_fadeoutTimer?.cancel();
} else if (_hoverIsActive) {
@@ -1292,20 +1308,20 @@
@override
bool isPointerAllowed(PointerDownEvent event) {
- if (!_hitTestInteractive(_customPaintKey, event.position)) {
+ if (!_hitTestInteractive(_customPaintKey, event.position, event.kind)) {
return false;
}
return super.isPointerAllowed(event);
}
- bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset) {
+ bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, PointerDeviceKind kind) {
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, offset);
- return painter.hitTestOnlyThumbInteractive(localOffset);
+ return painter.hitTestOnlyThumbInteractive(localOffset, kind);
}
}
@@ -1322,13 +1338,13 @@
@override
bool isPointerAllowed(PointerDownEvent event) {
- if (!_hitTestInteractive(_customPaintKey, event.position)) {
+ if (!_hitTestInteractive(_customPaintKey, event.position, event.kind)) {
return false;
}
return super.isPointerAllowed(event);
}
- bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset) {
+ bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, PointerDeviceKind kind) {
if (customPaintKey.currentContext == null) {
return false;
}
@@ -1336,7 +1352,7 @@
final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
final Offset localOffset = _getLocalOffset(customPaintKey, offset);
// We only receive track taps that are not on the thumb.
- return painter.hitTestInteractive(localOffset) && !painter.hitTestOnlyThumbInteractive(localOffset);
+ return painter.hitTestInteractive(localOffset, kind) && !painter.hitTestOnlyThumbInteractive(localOffset, kind);
}
}
diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart
index 92f6f1a..4220c78 100644
--- a/packages/flutter/test/widgets/scrollbar_test.dart
+++ b/packages/flutter/test/widgets/scrollbar_test.dart
@@ -866,4 +866,93 @@
paintsExactlyCountTimes(#drawRect, 2),
);
});
+
+ testWidgets('Scrollbar hit test area adjusts for PointerDeviceKind', (WidgetTester tester) async {
+ final ScrollController scrollController = ScrollController();
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: MediaQuery(
+ data: const MediaQueryData(),
+ child: PrimaryScrollController(
+ controller: scrollController,
+ child: RawScrollbar(
+ isAlwaysShown: true,
+ controller: scrollController,
+ child: const SingleChildScrollView(
+ child: SizedBox(width: 4000.0, height: 4000.0)
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 0.0);
+ expect(
+ find.byType(RawScrollbar),
+ paints
+ ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
+ ..rect(
+ rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
+ color: const Color(0x66BCBCBC),
+ ),
+ );
+
+ // Drag the scrollbar just outside of the painted thumb with touch input.
+ // The hit test area is padded to meet the minimum interactive size.
+ const double scrollAmount = 10.0;
+ final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(790.0, 45.0));
+ await tester.pumpAndSettle();
+ await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
+ await tester.pumpAndSettle();
+
+ // The scrollbar moved by scrollAmount, and the scrollOffset moved forward.
+ expect(scrollController.offset, greaterThan(0.0));
+ expect(
+ find.byType(RawScrollbar),
+ paints
+ ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
+ ..rect(
+ rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
+ color: const Color(0x66BCBCBC),
+ ),
+ );
+
+ // Move back to reset.
+ await dragScrollbarGesture.moveBy(const Offset(0.0, -scrollAmount));
+ await tester.pumpAndSettle();
+ expect(scrollController.offset, 0.0);
+ expect(
+ find.byType(RawScrollbar),
+ paints
+ ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
+ ..rect(
+ rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
+ color: const Color(0x66BCBCBC),
+ ),
+ );
+
+ // The same should not be possible with a mouse since it is more precise,
+ // the padding it not necessary.
+ final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
+ await gesture.addPointer();
+ addTearDown(gesture.removePointer);
+ await gesture.down(const Offset(790.0, 45.0));
+ await tester.pump();
+ await gesture.moveTo(const Offset(790.0, 55.0));
+ await gesture.up();
+ await tester.pumpAndSettle();
+ // The scrollbar/scrollable should not have moved.
+ expect(scrollController.offset, 0.0);
+ expect(
+ find.byType(RawScrollbar),
+ paints
+ ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
+ ..rect(
+ rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
+ color: const Color(0x66BCBCBC),
+ ),
+ );
+ });
}