From 03736a282ebb4bc319bcdde5205cbd81ab7bbd14 Mon Sep 17 00:00:00 2001 From: Dev TtangKong Date: Fri, 15 Aug 2025 03:13:54 +0900 Subject: [PATCH] Implements the Android native stretch effect as a fragment shader (Impeller-only). (#169293) > Sorry, Closing PR #169196 and reopening this in a new PR for clarity and a cleaner commit history. Fixes #82906 In the existing Flutter implementation, the Android stretch overscroll effect is achieved using Transform. However, this approach does not evenly stretch the screen as it does in the native Android environment. Therefore, in the Impeller environment, add or modify files to implement the effect using a fragment shader identical to the one used in native Android. > [!NOTE] > - The addition of a separate test file for StretchOverscrollEffect was not included because it would likely duplicate coverage already provided by the changes made in overscroll_stretch_indicator_test.dart within this PR. >> However, since determining whether stretching occurred by referencing global coordinates is currently considered impossible with the new Fragment Shader approach, the code was modified to partially exclude the relevant test logic in the Impeller. >> >> For reference, in the page_view_test.dart test, there was an issue with referencing the child Transform widget, but because the StretchOverscrollEffect widget is defined instead of the Transform widget in the Impeller environment, the code logic was adjusted accordingly. > > - Golden image tests were updated as the visual effect changes. ## Reference Source - https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/libs/hwui/effects/StretchEffect.cpp ## `Old` Skia (Using Transform) https://github.com/user-attachments/assets/22d8ff96-d875-4722-bf6f-f0ad15b9077d ## `New` Impeller (Using fragment shader) https://github.com/user-attachments/assets/73b6ef18-06b2-42ea-9793-c391ec2ce282 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Tong Mu Co-authored-by: Kate Lovett --- .../src/material/shaders/stretch_effect.frag | 159 ++ .../lib/src/widgets/overscroll_indicator.dart | 45 +- .../lib/src/widgets/stretch_effect.dart | 254 ++++ packages/flutter/lib/widgets.dart | 1 + .../overscroll_stretch_indicator_test.dart | 1317 +++++++++-------- .../flutter/test/widgets/page_view_test.dart | 20 +- .../test/widgets/stretch_effect_test.dart | 56 + packages/flutter_tools/lib/src/asset.dart | 2 +- .../test/general.shard/asset_bundle_test.dart | 44 +- .../test/general.shard/asset_test.dart | 60 +- 10 files changed, 1249 insertions(+), 709 deletions(-) create mode 100644 packages/flutter/lib/src/material/shaders/stretch_effect.frag create mode 100644 packages/flutter/lib/src/widgets/stretch_effect.dart create mode 100644 packages/flutter/test/widgets/stretch_effect_test.dart 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')); + } }); }); }