// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // This file is run as part of a reduced test set in CI on Mac and Windows // machines. @Tags(<String>['reduced-test-set']) library; import 'package:flutter/gestures.dart'; 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'; void main() { group('$ReorderableListView', () { const double itemHeight = 48.0; const List<String> originalListItems = <String>['Item 1', 'Item 2', 'Item 3', 'Item 4']; late List<String> listItems; void onReorder(int oldIndex, int newIndex) { if (oldIndex < newIndex) { newIndex -= 1; } final String element = listItems.removeAt(oldIndex); listItems.insert(newIndex, element); } Widget listItemToWidget(String listItem) { return SizedBox( key: Key(listItem), height: itemHeight, width: itemHeight, child: Text(listItem), ); } Widget build({ Widget? header, Widget? footer, Axis scrollDirection = Axis.vertical, bool reverse = false, EdgeInsets padding = EdgeInsets.zero, TextDirection textDirection = TextDirection.ltr, TargetPlatform? platform, }) { return MaterialApp( theme: ThemeData(platform: platform), home: Directionality( textDirection: textDirection, child: SizedBox( height: itemHeight * 10, width: itemHeight * 10, child: ReorderableListView( header: header, footer: footer, scrollDirection: scrollDirection, onReorder: onReorder, reverse: reverse, padding: padding, children: listItems.map<Widget>(listItemToWidget).toList(), ), ), ), ); } setUp(() { // Copy the original list into listItems. listItems = originalListItems.toList(); }); group('in vertical mode', () { testWidgetsWithLeakTracking('reorder is not triggered when children length is less or equals to 1', (WidgetTester tester) async { bool onReorderWasCalled = false; final List<String> currentListItems = listItems.take(1).toList(); final ReorderableListView reorderableListView = ReorderableListView( header: const Text('Header'), onReorder: (_, __) => onReorderWasCalled = true, children: currentListItems.map<Widget>(listItemToWidget).toList(), ); final List<String> currentOriginalListItems = originalListItems.take(1).toList(); await tester.pumpWidget(MaterialApp( home: SizedBox( height: itemHeight * 10, child: reorderableListView, ), )); expect(currentListItems, orderedEquals(currentOriginalListItems)); final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 1'))); await tester.pump(kLongPressTimeout + kPressTimeout); expect(currentListItems, orderedEquals(currentOriginalListItems)); await drag.moveTo(tester.getBottomLeft(find.text('Item 1')) * 2); expect(currentListItems, orderedEquals(currentOriginalListItems)); await drag.up(); expect(onReorderWasCalled, false); expect(currentListItems, orderedEquals(<String>['Item 1'])); }); testWidgetsWithLeakTracking('reorders its contents only when a drag finishes', (WidgetTester tester) async { await tester.pumpWidget(build()); expect(listItems, orderedEquals(originalListItems)); final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 1'))); await tester.pump(kLongPressTimeout + kPressTimeout); expect(listItems, orderedEquals(originalListItems)); await drag.moveTo(tester.getCenter(find.text('Item 4'))); expect(listItems, orderedEquals(originalListItems)); await drag.up(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 1', 'Item 4'])); }); testWidgetsWithLeakTracking('allows reordering from the very top to the very bottom', (WidgetTester tester) async { await tester.pumpWidget(build()); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), ); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); }); testWidgetsWithLeakTracking('allows reordering from the very bottom to the very top', (WidgetTester tester) async { await tester.pumpWidget(build()); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 4')), tester.getCenter(find.text('Item 1')), ); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); }); testWidgetsWithLeakTracking('allows reordering inside the middle of the widget', (WidgetTester tester) async { await tester.pumpWidget(build()); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 3')), tester.getCenter(find.text('Item 2')), ); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4'])); }); testWidgetsWithLeakTracking('properly reorders with a header', (WidgetTester tester) async { await tester.pumpWidget(build(header: const Text('Header Text'))); expect(find.text('Header Text'), findsOneWidget); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), ); await tester.pumpAndSettle(); expect(find.text('Header Text'), findsOneWidget); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); }); testWidgetsWithLeakTracking('properly reorders with a footer', (WidgetTester tester) async { await tester.pumpWidget(build(footer: const Text('Footer Text'))); expect(find.text('Footer Text'), findsOneWidget); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), ); await tester.pumpAndSettle(); expect(find.text('Footer Text'), findsOneWidget); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); }); testWidgetsWithLeakTracking('properly determines the vertical drop area extents', (WidgetTester tester) async { final Widget reorderableListView = ReorderableListView( onReorder: (int oldIndex, int newIndex) { }, children: const <Widget>[ SizedBox( key: Key('Normal item'), height: itemHeight, child: Text('Normal item'), ), SizedBox( key: Key('Tall item'), height: itemHeight * 2, child: Text('Tall item'), ), SizedBox( key: Key('Last item'), height: itemHeight, child: Text('Last item'), ), ], ); await tester.pumpWidget(MaterialApp( home: SizedBox( height: itemHeight * 10, child: reorderableListView, ), )); double getListHeight() { final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList)); return listScrollView.geometry!.maxPaintExtent; } const double kDraggingListHeight = 4 * itemHeight; // Drag a normal text item expect(getListHeight(), kDraggingListHeight); TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item'))); await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pumpAndSettle(); expect(getListHeight(), kDraggingListHeight); // Move it await drag.moveTo(tester.getCenter(find.text('Last item'))); await tester.pumpAndSettle(); expect(getListHeight(), kDraggingListHeight); // Drop it await drag.up(); await tester.pumpAndSettle(); expect(getListHeight(), kDraggingListHeight); // Drag a tall item drag = await tester.startGesture(tester.getCenter(find.text('Tall item'))); await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pumpAndSettle(); expect(getListHeight(), kDraggingListHeight); // Move it await drag.moveTo(tester.getCenter(find.text('Last item'))); await tester.pumpAndSettle(); expect(getListHeight(), kDraggingListHeight); // Drop it await drag.up(); await tester.pumpAndSettle(); expect(getListHeight(), kDraggingListHeight); }); testWidgetsWithLeakTracking('Vertical drag in progress golden image', (WidgetTester tester) async { debugDisableShadows = false; final Widget reorderableListView = ReorderableListView( children: <Widget>[ Container( key: const Key('pink'), width: double.infinity, height: itemHeight, color: Colors.pink, ), Container( key: const Key('blue'), width: double.infinity, height: itemHeight, color: Colors.blue, ), Container( key: const Key('green'), width: double.infinity, height: itemHeight, color: Colors.green, ), ], onReorder: (int oldIndex, int newIndex) { }, ); late final OverlayEntry entry; addTearDown(() => entry..remove()..dispose()); await tester.pumpWidget(MaterialApp( home: Container( color: Colors.white, height: itemHeight * 3, // Wrap in an overlay so that the golden image includes the dragged item. child: Overlay( initialEntries: <OverlayEntry>[ entry = OverlayEntry(builder: (BuildContext context) { // Wrap the list in padding to test that the positioning // is correct when the origin of the overlay is different // from the list. return Padding( padding: const EdgeInsets.all(24), child: reorderableListView, ); }), ], ), ), )); // Start dragging the second item. final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(const Key('blue')))); await tester.pump(kLongPressTimeout + kPressTimeout); // Drag it up to be partially over the top item. await drag.moveBy(const Offset(0, -itemHeight / 3)); await tester.pumpAndSettle(); // Should be an image of the second item overlapping the bottom of the // first with a gap between the first and third and a drop shadow on // the dragged item. await expectLater( find.byType(Overlay).last, matchesGoldenFile('reorderable_list_test.vertical.drop_area.png'), ); debugDisableShadows = true; }); testWidgetsWithLeakTracking('Preserves children states when the list parent changes the order', (WidgetTester tester) async { _StatefulState findState(Key key) { return find.byElementPredicate((Element element) => element.findAncestorWidgetOfExactType<_Stateful>()?.key == key) .evaluate() .first .findAncestorStateOfType<_StatefulState>()!; } await tester.pumpWidget(MaterialApp( home: ReorderableListView( children: <Widget>[ _Stateful(key: const Key('A')), _Stateful(key: const Key('B')), _Stateful(key: const Key('C')), ], onReorder: (int oldIndex, int newIndex) { }, ), )); await tester.tap(find.byKey(const Key('A'))); await tester.pumpAndSettle(); // Only the 'A' widget should be checked. expect(findState(const Key('A')).checked, true); expect(findState(const Key('B')).checked, false); expect(findState(const Key('C')).checked, false); await tester.pumpWidget(MaterialApp( home: ReorderableListView( children: <Widget>[ _Stateful(key: const Key('B')), _Stateful(key: const Key('C')), _Stateful(key: const Key('A')), ], onReorder: (int oldIndex, int newIndex) { }, ), )); // Only the 'A' widget should be checked. expect(findState(const Key('B')).checked, false); expect(findState(const Key('C')).checked, false); expect(findState(const Key('A')).checked, true); }); testWidgetsWithLeakTracking('Preserves children states when rebuilt', (WidgetTester tester) async { const Key firstBox = Key('key'); Widget build() { return MaterialApp( home: Directionality( textDirection: TextDirection.ltr, child: SizedBox( width: 100, height: 100, child: ReorderableListView( children: const <Widget>[ SizedBox(key: firstBox, width: 10, height: 10), ], onReorder: (_, __) {}, ), ), ), ); } // When the widget is rebuilt, the state of child should be consistent. await tester.pumpWidget(build()); final Element e0 = tester.element(find.byKey(firstBox)); await tester.pumpWidget(build()); final Element e1 = tester.element(find.byKey(firstBox)); expect(e0, equals(e1)); }); testWidgetsWithLeakTracking('Uses the PrimaryScrollController when available', (WidgetTester tester) async { final ScrollController primary = ScrollController(); addTearDown(primary.dispose); final Widget reorderableList = ReorderableListView( children: const <Widget>[ SizedBox(width: 100.0, height: 100.0, key: Key('C'), child: Text('C')), SizedBox(width: 100.0, height: 100.0, key: Key('B'), child: Text('B')), SizedBox(width: 100.0, height: 100.0, key: Key('A'), child: Text('A')), ], onReorder: (int oldIndex, int newIndex) { }, ); Widget buildWithScrollController(ScrollController controller) { return MaterialApp( home: PrimaryScrollController( controller: controller, child: SizedBox( height: 100.0, width: 100.0, child: reorderableList, ), ), ); } await tester.pumpWidget(buildWithScrollController(primary)); Scrollable scrollView = tester.widget( find.byType(Scrollable), ); expect(scrollView.controller, primary); // Now try changing the primary scroll controller and checking that the scroll view gets updated. final ScrollController primary2 = ScrollController(); addTearDown(primary2.dispose); await tester.pumpWidget(buildWithScrollController(primary2)); scrollView = tester.widget( find.byType(Scrollable), ); expect(scrollView.controller, primary2); }); testWidgetsWithLeakTracking('Test custom ScrollController behavior when set', (WidgetTester tester) async { const Key firstBox = Key('C'); const Key secondBox = Key('B'); const Key thirdBox = Key('A'); final ScrollController customController = ScrollController(); addTearDown(customController.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SizedBox( height: 150, child: ReorderableListView( scrollController: customController, onReorder: (int oldIndex, int newIndex) { }, children: const <Widget>[ SizedBox(width: 100.0, height: 100.0, key: firstBox, child: Text('C')), SizedBox(width: 100.0, height: 100.0, key: secondBox, child: Text('B')), SizedBox(width: 100.0, height: 100.0, key: thirdBox, child: Text('A')), ], ), ), ), ), ); // Check initial scroll offset of first list item relative to // the offset of the list view. customController.animateTo( 40.0, duration: const Duration(milliseconds: 200), curve: Curves.linear, ); await tester.pumpAndSettle(); Offset listViewTopLeft = tester.getTopLeft( find.byType(ReorderableListView), ); Offset firstBoxTopLeft = tester.getTopLeft( find.byKey(firstBox), ); expect(firstBoxTopLeft.dy, listViewTopLeft.dy - 40.0); // Drag the UI to see if the scroll controller updates accordingly await tester.drag( find.text('B'), const Offset(0.0, -100.0), ); listViewTopLeft = tester.getTopLeft( find.byType(ReorderableListView), ); firstBoxTopLeft = tester.getTopLeft( find.byKey(firstBox), ); // Initial scroll controller offset: 40.0 // Drag UI by 100.0 upwards vertically // First 20.0 px always ignored, so scroll offset is only // shifted by 80.0. // Final offset: 40.0 + 80.0 = 120.0 // The total distance available to scroll is 300.0 - 150.0 = 150.0, or // height of the ReorderableListView minus height of the SizedBox. Since // The final offset is less than this, it's not limited. expect(customController.offset, 120.0); }); testWidgetsWithLeakTracking('ReorderableList auto scrolling is fast enough', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/121603. final ScrollController controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: ReorderableListView.builder( scrollController: controller, itemCount: 100, itemBuilder: (BuildContext context, int index) { return Text('data', key: ValueKey<int>(index)); }, onReorder: (int oldIndex, int newIndex) {}, ), ), ), ); // Start gesture on first item. final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(const ValueKey<int>(0)))); await tester.pump(kLongPressTimeout + kPressTimeout); final Offset bottomRight = tester.getBottomRight(find.byType(ReorderableListView)); // Drag enough for move to start. await drag.moveTo(Offset(bottomRight.dx / 2, bottomRight.dy)); await tester.pump(); // Use a fixed value to make sure the default velocity scalar is bigger // than a certain amount. const double kMinimumAllowedAutoScrollDistancePer5ms = 1.7; await tester.pump(const Duration(milliseconds: 5)); expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms)); await tester.pump(const Duration(milliseconds: 5)); expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms * 2)); await tester.pump(const Duration(milliseconds: 5)); expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms * 3)); await tester.pump(const Duration(milliseconds: 5)); expect(controller.offset, greaterThan(kMinimumAllowedAutoScrollDistancePer5ms * 4)); }); testWidgetsWithLeakTracking('Still builds when no PrimaryScrollController is available', (WidgetTester tester) async { final Widget reorderableList = ReorderableListView( children: const <Widget>[ SizedBox(width: 100.0, height: 100.0, key: Key('C'), child: Text('C')), SizedBox(width: 100.0, height: 100.0, key: Key('B'), child: Text('B')), SizedBox(width: 100.0, height: 100.0, key: Key('A'), child: Text('A')), ], onReorder: (int oldIndex, int newIndex) { }, ); late final OverlayEntry entry; addTearDown(() => entry..remove()..dispose()); final Widget overlay = Overlay( initialEntries: <OverlayEntry>[ entry = OverlayEntry(builder: (BuildContext context) => reorderableList), ], ); final Widget boilerplate = Localizations( locale: const Locale('en'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child:SizedBox( width: 100.0, height: 100.0, child: Directionality( textDirection: TextDirection.ltr, child: overlay, ), ), ); await expectLater( () => tester.pumpWidget(boilerplate), returnsNormally, ); }); group('Accessibility (a11y/Semantics)', () { Map<CustomSemanticsAction, VoidCallback> getSemanticsActions(int index) { final Semantics semantics = find.ancestor( of: find.byKey(Key(listItems[index])), matching: find.byType(Semantics), ).evaluate().first.widget as Semantics; return semantics.properties.customSemanticsActions!; } const CustomSemanticsAction moveToStart = CustomSemanticsAction(label: 'Move to the start'); const CustomSemanticsAction moveToEnd = CustomSemanticsAction(label: 'Move to the end'); const CustomSemanticsAction moveUp = CustomSemanticsAction(label: 'Move up'); const CustomSemanticsAction moveDown = CustomSemanticsAction(label: 'Move down'); testWidgetsWithLeakTracking('Provides the correct accessibility actions in LTR and RTL modes', (WidgetTester tester) async { // The a11y actions for a vertical list are the same in LTR and RTL modes. final SemanticsHandle handle = tester.ensureSemantics(); for (final TextDirection direction in TextDirection.values) { await tester.pumpWidget(build()); // The first item can be moved down or to the end. final Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); expect(firstSemanticsActions.length, 2, reason: 'The first list item should have 2 custom actions with $direction.'); expect(firstSemanticsActions.containsKey(moveToStart), false, reason: 'The first item cannot `Move to the start` with $direction.'); expect(firstSemanticsActions.containsKey(moveUp), false, reason: 'The first item cannot `Move up` with $direction.'); expect(firstSemanticsActions.containsKey(moveDown), true, reason: 'The first item should be able to `Move down` with $direction.'); expect(firstSemanticsActions.containsKey(moveToEnd), true, reason: 'The first item should be able to `Move to the end` with $direction.'); // Items in the middle can be moved to the start, end, up or down. for (int i = 1; i < listItems.length - 1; i += 1) { final Map<CustomSemanticsAction, VoidCallback> ithSemanticsActions = getSemanticsActions(i); expect(ithSemanticsActions.length, 4, reason: 'List item $i should have 4 custom actions with $direction.'); expect(ithSemanticsActions.containsKey(moveToStart), true, reason: 'List item $i should be able to `Move to the start` with $direction.'); expect(ithSemanticsActions.containsKey(moveUp), true, reason: 'List item $i should be able to `Move up` with $direction.'); expect(ithSemanticsActions.containsKey(moveDown), true, reason: 'List item $i should be able to `Move down` with $direction.'); expect(ithSemanticsActions.containsKey(moveToEnd), true, reason: 'List item $i should be able to `Move to the end` with $direction.'); } // The last item can be moved up or to the start. final Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions(listItems.length - 1); expect(lastSemanticsActions.length, 2, reason: 'The last list item should have 2 custom actions with $direction.'); expect(lastSemanticsActions.containsKey(moveToStart), true, reason: 'The last item should be able to `Move to the start` with $direction.'); expect(lastSemanticsActions.containsKey(moveUp), true, reason: 'The last item should be able to `Move up` with $direction.'); expect(lastSemanticsActions.containsKey(moveDown), false, reason: 'The last item cannot `Move down` with $direction.'); expect(lastSemanticsActions.containsKey(moveToEnd), false, reason: 'The last item cannot `Move to the end` with $direction.'); } handle.dispose(); }); testWidgetsWithLeakTracking('First item accessibility (a11y) actions work', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to end: move Item 1 to the end of the list. await tester.pumpWidget(build()); Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); firstSemanticsActions[moveToEnd]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); // Test out move after: move Item 2 (the current first item) one space down. await tester.pumpWidget(build()); firstSemanticsActions = getSemanticsActions(0); firstSemanticsActions[moveDown]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1'])); handle.dispose(); }); testWidgetsWithLeakTracking('Middle item accessibility (a11y) actions work', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to end: move Item 2 to the end of the list. await tester.pumpWidget(build()); Map<CustomSemanticsAction, VoidCallback> middleSemanticsActions = getSemanticsActions(1); middleSemanticsActions[moveToEnd]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); // Test out move after: move Item 3 (the current second item) one space down. await tester.pumpWidget(build()); middleSemanticsActions = getSemanticsActions(1); middleSemanticsActions[moveDown]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 4', 'Item 3', 'Item 2'])); // Test out move after: move Item 3 (the current third item) one space up. await tester.pumpWidget(build()); middleSemanticsActions = getSemanticsActions(2); middleSemanticsActions[moveUp]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); // Test out move to start: move Item 4 (the current third item) to the start of the list. await tester.pumpWidget(build()); middleSemanticsActions = getSemanticsActions(2); middleSemanticsActions[moveToStart]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); handle.dispose(); }); testWidgetsWithLeakTracking('Last item accessibility (a11y) actions work', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to start: move Item 4 to the start of the list. await tester.pumpWidget(build()); Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions(listItems.length - 1); lastSemanticsActions[moveToStart]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); // Test out move up: move Item 3 (the current last item) one space up. await tester.pumpWidget(build()); lastSemanticsActions = getSemanticsActions(listItems.length - 1); lastSemanticsActions[moveUp]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); handle.dispose(); }); testWidgetsWithLeakTracking("Doesn't hide accessibility when a child declares its own semantics", (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); final Widget reorderableListView = ReorderableListView( onReorder: (int oldIndex, int newIndex) { }, children: <Widget>[ const SizedBox( key: Key('List tile 1'), height: itemHeight, child: Text('List tile 1'), ), SizedBox( key: const Key('Switch tile'), height: itemHeight, child: Material( child: SwitchListTile( title: const Text('Switch tile'), value: true, onChanged: (bool? newValue) { }, ), ), ), const SizedBox( key: Key('List tile 2'), height: itemHeight, child: Text('List tile 2'), ), ], ); await tester.pumpWidget(MaterialApp( home: SizedBox( height: itemHeight * 10, child: reorderableListView, ), )); // Get the switch tile's semantics: final SemanticsNode semanticsNode = tester.getSemantics(find.byKey(const Key('Switch tile'))); // Check for ReorderableListView custom semantics actions. expect(semanticsNode, matchesSemantics( customActions: const <CustomSemanticsAction>[ CustomSemanticsAction(label: 'Move up'), CustomSemanticsAction(label: 'Move down'), CustomSemanticsAction(label: 'Move to the end'), CustomSemanticsAction(label: 'Move to the start'), ], )); // Check for properties of SwitchTile semantics. late SemanticsNode child; semanticsNode.visitChildren((SemanticsNode node) { child = node; return false; }); expect(child, matchesSemantics( hasToggledState: true, isToggled: true, isEnabled: true, isFocusable: true, hasEnabledState: true, label: 'Switch tile', hasTapAction: true, )); handle.dispose(); }); }); }); group('in horizontal mode', () { testWidgetsWithLeakTracking('reorder is not triggered when children length is less or equals to 1', (WidgetTester tester) async { bool onReorderWasCalled = false; final List<String> currentListItems = listItems.take(1).toList(); final ReorderableListView reorderableListView = ReorderableListView( header: const Text('Header'), scrollDirection: Axis.horizontal, onReorder: (_, __) => onReorderWasCalled = true, children: currentListItems.map<Widget>(listItemToWidget).toList(), ); final List<String> currentOriginalListItems = originalListItems.take(1).toList(); await tester.pumpWidget(MaterialApp( home: SizedBox( height: itemHeight * 10, child: reorderableListView, ), )); expect(currentListItems, orderedEquals(currentOriginalListItems)); final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 1'))); await tester.pump(kLongPressTimeout + kPressTimeout); expect(currentListItems, orderedEquals(currentOriginalListItems)); await drag.moveTo(tester.getBottomLeft(find.text('Item 1')) * 2); expect(currentListItems, orderedEquals(currentOriginalListItems)); await drag.up(); expect(onReorderWasCalled, false); expect(currentListItems, orderedEquals(<String>['Item 1'])); }); testWidgetsWithLeakTracking('allows reordering from the very top to the very bottom', (WidgetTester tester) async { await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0), ); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); }); testWidgetsWithLeakTracking('allows reordering from the very bottom to the very top', (WidgetTester tester) async { await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 4')), tester.getCenter(find.text('Item 1')), ); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); }); testWidgetsWithLeakTracking('allows reordering inside the middle of the widget', (WidgetTester tester) async { await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 3')), tester.getCenter(find.text('Item 2')), ); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4'])); }); testWidgetsWithLeakTracking('properly reorders with a header', (WidgetTester tester) async { await tester.pumpWidget(build(header: const Text('Header Text'), scrollDirection: Axis.horizontal)); expect(find.text('Header Text'), findsOneWidget); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0), ); await tester.pumpAndSettle(); expect(find.text('Header Text'), findsOneWidget); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); await tester.pumpWidget(build(header: const Text('Header Text'), scrollDirection: Axis.horizontal)); await longPressDrag( tester, tester.getCenter(find.text('Item 4')), tester.getCenter(find.text('Item 3')), ); await tester.pumpAndSettle(); expect(find.text('Header Text'), findsOneWidget); expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1'])); }); testWidgetsWithLeakTracking('properly reorders with a footer', (WidgetTester tester) async { await tester.pumpWidget(build(footer: const Text('Footer Text'), scrollDirection: Axis.horizontal)); expect(find.text('Footer Text'), findsOneWidget); expect(listItems, orderedEquals(originalListItems)); await longPressDrag( tester, tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0), ); await tester.pumpAndSettle(); expect(find.text('Footer Text'), findsOneWidget); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); await tester.pumpWidget(build(footer: const Text('Footer Text'), scrollDirection: Axis.horizontal)); await longPressDrag( tester, tester.getCenter(find.text('Item 4')), tester.getCenter(find.text('Item 3')), ); await tester.pumpAndSettle(); expect(find.text('Footer Text'), findsOneWidget); expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1'])); }); testWidgetsWithLeakTracking('properly determines the horizontal drop area extents', (WidgetTester tester) async { final Widget reorderableListView = ReorderableListView( scrollDirection: Axis.horizontal, onReorder: (int oldIndex, int newIndex) { }, children: const <Widget>[ SizedBox( key: Key('Normal item'), width: itemHeight, child: Text('Normal item'), ), SizedBox( key: Key('Tall item'), width: itemHeight * 2, child: Text('Tall item'), ), SizedBox( key: Key('Last item'), width: itemHeight, child: Text('Last item'), ), ], ); await tester.pumpWidget(MaterialApp( home: SizedBox( width: itemHeight * 10, child: reorderableListView, ), )); double getListWidth() { final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList)); return listScrollView.geometry!.maxPaintExtent; } const double kDraggingListWidth = 4 * itemHeight; // Drag a normal text item expect(getListWidth(), kDraggingListWidth); TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item'))); await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pumpAndSettle(); expect(getListWidth(), kDraggingListWidth); // Move it await drag.moveTo(tester.getCenter(find.text('Last item'))); await tester.pumpAndSettle(); expect(getListWidth(), kDraggingListWidth); // Drop it await drag.up(); await tester.pumpAndSettle(); expect(getListWidth(), kDraggingListWidth); // Drag a tall item drag = await tester.startGesture(tester.getCenter(find.text('Tall item'))); await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pumpAndSettle(); expect(getListWidth(), kDraggingListWidth); // Move it await drag.moveTo(tester.getCenter(find.text('Last item'))); await tester.pumpAndSettle(); expect(getListWidth(), kDraggingListWidth); // Drop it await drag.up(); await tester.pumpAndSettle(); expect(getListWidth(), kDraggingListWidth); }); testWidgetsWithLeakTracking('Horizontal drag in progress golden image', (WidgetTester tester) async { debugDisableShadows = false; final Widget reorderableListView = ReorderableListView( scrollDirection: Axis.horizontal, onReorder: (int oldIndex, int newIndex) { }, children: <Widget>[ Container( key: const Key('pink'), height: double.infinity, width: itemHeight, color: Colors.pink, ), Container( key: const Key('blue'), height: double.infinity, width: itemHeight, color: Colors.blue, ), Container( key: const Key('green'), height: double.infinity, width: itemHeight, color: Colors.green, ), ], ); late final OverlayEntry entry; addTearDown(() => entry..remove()..dispose()); await tester.pumpWidget(MaterialApp( home: Container( color: Colors.white, width: itemHeight * 3, // Wrap in an overlay so that the golden image includes the dragged item. child: Overlay( initialEntries: <OverlayEntry>[ entry = OverlayEntry(builder: (BuildContext context) { // Wrap the list in padding to test that the positioning // is correct when the origin of the overlay is different // from the list. return Padding( padding: const EdgeInsets.all(24), child: reorderableListView, ); }), ], ), ), )); // Start dragging the second item. final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(const Key('blue')))); await tester.pump(kLongPressTimeout + kPressTimeout); // Drag it left to be partially over the first item. await drag.moveBy(const Offset(-itemHeight / 3, 0)); await tester.pumpAndSettle(); // Should be an image of the second item overlapping the right of the // first with a gap between the first and third and a drop shadow on // the dragged item. await expectLater( find.byType(Overlay).last, matchesGoldenFile('reorderable_list_test.horizontal.drop_area.png'), ); debugDisableShadows = true; }); testWidgetsWithLeakTracking('Preserves children states when the list parent changes the order', (WidgetTester tester) async { _StatefulState findState(Key key) { return find.byElementPredicate((Element element) => element.findAncestorWidgetOfExactType<_Stateful>()?.key == key) .evaluate() .first .findAncestorStateOfType<_StatefulState>()!; } await tester.pumpWidget(MaterialApp( home: ReorderableListView( onReorder: (int oldIndex, int newIndex) { }, scrollDirection: Axis.horizontal, children: <Widget>[ _Stateful(key: const Key('A')), _Stateful(key: const Key('B')), _Stateful(key: const Key('C')), ], ), )); await tester.tap(find.byKey(const Key('A'))); await tester.pumpAndSettle(); // Only the 'A' widget should be checked. expect(findState(const Key('A')).checked, true); expect(findState(const Key('B')).checked, false); expect(findState(const Key('C')).checked, false); await tester.pumpWidget(MaterialApp( home: ReorderableListView( onReorder: (int oldIndex, int newIndex) { }, scrollDirection: Axis.horizontal, children: <Widget>[ _Stateful(key: const Key('B')), _Stateful(key: const Key('C')), _Stateful(key: const Key('A')), ], ), )); // Only the 'A' widget should be checked. expect(findState(const Key('B')).checked, false); expect(findState(const Key('C')).checked, false); expect(findState(const Key('A')).checked, true); }); testWidgetsWithLeakTracking('Preserves children states when rebuilt', (WidgetTester tester) async { const Key firstBox = Key('key'); Widget build() { return MaterialApp( home: Directionality( textDirection: TextDirection.ltr, child: SizedBox( width: 100, height: 100, child: ReorderableListView( scrollDirection: Axis.horizontal, children: const <Widget>[ SizedBox(key: firstBox, width: 10, height: 10), ], onReorder: (_, __) {}, ), ), ), ); } // When the widget is rebuilt, the state of child should be consistent. await tester.pumpWidget(build()); final Element e0 = tester.element(find.byKey(firstBox)); await tester.pumpWidget(build()); final Element e1 = tester.element(find.byKey(firstBox)); expect(e0, equals(e1)); }); group('Accessibility (a11y/Semantics)', () { Map<CustomSemanticsAction, VoidCallback> getSemanticsActions(int index) { final Semantics semantics = find.ancestor( of: find.byKey(Key(listItems[index])), matching: find.byType(Semantics), ).evaluate().first.widget as Semantics; return semantics.properties.customSemanticsActions!; } const CustomSemanticsAction moveToStart = CustomSemanticsAction(label: 'Move to the start'); const CustomSemanticsAction moveToEnd = CustomSemanticsAction(label: 'Move to the end'); const CustomSemanticsAction moveLeft = CustomSemanticsAction(label: 'Move left'); const CustomSemanticsAction moveRight = CustomSemanticsAction(label: 'Move right'); testWidgetsWithLeakTracking('Provides the correct accessibility actions in LTR mode', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); // The first item can be moved right or to the end. final Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); expect(firstSemanticsActions.length, 2, reason: 'The first list item should have 2 custom actions.'); expect(firstSemanticsActions.containsKey(moveToStart), false, reason: 'The first item cannot `Move to the start`.'); expect(firstSemanticsActions.containsKey(moveLeft), false, reason: 'The first item cannot `Move left`.'); expect(firstSemanticsActions.containsKey(moveRight), true, reason: 'The first item should be able to `Move right`.'); expect(firstSemanticsActions.containsKey(moveToEnd), true, reason: 'The first item should be able to `Move to the end`.'); // Items in the middle can be moved to the start, end, left or right. for (int i = 1; i < listItems.length - 1; i += 1) { final Map<CustomSemanticsAction, VoidCallback> ithSemanticsActions = getSemanticsActions(i); expect(ithSemanticsActions.length, 4, reason: 'List item $i should have 4 custom actions.'); expect(ithSemanticsActions.containsKey(moveToStart), true, reason: 'List item $i should be able to `Move to the start`.'); expect(ithSemanticsActions.containsKey(moveLeft), true, reason: 'List item $i should be able to `Move left`.'); expect(ithSemanticsActions.containsKey(moveRight), true, reason: 'List item $i should be able to `Move right`.'); expect(ithSemanticsActions.containsKey(moveToEnd), true, reason: 'List item $i should be able to `Move to the end`.'); } // The last item can be moved left or to the start. final Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions(listItems.length - 1); expect(lastSemanticsActions.length, 2, reason: 'The last list item should have 2 custom actions.'); expect(lastSemanticsActions.containsKey(moveToStart), true, reason: 'The last item should be able to `Move to the start`.'); expect(lastSemanticsActions.containsKey(moveLeft), true, reason: 'The last item should be able to `Move left`.'); expect(lastSemanticsActions.containsKey(moveRight), false, reason: 'The last item cannot `Move right`.'); expect(lastSemanticsActions.containsKey(moveToEnd), false, reason: 'The last item cannot `Move to the end`.'); handle.dispose(); }); testWidgetsWithLeakTracking('Provides the correct accessibility actions in Right-To-Left directionality', (WidgetTester tester) async { // In RTL mode, the right is the start and the left is the end. // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. final SemanticsHandle handle = tester.ensureSemantics(); await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); // The first item can be moved right or to the end. final Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); expect(firstSemanticsActions.length, 2, reason: 'The first list item should have 2 custom actions.'); expect(firstSemanticsActions.containsKey(moveToStart), false, reason: 'The first item cannot `Move to the start`.'); expect(firstSemanticsActions.containsKey(moveRight), false, reason: 'The first item cannot `Move right`.'); expect(firstSemanticsActions.containsKey(moveLeft), true, reason: 'The first item should be able to `Move left`.'); expect(firstSemanticsActions.containsKey(moveToEnd), true, reason: 'The first item should be able to `Move to the end`.'); // Items in the middle can be moved to the start, end, left or right. for (int i = 1; i < listItems.length - 1; i += 1) { final Map<CustomSemanticsAction, VoidCallback> ithSemanticsActions = getSemanticsActions(i); expect(ithSemanticsActions.length, 4, reason: 'List item $i should have 4 custom actions.'); expect(ithSemanticsActions.containsKey(moveToStart), true, reason: 'List item $i should be able to `Move to the start`.'); expect(ithSemanticsActions.containsKey(moveRight), true, reason: 'List item $i should be able to `Move right`.'); expect(ithSemanticsActions.containsKey(moveLeft), true, reason: 'List item $i should be able to `Move left`.'); expect(ithSemanticsActions.containsKey(moveToEnd), true, reason: 'List item $i should be able to `Move to the end`.'); } // The last item can be moved left or to the start. final Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions(listItems.length - 1); expect(lastSemanticsActions.length, 2, reason: 'The last list item should have 2 custom actions.'); expect(lastSemanticsActions.containsKey(moveToStart), true, reason: 'The last item should be able to `Move to the start`.'); expect(lastSemanticsActions.containsKey(moveRight), true, reason: 'The last item should be able to `Move right`.'); expect(lastSemanticsActions.containsKey(moveLeft), false, reason: 'The last item cannot `Move left`.'); expect(lastSemanticsActions.containsKey(moveToEnd), false, reason: 'The last item cannot `Move to the end`.'); handle.dispose(); }); testWidgetsWithLeakTracking('First item accessibility (a11y) actions work in LTR mode', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to end: move Item 1 to the end of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); firstSemanticsActions[moveToEnd]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); // Test out move after: move Item 2 (the current first item) one space to the right. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); firstSemanticsActions = getSemanticsActions(0); firstSemanticsActions[moveRight]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1'])); handle.dispose(); }); testWidgetsWithLeakTracking('First item accessibility (a11y) actions work in Right-To-Left directionality', (WidgetTester tester) async { // In RTL mode, the right is the start and the left is the end. // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to end: move Item 1 to the end of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); Map<CustomSemanticsAction, VoidCallback> firstSemanticsActions = getSemanticsActions(0); firstSemanticsActions[moveToEnd]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); // Test out move after: move Item 2 (the current first item) one space to the left. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); firstSemanticsActions = getSemanticsActions(0); firstSemanticsActions[moveLeft]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1'])); handle.dispose(); }); testWidgetsWithLeakTracking('Middle item accessibility (a11y) actions work in LTR mode', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to end: move Item 2 to the end of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); Map<CustomSemanticsAction, VoidCallback> middleSemanticsActions = getSemanticsActions(1); middleSemanticsActions[moveToEnd]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); // Test out move after: move Item 3 (the current second item) one space to the right. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); middleSemanticsActions = getSemanticsActions(1); middleSemanticsActions[moveRight]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 4', 'Item 3', 'Item 2'])); // Test out move after: move Item 3 (the current third item) one space to the left. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); middleSemanticsActions = getSemanticsActions(2); middleSemanticsActions[moveLeft]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); // Test out move to start: move Item 4 (the current third item) to the start of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); middleSemanticsActions = getSemanticsActions(2); middleSemanticsActions[moveToStart]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); handle.dispose(); }); testWidgetsWithLeakTracking('Middle item accessibility (a11y) actions work in Right-To-Left directionality', (WidgetTester tester) async { // In RTL mode, the right is the start and the left is the end. // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to end: move Item 2 to the end of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); Map<CustomSemanticsAction, VoidCallback> middleSemanticsActions = getSemanticsActions(1); middleSemanticsActions[moveToEnd]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); // Test out move after: move Item 3 (the current second item) one space to the left. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); middleSemanticsActions = getSemanticsActions(1); middleSemanticsActions[moveLeft]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 4', 'Item 3', 'Item 2'])); // Test out move after: move Item 3 (the current third item) one space to the right. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); middleSemanticsActions = getSemanticsActions(2); middleSemanticsActions[moveRight]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 4', 'Item 2'])); // Test out move to start: move Item 4 (the current third item) to the start of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); middleSemanticsActions = getSemanticsActions(2); middleSemanticsActions[moveToStart]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); handle.dispose(); }); testWidgetsWithLeakTracking('Last item accessibility (a11y) actions work in LTR mode', (WidgetTester tester) async { final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to start: move Item 4 to the start of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions(listItems.length - 1); lastSemanticsActions[moveToStart]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); // Test out move before: move Item 3 (the current last item) one space to the left. await tester.pumpWidget(build(scrollDirection: Axis.horizontal)); lastSemanticsActions = getSemanticsActions(listItems.length - 1); lastSemanticsActions[moveLeft]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); handle.dispose(); }); testWidgetsWithLeakTracking('Last item accessibility (a11y) actions work in Right-To-Left directionality', (WidgetTester tester) async { // In RTL mode, the right is the start and the left is the end. // The array representation is unchanged (LTR), but the direction of the motion actions is reversed. final SemanticsHandle handle = tester.ensureSemantics(); expect(listItems, orderedEquals(originalListItems)); // Test out move to start: move Item 4 to the start of the list. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); Map<CustomSemanticsAction, VoidCallback> lastSemanticsActions = getSemanticsActions(listItems.length - 1); lastSemanticsActions[moveToStart]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); // Test out move before: move Item 3 (the current last item) one space to the right. await tester.pumpWidget(build(scrollDirection: Axis.horizontal, textDirection: TextDirection.rtl)); lastSemanticsActions = getSemanticsActions(listItems.length - 1); lastSemanticsActions[moveRight]!(); await tester.pumpAndSettle(); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2'])); handle.dispose(); }); }); }); testWidgetsWithLeakTracking('ReorderableListView.builder asserts on negative childCount', (WidgetTester tester) async { expect(() => ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return const SizedBox(); }, itemCount: -1, onReorder: (int from, int to) {}, ), throwsAssertionError); }); testWidgetsWithLeakTracking('ReorderableListView.builder only creates the children it needs', (WidgetTester tester) async { final Set<int> itemsCreated = <int>{}; await tester.pumpWidget(MaterialApp( home: ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { itemsCreated.add(index); return Text(index.toString(), key: ValueKey<int>(index)); }, itemCount: 1000, onReorder: (int from, int to) {}, ), )); // Should have only created the first 18 items. expect(itemsCreated, <int>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17}); }); group('Padding', () { testWidgetsWithLeakTracking('Padding with no header & footer', (WidgetTester tester) async { const EdgeInsets padding = EdgeInsets.fromLTRB(10, 20, 30, 40); // Vertical await tester.pumpWidget(build(padding: padding)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(10, 20, 770, 68)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(10, 164, 770, 212)); // Horizontal await tester.pumpWidget(build(padding: padding, scrollDirection: Axis.horizontal)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(10, 20, 58, 560)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(154, 20, 202, 560)); }); testWidgetsWithLeakTracking('Padding with header or footer', (WidgetTester tester) async { const EdgeInsets padding = EdgeInsets.fromLTRB(10, 20, 30, 40); const Key headerKey = Key('Header'); const Key footerKey = Key('Footer'); const Widget verticalHeader = SizedBox(key: headerKey, height: 10); const Widget horizontalHeader = SizedBox(key: headerKey, width: 10); const Widget verticalFooter = SizedBox(key: footerKey, height: 10); const Widget horizontalFooter = SizedBox(key: footerKey, width: 10); // Vertical Header await tester.pumpWidget(build(padding: padding, header: verticalHeader)); expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(10, 20, 770, 30)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(10, 30, 770, 78)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(10, 174, 770, 222)); // Vertical Footer await tester.pumpWidget(build(padding: padding, footer: verticalFooter)); expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(10, 212, 770, 222)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(10, 20, 770, 68)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(10, 164, 770, 212)); // Vertical Header, reversed await tester.pumpWidget(build(padding: padding, header: verticalHeader, reverse: true)); expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(10, 550, 770, 560)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(10, 502, 770, 550)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(10, 358, 770, 406)); // Vertical Footer, reversed await tester.pumpWidget(build(padding: padding, footer: verticalFooter, reverse: true)); expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(10, 358, 770, 368)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(10, 512, 770, 560)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(10, 368, 770, 416)); // Horizontal Header await tester.pumpWidget(build(padding: padding, header: horizontalHeader, scrollDirection: Axis.horizontal)); expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(10, 20, 20, 560)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(20, 20, 68, 560)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(164, 20, 212, 560)); // // Horizontal Footer await tester.pumpWidget(build(padding: padding, footer: horizontalFooter, scrollDirection: Axis.horizontal)); expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(202, 20, 212, 560)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(10, 20, 58, 560)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(154, 20, 202, 560)); // Horizontal Header, reversed await tester.pumpWidget(build(padding: padding, header: horizontalHeader, scrollDirection: Axis.horizontal, reverse: true)); expect(tester.getRect(find.byKey(headerKey)), const Rect.fromLTRB(760, 20, 770, 560)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(712, 20, 760, 560)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(568, 20, 616, 560)); // // Horizontal Footer, reversed await tester.pumpWidget(build(padding: padding, footer: horizontalFooter, scrollDirection: Axis.horizontal, reverse: true)); expect(tester.getRect(find.byKey(footerKey)), const Rect.fromLTRB(568, 20, 578, 560)); expect(tester.getRect(find.byKey(const Key('Item 1'))), const Rect.fromLTRB(722, 20, 770, 560)); expect(tester.getRect(find.byKey(const Key('Item 4'))), const Rect.fromLTRB(578, 20, 626, 560)); }); }); testWidgetsWithLeakTracking('ReorderableListView can be reversed', (WidgetTester tester) async { final Widget reorderableListView = ReorderableListView( reverse: true, onReorder: (int oldIndex, int newIndex) { }, children: const <Widget>[ SizedBox( key: Key('A'), child: Text('A'), ), SizedBox( key: Key('B'), child: Text('B'), ), SizedBox( key: Key('C'), child: Text('C'), ), ], ); await tester.pumpWidget(MaterialApp( home: reorderableListView, )); expect(tester.getCenter(find.text('A')).dy, greaterThan(tester.getCenter(find.text('B')).dy)); }); testWidgetsWithLeakTracking('Animation test when placing an item in place', (WidgetTester tester) async { const Key testItemKey = Key('Test item'); final Widget reorderableListView = ReorderableListView( onReorder: (int oldIndex, int newIndex) { }, children: const <Widget>[ SizedBox( key: Key('First item'), height: itemHeight, child: Text('First item'), ), SizedBox( key: testItemKey, height: itemHeight, child: Text('Test item'), ), SizedBox( key: Key('Last item'), height: itemHeight, child: Text('Last item'), ), ], ); await tester.pumpWidget(MaterialApp( home: SizedBox( height: itemHeight * 10, child: reorderableListView, ), )); Offset getTestItemPosition() { final RenderBox testItem = tester.renderObject<RenderBox>(find.byKey(testItemKey)); return testItem.localToGlobal(Offset.zero); } // Before pick it up. final Offset startPosition = getTestItemPosition(); // Pick it up. final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byKey(testItemKey))); await tester.pump(kLongPressTimeout + kPressTimeout); expect(getTestItemPosition(), startPosition); // Put it down. await gesture.up(); await tester.pump(); expect(getTestItemPosition(), startPosition); // After put it down. await tester.pumpAndSettle(); expect(getTestItemPosition(), startPosition); }); // TODO(djshuckerow): figure out how to write a test for scrolling the list. testWidgetsWithLeakTracking('ReorderableListView on desktop platforms should have drag handles', (WidgetTester tester) async { await tester.pumpWidget(build()); // All four items should have drag handles and not delayed listeners. expect(find.byIcon(Icons.drag_handle), findsNWidgets(4)); expect(find.byType(ReorderableDelayedDragStartListener), findsNothing); }, variant: TargetPlatformVariant.desktop()); testWidgetsWithLeakTracking('ReorderableListView on mobile platforms should not have drag handles', (WidgetTester tester) async { await tester.pumpWidget(build()); // All four items should have delayed listeners and not drag handles. expect(find.byType(ReorderableDelayedDragStartListener), findsNWidgets(4)); expect(find.byIcon(Icons.drag_handle), findsNothing); }, variant: TargetPlatformVariant.mobile()); testWidgetsWithLeakTracking('Vertical list renders drag handle in correct position', (WidgetTester tester) async { await tester.pumpWidget(build(platform: TargetPlatform.macOS)); final Finder listView = find.byType(ReorderableListView); final Finder item1 = find.byKey(const Key('Item 1')); final Finder dragHandle = find.byIcon(Icons.drag_handle).first; // Should be centered vertically within the item and 8 pixels from the right edge of the list. expect(tester.getCenter(dragHandle).dy, tester.getCenter(item1).dy); expect(tester.getTopRight(dragHandle).dx, tester.getSize(listView).width - 8); }); testWidgetsWithLeakTracking('Horizontal list renders drag handle in correct position', (WidgetTester tester) async { await tester.pumpWidget(build(scrollDirection: Axis.horizontal, platform: TargetPlatform.macOS)); final Finder listView = find.byType(ReorderableListView); final Finder item1 = find.byKey(const Key('Item 1')); final Finder dragHandle = find.byIcon(Icons.drag_handle).first; // Should be centered horizontally within the item and 8 pixels from the bottom of the list. expect(tester.getCenter(dragHandle).dx, tester.getCenter(item1).dx); expect(tester.getBottomRight(dragHandle).dy, tester.getSize(listView).height - 8); }); }); testWidgetsWithLeakTracking('ReorderableListView, can deal with the dragged item getting unmounted and rebuilt during drag', (WidgetTester tester) async { // See https://github.com/flutter/flutter/issues/74840 for more details. final List<int> items = List<int>.generate(100, (int index) => index); void handleReorder(int fromIndex, int toIndex) { if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); } // The list is 800x600, 8 items, each item is 800x100 with // an "item $index" text widget at the item's origin. Drags are initiated by // a simple press on the text widget. await tester.pumpWidget(MaterialApp( home: ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(items[index]), height: 100, child: ReorderableDragStartListener( index: index, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Text('item ${items[index]}'), ], ), ), ); }, buildDefaultDragHandles: false, itemCount: items.length, onReorder: handleReorder, ), )); // Drag item 0 downwards and force an auto scroll off the end of the list // far enough that item zeros original entry in the list is unmounted. final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); await tester.pump(kPressTimeout); // Off the bottom of the screen, which should autoscroll until we hit the // end of the list await drag.moveBy(const Offset(0, 700)); await tester.pump(const Duration(seconds: 30)); await tester.pumpAndSettle(); // Ensure we made it to the bottom (only 4 should be showing as there should // be a gap at the end for the drop area of the dragged item. for (final int i in <int>[95, 96, 97, 98, 99]) { expect(find.text('item $i'), findsOneWidget); } // Drag back to off the top of the list, which should autoscroll until // we hit the beginning of the list. This should cause the first item's // entry to be rebuilt. However, the contents should not be in both places. await drag.moveBy(const Offset(0, -1400)); await tester.pump(const Duration(seconds: 30)); await tester.pumpAndSettle(); // Release back at the top so item 0 should drop where it was await drag.up(); await tester.pumpAndSettle(); // Should not have changed anything for (final int i in <int>[0, 1, 2, 3, 4, 5]) { expect(find.text('item $i'), findsOneWidget); } expect(items.take(8), orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7])); }); testWidgetsWithLeakTracking('ReorderableListView calls onReorderStart and onReorderEnd correctly', (WidgetTester tester) async { final List<int> items = List<int>.generate(8, (int index) => index); int? startIndex, endIndex; final Finder item0 = find.textContaining('item 0'); void handleReorder(int fromIndex, int toIndex) { if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); } await tester.pumpWidget(MaterialApp( home: ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(items[index]), height: 100, child: ReorderableDragStartListener( index: index, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Text('item ${items[index]}'), ], ), ), ); }, buildDefaultDragHandles: false, itemCount: items.length, onReorder: handleReorder, onReorderStart: (int index) { startIndex = index; }, onReorderEnd: (int index) { endIndex = index; }, ), )); TestGesture drag = await tester.startGesture(tester.getCenter(item0)); await tester.pump(kPressTimeout); // Drag enough for move to start. await drag.moveBy(const Offset(0, 20)); expect(startIndex, equals(0)); expect(endIndex, isNull); // Move item0 from index 0 to index 3 await drag.moveBy(const Offset(0, 300)); await tester.pumpAndSettle(); await drag.up(); await tester.pumpAndSettle(); expect(endIndex, equals(3)); startIndex = null; endIndex = null; drag = await tester.startGesture(tester.getCenter(item0)); await tester.pump(kPressTimeout); // Drag enough for move to start. await drag.moveBy(const Offset(0, 20)); expect(startIndex, equals(2)); expect(endIndex, isNull); // Move item0 from index 2 to index 0 await drag.moveBy(const Offset(0, -200)); await tester.pumpAndSettle(); await drag.up(); await tester.pumpAndSettle(); expect(endIndex, equals(0)); }); testWidgetsWithLeakTracking('ReorderableListView throws an error when key is not passed to its children', (WidgetTester tester) async { final Widget reorderableListView = ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return SizedBox(child: Text('Item $index')); }, itemCount: 3, onReorder: (int oldIndex, int newIndex) { }, ); await tester.pumpWidget(MaterialApp( home: reorderableListView, )); final dynamic exception = tester.takeException(); expect(exception, isFlutterError); expect(exception.toString(), contains('Every item of ReorderableListView must have a key.')); }); testWidgetsWithLeakTracking('Throws an error if no overlay present', (WidgetTester tester) async { final Widget reorderableList = ReorderableListView( children: const <Widget>[ SizedBox(width: 100.0, height: 100.0, key: Key('C'), child: Text('C')), SizedBox(width: 100.0, height: 100.0, key: Key('B'), child: Text('B')), SizedBox(width: 100.0, height: 100.0, key: Key('A'), child: Text('A')), ], onReorder: (int oldIndex, int newIndex) { }, ); final Widget boilerplate = Localizations( locale: const Locale('en'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child: SizedBox( width: 100.0, height: 100.0, child: Directionality( textDirection: TextDirection.ltr, child: reorderableList, ), ), ); await tester.pumpWidget(boilerplate); final dynamic exception = tester.takeException(); expect(exception, isFlutterError); expect(exception.toString(), contains('No Overlay widget found')); expect(exception.toString(), contains('ReorderableListView widgets require an Overlay widget ancestor')); }); testWidgetsWithLeakTracking('ReorderableListView asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async { expect(() => ReorderableListView( itemExtent: 30, prototypeItem: const SizedBox(), onReorder: (int fromIndex, int toIndex) { }, children: const <Widget>[], ), throwsAssertionError); }); testWidgetsWithLeakTracking('ReorderableListView.builder asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async { final List<int> numbers = <int>[0,1,2]; expect(() => ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(numbers[index]), height: 20 + numbers[index] * 10, child: ReorderableDragStartListener( index: index, child: Text(numbers[index].toString()), ) ); }, itemCount: numbers.length, itemExtent: 30, prototypeItem: const SizedBox(), onReorder: (int fromIndex, int toIndex) { }, ), throwsAssertionError); }); testWidgetsWithLeakTracking('if itemExtent is non-null, children have same extent in the scroll direction', (WidgetTester tester) async { final List<int> numbers = <int>[0,1,2]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(numbers[index]), // children with different heights height: 20 + numbers[index] * 10, child: ReorderableDragStartListener( index: index, child: Text(numbers[index].toString()), ) ); }, itemCount: numbers.length, itemExtent: 30, onReorder: (int fromIndex, int toIndex) { }, ); }, ), ), ) ); final double item0Height = tester.getSize(find.text('0').hitTestable()).height; final double item1Height = tester.getSize(find.text('1').hitTestable()).height; final double item2Height = tester.getSize(find.text('2').hitTestable()).height; expect(item0Height, 30.0); expect(item1Height, 30.0); expect(item2Height, 30.0); }); testWidgetsWithLeakTracking('if prototypeItem is non-null, children have same extent in the scroll direction', (WidgetTester tester) async { final List<int> numbers = <int>[0,1,2]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(numbers[index]), // children with different heights height: 20 + numbers[index] * 10, child: ReorderableDragStartListener( index: index, child: Text(numbers[index].toString()), ) ); }, itemCount: numbers.length, prototypeItem: const SizedBox( height: 30, child: Text('3'), ), onReorder: (int oldIndex, int newIndex) { }, ); }, ), ), ) ); final double item0Height = tester.getSize(find.text('0').hitTestable()).height; final double item1Height = tester.getSize(find.text('1').hitTestable()).height; final double item2Height = tester.getSize(find.text('2').hitTestable()).height; expect(item0Height, 30.0); expect(item1Height, 30.0); expect(item2Height, 30.0); }); testWidgetsWithLeakTracking('ReorderableListView auto scrolls speed is configurable', (WidgetTester tester) async { Future<void> pumpFor({ required Duration duration, Duration interval = const Duration(milliseconds: 50), }) async { await tester.pump(); int times = (duration.inMilliseconds / interval.inMilliseconds).ceil(); while (times > 0) { await tester.pump(interval + const Duration(milliseconds: 1)); await tester.idle(); times--; } } Future<double> pumpListAndDrag({required double autoScrollerVelocityScalar}) async { final List<int> items = List<int>.generate(10, (int index) => index); final ScrollController scrollController = ScrollController(); addTearDown(scrollController.dispose); await tester.pumpWidget( MaterialApp( home: ReorderableListView.builder( itemBuilder: (BuildContext context, int index) { return Container( key: ValueKey<int>(items[index]), height: 100, color: items[index].isOdd ? Colors.red : Colors.green, child: ReorderableDragStartListener( index: index, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Text('item ${items[index]}'), const Icon(Icons.drag_handle), ], ), ), ); }, itemCount: items.length, onReorder: (int fromIndex, int toIndex) {}, scrollController: scrollController, autoScrollerVelocityScalar: autoScrollerVelocityScalar, ), ), ); expect(scrollController.offset, 0); final Finder item = find.text('item 0'); final TestGesture drag = await tester.startGesture(tester.getCenter(item)); // Drag just enough to touch the edge but not surpass it, so the // auto scroller is not yet triggered await drag.moveBy(const Offset(0, 500)); await pumpFor(duration: const Duration(milliseconds: 200)); expect(scrollController.offset, 0); // Now drag a little bit more so the auto scroller triggers await drag.moveBy(const Offset(0, 50)); await pumpFor( duration: const Duration(milliseconds: 600), interval: Duration(milliseconds: (1000 / autoScrollerVelocityScalar).round()), ); return scrollController.offset; } const double fastVelocityScalar = 20; final double offsetForFastScroller = await pumpListAndDrag(autoScrollerVelocityScalar: fastVelocityScalar); // Reset widget tree await tester.pumpWidget(const SizedBox()); const double slowVelocityScalar = 5; final double offsetForSlowScroller = await pumpListAndDrag(autoScrollerVelocityScalar: slowVelocityScalar); expect(offsetForFastScroller / offsetForSlowScroller, fastVelocityScalar / slowVelocityScalar); }); } Future<void> longPressDrag(WidgetTester tester, Offset start, Offset end) async { final TestGesture drag = await tester.startGesture(start); await tester.pump(kLongPressTimeout + kPressTimeout); await drag.moveTo(end); await tester.pump(kPressTimeout); await drag.up(); } class _Stateful extends StatefulWidget { // Ignoring the preference for const constructors because we want to test with regular non-const instances. // ignore:prefer_const_constructors_in_immutables _Stateful({super.key}); @override State<StatefulWidget> createState() => _StatefulState(); } class _StatefulState extends State<_Stateful> { bool? checked = false; @override Widget build(BuildContext context) { return SizedBox( width: 48.0, height: 48.0, child: Material( child: Checkbox( value: checked, onChanged: (bool? newValue) => checked = newValue, ), ), ); } }