[New Feature]Support mouse wheel event on the scrollbar widget (#109659)

* rebase master and add a test

* fix the test

* fix the test

* fix the test
This commit is contained in:
xubaolin 2022-11-05 08:57:52 +08:00 committed by GitHub
parent 96f9ca8302
commit 1aada6fc5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 255 additions and 121 deletions

View file

@ -1458,13 +1458,17 @@ class RawScrollbar extends StatefulWidget {
/// scrollbar track.
class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProviderStateMixin<T> {
Offset? _startDragScrollbarAxisOffset;
Offset? _lastDragUpdateOffset;
double? _startDragThumbOffset;
ScrollController? _currentController;
ScrollController? _cachedController;
Timer? _fadeoutTimer;
late AnimationController _fadeoutAnimationController;
late Animation<double> _fadeoutOpacityAnimation;
final GlobalKey _scrollbarPainterKey = GlobalKey();
bool _hoverIsActive = false;
bool _thumbDragging = false;
ScrollController? get _effectiveScrollController => widget.controller ?? PrimaryScrollController.maybeOf(context);
/// Used to paint the scrollbar.
///
@ -1550,12 +1554,11 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
}
void _validateInteractions(AnimationStatus status) {
final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.maybeOf(context);
if (status == AnimationStatus.dismissed) {
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 (scrollController != null && enableGestures) {
} 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());
@ -1566,7 +1569,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
if (!mounted) {
return true;
}
final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.maybeOf(context);
final ScrollController? scrollController = _effectiveScrollController;
final bool tryPrimary = widget.controller == null;
final String controllerForError = tryPrimary
? 'PrimaryScrollController'
@ -1698,11 +1701,11 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
}
void _updateScrollPosition(Offset updatedOffset) {
assert(_currentController != null);
assert(_cachedController != null);
assert(_startDragScrollbarAxisOffset != null);
assert(_startDragThumbOffset != null);
final ScrollPosition position = _currentController!.position;
final ScrollPosition position = _cachedController!.position;
late double primaryDelta;
switch (position.axisDirection) {
case AxisDirection.up:
@ -1761,9 +1764,9 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
/// current scroll controller does not have any attached positions.
@protected
Axis? getScrollbarDirection() {
assert(_currentController != null);
if (_currentController!.hasClients) {
return _currentController!.position.axis;
assert(_cachedController != null);
if (_cachedController!.hasClients) {
return _cachedController!.position.axis;
}
return null;
}
@ -1788,7 +1791,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
@mustCallSuper
void handleThumbPressStart(Offset localPosition) {
assert(_debugCheckHasValidScrollPosition());
_currentController = widget.controller ?? PrimaryScrollController.maybeOf(context);
_cachedController = _effectiveScrollController;
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
@ -1797,6 +1800,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
_fadeoutAnimationController.forward();
_startDragScrollbarAxisOffset = localPosition;
_startDragThumbOffset = scrollbarPainter.getThumbScrollOffset();
_thumbDragging = true;
}
/// Handler called when a currently active long press gesture moves.
@ -1806,7 +1810,11 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
@mustCallSuper
void handleThumbPressUpdate(Offset localPosition) {
assert(_debugCheckHasValidScrollPosition());
final ScrollPosition position = _currentController!.position;
if (_lastDragUpdateOffset == localPosition) {
return;
}
_lastDragUpdateOffset = localPosition;
final ScrollPosition position = _cachedController!.position;
if (!position.physics.shouldAcceptUserOffset(position)) {
return;
}
@ -1822,22 +1830,24 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
@mustCallSuper
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
assert(_debugCheckHasValidScrollPosition());
_thumbDragging = false;
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
_maybeStartFadeoutTimer();
_startDragScrollbarAxisOffset = null;
_lastDragUpdateOffset = null;
_startDragThumbOffset = null;
_currentController = null;
_cachedController = null;
}
void _handleTrackTapDown(TapDownDetails details) {
// The Scrollbar should page towards the position of the tap on the track.
assert(_debugCheckHasValidScrollPosition());
_currentController = widget.controller ?? PrimaryScrollController.maybeOf(context);
_cachedController = _effectiveScrollController;
final ScrollPosition position = _currentController!.position;
final ScrollPosition position = _cachedController!.position;
if (!position.physics.shouldAcceptUserOffset(position)) {
return;
}
@ -1845,22 +1855,22 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
double scrollIncrement;
// Is an increment calculator available?
final ScrollIncrementCalculator? calculator = Scrollable.maybeOf(
_currentController!.position.context.notificationContext!,
_cachedController!.position.context.notificationContext!,
)?.widget.incrementCalculator;
if (calculator != null) {
scrollIncrement = calculator(
ScrollIncrementDetails(
type: ScrollIncrementType.page,
metrics: _currentController!.position,
metrics: _cachedController!.position,
),
);
} else {
// Default page increment
scrollIncrement = 0.8 * _currentController!.position.viewportDimension;
scrollIncrement = 0.8 * _cachedController!.position.viewportDimension;
}
// Adjust scrollIncrement for direction
switch (_currentController!.position.axisDirection) {
switch (_cachedController!.position.axisDirection) {
case AxisDirection.up:
if (details.localPosition.dy > scrollbarPainter._thumbOffset) {
scrollIncrement = -scrollIncrement;
@ -1883,8 +1893,8 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
break;
}
_currentController!.position.moveTo(
_currentController!.position.pixels + scrollIncrement,
_cachedController!.position.moveTo(
_cachedController!.position.pixels + scrollIncrement,
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
);
@ -1892,8 +1902,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
// ScrollController takes precedence over ScrollNotification
bool _shouldUpdatePainter(Axis notificationAxis) {
final ScrollController? scrollController = widget.controller ??
PrimaryScrollController.maybeOf(context);
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.
@ -1979,8 +1988,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
Map<Type, GestureRecognizerFactory> get _gestures {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
final ScrollController? controller = widget.controller ?? PrimaryScrollController.maybeOf(context);
if (controller == null || !enableGestures) {
if (_effectiveScrollController == null || !enableGestures) {
return gestures;
}
@ -2086,6 +2094,64 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
_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 &&
(!_thumbDragging || kIsWeb)) {
final ScrollPosition position = _cachedController!.position;
if (event is PointerScrollEvent && position != null) {
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.
}
}
}
@override
void dispose() {
_fadeoutAnimationController.dispose();
@ -2103,43 +2169,46 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: RepaintBoundary(
child: RawGestureDetector(
gestures: _gestures,
child: MouseRegion(
onExit: (PointerExitEvent event) {
switch(event.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
if (enableGestures) {
handleHoverExit(event);
}
break;
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);
}
break;
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
case PointerDeviceKind.touch:
break;
}
},
child: CustomPaint(
key: _scrollbarPainterKey,
foregroundPainter: scrollbarPainter,
child: RepaintBoundary(child: widget.child),
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
gestures: _gestures,
child: MouseRegion(
onExit: (PointerExitEvent event) {
switch(event.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
if (enableGestures) {
handleHoverExit(event);
}
break;
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);
}
break;
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.unknown:
case PointerDeviceKind.touch:
break;
}
},
child: CustomPaint(
key: _scrollbarPainterKey,
foregroundPainter: scrollbarPainter,
child: RepaintBoundary(child: widget.child),
),
),
),
),

View file

@ -1200,25 +1200,32 @@ void main() {
pointer.hover(const Offset(793.0, 15.0));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0)));
await tester.pumpAndSettle();
// Scrolling while holding the drag on the scrollbar and still hovered over
// the scrollbar should not have changed the scroll offset.
expect(pointer.location, const Offset(793.0, 15.0));
expect(scrollController.offset, previousOffset);
expect(
find.byType(CupertinoScrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1),
const Radius.circular(4.0),
if (!kIsWeb) {
// Scrolling while holding the drag on the scrollbar and still hovered over
// the scrollbar should not have changed the scroll offset.
expect(pointer.location, const Offset(793.0, 15.0));
expect(scrollController.offset, previousOffset);
expect(
find.byType(CupertinoScrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1),
const Radius.circular(4.0),
),
color: _kScrollbarColor.color,
),
color: _kScrollbarColor.color,
),
);
);
} else {
expect(pointer.location, const Offset(793.0, 15.0));
expect(scrollController.offset, previousOffset + 20.0);
}
// Drag is still being held, move pointer to be hovering over another area
// of the scrollable (not over the scrollbar) and execute another pointer scroll
pointer.hover(tester.getCenter(find.byType(SingleChildScrollView)));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -70.0)));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0)));
await tester.pumpAndSettle();
// Scrolling while holding the drag on the scrollbar changed the offset
expect(pointer.location, const Offset(400.0, 300.0));

