blob: d159176a1ac8ad316be8240f52d0dda9148d3265 [file] [log] [blame]
// 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';
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', () {
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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);
});
testWidgets('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) { },
);
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>[
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;
});
testWidgets('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);
});
testWidgets('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));
});
testWidgets('Uses the PrimaryScrollController when available', (WidgetTester tester) async {
final ScrollController primary = ScrollController();
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();
await tester.pumpWidget(buildWithScrollController(primary2));
scrollView = tester.widget(
find.byType(Scrollable),
);
expect(scrollView.controller, primary2);
});
testWidgets('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();
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);
});
testWidgets('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) { },
);
final Widget overlay = Overlay(
initialEntries: <OverlayEntry>[
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');
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
testWidgets("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', () {
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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']));
});
testWidgets('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);
});
testWidgets('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,
),
],
);
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>[
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;
});
testWidgets('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);
});
testWidgets('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');
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
testWidgets('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();
});
});
});
testWidgets('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);
});
testWidgets('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', () {
testWidgets('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));
});
testWidgets('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));
});
});
testWidgets('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));
});
testWidgets('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.
testWidgets('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());
testWidgets('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());
testWidgets('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);
});
testWidgets('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);
});
});
testWidgets('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]}'),
],
),
),
);
},
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]));
});
testWidgets('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]}'),
],
),
),
);
},
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));
});
testWidgets('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.'));
});
testWidgets('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'));
});
testWidgets('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);
});
testWidgets('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);
});
testWidgets('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);
});
testWidgets('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);
});
testWidgets('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();
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,
),
),
);
}
}