Skip to content

Commit e69ea6d

Browse files
authored
Support flipping mouse scrolling axes through modifier keys (#115610)
* Maybe maybe * Nit * One more nit * ++ * Fix test * REview feedback * Add comment about ios * ++ * Doc nit * Handle trackpads * Review feedback
1 parent 54405bf commit e69ea6d

File tree

3 files changed

+187
-16
lines changed

3 files changed

+187
-16
lines changed

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

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import 'package:flutter/foundation.dart';
66
import 'package:flutter/gestures.dart';
77
import 'package:flutter/rendering.dart';
8+
import 'package:flutter/services.dart' show LogicalKeyboardKey;
89

910
import 'framework.dart';
1011
import 'overscroll_indicator.dart';
@@ -100,6 +101,7 @@ class ScrollBehavior {
100101
bool? scrollbars,
101102
bool? overscroll,
102103
Set<PointerDeviceKind>? dragDevices,
104+
Set<LogicalKeyboardKey>? pointerAxisModifiers,
103105
ScrollPhysics? physics,
104106
TargetPlatform? platform,
105107
@Deprecated(
@@ -112,9 +114,10 @@ class ScrollBehavior {
112114
delegate: this,
113115
scrollbars: scrollbars ?? true,
114116
overscroll: overscroll ?? true,
117+
dragDevices: dragDevices,
118+
pointerAxisModifiers: pointerAxisModifiers,
115119
physics: physics,
116120
platform: platform,
117-
dragDevices: dragDevices,
118121
androidOverscrollIndicator: androidOverscrollIndicator
119122
);
120123
}
@@ -132,6 +135,25 @@ class ScrollBehavior {
132135
/// impossible to select text in scrollable containers and is not recommended.
133136
Set<PointerDeviceKind> get dragDevices => _kTouchLikeDeviceTypes;
134137

138+
/// A set of [LogicalKeyboardKey]s that, when any or all are pressed in
139+
/// combination with a [PointerDeviceKind.mouse] pointer scroll event, will
140+
/// flip the axes of the scroll input.
141+
///
142+
/// This will for example, result in the input of a vertical mouse wheel, to
143+
/// move the [ScrollPosition] of a [ScrollView] with an [Axis.horizontal]
144+
/// scroll direction.
145+
///
146+
/// If other keys exclusive of this set are pressed during a scroll event, in
147+
/// conjunction with keys from this set, the scroll input will still be
148+
/// flipped.
149+
///
150+
/// Defaults to [LogicalKeyboardKey.shiftLeft],
151+
/// [LogicalKeyboardKey.shiftRight].
152+
Set<LogicalKeyboardKey> get pointerAxisModifiers => <LogicalKeyboardKey>{
153+
LogicalKeyboardKey.shiftLeft,
154+
LogicalKeyboardKey.shiftRight,
155+
};
156+
135157
/// Applies a [RawScrollbar] to the child widget on desktop platforms.
136158
Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) {
137159
// When modifying this function, consider modifying the implementation in
@@ -261,25 +283,31 @@ class _WrappedScrollBehavior implements ScrollBehavior {
261283
required this.delegate,
262284
this.scrollbars = true,
263285
this.overscroll = true,
286+
Set<PointerDeviceKind>? dragDevices,
287+
Set<LogicalKeyboardKey>? pointerAxisModifiers,
264288
this.physics,
265289
this.platform,
266-
Set<PointerDeviceKind>? dragDevices,
267290
AndroidOverscrollIndicator? androidOverscrollIndicator,
268291
}) : _androidOverscrollIndicator = androidOverscrollIndicator,
269-
_dragDevices = dragDevices;
292+
_dragDevices = dragDevices,
293+
_pointerAxisModifiers = pointerAxisModifiers;
270294

271295
final ScrollBehavior delegate;
272296
final bool scrollbars;
273297
final bool overscroll;
274298
final ScrollPhysics? physics;
275299
final TargetPlatform? platform;
276300
final Set<PointerDeviceKind>? _dragDevices;
301+
final Set<LogicalKeyboardKey>? _pointerAxisModifiers;
277302
@override
278303
final AndroidOverscrollIndicator? _androidOverscrollIndicator;
279304

280305
@override
281306
Set<PointerDeviceKind> get dragDevices => _dragDevices ?? delegate.dragDevices;
282307

308+
@override
309+
Set<LogicalKeyboardKey> get pointerAxisModifiers => _pointerAxisModifiers ?? delegate.pointerAxisModifiers;
310+
283311
@override
284312
AndroidOverscrollIndicator get androidOverscrollIndicator => _androidOverscrollIndicator ?? delegate.androidOverscrollIndicator;
285313

@@ -303,17 +331,19 @@ class _WrappedScrollBehavior implements ScrollBehavior {
303331
ScrollBehavior copyWith({
304332
bool? scrollbars,
305333
bool? overscroll,
334+
Set<PointerDeviceKind>? dragDevices,
335+
Set<LogicalKeyboardKey>? pointerAxisModifiers,
306336
ScrollPhysics? physics,
307337
TargetPlatform? platform,
308-
Set<PointerDeviceKind>? dragDevices,
309338
AndroidOverscrollIndicator? androidOverscrollIndicator
310339
}) {
311340
return delegate.copyWith(
312341
scrollbars: scrollbars ?? this.scrollbars,
313342
overscroll: overscroll ?? this.overscroll,
343+
dragDevices: dragDevices ?? this.dragDevices,
344+
pointerAxisModifiers: pointerAxisModifiers ?? this.pointerAxisModifiers,
314345
physics: physics ?? this.physics,
315346
platform: platform ?? this.platform,
316-
dragDevices: dragDevices ?? this.dragDevices,
317347
androidOverscrollIndicator: androidOverscrollIndicator ?? this.androidOverscrollIndicator,
318348
);
319349
}
@@ -333,9 +363,10 @@ class _WrappedScrollBehavior implements ScrollBehavior {
333363
return oldDelegate.delegate.runtimeType != delegate.runtimeType
334364
|| oldDelegate.scrollbars != scrollbars
335365
|| oldDelegate.overscroll != overscroll
366+
|| !setEquals<PointerDeviceKind>(oldDelegate.dragDevices, dragDevices)
367+
|| !setEquals<LogicalKeyboardKey>(oldDelegate.pointerAxisModifiers, pointerAxisModifiers)
336368
|| oldDelegate.physics != physics
337369
|| oldDelegate.platform != platform
338-
|| !setEquals<PointerDeviceKind>(oldDelegate.dragDevices, dragDevices)
339370
|| delegate.shouldNotify(oldDelegate.delegate);
340371
}
341372

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -756,12 +756,32 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
756756
);
757757
}
758758

759-
// Returns the delta that should result from applying [event] with axis and
760-
// direction taken into account.
759+
// Returns the delta that should result from applying [event] with axis,
760+
// direction, and any modifiers specified by the ScrollBehavior taken into
761+
// account.
761762
double _pointerSignalEventDelta(PointerScrollEvent event) {
762-
double delta = widget.axis == Axis.horizontal
763-
? event.scrollDelta.dx
764-
: event.scrollDelta.dy;
763+
late double delta;
764+
final Set<LogicalKeyboardKey> pressed = HardwareKeyboard.instance.logicalKeysPressed;
765+
final bool flipAxes = pressed.any(_configuration.pointerAxisModifiers.contains) &&
766+
// Axes are only flipped for physical mouse wheel input.
767+
// On some platforms, like web, trackpad input is handled through pointer
768+
// signals, but should not be included in this axis modifying behavior.
769+
// This is because on a trackpad, all directional axes are available to
770+
// the user, while mouse scroll wheels typically are restricted to one
771+
// axis.
772+
event.kind == PointerDeviceKind.mouse;
773+
774+
switch (widget.axis) {
775+
case Axis.horizontal:
776+
delta = flipAxes
777+
? event.scrollDelta.dy
778+
: event.scrollDelta.dx;
779+
break;
780+
case Axis.vertical:
781+
delta = flipAxes
782+
? event.scrollDelta.dx
783+
: event.scrollDelta.dy;
784+
}
765785

766786
if (axisDirectionIsReversed(widget.axisDirection)) {
767787
delta *= -1;

packages/flutter/test/widgets/scrollable_test.dart

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,31 @@ Future<void> pumpTest(
1616
TargetPlatform? platform, {
1717
bool scrollable = true,
1818
bool reverse = false,
19+
Set<LogicalKeyboardKey>? axisModifier,
20+
Axis scrollDirection = Axis.vertical,
1921
ScrollController? controller,
2022
bool enableMouseDrag = true,
2123
}) async {
2224
await tester.pumpWidget(MaterialApp(
23-
scrollBehavior: const NoScrollbarBehavior().copyWith(dragDevices: enableMouseDrag
24-
? <ui.PointerDeviceKind>{...ui.PointerDeviceKind.values}
25-
: null,
25+
scrollBehavior: const NoScrollbarBehavior().copyWith(
26+
dragDevices: enableMouseDrag
27+
? <ui.PointerDeviceKind>{...ui.PointerDeviceKind.values}
28+
: null,
29+
pointerAxisModifiers: axisModifier,
2630
),
2731
theme: ThemeData(
2832
platform: platform,
2933
),
3034
home: CustomScrollView(
3135
controller: controller,
3236
reverse: reverse,
37+
scrollDirection: scrollDirection,
3338
physics: scrollable ? null : const NeverScrollableScrollPhysics(),
34-
slivers: const <Widget>[
35-
SliverToBoxAdapter(child: SizedBox(height: 2000.0)),
39+
slivers: <Widget>[
40+
SliverToBoxAdapter(child: SizedBox(
41+
height: scrollDirection == Axis.vertical ? 2000.0 : null,
42+
width: scrollDirection == Axis.horizontal ? 2000.0 : null,
43+
)),
3644
],
3745
),
3846
));
@@ -399,6 +407,118 @@ void main() {
399407
expect(getScrollOffset(tester), 20.0);
400408
});
401409

410+
testWidgets('Scrolls horizontally when shift is pressed by default', (WidgetTester tester) async {
411+
await pumpTest(
412+
tester,
413+
debugDefaultTargetPlatformOverride,
414+
scrollDirection: Axis.horizontal,
415+
);
416+
417+
final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport));
418+
final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse);
419+
// Create a hover event so that |testPointer| has a location when generating the scroll.
420+
testPointer.hover(scrollEventLocation);
421+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
422+
// Vertical input not accepted
423+
expect(getScrollOffset(tester), 0.0);
424+
425+
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
426+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
427+
// Vertical input flipped to horizontal and accepted.
428+
expect(getScrollOffset(tester), 20.0);
429+
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
430+
await tester.pump();
431+
432+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
433+
// Vertical input not accepted
434+
expect(getScrollOffset(tester), 20.0);
435+
}, variant: TargetPlatformVariant.all());
436+
437+
testWidgets('Scroll axis is not flipped for trackpad', (WidgetTester tester) async {
438+
await pumpTest(
439+
tester,
440+
debugDefaultTargetPlatformOverride,
441+
scrollDirection: Axis.horizontal,
442+
);
443+
444+
final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport));
445+
final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.trackpad);
446+
// Create a hover event so that |testPointer| has a location when generating the scroll.
447+
testPointer.hover(scrollEventLocation);
448+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
449+
// Vertical input not accepted
450+
expect(getScrollOffset(tester), 0.0);
451+
452+
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);
453+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
454+
// Vertical input not flipped.
455+
expect(getScrollOffset(tester), 0.0);
456+
await tester.sendKeyUpEvent(LogicalKeyboardKey.shift);
457+
await tester.pump();
458+
459+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
460+
// Vertical input not accepted
461+
expect(getScrollOffset(tester), 0.0);
462+
}, variant: TargetPlatformVariant.all());
463+
464+
testWidgets('Scrolls horizontally when custom key is pressed', (WidgetTester tester) async {
465+
await pumpTest(
466+
tester,
467+
debugDefaultTargetPlatformOverride,
468+
scrollDirection: Axis.horizontal,
469+
axisModifier: <LogicalKeyboardKey>{ LogicalKeyboardKey.altLeft },
470+
);
471+
472+
final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport));
473+
final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse);
474+
// Create a hover event so that |testPointer| has a location when generating the scroll.
475+
testPointer.hover(scrollEventLocation);
476+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
477+
// Vertical input not accepted
478+
expect(getScrollOffset(tester), 0.0);
479+
480+
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
481+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
482+
// Vertical input flipped to horizontal and accepted.
483+
expect(getScrollOffset(tester), 20.0);
484+
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
485+
await tester.pump();
486+
487+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
488+
// Vertical input not accepted
489+
expect(getScrollOffset(tester), 20.0);
490+
}, variant: TargetPlatformVariant.all());
491+
492+
testWidgets('Still scrolls horizontally when other keys are pressed at the same time', (WidgetTester tester) async {
493+
await pumpTest(
494+
tester,
495+
debugDefaultTargetPlatformOverride,
496+
scrollDirection: Axis.horizontal,
497+
axisModifier: <LogicalKeyboardKey>{ LogicalKeyboardKey.altLeft },
498+
);
499+
500+
final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport));
501+
final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse);
502+
// Create a hover event so that |testPointer| has a location when generating the scroll.
503+
testPointer.hover(scrollEventLocation);
504+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
505+
// Vertical input not accepted
506+
expect(getScrollOffset(tester), 0.0);
507+
508+
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
509+
await tester.sendKeyDownEvent(LogicalKeyboardKey.space);
510+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
511+
// Vertical flipped & accepted.
512+
expect(getScrollOffset(tester), 20.0);
513+
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
514+
await tester.sendKeyUpEvent(LogicalKeyboardKey.space);
515+
await tester.pump();
516+
517+
await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0)));
518+
// Vertical input not accepted
519+
expect(getScrollOffset(tester), 20.0);
520+
}, variant: TargetPlatformVariant.all());
521+
402522
group('setCanDrag to false with active drag gesture: ', () {
403523
Future<void> pumpTestWidget(WidgetTester tester, { required bool canDrag }) {
404524
return tester.pumpWidget(

0 commit comments

Comments
 (0)