diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index 0b0a6dc0d8..8455e6ef7f 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'framework.dart'; import 'overscroll_indicator.dart'; @@ -100,6 +101,7 @@ class ScrollBehavior { bool? scrollbars, bool? overscroll, Set? dragDevices, + Set? pointerAxisModifiers, ScrollPhysics? physics, TargetPlatform? platform, @Deprecated( @@ -112,9 +114,10 @@ class ScrollBehavior { delegate: this, scrollbars: scrollbars ?? true, overscroll: overscroll ?? true, + dragDevices: dragDevices, + pointerAxisModifiers: pointerAxisModifiers, physics: physics, platform: platform, - dragDevices: dragDevices, androidOverscrollIndicator: androidOverscrollIndicator ); } @@ -132,6 +135,25 @@ class ScrollBehavior { /// impossible to select text in scrollable containers and is not recommended. Set get dragDevices => _kTouchLikeDeviceTypes; + /// A set of [LogicalKeyboardKey]s that, when any or all are pressed in + /// combination with a [PointerDeviceKind.mouse] pointer scroll event, will + /// flip the axes of the scroll input. + /// + /// This will for example, result in the input of a vertical mouse wheel, to + /// move the [ScrollPosition] of a [ScrollView] with an [Axis.horizontal] + /// scroll direction. + /// + /// If other keys exclusive of this set are pressed during a scroll event, in + /// conjunction with keys from this set, the scroll input will still be + /// flipped. + /// + /// Defaults to [LogicalKeyboardKey.shiftLeft], + /// [LogicalKeyboardKey.shiftRight]. + Set get pointerAxisModifiers => { + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + }; + /// Applies a [RawScrollbar] to the child widget on desktop platforms. Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) { // When modifying this function, consider modifying the implementation in @@ -261,12 +283,14 @@ class _WrappedScrollBehavior implements ScrollBehavior { required this.delegate, this.scrollbars = true, this.overscroll = true, + Set? dragDevices, + Set? pointerAxisModifiers, this.physics, this.platform, - Set? dragDevices, AndroidOverscrollIndicator? androidOverscrollIndicator, }) : _androidOverscrollIndicator = androidOverscrollIndicator, - _dragDevices = dragDevices; + _dragDevices = dragDevices, + _pointerAxisModifiers = pointerAxisModifiers; final ScrollBehavior delegate; final bool scrollbars; @@ -274,12 +298,16 @@ class _WrappedScrollBehavior implements ScrollBehavior { final ScrollPhysics? physics; final TargetPlatform? platform; final Set? _dragDevices; + final Set? _pointerAxisModifiers; @override final AndroidOverscrollIndicator? _androidOverscrollIndicator; @override Set get dragDevices => _dragDevices ?? delegate.dragDevices; + @override + Set get pointerAxisModifiers => _pointerAxisModifiers ?? delegate.pointerAxisModifiers; + @override AndroidOverscrollIndicator get androidOverscrollIndicator => _androidOverscrollIndicator ?? delegate.androidOverscrollIndicator; @@ -303,17 +331,19 @@ class _WrappedScrollBehavior implements ScrollBehavior { ScrollBehavior copyWith({ bool? scrollbars, bool? overscroll, + Set? dragDevices, + Set? pointerAxisModifiers, ScrollPhysics? physics, TargetPlatform? platform, - Set? dragDevices, AndroidOverscrollIndicator? androidOverscrollIndicator }) { return delegate.copyWith( scrollbars: scrollbars ?? this.scrollbars, overscroll: overscroll ?? this.overscroll, + dragDevices: dragDevices ?? this.dragDevices, + pointerAxisModifiers: pointerAxisModifiers ?? this.pointerAxisModifiers, physics: physics ?? this.physics, platform: platform ?? this.platform, - dragDevices: dragDevices ?? this.dragDevices, androidOverscrollIndicator: androidOverscrollIndicator ?? this.androidOverscrollIndicator, ); } @@ -333,9 +363,10 @@ class _WrappedScrollBehavior implements ScrollBehavior { return oldDelegate.delegate.runtimeType != delegate.runtimeType || oldDelegate.scrollbars != scrollbars || oldDelegate.overscroll != overscroll + || !setEquals(oldDelegate.dragDevices, dragDevices) + || !setEquals(oldDelegate.pointerAxisModifiers, pointerAxisModifiers) || oldDelegate.physics != physics || oldDelegate.platform != platform - || !setEquals(oldDelegate.dragDevices, dragDevices) || delegate.shouldNotify(oldDelegate.delegate); } diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 0ef83c64cc..73e50961c3 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -756,12 +756,32 @@ class ScrollableState extends State with TickerProviderStateMixin, R ); } - // Returns the delta that should result from applying [event] with axis and - // direction taken into account. + // Returns the delta that should result from applying [event] with axis, + // direction, and any modifiers specified by the ScrollBehavior taken into + // account. double _pointerSignalEventDelta(PointerScrollEvent event) { - double delta = widget.axis == Axis.horizontal - ? event.scrollDelta.dx - : event.scrollDelta.dy; + late double delta; + final Set pressed = HardwareKeyboard.instance.logicalKeysPressed; + final bool flipAxes = pressed.any(_configuration.pointerAxisModifiers.contains) && + // Axes are only flipped for physical mouse wheel input. + // On some platforms, like web, trackpad input is handled through pointer + // signals, but should not be included in this axis modifying behavior. + // This is because on a trackpad, all directional axes are available to + // the user, while mouse scroll wheels typically are restricted to one + // axis. + event.kind == PointerDeviceKind.mouse; + + switch (widget.axis) { + case Axis.horizontal: + delta = flipAxes + ? event.scrollDelta.dy + : event.scrollDelta.dx; + break; + case Axis.vertical: + delta = flipAxes + ? event.scrollDelta.dx + : event.scrollDelta.dy; + } if (axisDirectionIsReversed(widget.axisDirection)) { delta *= -1; diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 4b5e379290..2c7d236ce0 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -16,13 +16,17 @@ Future pumpTest( TargetPlatform? platform, { bool scrollable = true, bool reverse = false, + Set? axisModifier, + Axis scrollDirection = Axis.vertical, ScrollController? controller, bool enableMouseDrag = true, }) async { await tester.pumpWidget(MaterialApp( - scrollBehavior: const NoScrollbarBehavior().copyWith(dragDevices: enableMouseDrag - ? {...ui.PointerDeviceKind.values} - : null, + scrollBehavior: const NoScrollbarBehavior().copyWith( + dragDevices: enableMouseDrag + ? {...ui.PointerDeviceKind.values} + : null, + pointerAxisModifiers: axisModifier, ), theme: ThemeData( platform: platform, @@ -30,9 +34,13 @@ Future pumpTest( home: CustomScrollView( controller: controller, reverse: reverse, + scrollDirection: scrollDirection, physics: scrollable ? null : const NeverScrollableScrollPhysics(), - slivers: const [ - SliverToBoxAdapter(child: SizedBox(height: 2000.0)), + slivers: [ + SliverToBoxAdapter(child: SizedBox( + height: scrollDirection == Axis.vertical ? 2000.0 : null, + width: scrollDirection == Axis.horizontal ? 2000.0 : null, + )), ], ), )); @@ -399,6 +407,118 @@ void main() { expect(getScrollOffset(tester), 20.0); }); + testWidgets('Scrolls horizontally when shift is pressed by default', (WidgetTester tester) async { + await pumpTest( + tester, + debugDefaultTargetPlatformOverride, + scrollDirection: Axis.horizontal, + ); + + final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + // Create a hover event so that |testPointer| has a location when generating the scroll. + testPointer.hover(scrollEventLocation); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 0.0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input flipped to horizontal and accepted. + expect(getScrollOffset(tester), 20.0); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 20.0); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Scroll axis is not flipped for trackpad', (WidgetTester tester) async { + await pumpTest( + tester, + debugDefaultTargetPlatformOverride, + scrollDirection: Axis.horizontal, + ); + + final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.trackpad); + // Create a hover event so that |testPointer| has a location when generating the scroll. + testPointer.hover(scrollEventLocation); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 0.0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not flipped. + expect(getScrollOffset(tester), 0.0); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 0.0); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Scrolls horizontally when custom key is pressed', (WidgetTester tester) async { + await pumpTest( + tester, + debugDefaultTargetPlatformOverride, + scrollDirection: Axis.horizontal, + axisModifier: { LogicalKeyboardKey.altLeft }, + ); + + final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + // Create a hover event so that |testPointer| has a location when generating the scroll. + testPointer.hover(scrollEventLocation); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 0.0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input flipped to horizontal and accepted. + expect(getScrollOffset(tester), 20.0); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 20.0); + }, variant: TargetPlatformVariant.all()); + + testWidgets('Still scrolls horizontally when other keys are pressed at the same time', (WidgetTester tester) async { + await pumpTest( + tester, + debugDefaultTargetPlatformOverride, + scrollDirection: Axis.horizontal, + axisModifier: { LogicalKeyboardKey.altLeft }, + ); + + final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + // Create a hover event so that |testPointer| has a location when generating the scroll. + testPointer.hover(scrollEventLocation); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 0.0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical flipped & accepted. + expect(getScrollOffset(tester), 20.0); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.pump(); + + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + // Vertical input not accepted + expect(getScrollOffset(tester), 20.0); + }, variant: TargetPlatformVariant.all()); + group('setCanDrag to false with active drag gesture: ', () { Future pumpTestWidget(WidgetTester tester, { required bool canDrag }) { return tester.pumpWidget(