View file

@ -1634,33 +1634,39 @@ void main() {
pointer.hover(const Offset(798.0, 15.0));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0)));
await tester.pumpAndSettle();
// Scrolling while holding the drag on the scrollbar and still hovered over
// the scrollbar should not have changed the scroll offset.
expect(pointer.location, const Offset(798.0, 15.0));
expect(scrollController.offset, previousOffset);
expect(
find.byType(Scrollbar),
paints
..rect(
rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0),
color: Colors.transparent,
)
..line(
p1: const Offset(796.0, 0.0),
p2: const Offset(796.0, 600.0),
strokeWidth: 1.0,
color: Colors.transparent,
)
..rect(
rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0),
color: const Color(0x99000000),
),
);
if (!kIsWeb) {
// Scrolling while holding the drag on the scrollbar and still hovered over
// the scrollbar should not have changed the scroll offset.
expect(pointer.location, const Offset(798.0, 15.0));
expect(scrollController.offset, previousOffset);
expect(
find.byType(Scrollbar),
paints
..rect(
rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0),
color: Colors.transparent,
)
..line(
p1: const Offset(796.0, 0.0),
p2: const Offset(796.0, 600.0),
strokeWidth: 1.0,
color: Colors.transparent,
)
..rect(
rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0),
color: const Color(0x99000000),
),
);
} else {
expect(pointer.location, const Offset(798.0, 15.0));
expect(scrollController.offset, previousOffset + 20.0);
}
// Drag is still being held, move pointer to be hovering over another area
// of the scrollable (not over the scrollbar) and execute another pointer scroll
pointer.hover(tester.getCenter(find.byType(SingleChildScrollView)));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -70.0)));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0)));
await tester.pumpAndSettle();
// Scrolling while holding the drag on the scrollbar changed the offset
expect(pointer.location, const Offset(400.0, 300.0));

