From b176bce22b69e5ceeb05b5c499b65d8cc030d322 Mon Sep 17 00:00:00 2001 From: Amir Panahandeh Date: Tue, 30 Apr 2024 02:16:06 +0330 Subject: [PATCH] Add configurable hitTestBehavior to Scrollable (#146403) This PR adds `hitTestBehavior` to Scrollable as a configurable member. - https://github.com/flutter/flutter/issues/146401 --- .../src/widgets/list_wheel_scroll_view.dart | 9 ++ .../lib/src/widgets/nested_scroll_view.dart | 8 ++ .../flutter/lib/src/widgets/page_view.dart | 9 ++ .../flutter/lib/src/widgets/scroll_view.dart | 18 ++++ .../flutter/lib/src/widgets/scrollable.dart | 25 ++++- .../src/widgets/single_child_scroll_view.dart | 7 ++ .../widgets/two_dimensional_scroll_view.dart | 7 ++ .../flutter/test/widgets/scrollable_test.dart | 94 +++++++++++++++++++ .../two_dimensional_scroll_view_test.dart | 31 ++++++ .../test/widgets/two_dimensional_utils.dart | 1 + 10 files changed, 208 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart b/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart index ba52e9b2bd4..ce669174158 100644 --- a/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart @@ -425,6 +425,7 @@ class _FixedExtentScrollable extends Scrollable { required super.viewportBuilder, super.restorationId, super.scrollBehavior, + super.hitTestBehavior, }); final double itemExtent; @@ -570,6 +571,7 @@ class ListWheelScrollView extends StatefulWidget { this.onSelectedItemChanged, this.renderChildrenOutsideViewport = false, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, this.restorationId, this.scrollBehavior, required List children, @@ -603,6 +605,7 @@ class ListWheelScrollView extends StatefulWidget { this.onSelectedItemChanged, this.renderChildrenOutsideViewport = false, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, this.restorationId, this.scrollBehavior, required this.childDelegate, @@ -689,6 +692,11 @@ class ListWheelScrollView extends StatefulWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; + /// {@macro flutter.widgets.scrollable.hitTestBehavior} + /// + /// Defaults to [HitTestBehavior.opaque]. + final HitTestBehavior hitTestBehavior; + /// {@macro flutter.widgets.scrollable.restorationId} final String? restorationId; @@ -754,6 +762,7 @@ class _ListWheelScrollViewState extends State { physics: widget.physics, itemExtent: widget.itemExtent, restorationId: widget.restorationId, + hitTestBehavior: widget.hitTestBehavior, scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false), viewportBuilder: (BuildContext context, ViewportOffset offset) { return ListWheelViewport( diff --git a/packages/flutter/lib/src/widgets/nested_scroll_view.dart b/packages/flutter/lib/src/widgets/nested_scroll_view.dart index 77cf079b179..b1adb294837 100644 --- a/packages/flutter/lib/src/widgets/nested_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/nested_scroll_view.dart @@ -188,6 +188,7 @@ class NestedScrollView extends StatefulWidget { this.dragStartBehavior = DragStartBehavior.start, this.floatHeaderSlivers = false, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, this.restorationId, this.scrollBehavior, }); @@ -297,6 +298,11 @@ class NestedScrollView extends StatefulWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; + /// {@macro flutter.widgets.scrollable.hitTestBehavior} + /// + /// Defaults to [HitTestBehavior.opaque]. + final HitTestBehavior hitTestBehavior; + /// {@macro flutter.widgets.scrollable.restorationId} final String? restorationId; @@ -489,6 +495,7 @@ class NestedScrollViewState extends State { handle: _absorberHandle, clipBehavior: widget.clipBehavior, restorationId: widget.restorationId, + hitTestBehavior: widget.hitTestBehavior, ); }, ), @@ -506,6 +513,7 @@ class _NestedScrollViewCustomScrollView extends CustomScrollView { required super.slivers, required this.handle, required super.clipBehavior, + super.hitTestBehavior, super.dragStartBehavior, super.restorationId, }); diff --git a/packages/flutter/lib/src/widgets/page_view.dart b/packages/flutter/lib/src/widgets/page_view.dart index 5da1f1c3b8e..2a31d8e3e44 100644 --- a/packages/flutter/lib/src/widgets/page_view.dart +++ b/packages/flutter/lib/src/widgets/page_view.dart @@ -649,6 +649,7 @@ class PageView extends StatefulWidget { this.allowImplicitScrolling = false, this.restorationId, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, this.scrollBehavior, this.padEnds = true, }) : childrenDelegate = SliverChildListDelegate(children); @@ -693,6 +694,7 @@ class PageView extends StatefulWidget { this.allowImplicitScrolling = false, this.restorationId, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, this.scrollBehavior, this.padEnds = true, }) : childrenDelegate = SliverChildBuilderDelegate( @@ -725,6 +727,7 @@ class PageView extends StatefulWidget { this.allowImplicitScrolling = false, this.restorationId, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, this.scrollBehavior, this.padEnds = true, }); @@ -812,6 +815,11 @@ class PageView extends StatefulWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; + /// {@macro flutter.widgets.scrollable.hitTestBehavior} + /// + /// Defaults to [HitTestBehavior.opaque]. + final HitTestBehavior hitTestBehavior; + /// {@macro flutter.widgets.shadow.scrollBehavior} /// /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit @@ -915,6 +923,7 @@ class _PageViewState extends State { controller: _controller, physics: physics, restorationId: widget.restorationId, + hitTestBehavior: widget.hitTestBehavior, scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false), viewportBuilder: (BuildContext context, ViewportOffset position) { return Viewport( diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart index 4ff32530e74..3b85dcbe7ca 100644 --- a/packages/flutter/lib/src/widgets/scroll_view.dart +++ b/packages/flutter/lib/src/widgets/scroll_view.dart @@ -114,6 +114,7 @@ abstract class ScrollView extends StatelessWidget { this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, this.restorationId, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, }) : assert( !(controller != null && (primary ?? false)), 'Primary ScrollViews obtain their ScrollController via inheritance ' @@ -377,6 +378,11 @@ abstract class ScrollView extends StatelessWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; + /// {@macro flutter.widgets.scrollable.hitTestBehavior} + /// + /// Defaults to [HitTestBehavior.opaque]. + final HitTestBehavior hitTestBehavior; + /// Returns the [AxisDirection] in which the scroll view scrolls. /// /// Combines the [scrollDirection] with the [reverse] boolean to obtain the @@ -476,6 +482,7 @@ abstract class ScrollView extends StatelessWidget { scrollBehavior: scrollBehavior, semanticChildCount: semanticChildCount, restorationId: restorationId, + hitTestBehavior: hitTestBehavior, viewportBuilder: (BuildContext context, ViewportOffset offset) { return buildViewport(context, offset, axisDirection, slivers); }, @@ -665,6 +672,7 @@ class CustomScrollView extends ScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }); /// The slivers to place inside the viewport. @@ -804,6 +812,7 @@ abstract class BoxScrollView extends ScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }); /// The amount of space by which to inset the children. @@ -1240,6 +1249,7 @@ class ListView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }) : assert( (itemExtent == null && prototypeItem == null) || (itemExtent == null && itemExtentBuilder == null) || @@ -1318,6 +1328,7 @@ class ListView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }) : assert(itemCount == null || itemCount >= 0), assert(semanticChildCount == null || semanticChildCount <= itemCount!), assert( @@ -1410,6 +1421,7 @@ class ListView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }) : assert(itemCount >= 0), itemExtent = null, itemExtentBuilder = null, @@ -1465,6 +1477,7 @@ class ListView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }) : assert( (itemExtent == null && prototypeItem == null) || (itemExtent == null && itemExtentBuilder == null) || @@ -1856,6 +1869,7 @@ class GridView extends BoxScrollView { super.clipBehavior, super.keyboardDismissBehavior, super.restorationId, + super.hitTestBehavior, }) : childrenDelegate = SliverChildListDelegate( children, addAutomaticKeepAlives: addAutomaticKeepAlives, @@ -1912,6 +1926,7 @@ class GridView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }) : childrenDelegate = SliverChildBuilderDelegate( itemBuilder, findChildIndexCallback: findChildIndexCallback, @@ -1946,6 +1961,7 @@ class GridView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }); /// Creates a scrollable, 2D array of widgets with a fixed number of tiles in @@ -1985,6 +2001,7 @@ class GridView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }) : gridDelegate = SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, mainAxisSpacing: mainAxisSpacing, @@ -2038,6 +2055,7 @@ class GridView extends BoxScrollView { super.keyboardDismissBehavior, super.restorationId, super.clipBehavior, + super.hitTestBehavior, }) : gridDelegate = SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, mainAxisSpacing: mainAxisSpacing, diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 74f27b95411..7cedc263fbf 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -116,6 +116,7 @@ class Scrollable extends StatefulWidget { this.restorationId, this.scrollBehavior, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, }) : assert(semanticChildCount == null || semanticChildCount >= 0); /// {@template flutter.widgets.Scrollable.axisDirection} @@ -225,6 +226,18 @@ class Scrollable extends StatefulWidget { /// exclusion. final bool excludeFromSemantics; + /// {@template flutter.widgets.scrollable.hitTestBehavior} + /// Defines the behavior of gesture detector used in this [Scrollable]. + /// + /// This defaults to [HitTestBehavior.opaque] which means it prevents targets + /// behind this [Scrollable] from receiving events. + /// {@endtemplate} + /// + /// See also: + /// + /// * [HitTestBehavior], for an explanation on different behaviors. + final HitTestBehavior hitTestBehavior; + /// The number of children that will contribute semantic information. /// /// The value will be null if the number of children is unknown or unbounded. @@ -971,7 +984,7 @@ class ScrollableState extends State with TickerProviderStateMixin, R child: RawGestureDetector( key: _gestureDetectorKey, gestures: _gestureRecognizers, - behavior: HitTestBehavior.opaque, + behavior: widget.hitTestBehavior, excludeFromSemantics: widget.excludeFromSemantics, child: Semantics( explicitChildNodes: !widget.excludeFromSemantics, @@ -1732,6 +1745,7 @@ class TwoDimensionalScrollable extends StatefulWidget { this.excludeFromSemantics = false, this.diagonalDragBehavior = DiagonalDragBehavior.none, this.dragStartBehavior = DragStartBehavior.start, + this.hitTestBehavior = HitTestBehavior.opaque, }); /// How scrolling gestures should lock to one axis, or allow free movement @@ -1778,6 +1792,11 @@ class TwoDimensionalScrollable extends StatefulWidget { /// This value applies to both axes. final bool excludeFromSemantics; + /// {@macro flutter.widgets.scrollable.hitTestBehavior} + /// + /// This value applies to both axes. + final HitTestBehavior hitTestBehavior; + /// {@macro flutter.widgets.scrollable.dragStartBehavior} /// /// This value applies in both axes. @@ -1980,6 +1999,7 @@ class TwoDimensionalScrollableState extends State { restorationId: 'OuterVerticalTwoDimensionalScrollable', dragStartBehavior: widget.dragStartBehavior, diagonalDragBehavior: widget.diagonalDragBehavior, + hitTestBehavior: widget.hitTestBehavior, viewportBuilder: (BuildContext context, ViewportOffset verticalOffset) { return _HorizontalInnerDimension( key: _horizontalInnerScrollableKey, @@ -1996,6 +2016,7 @@ class TwoDimensionalScrollableState extends State { restorationId: 'InnerHorizontalTwoDimensionalScrollable', dragStartBehavior: widget.dragStartBehavior, diagonalDragBehavior: widget.diagonalDragBehavior, + hitTestBehavior: widget.hitTestBehavior, viewportBuilder: (BuildContext context, ViewportOffset horizontalOffset) { return widget.viewportBuilder(context, verticalOffset, horizontalOffset); }, @@ -2051,6 +2072,7 @@ class _VerticalOuterDimension extends Scrollable { super.excludeFromSemantics, super.dragStartBehavior, super.restorationId, + super.hitTestBehavior, this.diagonalDragBehavior = DiagonalDragBehavior.none, }) : assert(axisDirection == AxisDirection.up || axisDirection == AxisDirection.down); @@ -2315,6 +2337,7 @@ class _HorizontalInnerDimension extends Scrollable { super.excludeFromSemantics, super.dragStartBehavior, super.restorationId, + super.hitTestBehavior, this.diagonalDragBehavior = DiagonalDragBehavior.none, }) : assert(axisDirection == AxisDirection.left || axisDirection == AxisDirection.right); diff --git a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart index 14bc311e36a..6c87624ee6f 100644 --- a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart @@ -151,6 +151,7 @@ class SingleChildScrollView extends StatelessWidget { this.child, this.dragStartBehavior = DragStartBehavior.start, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, this.restorationId, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, }) : assert( @@ -218,6 +219,11 @@ class SingleChildScrollView extends StatelessWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; + /// {@macro flutter.widgets.scrollable.hitTestBehavior} + /// + /// Defaults to [HitTestBehavior.opaque]. + final HitTestBehavior hitTestBehavior; + /// {@macro flutter.widgets.scrollable.restorationId} final String? restorationId; @@ -249,6 +255,7 @@ class SingleChildScrollView extends StatelessWidget { physics: physics, restorationId: restorationId, clipBehavior: clipBehavior, + hitTestBehavior: hitTestBehavior, viewportBuilder: (BuildContext context, ViewportOffset offset) { return _SingleChildViewport( axisDirection: axisDirection, diff --git a/packages/flutter/lib/src/widgets/two_dimensional_scroll_view.dart b/packages/flutter/lib/src/widgets/two_dimensional_scroll_view.dart index b5a38bec6c6..e085e493bad 100644 --- a/packages/flutter/lib/src/widgets/two_dimensional_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/two_dimensional_scroll_view.dart @@ -62,6 +62,7 @@ abstract class TwoDimensionalScrollView extends StatelessWidget { this.dragStartBehavior = DragStartBehavior.start, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, this.clipBehavior = Clip.hardEdge, + this.hitTestBehavior = HitTestBehavior.opaque, }); /// A delegate that provides the children for the [TwoDimensionalScrollView]. @@ -107,6 +108,11 @@ abstract class TwoDimensionalScrollView extends StatelessWidget { /// {@macro flutter.widgets.scroll_view.keyboardDismissBehavior} final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + /// {@macro flutter.widgets.scrollable.hitTestBehavior} + /// + /// This value applies to both axes. + final HitTestBehavior hitTestBehavior; + /// {@macro flutter.material.Material.clipBehavior} /// /// Defaults to [Clip.hardEdge]. @@ -172,6 +178,7 @@ abstract class TwoDimensionalScrollView extends StatelessWidget { diagonalDragBehavior: diagonalDragBehavior, viewportBuilder: buildViewport, dragStartBehavior: dragStartBehavior, + hitTestBehavior: hitTestBehavior, ); final Widget scrollableResult = effectivePrimary diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index f6a73381793..d69a7595331 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -109,6 +109,100 @@ void resetScrollOffset(WidgetTester tester) { } void main() { + testWidgets('hitTestBehavior is respected', (WidgetTester tester) async { + HitTestBehavior? getBehavior(Type of) { + final RawGestureDetector widget = tester.widget(find.descendant( + of: find.byType(of), + matching: find.byType(RawGestureDetector), + )); + return widget.behavior; + } + + await tester.pumpWidget( + const MaterialApp( + home: SingleChildScrollView( + hitTestBehavior: HitTestBehavior.translucent, + ), + ), + ); + expect(getBehavior(SingleChildScrollView), HitTestBehavior.translucent); + + await tester.pumpWidget( + const MaterialApp( + home: CustomScrollView( + hitTestBehavior: HitTestBehavior.translucent, + ), + ), + ); + expect(getBehavior(CustomScrollView), HitTestBehavior.translucent); + + await tester.pumpWidget( + MaterialApp( + home: ListView( + hitTestBehavior: HitTestBehavior.translucent, + ), + ), + ); + expect(getBehavior(ListView), HitTestBehavior.translucent); + + await tester.pumpWidget( + MaterialApp( + home: GridView.extent( + maxCrossAxisExtent: 1, + hitTestBehavior: HitTestBehavior.translucent, + ), + ), + ); + expect(getBehavior(GridView), HitTestBehavior.translucent); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + hitTestBehavior: HitTestBehavior.translucent, + ), + ), + ); + expect(getBehavior(PageView), HitTestBehavior.translucent); + + await tester.pumpWidget( + MaterialApp( + home: ListWheelScrollView( + itemExtent: 10, + hitTestBehavior: HitTestBehavior.translucent, + children: const [], + ), + ), + ); + expect(getBehavior(ListWheelScrollView), HitTestBehavior.translucent); + }); + + testWidgets( + 'hitTestBehavior.translucent lets widgets underneath catch the hit', + (WidgetTester tester) async { + final Key key = UniqueKey(); + bool tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => tapped = true, + child: SizedBox(key: key, height: 300), + ), + ), + const SingleChildScrollView( + hitTestBehavior: HitTestBehavior.translucent, + ), + ], + ), + ), + ); + await tester.tapAt(tester.getCenter(find.byKey(key))); + expect(tapped, isTrue); + }); + testWidgets('Flings on different platforms', (WidgetTester tester) async { await pumpTest(tester, TargetPlatform.android); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); diff --git a/packages/flutter/test/widgets/two_dimensional_scroll_view_test.dart b/packages/flutter/test/widgets/two_dimensional_scroll_view_test.dart index 0b29d547f60..40ec6eda400 100644 --- a/packages/flutter/test/widgets/two_dimensional_scroll_view_test.dart +++ b/packages/flutter/test/widgets/two_dimensional_scroll_view_test.dart @@ -353,6 +353,37 @@ void main() { expect(scrollable.widget.dragStartBehavior, DragStartBehavior.down); }, variant: TargetPlatformVariant.all()); + testWidgets('TwoDimensionalScrollable with hitTestBehavior.translucent lets widgets underneath catch the hit', (WidgetTester tester) async { + bool tapped = false; + final Key key = UniqueKey(); + late final TwoDimensionalChildBuilderDelegate delegate; + addTearDown(() => delegate.dispose()); + await tester.pumpWidget(MaterialApp( + home: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => tapped = true, + child: SizedBox(key: key, height: 300), + ), + ), + SimpleBuilderTableView( + hitTestBehavior: HitTestBehavior.translucent, + delegate: delegate = TwoDimensionalChildBuilderDelegate( + builder: (BuildContext context, ChildVicinity vicinity) { + return const SizedBox(width: 50, height: 50); + }, + ), + ), + ], + ), + )); + await tester.pumpAndSettle(); + await tester.tapAt(tester.getCenter(find.byKey(key))); + expect(tapped, isTrue); + }, variant: TargetPlatformVariant.all()); + testWidgets('Interrupt fling with tap stops scrolling', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/133529 final List log = []; diff --git a/packages/flutter/test/widgets/two_dimensional_utils.dart b/packages/flutter/test/widgets/two_dimensional_utils.dart index dd13a277c39..b7950343592 100644 --- a/packages/flutter/test/widgets/two_dimensional_utils.dart +++ b/packages/flutter/test/widgets/two_dimensional_utils.dart @@ -84,6 +84,7 @@ class SimpleBuilderTableView extends TwoDimensionalScrollView { this.applyDimensions = true, this.forgetToLayoutChild = false, this.setLayoutOffset = true, + super.hitTestBehavior, }) : super(delegate: delegate); // Piped through for testing in RenderTwoDimensionalViewport