[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(