diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index dd8a4a07bff6..8fd6849fb0e1 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -819,6 +819,7 @@ class MaterialScrollBehavior extends ScrollBehavior { case AndroidOverscrollIndicator.stretch: return StretchingOverscrollIndicator( axisDirection: details.direction, + clipBehavior: details.clipBehavior, child: child, ); case AndroidOverscrollIndicator.glow: diff --git a/packages/flutter/lib/src/widgets/overscroll_indicator.dart b/packages/flutter/lib/src/widgets/overscroll_indicator.dart index a6f37626a625..1c1297c51332 100644 --- a/packages/flutter/lib/src/widgets/overscroll_indicator.dart +++ b/packages/flutter/lib/src/widgets/overscroll_indicator.dart @@ -653,9 +653,11 @@ class StretchingOverscrollIndicator extends StatefulWidget { super.key, required this.axisDirection, this.notificationPredicate = defaultScrollNotificationPredicate, + this.clipBehavior = Clip.hardEdge, this.child, }) : assert(axisDirection != null), - assert(notificationPredicate != null); + assert(notificationPredicate != null), + assert(clipBehavior != null); /// {@macro flutter.overscroll.axisDirection} final AxisDirection axisDirection; @@ -666,6 +668,11 @@ class StretchingOverscrollIndicator extends StatefulWidget { /// {@macro flutter.overscroll.notificationPredicate} final ScrollNotificationPredicate notificationPredicate; + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + /// The widget below this widget in the tree. /// /// The overscroll indicator will apply a stretch effect to this child. This @@ -806,7 +813,8 @@ class _StretchingOverscrollIndicatorState extends State with TickerProviderStateMixin, R final ScrollableDetails details = ScrollableDetails( direction: widget.axisDirection, controller: _effectiveScrollController, + clipBehavior: widget.clipBehavior, ); result = _configuration.buildScrollbar( @@ -812,7 +822,7 @@ class ScrollableState extends State with TickerProviderStateMixin, R state: this, position: position, registrar: registrar, - child: result + child: result, ); } @@ -1313,6 +1323,7 @@ class ScrollableDetails { const ScrollableDetails({ required this.direction, required this.controller, + required this.clipBehavior, }); /// The direction in which this widget scrolls. @@ -1326,6 +1337,13 @@ class ScrollableDetails { /// This can be used by [ScrollBehavior] to apply a [Scrollbar] to the associated /// [Scrollable]. final ScrollController controller; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// This can be used by [MaterialScrollBehavior] to clip [StretchingOverscrollIndicator]. + /// + /// Cannot be null. + final Clip clipBehavior; } /// With [_ScrollSemantics] certain child [SemanticsNode]s can be diff --git a/packages/flutter/test/material/app_test.dart b/packages/flutter/test/material/app_test.dart index c734de3c64fa..4c36be127fdf 100644 --- a/packages/flutter/test/material/app_test.dart +++ b/packages/flutter/test/material/app_test.dart @@ -11,6 +11,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1265,6 +1266,83 @@ void main() { expect(find.byType(GlowingOverscrollIndicator), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + testWidgets( + 'ListView clip behavior updates overscroll indicator clip behavior', (WidgetTester tester) async { + Widget buildFrame(Clip clipBehavior) { + return MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Column( + children: [ + SizedBox( + height: 300, + child: ListView.builder( + itemCount: 20, + clipBehavior: clipBehavior, + itemBuilder: (BuildContext context, int index){ + return Padding( + padding: const EdgeInsets.all(10.0), + child: Text('Index $index'), + ); + }, + ), + ), + Opacity( + opacity: 0.5, + child: Container( + color: const Color(0xD0FF0000), + height: 100, + ), + ), + ], + ), + ); + } + + // Test default clip behavior. + await tester.pumpWidget(buildFrame(Clip.hardEdge)); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + expect(find.text('Index 1'), findsOneWidget); + + RenderClipRect renderClip = tester.allRenderObjects.whereType().first; + // Currently not clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Index 1'))); + // Overscroll the start. + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(0)); + renderClip = tester.allRenderObjects.whereType().first; + // Now clipping + expect(renderClip.clipBehavior, equals(Clip.hardEdge)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Test custom clip behavior. + await tester.pumpWidget(buildFrame(Clip.none)); + + renderClip = tester.allRenderObjects.whereType().first; + // Currently not clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + gesture = await tester.startGesture(tester.getCenter(find.text('Index 1'))); + // Overscroll the start. + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(0)); + renderClip = tester.allRenderObjects.whereType().first; + // Now clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + await gesture.up(); + await tester.pumpAndSettle(); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + testWidgets('When `useInheritedMediaQuery` is true an existing MediaQuery is used if one is available', (WidgetTester tester) async { late BuildContext capturedContext; final UniqueKey uniqueKey = UniqueKey(); diff --git a/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart b/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart index 341f4924962d..373f29205866 100644 --- a/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart +++ b/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart @@ -454,6 +454,70 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('clipBehavior parameter updates overscroll clipping behavior', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/103491 + + Widget buildFrame(Clip clipBehavior) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(size: Size(800.0, 600.0)), + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(overscroll: false), + child: Column( + children: [ + StretchingOverscrollIndicator( + axisDirection: AxisDirection.down, + clipBehavior: clipBehavior, + child: SizedBox( + height: 300, + child: ListView.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index){ + return Padding( + padding: const EdgeInsets.all(10.0), + child: Text('Index $index'), + ); + }, + ), + ), + ), + Opacity( + opacity: 0.5, + child: Container( + color: const Color(0xD0FF0000), + height: 100, + ), + ), + ], + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(Clip.none)); + + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, 51.0); + RenderClipRect renderClip = tester.allRenderObjects.whereType().first; + // Currently not clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('Index 1'))); + // Overscroll the start. + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(0)); + renderClip = tester.allRenderObjects.whereType().first; + // Now clipping + expect(renderClip.clipBehavior, equals(Clip.none)); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + testWidgets('Stretch limit', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/99264 await tester.pumpWidget(