diff --git a/packages/flutter/lib/src/material/shaders/stretch_effect.frag b/packages/flutter/lib/src/material/shaders/stretch_effect.frag new file mode 100644 index 00000000000..53be5a6971e --- /dev/null +++ b/packages/flutter/lib/src/material/shaders/stretch_effect.frag @@ -0,0 +1,159 @@ +#version 320 es +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This shader was created based on or with reference to the implementation found at: +// https://cs.android.com/android/platform/superproject/main/+/512046e84bcc51cc241bc6599f83ab345e93ab12:frameworks/base/libs/hwui/effects/StretchEffect.cpp + +#include + +uniform vec2 u_size; +uniform sampler2D u_texture; + +// Multiplier to apply to scale effect. +uniform float u_max_stretch_intensity; + +// Normalized overscroll amount in the horizontal direction. +uniform float u_overscroll_x; + +// Normalized overscroll amount in the vertical direction. +uniform float u_overscroll_y; + +// u_interpolation_strength is the intensity of the interpolation. +uniform float u_interpolation_strength; + +float ease_in(float t, float d) { + return t * d; +} + +float compute_overscroll_start( + float in_pos, + float overscroll, + float u_stretch_affected_dist, + float u_inverse_stretch_affected_dist, + float distance_stretched, + float interpolation_strength +) { + float offset_pos = u_stretch_affected_dist - in_pos; + float pos_based_variation = mix( + 1.0, + ease_in(offset_pos, u_inverse_stretch_affected_dist), + interpolation_strength + ); + float stretch_intensity = overscroll * pos_based_variation; + return distance_stretched - (offset_pos / (1.0 + stretch_intensity)); +} + +float compute_overscroll_end( + float in_pos, + float overscroll, + float reverse_stretch_dist, + float u_stretch_affected_dist, + float u_inverse_stretch_affected_dist, + float distance_stretched, + float interpolation_strength, + float viewport_dimension +) { + float offset_pos = in_pos - reverse_stretch_dist; + float pos_based_variation = mix( + 1.0, + ease_in(offset_pos, u_inverse_stretch_affected_dist), + interpolation_strength + ); + float stretch_intensity = (-overscroll) * pos_based_variation; + return viewport_dimension - (distance_stretched - (offset_pos / (1.0 + stretch_intensity))); +} + +float compute_streched_effect( + float in_pos, + float overscroll, + float u_stretch_affected_dist, + float u_inverse_stretch_affected_dist, + float distance_stretched, + float distance_diff, + float interpolation_strength, + float viewport_dimension +) { + if (overscroll > 0.0) { + if (in_pos <= u_stretch_affected_dist) { + return compute_overscroll_start( + in_pos, overscroll, u_stretch_affected_dist, + u_inverse_stretch_affected_dist, distance_stretched, + interpolation_strength + ); + } else { + return distance_diff + in_pos; + } + } else if (overscroll < 0.0) { + float stretch_affected_dist_calc = viewport_dimension - u_stretch_affected_dist; + if (in_pos >= stretch_affected_dist_calc) { + return compute_overscroll_end( + in_pos, + overscroll, + stretch_affected_dist_calc, + u_stretch_affected_dist, + u_inverse_stretch_affected_dist, + distance_stretched, + interpolation_strength, + viewport_dimension + ); + } else { + return -distance_diff + in_pos; + } + } else { + return in_pos; + } +} + +out vec4 frag_color; + +void main() { + vec2 uv = FlutterFragCoord().xy / u_size; + float in_u_norm = uv.x; + float in_v_norm = uv.y; + + float out_u_norm; + float out_v_norm; + + bool isVertical = u_overscroll_y != 0; + float overscroll = isVertical ? u_overscroll_y : u_overscroll_x; + + float norm_distance_stretched = 1.0 / (1.0 + abs(overscroll)); + float norm_dist_diff = norm_distance_stretched - 1.0; + + const float norm_viewport = 1.0; + const float norm_stretch_affected_dist = 1.0; + const float norm_inverse_stretch_affected_dist = 1.0; + + out_u_norm = isVertical ? in_u_norm : compute_streched_effect( + in_u_norm, + overscroll, + norm_stretch_affected_dist, + norm_inverse_stretch_affected_dist, + norm_distance_stretched, + norm_dist_diff, + u_interpolation_strength, + norm_viewport + ); + + out_v_norm = isVertical ? compute_streched_effect( + in_v_norm, + overscroll, + norm_stretch_affected_dist, + norm_inverse_stretch_affected_dist, + norm_distance_stretched, + norm_dist_diff, + u_interpolation_strength, + norm_viewport + ) : in_v_norm; + + uv.x = out_u_norm; + #ifdef IMPELLER_TARGET_OPENGLES + uv.y = 1.0 - out_v_norm; + #else + uv.y = out_v_norm; + #endif + + frag_color = texture(u_texture, uv); +} diff --git a/packages/flutter/lib/src/widgets/overscroll_indicator.dart b/packages/flutter/lib/src/widgets/overscroll_indicator.dart index 7e1751a5cc2..952214bfd4e 100644 --- a/packages/flutter/lib/src/widgets/overscroll_indicator.dart +++ b/packages/flutter/lib/src/widgets/overscroll_indicator.dart @@ -23,6 +23,7 @@ import 'framework.dart'; import 'media_query.dart'; import 'notification_listener.dart'; import 'scroll_notification.dart'; +import 'stretch_effect.dart'; import 'ticker_provider.dart'; import 'transitions.dart'; @@ -774,20 +775,6 @@ class _StretchingOverscrollIndicatorState extends State widget.axisDirection, - _StretchDirection.leading => flipAxisDirection(widget.axisDirection), - }; - return switch (direction) { - AxisDirection.up => AlignmentDirectional.topCenter, - AxisDirection.down => AlignmentDirectional.bottomCenter, - AxisDirection.left => Alignment.centerLeft, - AxisDirection.right => Alignment.centerRight, - }; - } - @override void dispose() { _stretchController.dispose(); @@ -802,30 +789,34 @@ class _StretchingOverscrollIndicatorState extends State= -1.0 && stretchStrength <= 1.0, + 'stretchStrength must be between -1.0 and 1.0', + ); + + /// The overscroll strength applied for the stretching effect. + /// + /// The value should be between -1.0 and 1.0 inclusive. + /// + /// For the horizontal axis: + /// - Positive values apply a pull/stretch from left to right, + /// where 1.0 represents the maximum stretch to the right. + /// - Negative values apply a pull/stretch from right to left, + /// where -1.0 represents the maximum stretch to the left. + /// + /// For the vertical axis: + /// - Positive values apply a pull/stretch from top to bottom, + /// where 1.0 represents the maximum stretch downward. + /// - Negative values apply a pull/stretch from bottom to top, + /// where -1.0 represents the maximum stretch upward. + /// + /// {@tool snippet} + /// This example shows how to set the horizontal stretch strength to pull right. + /// + /// ```dart + /// const StretchEffect( + /// stretchStrength: 0.5, + /// axis: Axis.horizontal, + /// child: Text('Hello, World!'), + /// ); + /// ``` + /// {@end-tool} + final double stretchStrength; + + /// The axis along which the stretching overscroll effect is applied. + /// + /// Determines the direction of the stretch, either horizontal or vertical. + final Axis axis; + + /// The child widget that the stretching overscroll effect applies to. + final Widget child; + + AlignmentGeometry _getAlignment(TextDirection direction) { + final bool isForward = stretchStrength > 0; + + if (axis == Axis.vertical) { + return isForward ? AlignmentDirectional.topCenter : AlignmentDirectional.bottomCenter; + } + + // RTL horizontal. + if (direction == TextDirection.rtl) { + return isForward ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart; + } else { + return isForward ? AlignmentDirectional.centerStart : AlignmentDirectional.centerEnd; + } + } + + @override + Widget build(BuildContext context) { + if (ui.ImageFilter.isShaderFilterSupported) { + return _StretchOverscrollEffect(stretchStrength: stretchStrength, axis: axis, child: child); + } + + final TextDirection textDirection = Directionality.of(context); + double x = 1.0; + double y = 1.0; + + switch (axis) { + case Axis.horizontal: + x += stretchStrength.abs(); + case Axis.vertical: + y += stretchStrength.abs(); + } + + return Transform( + alignment: _getAlignment(textDirection), + transform: Matrix4.diagonal3Values(x, y, 1.0), + filterQuality: stretchStrength == 0 ? null : FilterQuality.medium, + child: child, + ); + } +} + +/// A widget that replicates the native Android stretch overscroll effect. +/// +/// This widget is used in the [StretchEffect] widget and creates +/// a stretch visual feedback when the user overscrolls at the edges. +/// +/// Only supported when using the Impeller rendering engine. +class _StretchOverscrollEffect extends StatefulWidget { + /// Creates a [_StretchOverscrollEffect] widget that applies a stretch + /// effect when the user overscrolls horizontally or vertically. + const _StretchOverscrollEffect({ + this.stretchStrength = 0.0, + required this.axis, + required this.child, + }) : assert( + stretchStrength >= -1.0 && stretchStrength <= 1.0, + 'stretchStrength must be between -1.0 and 1.0', + ); + + /// The overscroll strength applied for the stretching effect. + /// + /// The value should be between -1.0 and 1.0 inclusive. + /// For horizontal axis, Positive values apply a pull from + /// left to right, while negative values pull from right to left. + final double stretchStrength; + + /// The axis along which the stretching overscroll effect is applied. + /// + /// Determines the direction of the stretch, either horizontal or vertical. + final Axis axis; + + /// The child widget that the stretching overscroll effect applies to. + final Widget child; + + @override + State<_StretchOverscrollEffect> createState() => _StretchOverscrollEffectState(); +} + +class _StretchOverscrollEffectState extends State<_StretchOverscrollEffect> { + ui.FragmentShader? _fragmentShader; + + /// The maximum scale multiplier applied during a stretch effect. + static const double maxStretchIntensity = 1.0; + + /// The strength of the interpolation used for smoothing the effect. + static const double interpolationStrength = 0.7; + + /// A no-op [ui.ImageFilter] that uses the identity matrix. + static final ui.ImageFilter _emptyFilter = ui.ImageFilter.matrix(Matrix4.identity().storage); + + @override + void dispose() { + _fragmentShader?.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _StretchEffectShader.initializeShader(); + } + + @override + Widget build(BuildContext context) { + final bool isShaderNeeded = widget.stretchStrength.abs() > precisionErrorTolerance; + + final ui.ImageFilter imageFilter; + + if (_StretchEffectShader._initialized) { + _fragmentShader?.dispose(); + _fragmentShader = _StretchEffectShader._program!.fragmentShader(); + _fragmentShader!.setFloat(2, maxStretchIntensity); + if (widget.axis == Axis.vertical) { + _fragmentShader!.setFloat(3, 0.0); + _fragmentShader!.setFloat(4, widget.stretchStrength); + } else { + _fragmentShader!.setFloat(3, widget.stretchStrength); + _fragmentShader!.setFloat(4, 0.0); + } + _fragmentShader!.setFloat(5, interpolationStrength); + + imageFilter = ui.ImageFilter.shader(_fragmentShader!); + } else { + _fragmentShader?.dispose(); + _fragmentShader = null; + + imageFilter = _emptyFilter; + } + + return ImageFiltered( + imageFilter: imageFilter, + enabled: isShaderNeeded, + // A nearly-transparent pixels is used to ensure the shader gets applied, + // even when the child is visually transparent or has no paint operations. + child: CustomPaint( + painter: isShaderNeeded ? _StretchEffectPainter() : null, + child: widget.child, + ), + ); + } +} + +/// A [CustomPainter] that draws nearly transparent pixels at the four corners. +/// +/// This ensures the fragment shader covers the entire canvas by forcing +/// painting operations on all edges, preventing shader optimization skips. +class _StretchEffectPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final Paint paint = Paint() + ..color = const Color.fromARGB(1, 0, 0, 0) + ..style = PaintingStyle.fill; + + canvas.drawPoints(ui.PointMode.points, [ + Offset.zero, + Offset(size.width - 1, 0), + Offset(0, size.height - 1), + Offset(size.width - 1, size.height - 1), + ], paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _StretchEffectShader { + static bool _initCalled = false; + static bool _initialized = false; + static ui.FragmentProgram? _program; + + static void initializeShader() { + if (!_initCalled) { + ui.FragmentProgram.fromAsset('shaders/stretch_effect.frag').then(( + ui.FragmentProgram program, + ) { + _program = program; + _initialized = true; + }); + _initCalled = true; + } + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index bcb359732a1..81486a12f6c 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -154,6 +154,7 @@ export 'src/widgets/spacer.dart'; export 'src/widgets/spell_check.dart'; export 'src/widgets/standard_component_type.dart'; export 'src/widgets/status_transitions.dart'; +export 'src/widgets/stretch_effect.dart'; export 'src/widgets/system_context_menu.dart'; export 'src/widgets/table.dart'; export 'src/widgets/tap_region.dart'; diff --git a/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart b/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart index eec79a4e941..5a4e6854440 100644 --- a/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart +++ b/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart @@ -7,11 +7,18 @@ @Tags(['reduced-test-set']) library; +import 'dart:ui' as ui; + import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + // `StretchingOverscrollIndicator` uses a different algorithm when + // shader is available, therefore the tests must be different depending + // on shader support. + final bool shaderSupported = ui.ImageFilter.isShaderFilterSupported; + Widget buildTest( GlobalKey box1Key, GlobalKey box2Key, @@ -146,373 +153,401 @@ void main() { expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); }); - testWidgets('Stretch overscroll vertically', (WidgetTester tester) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll vertically', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller)); + await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller)); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.vertical.start.png'), - ); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.vertical.start.png'), + ); - TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll the start - await gesture.moveBy(const Offset(0.0, 200.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero).dy, greaterThan(255.0)); - expect(box3.localToGlobal(Offset.zero).dy, greaterThan(510.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.vertical.start.stretched.png'), - ); + TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll the start + await gesture.moveBy(const Offset(0.0, 200.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dy, greaterThan(255.0)); + expect(box3.localToGlobal(Offset.zero).dy, greaterThan(510.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.vertical.start.stretched.png'), + ); - await gesture.up(); - await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); - // Stretch released back to the start - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); + // Stretch released back to the start + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); - // Jump to end of the list - controller.jumpTo(controller.position.maxScrollExtent); - await tester.pumpAndSettle(); - expect(controller.offset, 150.0); - expect(box1.localToGlobal(Offset.zero).dy, -150.0); - expect(box2.localToGlobal(Offset.zero).dy, 100.0); - expect(box3.localToGlobal(Offset.zero).dy, 350.0); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.vertical.end.png'), - ); + // Jump to end of the list + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(controller.offset, 150.0); + expect(box1.localToGlobal(Offset.zero).dy, -150.0); + expect(box2.localToGlobal(Offset.zero).dy, 100.0); + expect(box3.localToGlobal(Offset.zero).dy, 350.0); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.vertical.end.png'), + ); - gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); - // Overscroll the end - await gesture.moveBy(const Offset(0.0, -200.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dy, lessThan(-165)); - expect(box2.localToGlobal(Offset.zero).dy, lessThan(90.0)); - expect(box3.localToGlobal(Offset.zero).dy, lessThan(350.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.vertical.end.stretched.png'), - ); + gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); + // Overscroll the end + await gesture.moveBy(const Offset(0.0, -200.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-165)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(90.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(350.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.vertical.end.stretched.png'), + ); - await gesture.up(); - await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); - // Stretch released back - expect(box1.localToGlobal(Offset.zero).dy, -150.0); - expect(box2.localToGlobal(Offset.zero).dy, 100.0); - expect(box3.localToGlobal(Offset.zero).dy, 350.0); - }); + // Stretch released back + expect(box1.localToGlobal(Offset.zero).dy, -150.0); + expect(box2.localToGlobal(Offset.zero).dy, 100.0); + expect(box3.localToGlobal(Offset.zero).dy, 350.0); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Stretch overscroll works in reverse - vertical', (WidgetTester tester) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll works in reverse - vertical', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller, reverse: true)); + await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller, reverse: true)); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), const Offset(0.0, 350.0)); - expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(0.0, -150.0)); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), const Offset(0.0, 350.0)); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, -150.0)); - final TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll - await gesture.moveBy(const Offset(0.0, -200.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dy, lessThan(350.0)); - expect(box2.localToGlobal(Offset.zero).dy, lessThan(100.0)); - expect(box3.localToGlobal(Offset.zero).dy, lessThan(-150.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.vertical.reverse.png'), - ); - }); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll + await gesture.moveBy(const Offset(0.0, -200.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero).dy, lessThan(350.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(100.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(-150.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.vertical.reverse.png'), + ); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Stretch overscroll works in reverse - horizontal', (WidgetTester tester) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll works in reverse - horizontal', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget( - buildTest(box1Key, box2Key, box3Key, controller, axis: Axis.horizontal, reverse: true), - ); + await tester.pumpWidget( + buildTest(box1Key, box2Key, box3Key, controller, axis: Axis.horizontal, reverse: true), + ); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), const Offset(500.0, 0.0)); - expect(box2.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(-100.0, 0.0)); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), const Offset(500.0, 0.0)); + expect(box2.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(-100.0, 0.0)); - final TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll - await gesture.moveBy(const Offset(-200.0, 0.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dx, lessThan(500.0)); - expect(box2.localToGlobal(Offset.zero).dx, lessThan(200.0)); - expect(box3.localToGlobal(Offset.zero).dx, lessThan(-100.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.reverse.png'), - ); - }); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll + await gesture.moveBy(const Offset(-200.0, 0.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero).dx, lessThan(500.0)); + expect(box2.localToGlobal(Offset.zero).dx, lessThan(200.0)); + expect(box3.localToGlobal(Offset.zero).dx, lessThan(-100.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.reverse.png'), + ); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Stretch overscroll works in reverse - horizontal - RTL', ( - WidgetTester tester, - ) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll works in reverse - horizontal - RTL', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget( - buildTest( - box1Key, - box2Key, - box3Key, - controller, - axis: Axis.horizontal, - reverse: true, - textDirection: TextDirection.rtl, - ), - ); + await tester.pumpWidget( + buildTest( + box1Key, + box2Key, + box3Key, + controller, + axis: Axis.horizontal, + reverse: true, + textDirection: TextDirection.rtl, + ), + ); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.start.png'), - ); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.start.png'), + ); - TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll the start - await gesture.moveBy(const Offset(200.0, 0.0)); - await tester.pumpAndSettle(); + TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll the start + await gesture.moveBy(const Offset(200.0, 0.0)); + await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero).dx, greaterThan(305.0)); - expect(box3.localToGlobal(Offset.zero).dx, greaterThan(610.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.start.stretched.png'), - ); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dx, greaterThan(305.0)); + expect(box3.localToGlobal(Offset.zero).dx, greaterThan(610.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.start.stretched.png'), + ); - await gesture.up(); - await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); - // Stretch released back to the start - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); + // Stretch released back to the start + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); - // Jump to end of the list - controller.jumpTo(controller.position.maxScrollExtent); - await tester.pumpAndSettle(); - expect(controller.offset, 100.0); - expect(box1.localToGlobal(Offset.zero).dx, -100.0); - expect(box2.localToGlobal(Offset.zero).dx, 200.0); - expect(box3.localToGlobal(Offset.zero).dx, 500.0); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.end.png'), - ); + // Jump to end of the list + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(controller.offset, 100.0); + expect(box1.localToGlobal(Offset.zero).dx, -100.0); + expect(box2.localToGlobal(Offset.zero).dx, 200.0); + expect(box3.localToGlobal(Offset.zero).dx, 500.0); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.end.png'), + ); - gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); - // Overscroll the end - await gesture.moveBy(const Offset(-200.0, 0.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dx, lessThan(-116.0)); - expect(box2.localToGlobal(Offset.zero).dx, lessThan(190.0)); - expect(box3.localToGlobal(Offset.zero).dx, lessThan(500.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.end.stretched.png'), - ); + gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); + // Overscroll the end + await gesture.moveBy(const Offset(-200.0, 0.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero).dx, lessThan(-116.0)); + expect(box2.localToGlobal(Offset.zero).dx, lessThan(190.0)); + expect(box3.localToGlobal(Offset.zero).dx, lessThan(500.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.reverse.rtl.end.stretched.png'), + ); - await gesture.up(); - await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); - // Stretch released back - expect(box1.localToGlobal(Offset.zero).dx, -100.0); - expect(box2.localToGlobal(Offset.zero).dx, 200.0); - expect(box3.localToGlobal(Offset.zero).dx, 500.0); - }); + // Stretch released back + expect(box1.localToGlobal(Offset.zero).dx, -100.0); + expect(box2.localToGlobal(Offset.zero).dx, 200.0); + expect(box3.localToGlobal(Offset.zero).dx, 500.0); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Stretch overscroll horizontally', (WidgetTester tester) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll horizontally', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget( - buildTest(box1Key, box2Key, box3Key, controller, axis: Axis.horizontal), - ); + await tester.pumpWidget( + buildTest(box1Key, box2Key, box3Key, controller, axis: Axis.horizontal), + ); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.start.png'), - ); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.start.png'), + ); - TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll the start - await gesture.moveBy(const Offset(200.0, 0.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero).dx, greaterThan(305.0)); - expect(box3.localToGlobal(Offset.zero).dx, greaterThan(610.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.start.stretched.png'), - ); + TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll the start + await gesture.moveBy(const Offset(200.0, 0.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dx, greaterThan(305.0)); + expect(box3.localToGlobal(Offset.zero).dx, greaterThan(610.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.start.stretched.png'), + ); - await gesture.up(); - await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); - // Stretch released back to the start - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); + // Stretch released back to the start + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(300.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(600.0, 0.0)); - // Jump to end of the list - controller.jumpTo(controller.position.maxScrollExtent); - await tester.pumpAndSettle(); - expect(controller.offset, 100.0); - expect(box1.localToGlobal(Offset.zero).dx, -100.0); - expect(box2.localToGlobal(Offset.zero).dx, 200.0); - expect(box3.localToGlobal(Offset.zero).dx, 500.0); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.end.png'), - ); + // Jump to end of the list + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(controller.offset, 100.0); + expect(box1.localToGlobal(Offset.zero).dx, -100.0); + expect(box2.localToGlobal(Offset.zero).dx, 200.0); + expect(box3.localToGlobal(Offset.zero).dx, 500.0); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.end.png'), + ); - gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); - // Overscroll the end - await gesture.moveBy(const Offset(-200.0, 0.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dx, lessThan(-116.0)); - expect(box2.localToGlobal(Offset.zero).dx, lessThan(190.0)); - expect(box3.localToGlobal(Offset.zero).dx, lessThan(500.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.end.stretched.png'), - ); + gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); + // Overscroll the end + await gesture.moveBy(const Offset(-200.0, 0.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero).dx, lessThan(-116.0)); + expect(box2.localToGlobal(Offset.zero).dx, lessThan(190.0)); + expect(box3.localToGlobal(Offset.zero).dx, lessThan(500.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.end.stretched.png'), + ); - await gesture.up(); - await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); - // Stretch released back - expect(box1.localToGlobal(Offset.zero).dx, -100.0); - expect(box2.localToGlobal(Offset.zero).dx, 200.0); - expect(box3.localToGlobal(Offset.zero).dx, 500.0); - }); + // Stretch released back + expect(box1.localToGlobal(Offset.zero).dx, -100.0); + expect(box2.localToGlobal(Offset.zero).dx, 200.0); + expect(box3.localToGlobal(Offset.zero).dx, 500.0); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Stretch overscroll horizontally RTL', (WidgetTester tester) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll horizontally RTL', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget( - buildTest( - box1Key, - box2Key, - box3Key, - controller, - axis: Axis.horizontal, - textDirection: TextDirection.rtl, - ), - ); + await tester.pumpWidget( + buildTest( + box1Key, + box2Key, + box3Key, + controller, + axis: Axis.horizontal, + textDirection: TextDirection.rtl, + ), + ); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), const Offset(500.0, 0.0)); - expect(box2.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(-100.0, 0.0)); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), const Offset(500.0, 0.0)); + expect(box2.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(-100.0, 0.0)); - final TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll - await gesture.moveBy(const Offset(-200.0, 0.0)); - await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dx, lessThan(500.0)); - expect(box2.localToGlobal(Offset.zero).dx, lessThan(200.0)); - expect(box3.localToGlobal(Offset.zero).dx, lessThan(-100.0)); - await expectLater( - find.byType(CustomScrollView), - matchesGoldenFile('overscroll_stretch.horizontal.rtl.png'), - ); - }); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll + await gesture.moveBy(const Offset(-200.0, 0.0)); + await tester.pumpAndSettle(); + expect(box1.localToGlobal(Offset.zero).dx, lessThan(500.0)); + expect(box2.localToGlobal(Offset.zero).dx, lessThan(200.0)); + expect(box3.localToGlobal(Offset.zero).dx, lessThan(-100.0)); + await expectLater( + find.byType(CustomScrollView), + matchesGoldenFile('overscroll_stretch.horizontal.rtl.png'), + ); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); testWidgets('Disallow stretching overscroll', (WidgetTester tester) async { final GlobalKey box1Key = GlobalKey(); @@ -733,373 +768,403 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('Stretch limit', (WidgetTester tester) async { - // Regression test for https://github.com/flutter/flutter/issues/99264 - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(), - child: ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(overscroll: false), - child: StretchingOverscrollIndicator( - axisDirection: AxisDirection.down, - 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'), - ); - }, + testWidgets( + 'Stretch limit', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/99264 + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(overscroll: false), + child: StretchingOverscrollIndicator( + axisDirection: AxisDirection.down, + 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'), + ); + }, + ), ), ), ), ), ), - ), - ); - const double maxStretchLocation = 52.63178407049861; + ); + const double maxStretchLocation = 52.63178407049861; - expect(find.text('Index 1'), findsOneWidget); - expect(tester.getCenter(find.text('Index 1')).dy, 51.0); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, 51.0); - TestGesture pointer = await tester.startGesture(tester.getCenter(find.text('Index 1'))); - // Overscroll beyond the limit (the viewport is 600.0). - await pointer.moveBy(const Offset(0.0, 610.0)); - await tester.pumpAndSettle(); - expect(find.text('Index 1'), findsOneWidget); - expect(tester.getCenter(find.text('Index 1')).dy, maxStretchLocation); + TestGesture pointer = await tester.startGesture(tester.getCenter(find.text('Index 1'))); + // Overscroll beyond the limit (the viewport is 600.0). + await pointer.moveBy(const Offset(0.0, 610.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, maxStretchLocation); - pointer = await tester.startGesture(tester.getCenter(find.text('Index 1'))); - // Overscroll way way beyond the limit - await pointer.moveBy(const Offset(0.0, 1000.0)); - await tester.pumpAndSettle(); - expect(find.text('Index 1'), findsOneWidget); - expect(tester.getCenter(find.text('Index 1')).dy, maxStretchLocation); + pointer = await tester.startGesture(tester.getCenter(find.text('Index 1'))); + // Overscroll way way beyond the limit + await pointer.moveBy(const Offset(0.0, 1000.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, maxStretchLocation); - await pointer.up(); - await tester.pumpAndSettle(); - }); + await pointer.up(); + await tester.pumpAndSettle(); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Multiple pointers will not exceed stretch limit', (WidgetTester tester) async { - // Regression test for https://github.com/flutter/flutter/issues/99264 - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(), - child: ScrollConfiguration( - behavior: const ScrollBehavior().copyWith(overscroll: false), - child: StretchingOverscrollIndicator( - axisDirection: AxisDirection.down, - 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'), - ); - }, + testWidgets( + 'Multiple pointers will not exceed stretch limit', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/99264 + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(overscroll: false), + child: StretchingOverscrollIndicator( + axisDirection: AxisDirection.down, + 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'), + ); + }, + ), ), ), ), ), ), - ), - ); - expect(find.text('Index 1'), findsOneWidget); - expect(tester.getCenter(find.text('Index 1')).dy, 51.0); + ); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, 51.0); - final TestGesture pointer1 = await tester.startGesture(tester.getCenter(find.text('Index 1'))); - // Overscroll the start. - await pointer1.moveBy(const Offset(0.0, 210.0)); - await tester.pumpAndSettle(); - expect(find.text('Index 1'), findsOneWidget); - double lastStretchedLocation = tester.getCenter(find.text('Index 1')).dy; - expect(lastStretchedLocation, greaterThan(51.0)); + final TestGesture pointer1 = await tester.startGesture( + tester.getCenter(find.text('Index 1')), + ); + // Overscroll the start. + await pointer1.moveBy(const Offset(0.0, 210.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + double lastStretchedLocation = tester.getCenter(find.text('Index 1')).dy; + expect(lastStretchedLocation, greaterThan(51.0)); - final TestGesture pointer2 = await tester.startGesture(tester.getCenter(find.text('Index 1'))); - // Add overscroll from an additional pointer - await pointer2.moveBy(const Offset(0.0, 210.0)); - await tester.pumpAndSettle(); - expect(find.text('Index 1'), findsOneWidget); - expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(lastStretchedLocation)); - lastStretchedLocation = tester.getCenter(find.text('Index 1')).dy; + final TestGesture pointer2 = await tester.startGesture( + tester.getCenter(find.text('Index 1')), + ); + // Add overscroll from an additional pointer + await pointer2.moveBy(const Offset(0.0, 210.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(lastStretchedLocation)); + lastStretchedLocation = tester.getCenter(find.text('Index 1')).dy; - final TestGesture pointer3 = await tester.startGesture(tester.getCenter(find.text('Index 1'))); - // Add overscroll from an additional pointer, exceeding the max stretch (600) - await pointer3.moveBy(const Offset(0.0, 210.0)); - await tester.pumpAndSettle(); - expect(find.text('Index 1'), findsOneWidget); - expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(lastStretchedLocation)); - lastStretchedLocation = tester.getCenter(find.text('Index 1')).dy; + final TestGesture pointer3 = await tester.startGesture( + tester.getCenter(find.text('Index 1')), + ); + // Add overscroll from an additional pointer, exceeding the max stretch (600) + await pointer3.moveBy(const Offset(0.0, 210.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, greaterThan(lastStretchedLocation)); + lastStretchedLocation = tester.getCenter(find.text('Index 1')).dy; - final TestGesture pointer4 = await tester.startGesture(tester.getCenter(find.text('Index 1'))); - // Since we have maxed out the overscroll, it should not have stretched - // further, regardless of the number of pointers. - await pointer4.moveBy(const Offset(0.0, 210.0)); - await tester.pumpAndSettle(); - expect(find.text('Index 1'), findsOneWidget); - expect(tester.getCenter(find.text('Index 1')).dy, lastStretchedLocation); + final TestGesture pointer4 = await tester.startGesture( + tester.getCenter(find.text('Index 1')), + ); + // Since we have maxed out the overscroll, it should not have stretched + // further, regardless of the number of pointers. + await pointer4.moveBy(const Offset(0.0, 210.0)); + await tester.pumpAndSettle(); + expect(find.text('Index 1'), findsOneWidget); + expect(tester.getCenter(find.text('Index 1')).dy, lastStretchedLocation); - await pointer1.up(); - await pointer2.up(); - await pointer3.up(); - await pointer4.up(); - await tester.pumpAndSettle(); - }); + await pointer1.up(); + await pointer2.up(); + await pointer3.up(); + await pointer4.up(); + await tester.pumpAndSettle(); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Stretch overscroll vertically, change direction mid scroll', ( - WidgetTester tester, - ) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll vertically, change direction mid scroll', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget( - buildTest( - box1Key, - box2Key, - box3Key, - controller, - // Setting the `boxHeight` to 100.0 will make the boxes fit in the - // scrollable viewport. - boxHeight: 100, - // To make the scroll view in the test still scrollable, we need to add - // the `AlwaysScrollableScrollPhysics`. - physics: const AlwaysScrollableScrollPhysics(), - ), - ); + await tester.pumpWidget( + buildTest( + box1Key, + box2Key, + box3Key, + controller, + // Setting the `boxHeight` to 100.0 will make the boxes fit in the + // scrollable viewport. + boxHeight: 100, + // To make the scroll view in the test still scrollable, we need to add + // the `AlwaysScrollableScrollPhysics`. + physics: const AlwaysScrollableScrollPhysics(), + ), + ); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 200.0)); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 200.0)); - final TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll the start - await gesture.moveBy(const Offset(0.0, 600.0)); - await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll the start + await gesture.moveBy(const Offset(0.0, 600.0)); + await tester.pumpAndSettle(); - // The boxes should now be at different locations because of the scaling. - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero).dy, greaterThan(103.0)); - expect(box3.localToGlobal(Offset.zero).dy, greaterThan(206.0)); + // The boxes should now be at different locations because of the scaling. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dy, greaterThan(103.0)); + expect(box3.localToGlobal(Offset.zero).dy, greaterThan(206.0)); - // Move the pointer up a miniscule amount to trigger a directional change. - await gesture.moveBy(const Offset(0.0, -20.0)); - await tester.pumpAndSettle(); + // Move the pointer up a miniscule amount to trigger a directional change. + await gesture.moveBy(const Offset(0.0, -20.0)); + await tester.pumpAndSettle(); - // The boxes should remain roughly at the same locations, since the pointer - // didn't move far. - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero).dy, greaterThan(103.0)); - expect(box3.localToGlobal(Offset.zero).dy, greaterThan(206.0)); + // The boxes should remain roughly at the same locations, since the pointer + // didn't move far. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dy, greaterThan(103.0)); + expect(box3.localToGlobal(Offset.zero).dy, greaterThan(206.0)); - // Now make the pointer overscroll to the end - await gesture.moveBy(const Offset(0.0, -1200.0)); - await tester.pumpAndSettle(); + // Now make the pointer overscroll to the end + await gesture.moveBy(const Offset(0.0, -1200.0)); + await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dy, lessThan(-19.0)); - expect(box2.localToGlobal(Offset.zero).dy, lessThan(85.0)); - expect(box3.localToGlobal(Offset.zero).dy, lessThan(188.0)); + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-19.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(85.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(188.0)); - // Release the pointer - await gesture.up(); - await tester.pumpAndSettle(); + // Release the pointer + await gesture.up(); + await tester.pumpAndSettle(); - // Now the boxes should be back to their original locations. - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 200.0)); - }); + // Now the boxes should be back to their original locations. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 200.0)); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Stretch overscroll horizontally, change direction mid scroll', ( - WidgetTester tester, - ) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Stretch overscroll horizontally, change direction mid scroll', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget( - buildTest( - box1Key, - box2Key, - box3Key, - controller, - // Setting the `boxWidth` to 100.0 will make the boxes fit in the - // scrollable viewport. - boxWidth: 100, - // To make the scroll view in the test still scrollable, we need to add - // the `AlwaysScrollableScrollPhysics`. - physics: const AlwaysScrollableScrollPhysics(), - axis: Axis.horizontal, - ), - ); + await tester.pumpWidget( + buildTest( + box1Key, + box2Key, + box3Key, + controller, + // Setting the `boxWidth` to 100.0 will make the boxes fit in the + // scrollable viewport. + boxWidth: 100, + // To make the scroll view in the test still scrollable, we need to add + // the `AlwaysScrollableScrollPhysics`. + physics: const AlwaysScrollableScrollPhysics(), + axis: Axis.horizontal, + ), + ); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(100.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(100.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); - final TestGesture gesture = await tester.startGesture( - tester.getCenter(find.byType(CustomScrollView)), - ); - // Overscroll the start - await gesture.moveBy(const Offset(600.0, 0.0)); - await tester.pumpAndSettle(); + final TestGesture gesture = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + // Overscroll the start + await gesture.moveBy(const Offset(600.0, 0.0)); + await tester.pumpAndSettle(); - // The boxes should now be at different locations because of the scaling. - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero).dx, greaterThan(102.0)); - expect(box3.localToGlobal(Offset.zero).dx, greaterThan(205.0)); + // The boxes should now be at different locations because of the scaling. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dx, greaterThan(102.0)); + expect(box3.localToGlobal(Offset.zero).dx, greaterThan(205.0)); - // Move the pointer up a miniscule amount to trigger a directional change. - await gesture.moveBy(const Offset(-20.0, 0.0)); - await tester.pumpAndSettle(); + // Move the pointer up a miniscule amount to trigger a directional change. + await gesture.moveBy(const Offset(-20.0, 0.0)); + await tester.pumpAndSettle(); - // The boxes should remain roughly at the same locations, since the pointer - // didn't move far. - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero).dx, greaterThan(102.0)); - expect(box3.localToGlobal(Offset.zero).dx, greaterThan(205.0)); + // The boxes should remain roughly at the same locations, since the pointer + // didn't move far. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dx, greaterThan(102.0)); + expect(box3.localToGlobal(Offset.zero).dx, greaterThan(205.0)); - // Now make the pointer overscroll to the end - await gesture.moveBy(const Offset(-1200.0, 0.0)); - await tester.pumpAndSettle(); + // Now make the pointer overscroll to the end + await gesture.moveBy(const Offset(-1200.0, 0.0)); + await tester.pumpAndSettle(); - expect(box1.localToGlobal(Offset.zero).dx, lessThan(-19.0)); - expect(box2.localToGlobal(Offset.zero).dx, lessThan(85.0)); - expect(box3.localToGlobal(Offset.zero).dx, lessThan(188.0)); + expect(box1.localToGlobal(Offset.zero).dx, lessThan(-19.0)); + expect(box2.localToGlobal(Offset.zero).dx, lessThan(85.0)); + expect(box3.localToGlobal(Offset.zero).dx, lessThan(188.0)); - // Release the pointer - await gesture.up(); - await tester.pumpAndSettle(); + // Release the pointer + await gesture.up(); + await tester.pumpAndSettle(); - // Now the boxes should be back to their original locations. - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(100.0, 0.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); - }); + // Now the boxes should be back to their original locations. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(100.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Fling toward the trailing edge causes stretch toward the leading edge', ( - WidgetTester tester, - ) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Fling toward the trailing edge causes stretch toward the leading edge', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller)); + await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller)); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); - await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); - // The boxes should now be at different locations because of the scaling. - expect(controller.offset, 150.0); - expect(box1.localToGlobal(Offset.zero).dy, lessThan(-160.0)); - expect(box2.localToGlobal(Offset.zero).dy, lessThan(93.0)); - expect(box3.localToGlobal(Offset.zero).dy, lessThan(347.0)); + // The boxes should now be at different locations because of the scaling. + expect(controller.offset, 150.0); + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-160.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(93.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(347.0)); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); - // The boxes should now be at their final position. - expect(controller.offset, 150.0); - expect(box1.localToGlobal(Offset.zero).dy, -150.0); - expect(box2.localToGlobal(Offset.zero).dy, 100.0); - expect(box3.localToGlobal(Offset.zero).dy, 350.0); - }); + // The boxes should now be at their final position. + expect(controller.offset, 150.0); + expect(box1.localToGlobal(Offset.zero).dy, -150.0); + expect(box2.localToGlobal(Offset.zero).dy, 100.0); + expect(box3.localToGlobal(Offset.zero).dy, 350.0); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); - testWidgets('Fling toward the leading edge causes stretch toward the trailing edge', ( - WidgetTester tester, - ) async { - final GlobalKey box1Key = GlobalKey(); - final GlobalKey box2Key = GlobalKey(); - final GlobalKey box3Key = GlobalKey(); - final ScrollController controller = ScrollController(); - addTearDown(controller.dispose); + testWidgets( + 'Fling toward the leading edge causes stretch toward the trailing edge', + (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); - await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller)); + await tester.pumpWidget(buildTest(box1Key, box2Key, box3Key, controller)); - expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); - expect(find.byType(GlowingOverscrollIndicator), findsNothing); - final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); - final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); - final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero), Offset.zero); - expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); - expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); - // We fling to the trailing edge and let it settle. - await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); - await tester.pumpAndSettle(); + // We fling to the trailing edge and let it settle. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); + await tester.pumpAndSettle(); - // We are now at the trailing edge - expect(controller.offset, 150.0); - expect(box1.localToGlobal(Offset.zero).dy, -150.0); - expect(box2.localToGlobal(Offset.zero).dy, 100.0); - expect(box3.localToGlobal(Offset.zero).dy, 350.0); + // We are now at the trailing edge + expect(controller.offset, 150.0); + expect(box1.localToGlobal(Offset.zero).dy, -150.0); + expect(box2.localToGlobal(Offset.zero).dy, 100.0); + expect(box3.localToGlobal(Offset.zero).dy, 350.0); - // Now fling to the leading edge - await tester.fling(find.byType(CustomScrollView), const Offset(0.0, 50.0), 10000.0); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 100)); + // Now fling to the leading edge + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, 50.0), 10000.0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); - // The boxes should now be at different locations because of the scaling. - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero).dy, 0.0); - expect(box2.localToGlobal(Offset.zero).dy, greaterThan(254.0)); - expect(box3.localToGlobal(Offset.zero).dy, greaterThan(508.0)); + // The boxes should now be at different locations because of the scaling. + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero).dy, 0.0); + expect(box2.localToGlobal(Offset.zero).dy, greaterThan(254.0)); + expect(box3.localToGlobal(Offset.zero).dy, greaterThan(508.0)); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); - // The boxes should now be at their final position. - expect(controller.offset, 0.0); - expect(box1.localToGlobal(Offset.zero).dy, 0.0); - expect(box2.localToGlobal(Offset.zero).dy, 250.0); - expect(box3.localToGlobal(Offset.zero).dy, 500.0); - }); + // The boxes should now be at their final position. + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero).dy, 0.0); + expect(box2.localToGlobal(Offset.zero).dy, 250.0); + expect(box3.localToGlobal(Offset.zero).dy, 500.0); + }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, + ); testWidgets( 'changing scroll direction during recede animation will not change the stretch direction', @@ -1166,6 +1231,8 @@ void main() { await gesture.up(); }, + // Skips this test when fragment shaders are used. + skip: shaderSupported, ); testWidgets('Stretch overscroll only uses image filter during stretch effect', ( @@ -1233,7 +1300,7 @@ void main() { // We fling to the trailing edge and let it settle. await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(milliseconds: 300)); // We are now at the trailing edge expect(overscrollNotification.velocity, lessThan(25)); diff --git a/packages/flutter/test/widgets/page_view_test.dart b/packages/flutter/test/widgets/page_view_test.dart index 92aa4509efb..3d127de0b42 100644 --- a/packages/flutter/test/widgets/page_view_test.dart +++ b/packages/flutter/test/widgets/page_view_test.dart @@ -2,6 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart'; @@ -1264,19 +1269,10 @@ void main() { controller.animateToPage(2, duration: const Duration(milliseconds: 300), curve: Curves.ease); await tester.pumpAndSettle(); - final Finder transformFinder = find.descendant( - of: find.byType(PageView), - matching: find.byType(Transform), + await expectLater( + find.byType(PageView), + matchesGoldenFile('page_view_no_stretch_precision_error.png'), ); - expect(transformFinder, findsOneWidget); - - // Get the Transform widget that stretches the PageView. - final Transform transform = tester.firstWidget( - find.descendant(of: find.byType(PageView), matching: find.byType(Transform)), - ); - - // Check the stretch factor in the first element of the transform matrix. - expect(transform.transform.storage.first, 1.0); }); testWidgets('PageController onAttach, onDetach', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/stretch_effect_test.dart b/packages/flutter/test/widgets/stretch_effect_test.dart new file mode 100644 index 00000000000..9f519c1e42f --- /dev/null +++ b/packages/flutter/test/widgets/stretch_effect_test.dart @@ -0,0 +1,56 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This file is run as part of a reduced test set in CI on Mac and Windows +// machines. +@Tags(['reduced-test-set']) +library; + +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // `StretchingOverscrollIndicator` uses a different algorithm when + // shader is available, therefore the tests must be different depending + // on shader support. + final bool shaderSupported = ui.ImageFilter.isShaderFilterSupported; + + testWidgets( + 'Stretch effect covers full viewport', + (WidgetTester tester) async { + // This test verifies that when the stretch effect is applied to a scrollable widget, + // it should cover the entire scrollable area (e.g., full height of the scroll view), + // even if the actual content inside has a smaller height. + // + // Without this behavior, the shader is clipped only to the content area, + // causing the stretch effect to render incorrectly or be invisible + // when the content doesn't fill the scroll view. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StretchEffect( + stretchStrength: 1, + axis: Axis.vertical, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Container(height: 100), + Container(height: 50, color: const Color.fromRGBO(255, 0, 0, 1)), + ], + ), + ), + ), + ); + + await expectLater( + find.byType(StretchEffect), + matchesGoldenFile('stretch_effect_covers_full_viewport.png'), + ); + }, + // Skips this test when fragment shaders are not used. + skip: !shaderSupported, + ); +} diff --git a/packages/flutter_tools/lib/src/asset.dart b/packages/flutter_tools/lib/src/asset.dart index 393f882f381..406ae53d825 100644 --- a/packages/flutter_tools/lib/src/asset.dart +++ b/packages/flutter_tools/lib/src/asset.dart @@ -50,7 +50,7 @@ const kMaterialFonts = >[ }, ]; -const kMaterialShaders = ['shaders/ink_sparkle.frag']; +const kMaterialShaders = ['shaders/ink_sparkle.frag', 'shaders/stretch_effect.frag']; /// Injected factory class for spawning [AssetBundle] instances. abstract class AssetBundleFactory { diff --git a/packages/flutter_tools/test/general.shard/asset_bundle_test.dart b/packages/flutter_tools/test/general.shard/asset_bundle_test.dart index 4edbc1b89cb..39c11fded0d 100644 --- a/packages/flutter_tools/test/general.shard/asset_bundle_test.dart +++ b/packages/flutter_tools/test/general.shard/asset_bundle_test.dart @@ -1017,26 +1017,30 @@ flutter: materialDir.childFile(shader).createSync(recursive: true); } - (globals.processManager as FakeProcessManager).addCommand( - FakeCommand( - command: [ - impellerc, - '--sksl', - '--iplr', - '--json', - '--sl=${fileSystem.path.join(output.path, 'shaders', 'ink_sparkle.frag')}', - '--spirv=${fileSystem.path.join(output.path, 'shaders', 'ink_sparkle.frag.spirv')}', - '--input=${fileSystem.path.join(materialDir.path, 'shaders', 'ink_sparkle.frag')}', - '--input-type=frag', - '--include=${fileSystem.path.join(materialDir.path, 'shaders')}', - '--include=$shaderLibDir', - ], - onRun: (_) { - fileSystem.file(outputPath).createSync(recursive: true); - fileSystem.file('$outputPath.spirv').createSync(recursive: true); - }, - ), - ); + final testShaders = ['ink_sparkle.frag', 'stretch_effect.frag']; + + for (final shader in testShaders) { + (globals.processManager as FakeProcessManager).addCommand( + FakeCommand( + command: [ + impellerc, + '--sksl', + '--iplr', + '--json', + '--sl=${fileSystem.path.join(output.path, 'shaders', shader)}', + '--spirv=${fileSystem.path.join(output.path, 'shaders', '$shader.spirv')}', + '--input=${fileSystem.path.join(materialDir.path, 'shaders', shader)}', + '--input-type=frag', + '--include=${fileSystem.path.join(materialDir.path, 'shaders')}', + '--include=$shaderLibDir', + ], + onRun: (_) { + fileSystem.file(outputPath).createSync(recursive: true); + fileSystem.file('$outputPath.spirv').createSync(recursive: true); + }, + ), + ); + } fileSystem.file('pubspec.yaml') ..createSync() diff --git a/packages/flutter_tools/test/general.shard/asset_test.dart b/packages/flutter_tools/test/general.shard/asset_test.dart index f64c64f6234..3e1f99ccedb 100644 --- a/packages/flutter_tools/test/general.shard/asset_test.dart +++ b/packages/flutter_tools/test/general.shard/asset_test.dart @@ -304,18 +304,23 @@ flutter: expect(assetBundle.inputFiles.map((File f) => f.path), []); }); + final testShaders = ['ink_sparkle.frag', 'stretch_effect.frag']; + testWithoutContext('bundles material shaders on non-web platforms', () async { - final String shaderPath = fileSystem.path.join( - flutterRoot, - 'packages', - 'flutter', - 'lib', - 'src', - 'material', - 'shaders', - 'ink_sparkle.frag', - ); - fileSystem.file(shaderPath).createSync(recursive: true); + for (final shader in testShaders) { + final String shaderPath = fileSystem.path.join( + flutterRoot, + 'packages', + 'flutter', + 'lib', + 'src', + 'material', + 'shaders', + shader, + ); + fileSystem.file(shaderPath).createSync(recursive: true); + } + writePackageConfigFiles(directory: fileSystem.currentDirectory, mainLibName: 'my_package'); fileSystem.file('pubspec.yaml').writeAsStringSync('name: my_package'); final assetBundle = ManifestAssetBundle( @@ -331,21 +336,26 @@ flutter: flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory), ); - expect(assetBundle.entries.keys, contains('shaders/ink_sparkle.frag')); + for (final shader in testShaders) { + expect(assetBundle.entries.keys, contains('shaders/$shader')); + } }); testWithoutContext('bundles material shaders on web platforms', () async { - final String shaderPath = fileSystem.path.join( - flutterRoot, - 'packages', - 'flutter', - 'lib', - 'src', - 'material', - 'shaders', - 'ink_sparkle.frag', - ); - fileSystem.file(shaderPath).createSync(recursive: true); + for (final shader in testShaders) { + final String shaderPath = fileSystem.path.join( + flutterRoot, + 'packages', + 'flutter', + 'lib', + 'src', + 'material', + 'shaders', + shader, + ); + fileSystem.file(shaderPath).createSync(recursive: true); + } + writePackageConfigFiles(directory: fileSystem.currentDirectory, mainLibName: 'my_package'); fileSystem.file('pubspec.yaml').writeAsStringSync('name: my_package'); final assetBundle = ManifestAssetBundle( @@ -361,7 +371,9 @@ flutter: flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory), ); - expect(assetBundle.entries.keys, contains('shaders/ink_sparkle.frag')); + for (final shader in testShaders) { + expect(assetBundle.entries.keys, contains('shaders/$shader')); + } }); }); }