// 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. import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'semantics_tester.dart'; Future<void> pumpTest( WidgetTester tester, TargetPlatform? platform, { bool scrollable = true, bool reverse = false, Set<LogicalKeyboardKey>? axisModifier, Axis scrollDirection = Axis.vertical, ScrollController? controller, bool enableMouseDrag = true, }) async { await tester.pumpWidget(MaterialApp( scrollBehavior: const NoScrollbarBehavior().copyWith( dragDevices: enableMouseDrag ? <ui.PointerDeviceKind>{...ui.PointerDeviceKind.values} : null, pointerAxisModifiers: axisModifier, ), theme: ThemeData( platform: platform, ), home: CustomScrollView( controller: controller, reverse: reverse, scrollDirection: scrollDirection, physics: scrollable ? null : const NeverScrollableScrollPhysics(), slivers: <Widget>[ SliverToBoxAdapter(child: SizedBox( height: scrollDirection == Axis.vertical ? 2000.0 : null, width: scrollDirection == Axis.horizontal ? 2000.0 : null, )), ], ), )); await tester.pump(const Duration(seconds: 5)); // to let the theme animate } class NoScrollbarBehavior extends MaterialScrollBehavior { const NoScrollbarBehavior(); @override Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) => child; } // Pump a nested scrollable. The outer scrollable contains a sliver of a // 300-pixel-long scrollable followed by a 2000-pixel-long content. Future<void> pumpDoubleScrollableTest( WidgetTester tester, TargetPlatform platform, ) async { await tester.pumpWidget(MaterialApp( theme: ThemeData( platform: platform, ), home: const CustomScrollView( slivers: <Widget>[ SliverToBoxAdapter( child: SizedBox( height: 300, child: CustomScrollView( slivers: <Widget>[ SliverToBoxAdapter(child: SizedBox(height: 2000.0)), ], ), ), ), SliverToBoxAdapter(child: SizedBox(height: 2000.0)), ], ), )); await tester.pump(const Duration(seconds: 5)); // to let the theme animate } const double dragOffset = 200.0; double getScrollOffset(WidgetTester tester, {bool last = true}) { Finder viewportFinder = find.byType(Viewport); if (last) { viewportFinder = viewportFinder.last; } final RenderViewport viewport = tester.renderObject(viewportFinder); return viewport.offset.pixels; } double getScrollVelocity(WidgetTester tester) { final RenderViewport viewport = tester.renderObject(find.byType(Viewport)); final ScrollPosition position = viewport.offset as ScrollPosition; return position.activity!.velocity; } void resetScrollOffset(WidgetTester tester) { final RenderViewport viewport = tester.renderObject(find.byType(Viewport)); final ScrollPosition position = viewport.offset as ScrollPosition; position.jumpTo(0.0); } void main() { testWidgets('Flings on different platforms', (WidgetTester tester) async { await pumpTest(tester, TargetPlatform.android); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); expect(getScrollOffset(tester), dragOffset); await tester.pump(); // trigger fling expect(getScrollOffset(tester), dragOffset); await tester.pump(const Duration(seconds: 5)); final double androidResult = getScrollOffset(tester); resetScrollOffset(tester); await pumpTest(tester, TargetPlatform.iOS); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); // Scroll starts ease into the scroll on iOS. expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); await tester.pump(); // trigger fling expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); await tester.pump(const Duration(seconds: 5)); final double iOSResult = getScrollOffset(tester); resetScrollOffset(tester); await pumpTest(tester, TargetPlatform.macOS); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); // Scroll starts ease into the scroll on iOS. expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); await tester.pump(); // trigger fling expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); await tester.pump(const Duration(seconds: 5)); final double macOSResult = getScrollOffset(tester); expect(macOSResult, lessThan(androidResult)); // macOS is slipperier than Android expect(androidResult, lessThan(iOSResult)); // iOS is slipperier than Android expect(macOSResult, lessThan(iOSResult)); // iOS is slipperier than macOS }); testWidgets('Holding scroll', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); await tester.drag(find.byType(Scrollable), const Offset(0.0, 200.0), touchSlopY: 0.0); expect(getScrollOffset(tester), -200.0); await tester.pump(); // trigger ballistic await tester.pump(const Duration(milliseconds: 10)); expect(getScrollOffset(tester), greaterThan(-200.0)); expect(getScrollOffset(tester), lessThan(0.0)); final double heldPosition = getScrollOffset(tester); // Hold and let go while in overscroll. final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); expect(await tester.pumpAndSettle(), 1); expect(getScrollOffset(tester), heldPosition); await gesture.up(); // Once the hold is let go, it should still snap back to origin. expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 3); expect(getScrollOffset(tester), 0.0); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Repeated flings builds momentum', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // trigger fling await tester.pump(const Duration(milliseconds: 10)); // Repeat the exact same motion. await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // On iOS, the velocity will be larger than the velocity of the last fling by a // non-trivial amount. expect(getScrollVelocity(tester), greaterThan(1100.0)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Repeated flings do not build momentum on Android', (WidgetTester tester) async { await pumpTest(tester, TargetPlatform.android); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // trigger fling await tester.pump(const Duration(milliseconds: 10)); // Repeat the exact same motion. await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // On Android, there is no momentum build. The final velocity is the same as the // velocity of the last fling. expect(getScrollVelocity(tester), moreOrLessEquals(1000.0)); }); testWidgets('A slower final fling does not apply carried momentum', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // trigger fling await tester.pump(const Duration(milliseconds: 10)); // Repeat the exact same motion to build momentum. await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // trigger the second fling await tester.pump(const Duration(milliseconds: 10)); // Make a final fling that is much slower. await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 200.0); await tester.pump(); // trigger the third fling await tester.pump(const Duration(milliseconds: 10)); // expect that there is no carried velocity expect(getScrollVelocity(tester), lessThan(200.0)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('No iOS/macOS momentum build with flings in opposite directions', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // trigger fling await tester.pump(const Duration(milliseconds: 10)); // Repeat the exact same motion in the opposite direction. await tester.fling(find.byType(Scrollable), const Offset(0.0, dragOffset), 1000.0); await tester.pump(); // The only applied velocity to the scrollable is the second fling that was in the // opposite direction. expect(getScrollVelocity(tester), -1000.0); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('No iOS/macOS momentum kept on hold gestures', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); await tester.pump(); // trigger fling await tester.pump(const Duration(milliseconds: 10)); expect(getScrollVelocity(tester), greaterThan(0.0)); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); await tester.pump(const Duration(milliseconds: 40)); await gesture.up(); // After a hold longer than 2 frames, previous velocity is lost. expect(getScrollVelocity(tester), 0.0); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Drags creeping unaffected on Android', (WidgetTester tester) async { await pumpTest(tester, TargetPlatform.android); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); await gesture.moveBy(const Offset(0.0, -0.5)); expect(getScrollOffset(tester), 0.5); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 10)); expect(getScrollOffset(tester), 1.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); expect(getScrollOffset(tester), 1.5); }); testWidgets('Drags creeping must break threshold on iOS/macOS', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); await gesture.moveBy(const Offset(0.0, -0.5)); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 10)); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 30)); // Now -2.5 in total. expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 40)); // Now -3.5, just reached threshold. expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 50)); // -0.5 over threshold transferred. expect(getScrollOffset(tester), 0.5); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Big drag over threshold magnitude preserved on iOS/macOS', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); await gesture.moveBy(const Offset(0.0, -30.0)); // No offset lost from threshold. expect(getScrollOffset(tester), 30.0); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Slow threshold breaks are attenuated on iOS/macOS', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); // This is a typical 'hesitant' iOS scroll start. await gesture.moveBy(const Offset(0.0, -10.0)); expect(getScrollOffset(tester), moreOrLessEquals(1.1666666666666667)); await gesture.moveBy(const Offset(0.0, -10.0), timeStamp: const Duration(milliseconds: 20)); // Subsequent motions unaffected. expect(getScrollOffset(tester), moreOrLessEquals(11.16666666666666673)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Small continuing motion preserved on iOS/macOS', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); await gesture.moveBy(const Offset(0.0, -30.0)); // Break threshold. expect(getScrollOffset(tester), 30.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 40)); expect(getScrollOffset(tester), 31.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 60)); expect(getScrollOffset(tester), 31.5); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Motion stop resets threshold on iOS/macOS', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true)); await gesture.moveBy(const Offset(0.0, -30.0)); // Break threshold. expect(getScrollOffset(tester), 30.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); expect(getScrollOffset(tester), 30.5); await gesture.moveBy(Offset.zero, timeStamp: const Duration(milliseconds: 21)); // Stationary too long, threshold reset. await gesture.moveBy(Offset.zero, timeStamp: const Duration(milliseconds: 120)); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 140)); expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 150)); expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 160)); expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 170)); // New threshold broken. expect(getScrollOffset(tester), 31.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180)); expect(getScrollOffset(tester), 32.5); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Scroll pointer signals are handled on Fuchsia', (WidgetTester tester) async { await pumpTest(tester, TargetPlatform.fuchsia); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); expect(getScrollOffset(tester), 20.0); // Pointer signals should not cause overscroll. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0))); expect(getScrollOffset(tester), 0.0); }); testWidgets('Scroll pointer signals are handled when there is competition', (WidgetTester tester) async { // This is a regression test. When there are multiple scrollables listening // to the same event, for example when scrollables are nested, there used // to be exceptions at scrolling events. await pumpDoubleScrollableTest(tester, TargetPlatform.fuchsia); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport).last); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); expect(getScrollOffset(tester), 20.0); // Pointer signals should not cause overscroll. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0))); expect(getScrollOffset(tester), 0.0); }); testWidgets('Scroll pointer signals are ignored when scrolling is disabled', (WidgetTester tester) async { await pumpTest(tester, TargetPlatform.fuchsia, scrollable: false); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); expect(getScrollOffset(tester), 0.0); }); testWidgets('Holding scroll and Scroll pointer signal will update ScrollDirection.forward / ScrollDirection.reverse', (WidgetTester tester) async { ScrollDirection? lastUserScrollingDirection; final ScrollController controller = ScrollController(); addTearDown(controller.dispose); await pumpTest(tester, TargetPlatform.fuchsia, controller: controller); controller.addListener(() { if (controller.position.userScrollDirection != ScrollDirection.idle) { lastUserScrollingDirection = controller.position.userScrollDirection; } }); await tester.drag(find.byType(Scrollable), const Offset(0.0, -20.0), touchSlopY: 0.0); expect(lastUserScrollingDirection, ScrollDirection.reverse); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); expect(lastUserScrollingDirection, ScrollDirection.reverse); await tester.drag(find.byType(Scrollable), const Offset(0.0, 20.0), touchSlopY: 0.0); expect(lastUserScrollingDirection, ScrollDirection.forward); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -20.0))); expect(lastUserScrollingDirection, ScrollDirection.forward); }); testWidgets('Scrolls in correct direction when scroll axis is reversed', (WidgetTester tester) async { await pumpTest(tester, TargetPlatform.fuchsia, reverse: true); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -20.0))); expect(getScrollOffset(tester), 20.0); }); testWidgets('Scrolls horizontally when shift is pressed by default', (WidgetTester tester) async { await pumpTest( tester, debugDefaultTargetPlatformOverride, scrollDirection: Axis.horizontal, ); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 0.0); await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input flipped to horizontal and accepted. expect(getScrollOffset(tester), 20.0); await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); await tester.pump(); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 20.0); }, variant: TargetPlatformVariant.all()); testWidgets('Scroll axis is not flipped for trackpad', (WidgetTester tester) async { await pumpTest( tester, debugDefaultTargetPlatformOverride, scrollDirection: Axis.horizontal, ); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.trackpad); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 0.0); await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not flipped. expect(getScrollOffset(tester), 0.0); await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); await tester.pump(); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 0.0); }, variant: TargetPlatformVariant.all()); testWidgets('Scrolls horizontally when custom key is pressed', (WidgetTester tester) async { await pumpTest( tester, debugDefaultTargetPlatformOverride, scrollDirection: Axis.horizontal, axisModifier: <LogicalKeyboardKey>{ LogicalKeyboardKey.altLeft }, ); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 0.0); await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input flipped to horizontal and accepted. expect(getScrollOffset(tester), 20.0); await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await tester.pump(); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 20.0); }, variant: TargetPlatformVariant.all()); testWidgets('Still scrolls horizontally when other keys are pressed at the same time', (WidgetTester tester) async { await pumpTest( tester, debugDefaultTargetPlatformOverride, scrollDirection: Axis.horizontal, axisModifier: <LogicalKeyboardKey>{ LogicalKeyboardKey.altLeft }, ); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 0.0); await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.space); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical flipped & accepted. expect(getScrollOffset(tester), 20.0); await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await tester.sendKeyUpEvent(LogicalKeyboardKey.space); await tester.pump(); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); // Vertical input not accepted expect(getScrollOffset(tester), 20.0); }, variant: TargetPlatformVariant.all()); group('setCanDrag to false with active drag gesture: ', () { Future<void> pumpTestWidget(WidgetTester tester, { required bool canDrag }) { return tester.pumpWidget( MaterialApp( home: CustomScrollView( physics: canDrag ? const AlwaysScrollableScrollPhysics() : const NeverScrollableScrollPhysics(), slivers: <Widget>[ SliverToBoxAdapter( child: SizedBox( height: 2000, child: GestureDetector(onTap: () {}), ), ), ], ), ), ); } testWidgets('Hold does not disable user interaction', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/66816. await pumpTestWidget(tester, canDrag: true); final RenderIgnorePointer renderIgnorePointer = tester.renderObject<RenderIgnorePointer>( find.descendant(of: find.byType(CustomScrollView), matching: find.byType(IgnorePointer)), ); expect(renderIgnorePointer.ignoring, false); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); expect(renderIgnorePointer.ignoring, false); await pumpTestWidget(tester, canDrag: false); expect(renderIgnorePointer.ignoring, false); await gesture.up(); expect(renderIgnorePointer.ignoring, false); }); testWidgets('Drag disables user interaction when recognized', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/66816. await pumpTestWidget(tester, canDrag: true); final RenderIgnorePointer renderIgnorePointer = tester.renderObject<RenderIgnorePointer>( find.descendant(of: find.byType(CustomScrollView), matching: find.byType(IgnorePointer)), ); expect(renderIgnorePointer.ignoring, false); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); expect(renderIgnorePointer.ignoring, false); await gesture.moveBy(const Offset(0, -100)); // Starts ignoring when the drag is recognized. expect(renderIgnorePointer.ignoring, true); await pumpTestWidget(tester, canDrag: false); expect(renderIgnorePointer.ignoring, false); await gesture.up(); expect(renderIgnorePointer.ignoring, false); }); testWidgets('Ballistic disables user interaction until it stops', (WidgetTester tester) async { await pumpTestWidget(tester, canDrag: true); final RenderIgnorePointer renderIgnorePointer = tester.renderObject<RenderIgnorePointer>( find.descendant(of: find.byType(CustomScrollView), matching: find.byType(IgnorePointer)), ); expect(renderIgnorePointer.ignoring, false); // Starts ignoring when the drag is recognized. await tester.fling(find.byType(Scrollable), const Offset(0, -100), 1000); expect(renderIgnorePointer.ignoring, true); await tester.pump(); // When the activity ends we should stop ignoring pointers. await tester.pumpAndSettle(); expect(renderIgnorePointer.ignoring, false); }); }); testWidgets('Can recommendDeferredLoadingForContext - animation', (WidgetTester tester) async { final List<String> widgetTracker = <String>[]; int cheapWidgets = 0; int expensiveWidgets = 0; final ScrollController controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView.builder( controller: controller, itemBuilder: (BuildContext context, int index) { if (Scrollable.recommendDeferredLoadingForContext(context)) { cheapWidgets += 1; widgetTracker.add('cheap'); return const SizedBox(height: 50.0); } widgetTracker.add('expensive'); expensiveWidgets += 1; return const SizedBox(height: 50.0); }, ), )); await tester.pumpAndSettle(); expect(expensiveWidgets, 17); expect(cheapWidgets, 0); // The position value here is different from the maximum velocity we will // reach, which is controlled by a combination of curve, duration, and // position. // This is just meant to be a pretty good simulation. A linear curve // with these same parameters will never back off on the velocity enough // to reset here. controller.animateTo( 5000, duration: const Duration(seconds: 2), curve: Curves.linear, ); expect(expensiveWidgets, 17); expect(widgetTracker.every((String type) => type == 'expensive'), true); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(expensiveWidgets, 17); expect(cheapWidgets, 25); expect(widgetTracker.skip(17).every((String type) => type == 'cheap'), true); await tester.pumpAndSettle(); expect(expensiveWidgets, 22); expect(cheapWidgets, 95); expect(widgetTracker.skip(17).skip(25).take(70).every((String type) => type == 'cheap'), true); expect(widgetTracker.skip(17).skip(25).skip(70).every((String type) => type == 'expensive'), true); }); testWidgets('Can recommendDeferredLoadingForContext - ballistics', (WidgetTester tester) async { int cheapWidgets = 0; int expensiveWidgets = 0; await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView.builder( itemBuilder: (BuildContext context, int index) { if (Scrollable.recommendDeferredLoadingForContext(context)) { cheapWidgets += 1; return const SizedBox(height: 50.0); } expensiveWidgets += 1; return SizedBox(key: ValueKey<String>('Box $index'), height: 50.0); }, ), )); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey<String>('Box 0')), findsOneWidget); expect(find.byKey(const ValueKey<String>('Box 52')), findsNothing); expect(expensiveWidgets, 17); expect(cheapWidgets, 0); // Getting the tester to simulate a life-like fling is difficult. // Instead, just manually drive the activity with a ballistic simulation as // if the user has flung the list. Scrollable.of(find.byType(SizedBox).evaluate().first).position.activity!.delegate.goBallistic(4000); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey<String>('Box 0')), findsNothing); expect(find.byKey(const ValueKey<String>('Box 52')), findsOneWidget); expect(expensiveWidgets, 40); expect(cheapWidgets, 21); }); testWidgets('Can recommendDeferredLoadingForContext - override heuristic', (WidgetTester tester) async { int cheapWidgets = 0; int expensiveWidgets = 0; await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView.builder( physics: SuperPessimisticScrollPhysics(), itemBuilder: (BuildContext context, int index) { if (Scrollable.recommendDeferredLoadingForContext(context)) { cheapWidgets += 1; return SizedBox(key: ValueKey<String>('Cheap box $index'), height: 50.0); } expensiveWidgets += 1; return SizedBox(key: ValueKey<String>('Box $index'), height: 50.0); }, ), )); await tester.pumpAndSettle(); final ScrollPosition position = Scrollable.of(find.byType(SizedBox).evaluate().first).position; final SuperPessimisticScrollPhysics physics = position.physics as SuperPessimisticScrollPhysics; expect(find.byKey(const ValueKey<String>('Box 0')), findsOneWidget); expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsNothing); expect(physics.count, 17); expect(expensiveWidgets, 17); expect(cheapWidgets, 0); // Getting the tester to simulate a life-like fling is difficult. // Instead, just manually drive the activity with a ballistic simulation as // if the user has flung the list. position.activity!.delegate.goBallistic(4000); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey<String>('Box 0')), findsNothing); expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsOneWidget); expect(expensiveWidgets, 17); expect(cheapWidgets, 44); expect(physics.count, 44 + 17); }); testWidgets('Can recommendDeferredLoadingForContext - override heuristic and always return true', (WidgetTester tester) async { int cheapWidgets = 0; int expensiveWidgets = 0; await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: ListView.builder( physics: const ExtraSuperPessimisticScrollPhysics(), itemBuilder: (BuildContext context, int index) { if (Scrollable.recommendDeferredLoadingForContext(context)) { cheapWidgets += 1; return SizedBox(key: ValueKey<String>('Cheap box $index'), height: 50.0); } expensiveWidgets += 1; return SizedBox(key: ValueKey<String>('Box $index'), height: 50.0); }, ), )); await tester.pumpAndSettle(); final ScrollPosition position = Scrollable.of(find.byType(SizedBox).evaluate().first).position; expect(find.byKey(const ValueKey<String>('Cheap box 0')), findsOneWidget); expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsNothing); expect(expensiveWidgets, 0); expect(cheapWidgets, 17); // Getting the tester to simulate a life-like fling is difficult. // Instead, just manually drive the activity with a ballistic simulation as // if the user has flung the list. position.activity!.delegate.goBallistic(4000); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey<String>('Cheap box 0')), findsNothing); expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsOneWidget); expect(expensiveWidgets, 0); expect(cheapWidgets, 61); }); testWidgets('ensureVisible does not move PageViews', (WidgetTester tester) async { final PageController controller = PageController(); addTearDown(controller.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: PageView( controller: controller, children: List<ListView>.generate( 3, (int pageIndex) { return ListView( key: Key('list_$pageIndex'), children: List<Widget>.generate( 100, (int listIndex) { return Row( children: <Widget>[ Container( key: Key('${pageIndex}_${listIndex}_0'), color: Colors.red, width: 200, height: 10, ), Container( key: Key('${pageIndex}_${listIndex}_1'), color: Colors.blue, width: 200, height: 10, ), Container( key: Key('${pageIndex}_${listIndex}_2'), color: Colors.green, width: 200, height: 10, ), ], ); }, ), ); }, ), ), ), ); final Finder targetMidRightPage0 = find.byKey(const Key('0_25_2')); final Finder targetMidRightPage1 = find.byKey(const Key('1_25_2')); final Finder targetMidLeftPage1 = find.byKey(const Key('1_25_0')); expect(find.byKey(const Key('list_0')), findsOneWidget); expect(find.byKey(const Key('list_1')), findsNothing); expect(targetMidRightPage0, findsOneWidget); expect(targetMidRightPage1, findsNothing); expect(targetMidLeftPage1, findsNothing); await tester.ensureVisible(targetMidRightPage0); await tester.pumpAndSettle(); expect(targetMidRightPage0, findsOneWidget); expect(targetMidRightPage1, findsNothing); expect(targetMidLeftPage1, findsNothing); controller.jumpToPage(1); await tester.pumpAndSettle(); expect(find.byKey(const Key('list_0')), findsNothing); expect(find.byKey(const Key('list_1')), findsOneWidget); await tester.ensureVisible(targetMidRightPage1); await tester.pumpAndSettle(); expect(targetMidRightPage0, findsNothing); expect(targetMidRightPage1, findsOneWidget); expect(targetMidLeftPage1, findsOneWidget); await tester.ensureVisible(targetMidLeftPage1); await tester.pumpAndSettle(); expect(targetMidRightPage0, findsNothing); expect(targetMidRightPage1, findsOneWidget); expect(targetMidLeftPage1, findsOneWidget); }); testWidgets('ensureVisible does not move TabViews', (WidgetTester tester) async { final TickerProvider vsync = TestTickerProvider(); final TabController controller = TabController( length: 3, vsync: vsync, ); addTearDown(controller.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: TabBarView( controller: controller, children: List<ListView>.generate( 3, (int pageIndex) { return ListView( key: Key('list_$pageIndex'), children: List<Widget>.generate( 100, (int listIndex) { return Row( children: <Widget>[ Container( key: Key('${pageIndex}_${listIndex}_0'), color: Colors.red, width: 200, height: 10, ), Container( key: Key('${pageIndex}_${listIndex}_1'), color: Colors.blue, width: 200, height: 10, ), Container( key: Key('${pageIndex}_${listIndex}_2'), color: Colors.green, width: 200, height: 10, ), ], ); }, ), ); }, ), ), ), ); final Finder targetMidRightPage0 = find.byKey(const Key('0_25_2')); final Finder targetMidRightPage1 = find.byKey(const Key('1_25_2')); final Finder targetMidLeftPage1 = find.byKey(const Key('1_25_0')); expect(find.byKey(const Key('list_0')), findsOneWidget); expect(find.byKey(const Key('list_1')), findsNothing); expect(targetMidRightPage0, findsOneWidget); expect(targetMidRightPage1, findsNothing); expect(targetMidLeftPage1, findsNothing); await tester.ensureVisible(targetMidRightPage0); await tester.pumpAndSettle(); expect(targetMidRightPage0, findsOneWidget); expect(targetMidRightPage1, findsNothing); expect(targetMidLeftPage1, findsNothing); controller.index = 1; await tester.pumpAndSettle(); expect(find.byKey(const Key('list_0')), findsNothing); expect(find.byKey(const Key('list_1')), findsOneWidget); await tester.ensureVisible(targetMidRightPage1); await tester.pumpAndSettle(); expect(targetMidRightPage0, findsNothing); expect(targetMidRightPage1, findsOneWidget); expect(targetMidLeftPage1, findsOneWidget); await tester.ensureVisible(targetMidLeftPage1); await tester.pumpAndSettle(); expect(targetMidRightPage0, findsNothing); expect(targetMidRightPage1, findsOneWidget); expect(targetMidLeftPage1, findsOneWidget); }); testWidgets('PointerScroll on nested NeverScrollable ListView goes to outer Scrollable.', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/70948 final ScrollController outerController = ScrollController(); addTearDown(outerController.dispose); final ScrollController innerController = ScrollController(); addTearDown(innerController.dispose); await tester.pumpWidget(MaterialApp( theme: ThemeData(useMaterial3: false), home: Scaffold( body: SingleChildScrollView( controller: outerController, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Column( children: <Widget>[ for (int i = 0; i < 100; i++) Text('SingleChildScrollView $i'), ], ), SizedBox( height: 3000, width: 400, child: ListView.builder( controller: innerController, physics: const NeverScrollableScrollPhysics(), itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Nested NeverScrollable ListView $index'); }, ), ), ], ), ), ), )); expect(outerController.position.pixels, 0.0); expect(innerController.position.pixels, 0.0); final Offset outerScrollable = tester.getCenter(find.text('SingleChildScrollView 3')); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Hover over the outer scroll view and create a pointer scroll. testPointer.hover(outerScrollable); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); await tester.pump(const Duration(milliseconds: 250)); expect(outerController.position.pixels, 20.0); expect(innerController.position.pixels, 0.0); final Offset innerScrollable = tester.getCenter(find.text('Nested NeverScrollable ListView 20')); // Hover over the inner scroll view and create a pointer scroll. // This inner scroll view is not scrollable, and so the outer should scroll. testPointer.hover(innerScrollable); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -20.0))); await tester.pump(const Duration(milliseconds: 250)); expect(outerController.position.pixels, 0.0); expect(innerController.position.pixels, 0.0); }); // Regression test for https://github.com/flutter/flutter/issues/71949 testWidgets('Zero offset pointer scroll should not trigger an assertion.', (WidgetTester tester) async { final ScrollController controller = ScrollController(); addTearDown(controller.dispose); Widget build(double height) { return MaterialApp( home: Scaffold( body: SizedBox( width: double.infinity, height: height, child: SingleChildScrollView( controller: controller, child: const SizedBox( width: double.infinity, height: 300.0, ), ), ), ), ); } await tester.pumpWidget(build(200.0)); expect(controller.position.pixels, 0.0); controller.jumpTo(100.0); expect(controller.position.pixels, 100.0); // Make the outer constraints larger that the scrollable widget is no longer able to scroll. await tester.pumpWidget(build(300.0)); expect(controller.position.pixels, 0.0); expect(controller.position.maxScrollExtent, 0.0); // Hover over the scroll view and create a zero offset pointer scroll. final Offset scrollable = tester.getCenter(find.byType(SingleChildScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); testPointer.hover(scrollable); await tester.sendEventToBinding(testPointer.scroll(Offset.zero)); expect(tester.takeException(), null); }); testWidgets('Accepts drag with unknown device kind by default', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/90912. await tester.pumpWidget( const MaterialApp( home: CustomScrollView( slivers: <Widget>[ SliverToBoxAdapter(child: SizedBox(height: 2000.0)), ], ), ) ); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.unknown); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 200); await gesture.moveBy(const Offset(0.0, 200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 0.0); await gesture.removePointer(); await tester.pump(); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS, TargetPlatform.android })); testWidgets('Does not scroll with mouse pointer drag when behavior is configured to ignore them', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride, enableMouseDrag: false); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); await gesture.moveBy(const Offset(0.0, -200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, 200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 0.0); await gesture.removePointer(); await tester.pump(); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS, TargetPlatform.android })); testWidgets("Support updating 'ScrollBehavior.dragDevices' at runtime", (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/111716 Widget buildFrame(Set<ui.PointerDeviceKind>? dragDevices) { return MaterialApp( scrollBehavior: const NoScrollbarBehavior().copyWith( dragDevices: dragDevices, ), home: ListView.builder( itemCount: 1000, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ); } await tester.pumpWidget(buildFrame(<ui.PointerDeviceKind>{ui.PointerDeviceKind.mouse})); await tester.drag(find.byType(Scrollable), const Offset(0.0, -100.0), kind: ui.PointerDeviceKind.mouse); // Matching device should allow user scrolling. expect(getScrollOffset(tester), 100.0); await tester.pumpWidget(buildFrame(<ui.PointerDeviceKind>{ui.PointerDeviceKind.stylus})); await tester.drag(find.byType(Scrollable), const Offset(0.0, -100.0), kind: ui.PointerDeviceKind.mouse); // Non-matching device should not allow user scrolling. expect(getScrollOffset(tester), 100.0); await tester.drag(find.byType(Scrollable), const Offset(0.0, -100.0), kind: ui.PointerDeviceKind.stylus); // Matching device should allow user scrolling. expect(getScrollOffset(tester), 200.0); }); testWidgets('Does scroll with mouse pointer drag when behavior is not configured to ignore them', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); await gesture.moveBy(const Offset(0.0, -200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 200.0); await gesture.moveBy(const Offset(0.0, 200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 0.0); await gesture.removePointer(); await tester.pump(); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS, TargetPlatform.android })); testWidgets('Updated content dimensions correctly reflect in semantics', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/40419. final SemanticsHandle handle = tester.ensureSemantics(); final UniqueKey listView = UniqueKey(); await tester.pumpWidget(MaterialApp( home: TickerMode( enabled: true, child: ListView.builder( key: listView, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), )); SemanticsNode scrollableNode = tester.getSemantics(find.descendant(of: find.byKey(listView), matching: find.byType(RawGestureDetector))); SemanticsNode? syntheticScrollableNode; scrollableNode.visitChildren((SemanticsNode node) { syntheticScrollableNode = node; return true; }); expect(syntheticScrollableNode!.hasFlag(ui.SemanticsFlag.hasImplicitScrolling), isTrue); // Disabled the ticker mode to trigger didChangeDependencies on Scrollable. // This can happen when a route is push or pop from top. // It will reconstruct the scroll position and apply content dimensions. await tester.pumpWidget(MaterialApp( home: TickerMode( enabled: false, child: ListView.builder( key: listView, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('Item $index'); }, ), ), )); await tester.pump(); // The correct workflow will be the following: // 1. _RenderScrollSemantics receives a new scroll position without content // dimensions and creates a SemanticsNode without implicit scroll. // 2. The content dimensions are applied to the scroll position during the // layout phase, and the scroll position marks the semantics node of // _RenderScrollSemantics dirty. // 3. The _RenderScrollSemantics rebuilds its semantics node with implicit // scroll. scrollableNode = tester.getSemantics(find.descendant(of: find.byKey(listView), matching: find.byType(RawGestureDetector))); syntheticScrollableNode = null; scrollableNode.visitChildren((SemanticsNode node) { syntheticScrollableNode = node; return true; }); expect(syntheticScrollableNode!.hasFlag(ui.SemanticsFlag.hasImplicitScrolling), isTrue); handle.dispose(); }); testWidgets('Two panel semantics is added to the sibling nodes of direct children', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); final UniqueKey key = UniqueKey(); await tester.pumpWidget(MaterialApp( home: Scaffold( body: ListView( key: key, children: const <Widget>[ TextField( autofocus: true, decoration: InputDecoration( prefixText: 'prefix', ), ), ], ), ), )); // Wait for focus. await tester.pumpAndSettle(); final SemanticsNode scrollableNode = tester.getSemantics(find.byKey(key)); SemanticsNode? intermediateNode; scrollableNode.visitChildren((SemanticsNode node) { intermediateNode = node; return true; }); SemanticsNode? syntheticScrollableNode; intermediateNode!.visitChildren((SemanticsNode node) { syntheticScrollableNode = node; return true; }); expect(syntheticScrollableNode!.hasFlag(ui.SemanticsFlag.hasImplicitScrolling), isTrue); int numberOfChild = 0; syntheticScrollableNode!.visitChildren((SemanticsNode node) { expect(node.isTagged(RenderViewport.useTwoPaneSemantics), isTrue); numberOfChild += 1; return true; }); expect(numberOfChild, 2); handle.dispose(); }); testWidgets('Scroll inertia cancel event', (WidgetTester tester) async { await pumpTest(tester, null); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); expect(getScrollOffset(tester), dragOffset); await tester.pump(); // trigger fling expect(getScrollOffset(tester), dragOffset); await tester.pump(const Duration(milliseconds: 200)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); await tester.sendEventToBinding(testPointer.hover(tester.getCenter(find.byType(Scrollable)))); await tester.sendEventToBinding(testPointer.scrollInertiaCancel()); // Cancel partway through. await tester.pump(); expect(getScrollOffset(tester), closeTo(344.0642, 0.0001)); await tester.pump(const Duration(milliseconds: 4800)); expect(getScrollOffset(tester), closeTo(344.0642, 0.0001)); }); testWidgets('Swapping viewports in a scrollable does not crash', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); final GlobalKey key = GlobalKey(); final GlobalKey key1 = GlobalKey(); Widget buildScrollable(bool withViewPort) { return Scrollable( key: key, viewportBuilder: (BuildContext context, ViewportOffset position) { if (withViewPort) { final ViewportOffset offset = ViewportOffset.zero(); addTearDown(() => offset.dispose()); return Viewport( slivers: <Widget>[ SliverToBoxAdapter(child: Semantics(key: key1, container: true, child: const Text('text1'))) ], offset: offset, ); } return Semantics(key: key1, container: true, child: const Text('text1')); }, ); } // This should cache the inner node in Scrollable with the children text1. await tester.pumpWidget( MaterialApp( home: buildScrollable(true), ), ); expect(semantics, includesNodeWith(tags: <SemanticsTag>{RenderViewport.useTwoPaneSemantics})); // This does not use two panel, this should clear cached inner node. await tester.pumpWidget( MaterialApp( home: buildScrollable(false), ), ); expect(semantics, isNot(includesNodeWith(tags: <SemanticsTag>{RenderViewport.useTwoPaneSemantics}))); // If the inner node was cleared in the previous step, this should not crash. await tester.pumpWidget( MaterialApp( home: buildScrollable(true), ), ); expect(semantics, includesNodeWith(tags: <SemanticsTag>{RenderViewport.useTwoPaneSemantics})); expect(tester.takeException(), isNull); semantics.dispose(); }); testWidgets('deltaToScrollOrigin getter', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: CustomScrollView( slivers: <Widget>[ SliverToBoxAdapter(child: SizedBox(height: 2000.0)), ], ), ) ); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.unknown); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 200); final ScrollableState scrollable = tester.state(find.byType(Scrollable)); expect(scrollable.deltaToScrollOrigin, const Offset(0.0, 200)); }); testWidgets('resolvedPhysics getter', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData.light().copyWith( platform: TargetPlatform.android, ), home: const CustomScrollView( physics: AlwaysScrollableScrollPhysics(), slivers: <Widget>[ SliverToBoxAdapter(child: SizedBox(height: 2000.0)), ], ), ) ); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.unknown); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -200)); await tester.pump(); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 200); final ScrollableState scrollable = tester.state(find.byType(Scrollable)); String types(ScrollPhysics? value) => value!.parent == null ? '${value.runtimeType}' : '${value.runtimeType} ${types(value.parent)}'; expect( types(scrollable.resolvedPhysics), 'AlwaysScrollableScrollPhysics ClampingScrollPhysics RangeMaintainingScrollPhysics', ); }); testWidgets('dragDevices change updates widget', (WidgetTester tester) async { bool enable = false; await tester.pumpWidget( Builder( builder: (BuildContext context) { return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return MaterialApp( home: Scaffold( body: Scrollable( scrollBehavior: const MaterialScrollBehavior().copyWith(dragDevices: <ui.PointerDeviceKind>{ if (enable) ui.PointerDeviceKind.mouse, }), viewportBuilder: (BuildContext context, ViewportOffset position) => Viewport( offset: position, slivers: const <Widget>[ SliverToBoxAdapter(child: SizedBox(height: 2000.0)), ], ), ), floatingActionButton: FloatingActionButton(onPressed: () { setState(() { enable = !enable; }); }), ), ); }, ); }, ) ); // Gesture should not work. TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -200)); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 0.0); // Change state to include mouse pointer device. await tester.tap(find.byType(FloatingActionButton)); await tester.pump(); // Gesture should work after state change. gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -200)); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 200); }); testWidgets('dragDevices change updates widget when oldWidget scrollBehavior is null', (WidgetTester tester) async { ScrollBehavior? scrollBehavior; await tester.pumpWidget( Builder( builder: (BuildContext context) { return StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return MaterialApp( home: Scaffold( body: Scrollable( physics: const ScrollPhysics(), scrollBehavior: scrollBehavior, viewportBuilder: (BuildContext context, ViewportOffset position) => Viewport( offset: position, slivers: const <Widget>[ SliverToBoxAdapter(child: SizedBox(height: 2000.0)), ], ), ), floatingActionButton: FloatingActionButton(onPressed: () { setState(() { scrollBehavior = const MaterialScrollBehavior().copyWith(dragDevices: <ui.PointerDeviceKind>{ ui.PointerDeviceKind.mouse }); }); }), ), ); }, ); }, ) ); // Gesture should not work. TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -200)); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 0.0); // Change state to include mouse pointer device. await tester.tap(find.byType(FloatingActionButton)); await tester.pump(); // Gesture should work after state change. gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); expect(getScrollOffset(tester), 0.0); await gesture.moveBy(const Offset(0.0, -200)); await tester.pumpAndSettle(); expect(getScrollOffset(tester), 200); }); } // ignore: must_be_immutable class SuperPessimisticScrollPhysics extends ScrollPhysics { SuperPessimisticScrollPhysics({super.parent}); int count = 0; @override bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) { count++; return velocity > 1; } @override ScrollPhysics applyTo(ScrollPhysics? ancestor) { return SuperPessimisticScrollPhysics(parent: buildParent(ancestor)); } } class ExtraSuperPessimisticScrollPhysics extends ScrollPhysics { const ExtraSuperPessimisticScrollPhysics({super.parent}); @override bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) { return true; } @override ScrollPhysics applyTo(ScrollPhysics? ancestor) { return ExtraSuperPessimisticScrollPhysics(parent: buildParent(ancestor)); } } class TestTickerProvider extends TickerProvider { @override Ticker createTicker(TickerCallback onTick) { return Ticker(onTick); } }