View file

@ -1607,33 +1607,39 @@ void main() {
pointer.hover(const Offset(798.0, 15.0));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0)));
await tester.pumpAndSettle();
// Scrolling while holding the drag on the scrollbar and still hovered over
// the scrollbar should not have changed the scroll offset.
expect(pointer.location, const Offset(798.0, 15.0));
expect(scrollController.offset, previousOffset);
expect(
find.byType(RawScrollbar),
paints
..rect(
rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
color: const Color(0x00000000),
)
..line(
p1: const Offset(794.0, 0.0),
p2: const Offset(794.0, 600.0),
strokeWidth: 1.0,
color: const Color(0x00000000),
)
..rect(
rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
color: const Color(0x66bcbcbc),
),
);
if (!kIsWeb) {
// Scrolling while holding the drag on the scrollbar and still hovered over
// the scrollbar should not have changed the scroll offset.
expect(pointer.location, const Offset(798.0, 15.0));
expect(scrollController.offset, previousOffset);
expect(
find.byType(RawScrollbar),
paints
..rect(
rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0),
color: const Color(0x00000000),
)
..line(
p1: const Offset(794.0, 0.0),
p2: const Offset(794.0, 600.0),
strokeWidth: 1.0,
color: const Color(0x00000000),
)
..rect(
rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
color: const Color(0x66bcbcbc),
),
);
} else {
expect(pointer.location, const Offset(798.0, 15.0));
expect(scrollController.offset, previousOffset + 20.0);
}
// Drag is still being held, move pointer to be hovering over another area
// of the scrollable (not over the scrollbar) and execute another pointer scroll
pointer.hover(tester.getCenter(find.byType(SingleChildScrollView)));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -70.0)));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0)));
await tester.pumpAndSettle();
// Scrolling while holding the drag on the scrollbar changed the offset
expect(pointer.location, const Offset(400.0, 300.0));
@ -2788,4 +2794,50 @@ void main() {
),
);
});
testWidgets('The bar support mouse wheel event', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/pull/109659
final ScrollController scrollController = ScrollController();
Widget buildFrame() {
return Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: PrimaryScrollController(
controller: scrollController,
child: RawScrollbar(
thumbVisibility: true,
controller: scrollController,
child: const SingleChildScrollView(
primary: true,
child: SizedBox(
width: double.infinity,
height: 1200.0,
),
),
),
),
),
);
}
await tester.pumpWidget(buildFrame());
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
// Execute a pointer scroll hover on the scroll bar
final TestPointer pointer = TestPointer(1, ui.PointerDeviceKind.mouse);
pointer.hover(const Offset(798.0, 15.0));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 30.0)));
await tester.pumpAndSettle();
expect(scrollController.offset, 30.0);
// Execute a pointer scroll outside the scroll bar
pointer.hover(const Offset(198.0, 15.0));
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 70.0)));
await tester.pumpAndSettle();
expect(scrollController.offset, 100.0);
}, variant: TargetPlatformVariant.all());
}