// 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 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; import '../rendering/rendering_tester.dart' show TestClipPaintingContext; import 'semantics_tester.dart'; class TestScrollPosition extends ScrollPositionWithSingleContext { TestScrollPosition({ required super.physics, required ScrollContext state, double super.initialPixels, super.oldPosition, }) : super( context: state, ); } class TestScrollController extends ScrollController { @override ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { return TestScrollPosition( physics: physics, state: context, initialPixels: initialScrollOffset, oldPosition: oldPosition, ); } } Widget primaryScrollControllerBoilerplate({ required Widget child, required ScrollController controller }) { return Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: const MediaQueryData(), child: PrimaryScrollController( controller: controller, child: child, ), ), ); } void main() { testWidgets('SingleChildScrollView overflow and clipRect test', (WidgetTester tester) async { // the test widowSize is Size(800.0, 600.0) await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( child: Container(height: 600.0), ), ), ); // 1st, check that the render object has received the default clip behavior. final dynamic renderObject = tester.allRenderObjects.where((RenderObject o) => o.runtimeType.toString() == '_RenderSingleChildViewport').first; expect(renderObject.clipBehavior, equals(Clip.hardEdge)); // ignore: avoid_dynamic_calls // 2nd, height == widow.height test: check that the painting context does not call pushClipRect . TestClipPaintingContext context = TestClipPaintingContext(); renderObject.paint(context, Offset.zero); // ignore: avoid_dynamic_calls expect(context.clipBehavior, equals(Clip.none)); // 3rd, height overflow test: check that the painting context call pushClipRect. await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( child: Container(height: 600.1), ), ), ); renderObject.paint(context, Offset.zero); // ignore: avoid_dynamic_calls expect(context.clipBehavior, equals(Clip.hardEdge)); // 4th, width == widow.width test: check that the painting context do not call pushClipRect. context = TestClipPaintingContext(); expect(context.clipBehavior, equals(Clip.none)); // initial value await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container(width: 800.0), ), ), ); renderObject.paint(context, Offset.zero); // ignore: avoid_dynamic_calls expect(context.clipBehavior, equals(Clip.none)); // 5th, width overflow test: check that the painting context call pushClipRect. await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container(width: 800.1), ), ), ); renderObject.paint(context, Offset.zero); // ignore: avoid_dynamic_calls expect(context.clipBehavior, equals(Clip.hardEdge)); }); testWidgets('SingleChildScrollView respects clipBehavior', (WidgetTester tester) async { await tester.pumpWidget(SingleChildScrollView(child: Container(height: 2000.0))); // 1st, check that the render object has received the default clip behavior. final dynamic renderObject = tester.allRenderObjects.where((RenderObject o) => o.runtimeType.toString() == '_RenderSingleChildViewport').first; expect(renderObject.clipBehavior, equals(Clip.hardEdge)); // ignore: avoid_dynamic_calls // 2nd, check that the painting context has received the default clip behavior. final TestClipPaintingContext context = TestClipPaintingContext(); renderObject.paint(context, Offset.zero); // ignore: avoid_dynamic_calls expect(context.clipBehavior, equals(Clip.hardEdge)); // 3rd, check that the underlying Scrollable has the same clipBehavior // Regression test for https://github.com/flutter/flutter/issues/133330 Finder scrollable = find.byWidgetPredicate((Widget widget) => widget is Scrollable); expect( (tester.widget(scrollable) as Scrollable).clipBehavior, Clip.hardEdge, ); // 4th, pump a new widget to check that the render object can update its clip behavior. await tester.pumpWidget(SingleChildScrollView(clipBehavior: Clip.antiAlias, child: Container(height: 2000.0))); expect(renderObject.clipBehavior, equals(Clip.antiAlias)); // ignore: avoid_dynamic_calls // 5th, check that a non-default clip behavior can be sent to the painting context. renderObject.paint(context, Offset.zero); // ignore: avoid_dynamic_calls expect(context.clipBehavior, equals(Clip.antiAlias)); // 6th, check that the underlying Scrollable has the same clipBehavior // Regression test for https://github.com/flutter/flutter/issues/133330 scrollable = find.byWidgetPredicate((Widget widget) => widget is Scrollable); expect( (tester.widget(scrollable) as Scrollable).clipBehavior, Clip.antiAlias, ); }); testWidgetsWithLeakTracking('SingleChildScrollView control test', (WidgetTester tester) async { await tester.pumpWidget(SingleChildScrollView( child: Container( height: 2000.0, color: const Color(0xFF00FF00), ), )); final RenderBox box = tester.renderObject(find.byType(Container)); expect(box.localToGlobal(Offset.zero), equals(Offset.zero)); await tester.drag(find.byType(SingleChildScrollView), const Offset(-200.0, -200.0)); expect(box.localToGlobal(Offset.zero), equals(const Offset(0.0, -200.0))); }); testWidgetsWithLeakTracking('Changing controllers changes scroll position', (WidgetTester tester) async { final TestScrollController controller = TestScrollController(); addTearDown(controller.dispose); await tester.pumpWidget(SingleChildScrollView( child: Container( height: 2000.0, color: const Color(0xFF00FF00), ), )); await tester.pumpWidget(SingleChildScrollView( controller: controller, child: Container( height: 2000.0, color: const Color(0xFF00FF00), ), )); final ScrollableState scrollable = tester.state(find.byType(Scrollable)); expect(scrollable.position, isA<TestScrollPosition>()); }); testWidgetsWithLeakTracking('Sets PrimaryScrollController when primary', (WidgetTester tester) async { final ScrollController primaryScrollController = ScrollController(); addTearDown(primaryScrollController.dispose); await tester.pumpWidget(PrimaryScrollController( controller: primaryScrollController, child: SingleChildScrollView( primary: true, child: Container( height: 2000.0, color: const Color(0xFF00FF00), ), ), )); final Scrollable scrollable = tester.widget(find.byType(Scrollable)); expect(scrollable.controller, primaryScrollController); }); testWidgetsWithLeakTracking('Changing scroll controller inside dirty layout builder does not assert', (WidgetTester tester) async { final ScrollController controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget(Center( child: SizedBox( width: 750.0, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return SingleChildScrollView( child: Container( height: 2000.0, color: const Color(0xFF00FF00), ), ); }, ), ), )); await tester.pumpWidget(Center( child: SizedBox( width: 700.0, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return SingleChildScrollView( controller: controller, child: Container( height: 2000.0, color: const Color(0xFF00FF00), ), ); }, ), ), )); }); testWidgetsWithLeakTracking('Vertical SingleChildScrollViews are not primary by default', (WidgetTester tester) async { const SingleChildScrollView view = SingleChildScrollView(); expect(view.primary, isNull); }); testWidgetsWithLeakTracking('Horizontal SingleChildScrollViews are not primary by default', (WidgetTester tester) async { const SingleChildScrollView view = SingleChildScrollView(scrollDirection: Axis.horizontal); expect(view.primary, isNull); }); testWidgetsWithLeakTracking('SingleChildScrollViews with controllers are not primary by default', (WidgetTester tester) async { final ScrollController controller = ScrollController(); addTearDown(controller.dispose); final SingleChildScrollView view = SingleChildScrollView( controller: controller, ); expect(view.primary, isNull); }); testWidgetsWithLeakTracking('Vertical SingleChildScrollViews use PrimaryScrollController by default on mobile', (WidgetTester tester) async { final ScrollController controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget(primaryScrollControllerBoilerplate( child: const SingleChildScrollView(), controller: controller, )); expect(controller.hasClients, isTrue); }, variant: TargetPlatformVariant.mobile()); testWidgetsWithLeakTracking("Vertical SingleChildScrollViews don't use PrimaryScrollController by default on desktop", (WidgetTester tester) async { final ScrollController controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget(primaryScrollControllerBoilerplate( child: const SingleChildScrollView(), controller: controller, )); expect(controller.hasClients, isFalse); }, variant: TargetPlatformVariant.desktop()); testWidgetsWithLeakTracking('Nested scrollables have a null PrimaryScrollController', (WidgetTester tester) async { const Key innerKey = Key('inner'); final ScrollController primaryScrollController = ScrollController(); addTearDown(primaryScrollController.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: PrimaryScrollController( controller: primaryScrollController, child: SingleChildScrollView( primary: true, child: Container( constraints: const BoxConstraints(maxHeight: 200.0), child: ListView(key: innerKey, primary: true), ), ), ), ), ); final Scrollable innerScrollable = tester.widget( find.descendant( of: find.byKey(innerKey), matching: find.byType(Scrollable), ), ); expect(innerScrollable.controller, isNull); }); testWidgetsWithLeakTracking('SingleChildScrollView semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); final ScrollController controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( controller: controller, child: Column( children: List<Widget>.generate(30, (int i) { return SizedBox( height: 200.0, child: Text('Tile $i'), ); }), ), ), ), ); List<TestSemantics> generateSemanticsChildren({int startHidden = -1, int endHidden = 30}) { final List<TestSemantics> children = <TestSemantics>[]; for (int index = 0; index < 30; index += 1) { final bool isHidden = index <= startHidden || index >= endHidden; children.add(TestSemantics( label: 'Tile $index', textDirection: TextDirection.ltr, flags: isHidden ? const <SemanticsFlag>[SemanticsFlag.isHidden] : 0, )); } return children; } expect(semantics, hasSemantics( TestSemantics( children: <TestSemantics>[ TestSemantics( flags: <SemanticsFlag>[ SemanticsFlag.hasImplicitScrolling, ], actions: <SemanticsAction>[ SemanticsAction.scrollUp, ], children: generateSemanticsChildren(endHidden: 3), ), ], ), ignoreRect: true, ignoreTransform: true, ignoreId: true, )); controller.jumpTo(3000.0); await tester.pumpAndSettle(); expect(semantics, hasSemantics( TestSemantics( children: <TestSemantics>[ TestSemantics( flags: <SemanticsFlag>[ SemanticsFlag.hasImplicitScrolling, ], actions: <SemanticsAction>[ SemanticsAction.scrollUp, SemanticsAction.scrollDown, ], children: generateSemanticsChildren(startHidden: 14, endHidden: 18), ), ], ), ignoreRect: true, ignoreTransform: true, ignoreId: true, )); controller.jumpTo(6000.0); await tester.pumpAndSettle(); expect(semantics, hasSemantics( TestSemantics( children: <TestSemantics>[ TestSemantics( flags: <SemanticsFlag>[ SemanticsFlag.hasImplicitScrolling, ], actions: <SemanticsAction>[ SemanticsAction.scrollDown, ], children: generateSemanticsChildren(startHidden: 26), ), ], ), ignoreRect: true, ignoreTransform: true, ignoreId: true, )); semantics.dispose(); }); testWidgetsWithLeakTracking('SingleChildScrollView semantics clips cover entire child vertical', (WidgetTester tester) async { final ScrollController controller = ScrollController(); addTearDown(controller.dispose); final UniqueKey scrollView = UniqueKey(); final UniqueKey childBox = UniqueKey(); const double length = 10000; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( key: scrollView, controller: controller, child: SizedBox(key: childBox, height: length), ), ), ); final RenderObject scrollRenderObject = tester.renderObject(find.byKey(scrollView)); RenderAbstractViewport? viewport; void findsRenderViewPort(RenderObject child) { if (viewport != null) { return; } if (child is RenderAbstractViewport) { viewport = child; return; } child.visitChildren(findsRenderViewPort); } scrollRenderObject.visitChildren(findsRenderViewPort); expect(viewport, isNotNull); final RenderObject childRenderObject = tester.renderObject(find.byKey(childBox)); Rect semanticsClip = viewport!.describeSemanticsClip(childRenderObject)!; expect(semanticsClip.size.height, length); controller.jumpTo(2000); await tester.pump(); semanticsClip = viewport!.describeSemanticsClip(childRenderObject)!; expect(semanticsClip.size.height, length); }); testWidgetsWithLeakTracking('SingleChildScrollView semantics clips cover entire child', (WidgetTester tester) async { final ScrollController controller = ScrollController(); addTearDown(controller.dispose); final UniqueKey scrollView = UniqueKey(); final UniqueKey childBox = UniqueKey(); const double length = 10000; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SingleChildScrollView( key: scrollView, scrollDirection: Axis.horizontal, controller: controller, child: SizedBox(key: childBox, width: length), ), ), ); final RenderObject scrollRenderObject = tester.renderObject(find.byKey(scrollView)); RenderAbstractViewport? viewport; void findsRenderViewPort(RenderObject child) { if (viewport != null) { return; } if (child is RenderAbstractViewport) { viewport = child; return; } child.visitChildren(findsRenderViewPort); } scrollRenderObject.visitChildren(findsRenderViewPort); expect(viewport, isNotNull); final RenderObject childRenderObject = tester.renderObject(find.byKey(childBox)); Rect semanticsClip = viewport!.describeSemanticsClip(childRenderObject)!; expect(semanticsClip.size.width, length); controller.jumpTo(2000); await tester.pump(); semanticsClip = viewport!.describeSemanticsClip(childRenderObject)!; expect(semanticsClip.size.width, length); }); testWidgetsWithLeakTracking('SingleChildScrollView getOffsetToReveal - will not assert on axis mismatch', (WidgetTester tester) async { final ScrollController controller = ScrollController(initialScrollOffset: 300.0); addTearDown(controller.dispose); List<Widget> children; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Center( child: SizedBox( height: 200.0, width: 300.0, child: SingleChildScrollView( controller: controller, child: Column( children: children = List<Widget>.generate(20, (int i) { return SizedBox( height: 100.0, width: 300.0, child: Text('Tile $i'), ); }), ), ), ), ), ), ); final RenderAbstractViewport viewport = tester.allRenderObjects.whereType<RenderAbstractViewport>().first; final RenderObject target = tester.renderObject(find.byWidget(children[5])); viewport.getOffsetToReveal(target, 0.0, axis: Axis.horizontal); }); testWidgetsWithLeakTracking('SingleChildScrollView getOffsetToReveal - down', (WidgetTester tester) async { final ScrollController controller = ScrollController(initialScrollOffset: 300.0); addTearDown(controller.dispose); List<Widget> children; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Center( child: SizedBox( height: 200.0, width: 300.0, child: SingleChildScrollView( controller: controller, child: Column( children: children = List<Widget>.generate(20, (int i) { return SizedBox( height: 100.0, width: 300.0, child: Text('Tile $i'), ); }), ), ), ), ), ), ); final RenderAbstractViewport viewport = tester.allRenderObjects.whereType<RenderAbstractViewport>().first; final RenderObject target = tester.renderObject(find.byWidget(children[5])); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); expect(revealed.offset, 500.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 0.0, 300.0, 100.0)); revealed = viewport.getOffsetToReveal(target, 1.0); expect(revealed.offset, 400.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 100.0, 300.0, 100.0)); revealed = viewport.getOffsetToReveal(target, 0.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 540.0); expect(revealed.rect, const Rect.fromLTWH(40.0, 0.0, 10.0, 10.0)); revealed = viewport.getOffsetToReveal(target, 1.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 350.0); expect(revealed.rect, const Rect.fromLTWH(40.0, 190.0, 10.0, 10.0)); }); testWidgetsWithLeakTracking('SingleChildScrollView getOffsetToReveal - up', (WidgetTester tester) async { final ScrollController controller = ScrollController(initialScrollOffset: 300.0); addTearDown(controller.dispose); final List<Widget> children = List<Widget>.generate(20, (int i) { return SizedBox( height: 100.0, width: 300.0, child: Text('Tile $i'), ); }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Center( child: SizedBox( height: 200.0, width: 300.0, child: SingleChildScrollView( controller: controller, reverse: true, child: Column( children: children.reversed.toList(), ), ), ), ), ), ); final RenderAbstractViewport viewport = tester.allRenderObjects.whereType<RenderAbstractViewport>().first; final RenderObject target = tester.renderObject(find.byWidget(children[5])); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); expect(revealed.offset, 500.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 100.0, 300.0, 100.0)); revealed = viewport.getOffsetToReveal(target, 1.0); expect(revealed.offset, 400.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 0.0, 300.0, 100.0)); revealed = viewport.getOffsetToReveal(target, 0.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 550.0); expect(revealed.rect, const Rect.fromLTWH(40.0, 190.0, 10.0, 10.0)); revealed = viewport.getOffsetToReveal(target, 1.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 360.0); expect(revealed.rect, const Rect.fromLTWH(40.0, 0.0, 10.0, 10.0)); }); testWidgetsWithLeakTracking('SingleChildScrollView getOffsetToReveal - right', (WidgetTester tester) async { final ScrollController controller = ScrollController(initialScrollOffset: 300.0); addTearDown(controller.dispose); List<Widget> children; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Center( child: SizedBox( height: 300.0, width: 200.0, child: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: controller, child: Row( children: children = List<Widget>.generate(20, (int i) { return SizedBox( height: 300.0, width: 100.0, child: Text('Tile $i'), ); }), ), ), ), ), ), ); final RenderAbstractViewport viewport = tester.allRenderObjects.whereType<RenderAbstractViewport>().first; final RenderObject target = tester.renderObject(find.byWidget(children[5])); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); expect(revealed.offset, 500.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 0.0, 100.0, 300.0)); revealed = viewport.getOffsetToReveal(target, 1.0); expect(revealed.offset, 400.0); expect(revealed.rect, const Rect.fromLTWH(100.0, 0.0, 100.0, 300.0)); revealed = viewport.getOffsetToReveal(target, 0.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 540.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 40.0, 10.0, 10.0)); revealed = viewport.getOffsetToReveal(target, 1.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 350.0); expect(revealed.rect, const Rect.fromLTWH(190.0, 40.0, 10.0, 10.0)); }); testWidgetsWithLeakTracking('SingleChildScrollView getOffsetToReveal - left', (WidgetTester tester) async { final ScrollController controller = ScrollController(initialScrollOffset: 300.0); addTearDown(controller.dispose); final List<Widget> children = List<Widget>.generate(20, (int i) { return SizedBox( height: 300.0, width: 100.0, child: Text('Tile $i'), ); }); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Center( child: SizedBox( height: 300.0, width: 200.0, child: SingleChildScrollView( scrollDirection: Axis.horizontal, reverse: true, controller: controller, child: Row( children: children.reversed.toList(), ), ), ), ), ), ); final RenderAbstractViewport viewport = tester.allRenderObjects.whereType<RenderAbstractViewport>().first; final RenderObject target = tester.renderObject(find.byWidget(children[5])); RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); expect(revealed.offset, 500.0); expect(revealed.rect, const Rect.fromLTWH(100.0, 0.0, 100.0, 300.0)); revealed = viewport.getOffsetToReveal(target, 1.0); expect(revealed.offset, 400.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 0.0, 100.0, 300.0)); revealed = viewport.getOffsetToReveal(target, 0.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 550.0); expect(revealed.rect, const Rect.fromLTWH(190.0, 40.0, 10.0, 10.0)); revealed = viewport.getOffsetToReveal(target, 1.0, rect: const Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); expect(revealed.offset, 360.0); expect(revealed.rect, const Rect.fromLTWH(0.0, 40.0, 10.0, 10.0)); }); testWidgetsWithLeakTracking('Nested SingleChildScrollView showOnScreen', (WidgetTester tester) async { final List<List<Widget>> children = List<List<Widget>>.generate(10, (int x) { return List<Widget>.generate(10, (int y) { return SizedBox( key: UniqueKey(), height: 100.0, width: 100.0, ); }); }); late ScrollController controllerX; addTearDown(() => controllerX.dispose()); late ScrollController controllerY; addTearDown(() => controllerY.dispose()); /// Builds a gird: /// /// <- x -> /// 0 1 2 3 4 5 6 7 8 9 /// 0 c c c c c c c c c c /// 1 c c c c c c c c c c /// 2 c c c c c c c c c c /// 3 c c c c c c c c c c y /// 4 c c c c v v c c c c /// 5 c c c c v v c c c c /// 6 c c c c c c c c c c /// 7 c c c c c c c c c c /// 8 c c c c c c c c c c /// 9 c c c c c c c c c c /// /// Each c is a 100x100 container, v are containers visible in initial /// viewport. await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Center( child: SizedBox( height: 200.0, width: 200.0, child: SingleChildScrollView( controller: controllerY = ScrollController(initialScrollOffset: 400.0), child: SingleChildScrollView( controller: controllerX = ScrollController(initialScrollOffset: 400.0), scrollDirection: Axis.horizontal, child: Column( children: children.map((List<Widget> widgets) { return Row( children: widgets, ); }).toList(), ), ), ), ), ), ), ); expect(controllerX.offset, 400.0); expect(controllerY.offset, 400.0); // Already in viewport tester.renderObject(find.byWidget(children[4][4])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 400.0); expect(controllerY.offset, 400.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Above viewport tester.renderObject(find.byWidget(children[3][4])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 400.0); expect(controllerY.offset, 300.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Below viewport tester.renderObject(find.byWidget(children[6][4])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 400.0); expect(controllerY.offset, 500.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Left of viewport tester.renderObject(find.byWidget(children[4][3])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 300.0); expect(controllerY.offset, 400.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Right of viewport tester.renderObject(find.byWidget(children[4][6])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 500.0); expect(controllerY.offset, 400.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Above and left of viewport tester.renderObject(find.byWidget(children[3][3])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 300.0); expect(controllerY.offset, 300.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Below and left of viewport tester.renderObject(find.byWidget(children[6][3])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 300.0); expect(controllerY.offset, 500.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Above and right of viewport tester.renderObject(find.byWidget(children[3][6])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 500.0); expect(controllerY.offset, 300.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Below and right of viewport tester.renderObject(find.byWidget(children[6][6])).showOnScreen(); await tester.pumpAndSettle(); expect(controllerX.offset, 500.0); expect(controllerY.offset, 500.0); controllerX.jumpTo(400.0); controllerY.jumpTo(400.0); await tester.pumpAndSettle(); // Below and right of viewport with animations tester.renderObject(find.byWidget(children[6][6])).showOnScreen(duration: const Duration(seconds: 2)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(tester.hasRunningAnimations, isTrue); expect(controllerX.offset, greaterThan(400.0)); expect(controllerX.offset, lessThan(500.0)); expect(controllerY.offset, greaterThan(400.0)); expect(controllerY.offset, lessThan(500.0)); await tester.pumpAndSettle(); expect(controllerX.offset, 500.0); expect(controllerY.offset, 500.0); }); group('Nested SingleChildScrollView (same orientation) showOnScreen', () { late List<Widget> children; Future<void> buildNestedScroller({ required WidgetTester tester, ScrollController? inner, ScrollController? outer }) { return tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Center( child: SizedBox( height: 200.0, width: 300.0, child: SingleChildScrollView( controller: outer, child: Column( children: <Widget>[ const SizedBox( height: 200.0, ), SizedBox( height: 200.0, width: 300.0, child: SingleChildScrollView( controller: inner, child: Column( children: children = List<Widget>.generate(10, (int i) { return SizedBox( height: 100.0, width: 300.0, child: Text('$i'), ); }), ), ), ), const SizedBox( height: 200.0, ), ], ), ), ), ), ), ); } testWidgetsWithLeakTracking('in view in inner, but not in outer', (WidgetTester tester) async { final ScrollController inner = ScrollController(); addTearDown(inner.dispose); final ScrollController outer = ScrollController(); addTearDown(outer.dispose); await buildNestedScroller( tester: tester, inner: inner, outer: outer, ); expect(outer.offset, 0.0); expect(inner.offset, 0.0); tester.renderObject(find.byWidget(children[0])).showOnScreen(); await tester.pumpAndSettle(); expect(inner.offset, 0.0); expect(outer.offset, 100.0); }); testWidgetsWithLeakTracking('not in view of neither inner nor outer', (WidgetTester tester) async { final ScrollController inner = ScrollController(); addTearDown(inner.dispose); final ScrollController outer = ScrollController(); addTearDown(outer.dispose); await buildNestedScroller( tester: tester, inner: inner, outer: outer, ); expect(outer.offset, 0.0); expect(inner.offset, 0.0); tester.renderObject(find.byWidget(children[5])).showOnScreen(); await tester.pumpAndSettle(); expect(inner.offset, 400.0); expect(outer.offset, 200.0); }); testWidgetsWithLeakTracking('in view in inner and outer', (WidgetTester tester) async { final ScrollController inner = ScrollController(initialScrollOffset: 200.0); addTearDown(inner.dispose); final ScrollController outer = ScrollController(initialScrollOffset: 200.0); addTearDown(outer.dispose); await buildNestedScroller( tester: tester, inner: inner, outer: outer, ); expect(outer.offset, 200.0); expect(inner.offset, 200.0); tester.renderObject(find.byWidget(children[2])).showOnScreen(); await tester.pumpAndSettle(); expect(outer.offset, 200.0); expect(inner.offset, 200.0); }); testWidgetsWithLeakTracking('inner shown in outer, but item not visible', (WidgetTester tester) async { final ScrollController inner = ScrollController(initialScrollOffset: 200.0); addTearDown(inner.dispose); final ScrollController outer = ScrollController(initialScrollOffset: 200.0); addTearDown(outer.dispose); await buildNestedScroller( tester: tester, inner: inner, outer: outer, ); expect(outer.offset, 200.0); expect(inner.offset, 200.0); tester.renderObject(find.byWidget(children[5])).showOnScreen(); await tester.pumpAndSettle(); expect(outer.offset, 200.0); expect(inner.offset, 400.0); }); testWidgetsWithLeakTracking('inner half shown in outer, item only visible in inner', (WidgetTester tester) async { final ScrollController inner = ScrollController(); addTearDown(inner.dispose); final ScrollController outer = ScrollController(initialScrollOffset: 100.0); addTearDown(outer.dispose); await buildNestedScroller( tester: tester, inner: inner, outer: outer, ); expect(outer.offset, 100.0); expect(inner.offset, 0.0); tester.renderObject(find.byWidget(children[1])).showOnScreen(); await tester.pumpAndSettle(); expect(outer.offset, 200.0); expect(inner.offset, 0.0); }); }); testWidgetsWithLeakTracking('keyboardDismissBehavior tests', (WidgetTester tester) async { final List<FocusNode> focusNodes = List<FocusNode>.generate(50, (int i) => FocusNode()); addTearDown(() { for (final FocusNode node in focusNodes) { node.dispose(); } }); Future<void> boilerplate(ScrollViewKeyboardDismissBehavior behavior) { return tester.pumpWidget( MaterialApp( home: Scaffold( body: SingleChildScrollView( padding: EdgeInsets.zero, keyboardDismissBehavior: behavior, child: Column( children: focusNodes.map((FocusNode focusNode) { return SizedBox( height: 50, child: TextField(focusNode: focusNode), ); }).toList(), ), ), ), ), ); } // ScrollViewKeyboardDismissBehavior.onDrag dismiss keyboard on drag await boilerplate(ScrollViewKeyboardDismissBehavior.onDrag); Finder finder = find.byType(TextField).first; TextField textField = tester.widget(finder); await tester.showKeyboard(finder); expect(textField.focusNode!.hasFocus, isTrue); await tester.drag(finder, const Offset(0.0, -40.0)); await tester.pumpAndSettle(); expect(textField.focusNode!.hasFocus, isFalse); // ScrollViewKeyboardDismissBehavior.manual does no dismiss the keyboard await boilerplate(ScrollViewKeyboardDismissBehavior.manual); finder = find.byType(TextField).first; textField = tester.widget(finder); await tester.showKeyboard(finder); expect(textField.focusNode!.hasFocus, isTrue); await tester.drag(finder, const Offset(0.0, -40.0)); await tester.pumpAndSettle(); expect(textField.focusNode!.hasFocus, isTrue); }); }