diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 3b288f9a48..d1af1b26b8 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/gestures.dart'; @@ -628,25 +629,35 @@ class ScrollableState extends State with TickerProviderStateMixin, R // SCROLL WHEEL + // Returns the offset that should result from applying [event] to the current + // position, taking min/max scroll extent into account. + double _targetScrollOffsetForPointerScroll(PointerScrollEvent event) { + final double delta = _pointerSignalEventDelta(event); + return math.min(math.max(position.pixels + delta, position.minScrollExtent), + position.maxScrollExtent); + } + // Returns the delta that should result from applying [event] with axis and // direction taken into account. - double _targetScrollDeltaForPointerScroll(PointerScrollEvent event) { + double _pointerSignalEventDelta(PointerScrollEvent event) { double delta = widget.axis == Axis.horizontal - ? event.scrollDelta.dx - : event.scrollDelta.dy; + ? event.scrollDelta.dx + : event.scrollDelta.dy; if (axisDirectionIsReversed(widget.axisDirection)) { delta *= -1; } - return delta; } void _receivedPointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent && _position != null) { - final double targetScrollOffset = _targetScrollDeltaForPointerScroll(event); + if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) { + return; + } + final double targetScrollOffset = _targetScrollOffsetForPointerScroll(event); // Only express interest in the event if it would actually result in a scroll. - if (targetScrollOffset != 0) { + if (targetScrollOffset != position.pixels) { GestureBinding.instance!.pointerSignalResolver.register(event, _handlePointerScroll); } } @@ -654,12 +665,9 @@ class ScrollableState extends State with TickerProviderStateMixin, R void _handlePointerScroll(PointerEvent event) { assert(event is PointerScrollEvent); - if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) { - return; - } - final double targetScrollOffset = _targetScrollDeltaForPointerScroll(event as PointerScrollEvent); - if (targetScrollOffset != 0) { - position.pointerScroll(targetScrollOffset); + final double targetScrollOffset = _targetScrollOffsetForPointerScroll(event as PointerScrollEvent); + if (targetScrollOffset != position.pixels) { + position.pointerScroll(_pointerSignalEventDelta(event)); } } diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 0fe718e777..426f122f2a 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -1148,6 +1148,64 @@ void main() { expect(targetMidRightPage1, findsOneWidget); expect(targetMidLeftPage1, findsOneWidget); }); + + testWidgets('PointerScroll on nested NeverScrollable ListView goes to outer Scrollable.', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/70948 + final ScrollController outerController = ScrollController(); + final ScrollController innerController = ScrollController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + controller: outerController, + child: Container( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Column( + children: [ + for (int i = 0; i < 100; i++) + Text('SingleChildScrollView $i'), + ] + ), + Container( + height: 3000, + width: 400, + child: ListView.builder( + controller: innerController, + physics: const NeverScrollableScrollPhysics(), + itemCount: 100, + itemBuilder: (BuildContext context, int index) { + return Text('Nested NeverScrollable ListView $index'); + }, + ) + ), + ] + ) + ) + ) + ), + )); + expect(outerController.position.pixels, 0.0); + expect(innerController.position.pixels, 0.0); + final Offset outerScrollable = tester.getCenter(find.text('SingleChildScrollView 3')); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + // Hover over the outer scroll view and create a pointer scroll. + testPointer.hover(outerScrollable); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); + await tester.pump(const Duration(milliseconds: 250)); + expect(outerController.position.pixels, 20.0); + expect(innerController.position.pixels, 0.0); + + final Offset innerScrollable = tester.getCenter(find.text('Nested NeverScrollable ListView 20')); + // Hover over the inner scroll view and create a pointer scroll. + // This inner scroll view is not scrollable, and so the outer should scroll. + testPointer.hover(innerScrollable); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -20.0))); + await tester.pump(const Duration(milliseconds: 250)); + expect(outerController.position.pixels, 0.0); + expect(innerController.position.pixels, 0.0); + }); } // ignore: must_be_immutable