Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 1aada6f

Browse files
authored
[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
1 parent 96f9ca8 commit 1aada6f

File tree

4 files changed

+255
-121
lines changed

4 files changed

+255
-121
lines changed

packages/flutter/lib/src/widgets/scrollbar.dart

Lines changed: 130 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,13 +1458,17 @@ class RawScrollbar extends StatefulWidget {
14581458
/// scrollbar track.
14591459
class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProviderStateMixin<T> {
14601460
Offset? _startDragScrollbarAxisOffset;
1461+
Offset? _lastDragUpdateOffset;
14611462
double? _startDragThumbOffset;
1462-
ScrollController? _currentController;
1463+
ScrollController? _cachedController;
14631464
Timer? _fadeoutTimer;
14641465
late AnimationController _fadeoutAnimationController;
14651466
late Animation<double> _fadeoutOpacityAnimation;
14661467
final GlobalKey _scrollbarPainterKey = GlobalKey();
14671468
bool _hoverIsActive = false;
1469+
bool _thumbDragging = false;
1470+
1471+
ScrollController? get _effectiveScrollController => widget.controller ?? PrimaryScrollController.maybeOf(context);
14681472

14691473
/// Used to paint the scrollbar.
14701474
///
@@ -1550,12 +1554,11 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
15501554
}
15511555

15521556
void _validateInteractions(AnimationStatus status) {
1553-
final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.maybeOf(context);
15541557
if (status == AnimationStatus.dismissed) {
15551558
assert(_fadeoutOpacityAnimation.value == 0.0);
15561559
// We do not check for a valid scroll position if the scrollbar is not
15571560
// visible, because it cannot be interacted with.
1558-
} else if (scrollController != null && enableGestures) {
1561+
} else if (_effectiveScrollController != null && enableGestures) {
15591562
// Interactive scrollbars need to be properly configured. If it is visible
15601563
// for interaction, ensure we are set up properly.
15611564
assert(_debugCheckHasValidScrollPosition());
@@ -1566,7 +1569,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
15661569
if (!mounted) {
15671570
return true;
15681571
}
1569-
final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.maybeOf(context);
1572+
final ScrollController? scrollController = _effectiveScrollController;
15701573
final bool tryPrimary = widget.controller == null;
15711574
final String controllerForError = tryPrimary
15721575
? 'PrimaryScrollController'
@@ -1698,11 +1701,11 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
16981701
}
16991702

17001703
void _updateScrollPosition(Offset updatedOffset) {
1701-
assert(_currentController != null);
1704+
assert(_cachedController != null);
17021705
assert(_startDragScrollbarAxisOffset != null);
17031706
assert(_startDragThumbOffset != null);
17041707

1705-
final ScrollPosition position = _currentController!.position;
1708+
final ScrollPosition position = _cachedController!.position;
17061709
late double primaryDelta;
17071710
switch (position.axisDirection) {
17081711
case AxisDirection.up:
@@ -1761,9 +1764,9 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
17611764
/// current scroll controller does not have any attached positions.
17621765
@protected
17631766
Axis? getScrollbarDirection() {
1764-
assert(_currentController != null);
1765-
if (_currentController!.hasClients) {
1766-
return _currentController!.position.axis;
1767+
assert(_cachedController != null);
1768+
if (_cachedController!.hasClients) {
1769+
return _cachedController!.position.axis;
17671770
}
17681771
return null;
17691772
}
@@ -1788,7 +1791,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
17881791
@mustCallSuper
17891792
void handleThumbPressStart(Offset localPosition) {
17901793
assert(_debugCheckHasValidScrollPosition());
1791-
_currentController = widget.controller ?? PrimaryScrollController.maybeOf(context);
1794+
_cachedController = _effectiveScrollController;
17921795
final Axis? direction = getScrollbarDirection();
17931796
if (direction == null) {
17941797
return;
@@ -1797,6 +1800,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
17971800
_fadeoutAnimationController.forward();
17981801
_startDragScrollbarAxisOffset = localPosition;
17991802
_startDragThumbOffset = scrollbarPainter.getThumbScrollOffset();
1803+
_thumbDragging = true;
18001804
}
18011805

18021806
/// Handler called when a currently active long press gesture moves.
@@ -1806,7 +1810,11 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
18061810
@mustCallSuper
18071811
void handleThumbPressUpdate(Offset localPosition) {
18081812
assert(_debugCheckHasValidScrollPosition());
1809-
final ScrollPosition position = _currentController!.position;
1813+
if (_lastDragUpdateOffset == localPosition) {
1814+
return;
1815+
}
1816+
_lastDragUpdateOffset = localPosition;
1817+
final ScrollPosition position = _cachedController!.position;
18101818
if (!position.physics.shouldAcceptUserOffset(position)) {
18111819
return;
18121820
}
@@ -1822,45 +1830,47 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
18221830
@mustCallSuper
18231831
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
18241832
assert(_debugCheckHasValidScrollPosition());
1833+
_thumbDragging = false;
18251834
final Axis? direction = getScrollbarDirection();
18261835
if (direction == null) {
18271836
return;
18281837
}
18291838
_maybeStartFadeoutTimer();
18301839
_startDragScrollbarAxisOffset = null;
1840+
_lastDragUpdateOffset = null;
18311841
_startDragThumbOffset = null;
1832-
_currentController = null;
1842+
_cachedController = null;
18331843
}
18341844

18351845
void _handleTrackTapDown(TapDownDetails details) {
18361846
// The Scrollbar should page towards the position of the tap on the track.
18371847
assert(_debugCheckHasValidScrollPosition());
1838-
_currentController = widget.controller ?? PrimaryScrollController.maybeOf(context);
1848+
_cachedController = _effectiveScrollController;
18391849

1840-
final ScrollPosition position = _currentController!.position;
1850+
final ScrollPosition position = _cachedController!.position;
18411851
if (!position.physics.shouldAcceptUserOffset(position)) {
18421852
return;
18431853
}
18441854

18451855
double scrollIncrement;
18461856
// Is an increment calculator available?
18471857
final ScrollIncrementCalculator? calculator = Scrollable.maybeOf(
1848-
_currentController!.position.context.notificationContext!,
1858+
_cachedController!.position.context.notificationContext!,
18491859
)?.widget.incrementCalculator;
18501860
if (calculator != null) {
18511861
scrollIncrement = calculator(
18521862
ScrollIncrementDetails(
18531863
type: ScrollIncrementType.page,
1854-
metrics: _currentController!.position,
1864+
metrics: _cachedController!.position,
18551865
),
18561866
);
18571867
} else {
18581868
// Default page increment
1859-
scrollIncrement = 0.8 * _currentController!.position.viewportDimension;
1869+
scrollIncrement = 0.8 * _cachedController!.position.viewportDimension;
18601870
}
18611871

18621872
// Adjust scrollIncrement for direction
1863-
switch (_currentController!.position.axisDirection) {
1873+
switch (_cachedController!.position.axisDirection) {
18641874
case AxisDirection.up:
18651875
if (details.localPosition.dy > scrollbarPainter._thumbOffset) {
18661876
scrollIncrement = -scrollIncrement;
@@ -1883,17 +1893,16 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
18831893
break;
18841894
}
18851895

1886-
_currentController!.position.moveTo(
1887-
_currentController!.position.pixels + scrollIncrement,
1896+
_cachedController!.position.moveTo(
1897+
_cachedController!.position.pixels + scrollIncrement,
18881898
duration: const Duration(milliseconds: 100),
18891899
curve: Curves.easeInOut,
18901900
);
18911901
}
18921902

18931903
// ScrollController takes precedence over ScrollNotification
18941904
bool _shouldUpdatePainter(Axis notificationAxis) {
1895-
final ScrollController? scrollController = widget.controller ??
1896-
PrimaryScrollController.maybeOf(context);
1905+
final ScrollController? scrollController = _effectiveScrollController;
18971906
// Only update the painter of this scrollbar if the notification
18981907
// metrics do not conflict with the information we have from the scroll
18991908
// controller.
@@ -1979,8 +1988,7 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
19791988

19801989
Map<Type, GestureRecognizerFactory> get _gestures {
19811990
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
1982-
final ScrollController? controller = widget.controller ?? PrimaryScrollController.maybeOf(context);
1983-
if (controller == null || !enableGestures) {
1991+
if (_effectiveScrollController == null || !enableGestures) {
19841992
return gestures;
19851993
}
19861994

@@ -2086,6 +2094,64 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
20862094
_maybeStartFadeoutTimer();
20872095
}
20882096

2097+
// Returns the delta that should result from applying [event] with axis and
2098+
// direction taken into account.
2099+
double _pointerSignalEventDelta(PointerScrollEvent event) {
2100+
assert(_cachedController != null);
2101+
double delta = _cachedController!.position.axis == Axis.horizontal
2102+
? event.scrollDelta.dx
2103+
: event.scrollDelta.dy;
2104+
2105+
if (axisDirectionIsReversed(_cachedController!.position.axisDirection)) {
2106+
delta *= -1;
2107+
}
2108+
return delta;
2109+
}
2110+
2111+
// Returns the offset that should result from applying [event] to the current
2112+
// position, taking min/max scroll extent into account.
2113+
double _targetScrollOffsetForPointerScroll(double delta) {
2114+
assert(_cachedController != null);
2115+
return math.min(
2116+
math.max(_cachedController!.position.pixels + delta, _cachedController!.position.minScrollExtent),
2117+
_cachedController!.position.maxScrollExtent,
2118+
);
2119+
}
2120+
2121+
void _handlePointerScroll(PointerEvent event) {
2122+
assert(event is PointerScrollEvent);
2123+
_cachedController = _effectiveScrollController;
2124+
final double delta = _pointerSignalEventDelta(event as PointerScrollEvent);
2125+
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
2126+
if (delta != 0.0 && targetScrollOffset != _cachedController!.position.pixels) {
2127+
_cachedController!.position.pointerScroll(delta);
2128+
}
2129+
}
2130+
2131+
void _receivedPointerSignal(PointerSignalEvent event) {
2132+
_cachedController = _effectiveScrollController;
2133+
// Only try to scroll if the bar absorb the hit test.
2134+
if ((scrollbarPainter.hitTest(event.localPosition) ?? false) &&
2135+
_cachedController != null &&
2136+
_cachedController!.hasClients &&
2137+
(!_thumbDragging || kIsWeb)) {
2138+
final ScrollPosition position = _cachedController!.position;
2139+
if (event is PointerScrollEvent && position != null) {
2140+
if (!position.physics.shouldAcceptUserOffset(position)) {
2141+
return;
2142+
}
2143+
final double delta = _pointerSignalEventDelta(event);
2144+
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
2145+
if (delta != 0.0 && targetScrollOffset != position.pixels) {
2146+
GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
2147+
}
2148+
} else if (event is PointerScrollInertiaCancelEvent) {
2149+
position.jumpTo(position.pixels);
2150+
// Don't use the pointer signal resolver, all hit-tested scrollables should stop.
2151+
}
2152+
}
2153+
}
2154+
20892155
@override
20902156
void dispose() {
20912157
_fadeoutAnimationController.dispose();
@@ -2103,43 +2169,46 @@ class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProv
21032169
child: NotificationListener<ScrollNotification>(
21042170
onNotification: _handleScrollNotification,
21052171
child: RepaintBoundary(
2106-
child: RawGestureDetector(
2107-
gestures: _gestures,
2108-
child: MouseRegion(
2109-
onExit: (PointerExitEvent event) {
2110-
switch(event.kind) {
2111-
case PointerDeviceKind.mouse:
2112-
case PointerDeviceKind.trackpad:
2113-
if (enableGestures) {
2114-
handleHoverExit(event);
2115-
}
2116-
break;
2117-
case PointerDeviceKind.stylus:
2118-
case PointerDeviceKind.invertedStylus:
2119-
case PointerDeviceKind.unknown:
2120-
case PointerDeviceKind.touch:
2121-
break;
2122-
}
2123-
},
2124-
onHover: (PointerHoverEvent event) {
2125-
switch(event.kind) {
2126-
case PointerDeviceKind.mouse:
2127-
case PointerDeviceKind.trackpad:
2128-
if (enableGestures) {
2129-
handleHover(event);
2130-
}
2131-
break;
2132-
case PointerDeviceKind.stylus:
2133-
case PointerDeviceKind.invertedStylus:
2134-
case PointerDeviceKind.unknown:
2135-
case PointerDeviceKind.touch:
2136-
break;
2137-
}
2138-
},
2139-
child: CustomPaint(
2140-
key: _scrollbarPainterKey,
2141-
foregroundPainter: scrollbarPainter,
2142-
child: RepaintBoundary(child: widget.child),
2172+
child: Listener(
2173+
onPointerSignal: _receivedPointerSignal,
2174+
child: RawGestureDetector(
2175+
gestures: _gestures,
2176+
child: MouseRegion(
2177+
onExit: (PointerExitEvent event) {
2178+
switch(event.kind) {
2179+
case PointerDeviceKind.mouse:
2180+
case PointerDeviceKind.trackpad:
2181+
if (enableGestures) {
2182+
handleHoverExit(event);
2183+
}
2184+
break;
2185+
case PointerDeviceKind.stylus:
2186+
case PointerDeviceKind.invertedStylus:
2187+
case PointerDeviceKind.unknown:
2188+
case PointerDeviceKind.touch:
2189+
break;
2190+
}
2191+
},
2192+
onHover: (PointerHoverEvent event) {
2193+
switch(event.kind) {
2194+
case PointerDeviceKind.mouse:
2195+
case PointerDeviceKind.trackpad:
2196+
if (enableGestures) {
2197+
handleHover(event);
2198+
}
2199+
break;
2200+
case PointerDeviceKind.stylus:
2201+
case PointerDeviceKind.invertedStylus:
2202+
case PointerDeviceKind.unknown:
2203+
case PointerDeviceKind.touch:
2204+
break;
2205+
}
2206+
},
2207+
child: CustomPaint(
2208+
key: _scrollbarPainterKey,
2209+
foregroundPainter: scrollbarPainter,
2210+
child: RepaintBoundary(child: widget.child),
2211+
),
21432212
),
21442213
),
21452214
),

packages/flutter/test/cupertino/scrollbar_test.dart

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,25 +1200,32 @@ void main() {
12001200
pointer.hover(const Offset(793.0, 15.0));
12011201
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0)));
12021202
await tester.pumpAndSettle();
1203-
// Scrolling while holding the drag on the scrollbar and still hovered over
1204-
// the scrollbar should not have changed the scroll offset.
1205-
expect(pointer.location, const Offset(793.0, 15.0));
1206-
expect(scrollController.offset, previousOffset);
1207-
expect(
1208-
find.byType(CupertinoScrollbar),
1209-
paints..rrect(
1210-
rrect: RRect.fromRectAndRadius(
1211-
const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1),
1212-
const Radius.circular(4.0),
1203+
1204+
if (!kIsWeb) {
1205+
// Scrolling while holding the drag on the scrollbar and still hovered over
1206+
// the scrollbar should not have changed the scroll offset.
1207+
expect(pointer.location, const Offset(793.0, 15.0));
1208+
expect(scrollController.offset, previousOffset);
1209+
expect(
1210+
find.byType(CupertinoScrollbar),
1211+
paints..rrect(
1212+
rrect: RRect.fromRectAndRadius(
1213+
const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1),
1214+
const Radius.circular(4.0),
1215+
),
1216+
color: _kScrollbarColor.color,
12131217
),
1214-
color: _kScrollbarColor.color,
1215-
),
1216-
);
1218+
);
1219+
} else {
1220+
expect(pointer.location, const Offset(793.0, 15.0));
1221+
expect(scrollController.offset, previousOffset + 20.0);
1222+
}
1223+
12171224

12181225
// Drag is still being held, move pointer to be hovering over another area
12191226
// of the scrollable (not over the scrollbar) and execute another pointer scroll
12201227
pointer.hover(tester.getCenter(find.byType(SingleChildScrollView)));
1221-
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -70.0)));
1228+
await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0)));
12221229
await tester.pumpAndSettle();
12231230
// Scrolling while holding the drag on the scrollbar changed the offset
12241231
expect(pointer.location, const Offset(400.0, 300.0));

0 commit comments

Comments
 (0)