[Reland]: Fix `StretchingOverscrollIndicator` clipping and add `clipBehavior` parameter (#106287)

diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart
index dd8a4a0..3728eb9 100644
--- a/packages/flutter/lib/src/material/app.dart
+++ b/packages/flutter/lib/src/material/app.dart
@@ -819,6 +819,7 @@
           case AndroidOverscrollIndicator.stretch:
             return StretchingOverscrollIndicator(
               axisDirection: details.direction,
+              clipBehavior: details.clipBehavior ?? Clip.hardEdge,
               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 a6f3762..1c1297c 100644
--- a/packages/flutter/lib/src/widgets/overscroll_indicator.dart
+++ b/packages/flutter/lib/src/widgets/overscroll_indicator.dart
@@ -653,9 +653,11 @@
     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 @@
   /// {@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 @@
           // screen, overflow from transforming the viewport is irrelevant.
           return ClipRect(
             clipBehavior: stretch != 0.0 && viewportDimension != mainAxisSize
-              ? Clip.hardEdge : Clip.none,
+              ? widget.clipBehavior
+              : Clip.none,
             child: transform,
           );
         },
diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart
index c8b37e0..ff60f51 100644
--- a/packages/flutter/lib/src/widgets/scroll_view.dart
+++ b/packages/flutter/lib/src/widgets/scroll_view.dart
@@ -425,6 +425,7 @@
       viewportBuilder: (BuildContext context, ViewportOffset offset) {
         return buildViewport(context, offset, axisDirection, slivers);
       },
+      clipBehavior: clipBehavior,
     );
 
     final Widget scrollableResult = effectivePrimary && scrollController != null
diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart
index 279c5bb..1cc4cf3 100644
--- a/packages/flutter/lib/src/widgets/scrollable.dart
+++ b/packages/flutter/lib/src/widgets/scrollable.dart
@@ -96,6 +96,7 @@
     this.dragStartBehavior = DragStartBehavior.start,
     this.restorationId,
     this.scrollBehavior,
+    this.clipBehavior = Clip.hardEdge,
   }) : assert(axisDirection != null),
        assert(dragStartBehavior != null),
        assert(viewportBuilder != null),
@@ -260,6 +261,15 @@
   /// [ScrollBehavior].
   final ScrollBehavior? scrollBehavior;
 
+  /// {@macro flutter.material.Material.clipBehavior}
+  ///
+  /// Defaults to [Clip.hardEdge].
+  ///
+  /// This is passed to decorators in [ScrollableDetails], and does not directly affect
+  /// clipping of the [Scrollable]. This reflects the same [Clip] that is provided
+  /// to [ScrollView.clipBehavior] and is supplied to the [Viewport].
+  final Clip clipBehavior;
+
   /// The axis along which the scroll view scrolls.
   ///
   /// Determined by the [axisDirection].
@@ -796,6 +806,7 @@
     final ScrollableDetails details = ScrollableDetails(
       direction: widget.axisDirection,
       controller: _effectiveScrollController,
+      clipBehavior: widget.clipBehavior,
     );
 
     result = _configuration.buildScrollbar(
@@ -811,7 +822,7 @@
         state: this,
         position: position,
         registrar: registrar,
-        child: result
+        child: result,
       );
     }
 
@@ -1312,6 +1323,7 @@
   const ScrollableDetails({
     required this.direction,
     required this.controller,
+    this.clipBehavior,
   });
 
   /// The direction in which this widget scrolls.
@@ -1325,6 +1337,13 @@
   /// 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].
+  ///
+  /// Defaults to 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 c734de3..4c36be1 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 @@
     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: <Widget>[
+              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<RenderClipRect>().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<RenderClipRect>().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<RenderClipRect>().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<RenderClipRect>().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 341f492..373f292 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 @@
     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: <Widget>[
+                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<RenderClipRect>().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<RenderClipRect>().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(