// 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 pumpTest( WidgetTester tester, TargetPlatform? platform, { bool scrollable = true, bool reverse = false, Set? axisModifier, Axis scrollDirection = Axis.vertical, ScrollController? controller, bool enableMouseDrag = true, }) async { await tester.pumpWidget( MaterialApp( scrollBehavior: const NoScrollbarBehavior().copyWith( dragDevices: enableMouseDrag ? {...ui.PointerDeviceKind.values} : null, pointerAxisModifiers: axisModifier, ), theme: ThemeData(platform: platform), home: CustomScrollView( controller: controller, reverse: reverse, scrollDirection: scrollDirection, physics: scrollable ? null : const NeverScrollableScrollPhysics(), slivers: [ 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 pumpDoubleScrollableTest(WidgetTester tester, TargetPlatform platform) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: platform), home: const CustomScrollView( slivers: [ SliverToBoxAdapter( child: SizedBox( height: 300, child: CustomScrollView( slivers: [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 position = viewport.offset as ScrollPosition; return position.activity!.velocity; } void resetScrollOffset(WidgetTester tester) { final RenderViewport viewport = tester.renderObject(find.byType(Viewport)); final position = viewport.offset as ScrollPosition; position.jumpTo(0.0); } void main() { testWidgets('hitTestBehavior is respected', (WidgetTester tester) async { HitTestBehavior? getBehavior(Type of) { final RawGestureDetector widget = tester.widget( find.descendant(of: find.byType(of), matching: find.byType(RawGestureDetector)), ); return widget.behavior; } await tester.pumpWidget( const MaterialApp(home: SingleChildScrollView(hitTestBehavior: HitTestBehavior.translucent)), ); expect(getBehavior(SingleChildScrollView), HitTestBehavior.translucent); await tester.pumpWidget( const MaterialApp(home: CustomScrollView(hitTestBehavior: HitTestBehavior.translucent)), ); expect(getBehavior(CustomScrollView), HitTestBehavior.translucent); await tester.pumpWidget( MaterialApp(home: ListView(hitTestBehavior: HitTestBehavior.translucent)), ); expect(getBehavior(ListView), HitTestBehavior.translucent); await tester.pumpWidget( MaterialApp( home: GridView.extent(maxCrossAxisExtent: 1, hitTestBehavior: HitTestBehavior.translucent), ), ); expect(getBehavior(GridView), HitTestBehavior.translucent); await tester.pumpWidget( MaterialApp(home: PageView(hitTestBehavior: HitTestBehavior.translucent)), ); expect(getBehavior(PageView), HitTestBehavior.translucent); await tester.pumpWidget( MaterialApp( home: ListWheelScrollView( itemExtent: 10, hitTestBehavior: HitTestBehavior.translucent, children: const [], ), ), ); expect(getBehavior(ListWheelScrollView), HitTestBehavior.translucent); }); testWidgets('hitTestBehavior.translucent lets widgets underneath catch the hit', ( WidgetTester tester, ) async { final Key key = UniqueKey(); var tapped = false; await tester.pumpWidget( MaterialApp( home: Stack( children: [ Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => tapped = true, child: SizedBox(key: key, height: 300), ), ), const SingleChildScrollView(hitTestBehavior: HitTestBehavior.translucent), ], ), ), ); await tester.tapAt(tester.getCenter(find.byKey(key))); expect(tapped, isTrue); }); 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.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.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.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.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.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.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.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.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.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.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(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(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(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( 'Engine is notified of ignored pointer signals (no scroll physics)', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride, scrollable: false); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); var allowedPlatformDefault = false; await tester.sendEventToBinding( testPointer.scroll( const Offset(0.0, 20.0), onRespond: ({required bool allowPlatformDefault}) { allowedPlatformDefault = allowPlatformDefault; }, ), ); expect( allowedPlatformDefault, isTrue, reason: 'Engine should be notified of ignored scroll pointer signals.', ); }, variant: TargetPlatformVariant.all(), ); testWidgets( 'Engine is notified of rejected scroll events (wrong direction)', (WidgetTester tester) async { await pumpTest(tester, debugDefaultTargetPlatformOverride, scrollDirection: Axis.horizontal); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); // Horizontal input is accepted await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); await tester.sendEventToBinding( testPointer.scroll( const Offset(0.0, 10.0), onRespond: ({required bool allowPlatformDefault}) { fail('The engine should not be notified when the scroll is accepted.'); }, ), ); await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); await tester.pump(); // Vertical input not accepted var allowedPlatformDefault = false; await tester.sendEventToBinding( testPointer.scroll( const Offset(0.0, 20.0), onRespond: ({required bool allowPlatformDefault}) { allowedPlatformDefault = allowPlatformDefault; }, ), ); expect( allowedPlatformDefault, isTrue, reason: 'Engine should be notified when scroll is rejected by the scrollable.', ); }, variant: TargetPlatformVariant.all(), ); testWidgets( 'Holding scroll and Scroll pointer signal will update ScrollDirection.forward / ScrollDirection.reverse', (WidgetTester tester) async { ScrollDirection? lastUserScrollingDirection; final 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(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(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(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(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.altLeft}, ); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final 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.altLeft}, ); final Offset scrollEventLocation = tester.getCenter(find.byType(Viewport)); final 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 pumpTestWidget(WidgetTester tester, {required bool canDrag}) { return tester.pumpWidget( MaterialApp( home: CustomScrollView( physics: canDrag ? const AlwaysScrollableScrollPhysics() : const NeverScrollableScrollPhysics(), slivers: [ 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( 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( 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( 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 widgetTracker = []; var cheapWidgets = 0; var expensiveWidgets = 0; final 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 { var cheapWidgets = 0; var 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('Box $index'), height: 50.0); }, ), ), ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('Box 0')), findsOneWidget); expect(find.byKey(const ValueKey('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('Box 0')), findsNothing); expect(find.byKey(const ValueKey('Box 52')), findsOneWidget); expect(expensiveWidgets, 40); expect(cheapWidgets, 21); }); testWidgets('Can recommendDeferredLoadingForContext - override heuristic', ( WidgetTester tester, ) async { var cheapWidgets = 0; var 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('Cheap box $index'), height: 50.0); } expensiveWidgets += 1; return SizedBox(key: ValueKey('Box $index'), height: 50.0); }, ), ), ); await tester.pumpAndSettle(); final ScrollPosition position = Scrollable.of(find.byType(SizedBox).evaluate().first).position; final physics = position.physics as SuperPessimisticScrollPhysics; expect(find.byKey(const ValueKey('Box 0')), findsOneWidget); expect(find.byKey(const ValueKey('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('Box 0')), findsNothing); expect(find.byKey(const ValueKey('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 { var cheapWidgets = 0; var 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('Cheap box $index'), height: 50.0); } expensiveWidgets += 1; return SizedBox(key: ValueKey('Box $index'), height: 50.0); }, ), ), ); await tester.pumpAndSettle(); final ScrollPosition position = Scrollable.of( find.byType(SizedBox).evaluate().first, ).position; expect(find.byKey(const ValueKey('Cheap box 0')), findsOneWidget); expect(find.byKey(const ValueKey('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('Cheap box 0')), findsNothing); expect(find.byKey(const ValueKey('Cheap box 52')), findsOneWidget); expect(expensiveWidgets, 0); expect(cheapWidgets, 61); }, ); testWidgets('ensureVisible does not move PageViews', (WidgetTester tester) async { final controller = PageController(); addTearDown(controller.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: PageView( controller: controller, children: List.generate(3, (int pageIndex) { return ListView( key: Key('list_$pageIndex'), children: List.generate(100, (int listIndex) { return Row( children: [ 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 controller = TabController(length: 3, vsync: vsync); addTearDown(controller.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: TabBarView( controller: controller, children: List.generate(3, (int pageIndex) { return ListView( key: Key('list_$pageIndex'), children: List.generate(100, (int listIndex) { return Row( children: [ 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 outerController = ScrollController(); addTearDown(outerController.dispose); final 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: [ Column( children: [ 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(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 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(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: [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.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.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? 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.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.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.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 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 key = UniqueKey(); await tester.pumpWidget( MaterialApp( home: Scaffold( body: ListView( key: key, children: const [ 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); var 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(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 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 offset = ViewportOffset.zero(); addTearDown(() => offset.dispose()); return Viewport( slivers: [ 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: {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: {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: {RenderViewport.useTwoPaneSemantics})); expect(tester.takeException(), isNull); semantics.dispose(); }); testWidgets('deltaToScrollOrigin getter', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: CustomScrollView( slivers: [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(platform: TargetPlatform.android), home: const CustomScrollView( physics: AlwaysScrollableScrollPhysics(), slivers: [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 { var 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: {if (enable) ui.PointerDeviceKind.mouse}, ), viewportBuilder: (BuildContext context, ViewportOffset position) => Viewport( offset: position, slivers: const [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 [SliverToBoxAdapter(child: SizedBox(height: 2000.0))], ), ), floatingActionButton: FloatingActionButton( onPressed: () { setState(() { scrollBehavior = const MaterialScrollBehavior().copyWith( dragDevices: {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); } }