reorderable_list_test.dart 86.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6 7
// This file is run as part of a reduced test set in CI on Mac and Windows
// machines.
@Tags(<String>['reduced-test-set'])
8
library;
9

10
import 'package:flutter/gestures.dart';
11
import 'package:flutter/material.dart';
12
import 'package:flutter/rendering.dart';
13
import 'package:flutter_test/flutter_test.dart';
14
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
15 16 17 18

void main() {
  group('$ReorderableListView', () {
    const double itemHeight = 48.0;
19
    const List<String> originalListItems = <String>['Item 1', 'Item 2', 'Item 3', 'Item 4'];
20
    late List<String> listItems;
21 22 23 24 25 26 27 28 29 30

    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) {
31 32
      return SizedBox(
        key: Key(listItem),
33 34
        height: itemHeight,
        width: itemHeight,
35
        child: Text(listItem),
36 37 38
      );
    }

39 40
    Widget build({
      Widget? header,
41
      Widget? footer,
42
      Axis scrollDirection = Axis.vertical,
43 44
      bool reverse = false,
      EdgeInsets padding = EdgeInsets.zero,
45 46 47
      TextDirection textDirection = TextDirection.ltr,
      TargetPlatform? platform,
    }) {
48
      return MaterialApp(
49
        theme: ThemeData(platform: platform),
50
        home: Directionality(
51
          textDirection: textDirection,
52
          child: SizedBox(
53 54
            height: itemHeight * 10,
            width: itemHeight * 10,
55
            child: ReorderableListView(
56
              header: header,
57
              footer: footer,
58 59
              scrollDirection: scrollDirection,
              onReorder: onReorder,
60 61
              reverse: reverse,
              padding: padding,
62
              children: listItems.map<Widget>(listItemToWidget).toList(),
63
            ),
64 65 66 67 68 69 70 71 72 73 74
          ),
        ),
      );
    }

    setUp(() {
      // Copy the original list into listItems.
      listItems = originalListItems.toList();
    });

    group('in vertical mode', () {
75
      testWidgetsWithLeakTracking('reorder is not triggered when children length is less or equals to 1', (WidgetTester tester) async {
76 77 78 79 80
        bool onReorderWasCalled = false;
        final List<String> currentListItems = listItems.take(1).toList();
        final ReorderableListView reorderableListView = ReorderableListView(
          header: const Text('Header'),
          onReorder: (_, __) => onReorderWasCalled = true,
81
          children: currentListItems.map<Widget>(listItemToWidget).toList(),
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
        );
        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']));
      });

101
      testWidgetsWithLeakTracking('reorders its contents only when a drag finishes', (WidgetTester tester) async {
102 103 104 105 106 107 108 109
        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();
110
        await tester.pumpAndSettle();
111 112 113
        expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 1', 'Item 4']));
      });

114
      testWidgetsWithLeakTracking('allows reordering from the very top to the very bottom', (WidgetTester tester) async {
115 116 117 118 119 120 121
        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),
        );
122
        await tester.pumpAndSettle();
123 124 125
        expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
      });

126
      testWidgetsWithLeakTracking('allows reordering from the very bottom to the very top', (WidgetTester tester) async {
127 128 129 130 131 132 133
        await tester.pumpWidget(build());
        expect(listItems, orderedEquals(originalListItems));
        await longPressDrag(
          tester,
          tester.getCenter(find.text('Item 4')),
          tester.getCenter(find.text('Item 1')),
        );
134
        await tester.pumpAndSettle();
135 136 137
        expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3']));
      });

138
      testWidgetsWithLeakTracking('allows reordering inside the middle of the widget', (WidgetTester tester) async {
139 140 141 142 143 144 145
        await tester.pumpWidget(build());
        expect(listItems, orderedEquals(originalListItems));
        await longPressDrag(
          tester,
          tester.getCenter(find.text('Item 3')),
          tester.getCenter(find.text('Item 2')),
        );
146
        await tester.pumpAndSettle();
147 148 149
        expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4']));
      });

150
      testWidgetsWithLeakTracking('properly reorders with a header', (WidgetTester tester) async {
151 152 153 154 155 156 157 158
        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),
        );
159
        await tester.pumpAndSettle();
160 161 162 163
        expect(find.text('Header Text'), findsOneWidget);
        expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
      });

164
      testWidgetsWithLeakTracking('properly reorders with a footer', (WidgetTester tester) async {
165 166 167 168 169 170 171 172 173 174 175 176 177
        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']));
      });

178
      testWidgetsWithLeakTracking('properly determines the vertical drop area extents', (WidgetTester tester) async {
179
        final Widget reorderableListView = ReorderableListView(
180
          onReorder: (int oldIndex, int newIndex) { },
181
          children: const <Widget>[
182 183
            SizedBox(
              key: Key('Normal item'),
184
              height: itemHeight,
185
              child: Text('Normal item'),
186
            ),
187 188
            SizedBox(
              key: Key('Tall item'),
189
              height: itemHeight * 2,
190
              child: Text('Tall item'),
191
            ),
192 193
            SizedBox(
              key: Key('Last item'),
194
              height: itemHeight,
195
              child: Text('Last item'),
196
            ),
197 198
          ],
        );
199 200
        await tester.pumpWidget(MaterialApp(
          home: SizedBox(
201 202 203 204 205
            height: itemHeight * 10,
            child: reorderableListView,
          ),
        ));

206 207 208
        double getListHeight() {
          final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList));
          return listScrollView.geometry!.maxPaintExtent;
209 210
        }

211
        const double kDraggingListHeight = 4 * itemHeight;
212
        // Drag a normal text item
213
        expect(getListHeight(), kDraggingListHeight);
214 215 216
        TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item')));
        await tester.pump(kLongPressTimeout + kPressTimeout);
        await tester.pumpAndSettle();
217
        expect(getListHeight(), kDraggingListHeight);
218 219 220 221

        // Move it
        await drag.moveTo(tester.getCenter(find.text('Last item')));
        await tester.pumpAndSettle();
222
        expect(getListHeight(), kDraggingListHeight);
223 224 225 226

        // Drop it
        await drag.up();
        await tester.pumpAndSettle();
227
        expect(getListHeight(), kDraggingListHeight);
228 229 230 231 232

        // Drag a tall item
        drag = await tester.startGesture(tester.getCenter(find.text('Tall item')));
        await tester.pump(kLongPressTimeout + kPressTimeout);
        await tester.pumpAndSettle();
233
        expect(getListHeight(), kDraggingListHeight);
234 235 236
        // Move it
        await drag.moveTo(tester.getCenter(find.text('Last item')));
        await tester.pumpAndSettle();
237
        expect(getListHeight(), kDraggingListHeight);
238 239 240 241

        // Drop it
        await drag.up();
        await tester.pumpAndSettle();
242
        expect(getListHeight(), kDraggingListHeight);
243 244
      });

245
      testWidgetsWithLeakTracking('Vertical drag in progress golden image', (WidgetTester tester) async {
246
        debugDisableShadows = false;
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269
        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) { },
        );
270 271 272 273

        late final OverlayEntry entry;
        addTearDown(() => entry..remove()..dispose());

274
        await tester.pumpWidget(MaterialApp(
275 276
          home: Container(
            color: Colors.white,
277
            height: itemHeight * 3,
278 279 280
            // Wrap in an overlay so that the golden image includes the dragged item.
            child: Overlay(
              initialEntries: <OverlayEntry>[
281
                entry = OverlayEntry(builder: (BuildContext context) {
282 283 284 285 286 287 288 289 290 291
                  // 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,
                  );
                }),
              ],
            ),
292 293 294
          ),
        ));

295 296
        // Start dragging the second item.
        final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(const Key('blue'))));
297
        await tester.pump(kLongPressTimeout + kPressTimeout);
298 299 300

        // Drag it up to be partially over the top item.
        await drag.moveBy(const Offset(0, -itemHeight / 3));
301
        await tester.pumpAndSettle();
302 303 304 305

        // 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.
306
        await expectLater(
307
          find.byType(Overlay).last,
308 309
          matchesGoldenFile('reorderable_list_test.vertical.drop_area.png'),
        );
310
        debugDisableShadows = true;
311 312
      });

313
      testWidgetsWithLeakTracking('Preserves children states when the list parent changes the order', (WidgetTester tester) async {
314
        _StatefulState findState(Key key) {
315
          return find.byElementPredicate((Element element) => element.findAncestorWidgetOfExactType<_Stateful>()?.key == key)
316 317
              .evaluate()
              .first
318
              .findAncestorStateOfType<_StatefulState>()!;
319
        }
320 321
        await tester.pumpWidget(MaterialApp(
          home: ReorderableListView(
322
            children: <Widget>[
323 324 325
              _Stateful(key: const Key('A')),
              _Stateful(key: const Key('B')),
              _Stateful(key: const Key('C')),
326
            ],
327
            onReorder: (int oldIndex, int newIndex) { },
328 329 330 331 332 333 334 335 336
          ),
        ));
        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);

337 338
        await tester.pumpWidget(MaterialApp(
          home: ReorderableListView(
339
            children: <Widget>[
340 341 342
              _Stateful(key: const Key('B')),
              _Stateful(key: const Key('C')),
              _Stateful(key: const Key('A')),
343
            ],
344
            onReorder: (int oldIndex, int newIndex) { },
345 346 347 348 349 350 351
          ),
        ));
        // 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);
      });
352

353
      testWidgetsWithLeakTracking('Preserves children states when rebuilt', (WidgetTester tester) async {
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380
        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));
      });

381
      testWidgetsWithLeakTracking('Uses the PrimaryScrollController when available', (WidgetTester tester) async {
382
        final ScrollController primary = ScrollController();
383
        addTearDown(primary.dispose);
384
        final Widget reorderableList = ReorderableListView(
385
          children: const <Widget>[
386 387 388
            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')),
389
          ],
390
          onReorder: (int oldIndex, int newIndex) { },
391 392 393
        );

        Widget buildWithScrollController(ScrollController controller) {
394 395
          return MaterialApp(
            home: PrimaryScrollController(
396
              controller: controller,
397
              child: SizedBox(
398 399 400 401 402 403 404 405 406
                height: 100.0,
                width: 100.0,
                child: reorderableList,
              ),
            ),
          );
        }

        await tester.pumpWidget(buildWithScrollController(primary));
407 408
        Scrollable scrollView = tester.widget(
          find.byType(Scrollable),
409 410 411 412
        );
        expect(scrollView.controller, primary);

        // Now try changing the primary scroll controller and checking that the scroll view gets updated.
413
        final ScrollController primary2 = ScrollController();
414 415
        addTearDown(primary2.dispose);

416 417
        await tester.pumpWidget(buildWithScrollController(primary2));
        scrollView = tester.widget(
418
          find.byType(Scrollable),
419 420 421 422
        );
        expect(scrollView.controller, primary2);
      });

423
      testWidgetsWithLeakTracking('Test custom ScrollController behavior when set', (WidgetTester tester) async {
424 425 426 427
        const Key firstBox = Key('C');
        const Key secondBox = Key('B');
        const Key thirdBox = Key('A');
        final ScrollController customController = ScrollController();
428 429
        addTearDown(customController.dispose);

430 431 432 433
        await tester.pumpWidget(
          MaterialApp(
            home: Scaffold(
              body: SizedBox(
434
                height: 150,
435 436 437 438
                child: ReorderableListView(
                  scrollController: customController,
                  onReorder: (int oldIndex, int newIndex) { },
                  children: const <Widget>[
439 440 441
                    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')),
442 443 444 445 446 447 448 449 450 451 452 453
                  ],
                ),
              ),
            ),
          ),
        );

        // 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),
454
          curve: Curves.linear,
455 456 457 458 459 460
        );
        await tester.pumpAndSettle();
        Offset listViewTopLeft = tester.getTopLeft(
          find.byType(ReorderableListView),
        );
        Offset firstBoxTopLeft = tester.getTopLeft(
461
          find.byKey(firstBox),
462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480
        );
        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
481 482 483
        // 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.
484 485 486
        expect(customController.offset, 120.0);
      });

487
      testWidgetsWithLeakTracking('ReorderableList auto scrolling is fast enough', (WidgetTester tester) async {
488 489
        // Regression test for https://github.com/flutter/flutter/issues/121603.
        final ScrollController controller = ScrollController();
490 491
        addTearDown(controller.dispose);

492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527
        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));
      });

528
      testWidgetsWithLeakTracking('Still builds when no PrimaryScrollController is available', (WidgetTester tester) async {
529
        final Widget reorderableList = ReorderableListView(
530
          children: const <Widget>[
531 532 533
            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')),
534
          ],
535
          onReorder: (int oldIndex, int newIndex) { },
536
        );
537 538 539 540

        late final OverlayEntry entry;
        addTearDown(() => entry..remove()..dispose());

541 542
        final Widget overlay = Overlay(
          initialEntries: <OverlayEntry>[
543
            entry = OverlayEntry(builder: (BuildContext context) => reorderableList),
544 545
          ],
        );
546
        final Widget boilerplate = Localizations(
547 548 549 550 551
          locale: const Locale('en'),
          delegates: const <LocalizationsDelegate<dynamic>>[
            DefaultMaterialLocalizations.delegate,
            DefaultWidgetsLocalizations.delegate,
          ],
552
          child:SizedBox(
553 554
            width: 100.0,
            height: 100.0,
555
            child: Directionality(
556
              textDirection: TextDirection.ltr,
557
              child: overlay,
558 559 560
            ),
          ),
        );
561 562 563 564
        await expectLater(
          () => tester.pumpWidget(boilerplate),
          returnsNormally,
        );
565
      });
566 567 568 569

      group('Accessibility (a11y/Semantics)', () {
        Map<CustomSemanticsAction, VoidCallback> getSemanticsActions(int index) {
          final Semantics semantics = find.ancestor(
570
            of: find.byKey(Key(listItems[index])),
571
            matching: find.byType(Semantics),
572
          ).evaluate().first.widget as Semantics;
573
          return semantics.properties.customSemanticsActions!;
574 575 576 577 578 579 580
        }

        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');

581
        testWidgetsWithLeakTracking('Provides the correct accessibility actions in LTR and RTL modes', (WidgetTester tester) async {
582 583
          // The a11y actions for a vertical list are the same in LTR and RTL modes.
          final SemanticsHandle handle = tester.ensureSemantics();
584
          for (final TextDirection direction in TextDirection.values) {
585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615
            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();
        });

616
        testWidgetsWithLeakTracking('First item accessibility (a11y) actions work', (WidgetTester tester) async {
617 618 619 620 621 622
          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);
623
          firstSemanticsActions[moveToEnd]!();
624 625 626 627 628 629
          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);
630
          firstSemanticsActions[moveDown]!();
631 632 633 634 635 636
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1']));

          handle.dispose();
        });

637
        testWidgetsWithLeakTracking('Middle item accessibility (a11y) actions work', (WidgetTester tester) async {
638 639 640 641 642 643
          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);
644
          middleSemanticsActions[moveToEnd]!();
645 646 647 648 649 650
          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);
651
          middleSemanticsActions[moveDown]!();
652 653 654 655 656 657
          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);
658
          middleSemanticsActions[moveUp]!();
659 660 661 662 663 664
          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);
665
          middleSemanticsActions[moveToStart]!();
666 667 668 669 670 671
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2']));

          handle.dispose();
        });

672
        testWidgetsWithLeakTracking('Last item accessibility (a11y) actions work', (WidgetTester tester) async {
673 674 675 676 677 678
          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);
679
          lastSemanticsActions[moveToStart]!();
680 681 682 683 684 685
          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);
686
          lastSemanticsActions[moveUp]!();
687 688 689 690 691
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2']));

          handle.dispose();
        });
692

693
        testWidgetsWithLeakTracking("Doesn't hide accessibility when a child declares its own semantics", (WidgetTester tester) async {
694
          final SemanticsHandle handle = tester.ensureSemantics();
695
          final Widget reorderableListView = ReorderableListView(
696
            onReorder: (int oldIndex, int newIndex) { },
697 698 699 700 701 702
            children: <Widget>[
              const SizedBox(
                key: Key('List tile 1'),
                height: itemHeight,
                child: Text('List tile 1'),
              ),
703
              SizedBox(
704 705
                key: const Key('Switch tile'),
                height: itemHeight,
706 707
                child: Material(
                  child: SwitchListTile(
708 709
                    title: const Text('Switch tile'),
                    value: true,
710
                    onChanged: (bool? newValue) { },
711 712 713 714 715 716 717 718 719 720
                  ),
                ),
              ),
              const SizedBox(
                key: Key('List tile 2'),
                height: itemHeight,
                child: Text('List tile 2'),
              ),
            ],
          );
721 722
          await tester.pumpWidget(MaterialApp(
            home: SizedBox(
723 724 725 726 727 728
              height: itemHeight * 10,
              child: reorderableListView,
            ),
          ));

          // Get the switch tile's semantics:
729
          final SemanticsNode semanticsNode = tester.getSemantics(find.byKey(const Key('Switch tile')));
730

731
          // Check for ReorderableListView custom semantics actions.
732
          expect(semanticsNode, matchesSemantics(
733 734 735 736 737 738 739 740 741 742 743 744 745 746 747
            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(
748 749 750
            hasToggledState: true,
            isToggled: true,
            isEnabled: true,
751
            isFocusable: true,
752 753 754 755 756 757
            hasEnabledState: true,
            label: 'Switch tile',
            hasTapAction: true,
          ));
          handle.dispose();
        });
758
      });
759 760 761
    });

    group('in horizontal mode', () {
762
      testWidgetsWithLeakTracking('reorder is not triggered when children length is less or equals to 1', (WidgetTester tester) async {
763 764 765 766 767 768
        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,
769
          children: currentListItems.map<Widget>(listItemToWidget).toList(),
770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788
        );
        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']));
      });

789
      testWidgetsWithLeakTracking('allows reordering from the very top to the very bottom', (WidgetTester tester) async {
790 791 792 793 794 795 796
        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),
        );
797
        await tester.pumpAndSettle();
798 799 800
        expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
      });

801
      testWidgetsWithLeakTracking('allows reordering from the very bottom to the very top', (WidgetTester tester) async {
802 803 804 805 806 807 808
        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')),
        );
809
        await tester.pumpAndSettle();
810 811 812
        expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3']));
      });

813
      testWidgetsWithLeakTracking('allows reordering inside the middle of the widget', (WidgetTester tester) async {
814 815 816 817 818 819 820
        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')),
        );
821
        await tester.pumpAndSettle();
822 823 824
        expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4']));
      });

825
      testWidgetsWithLeakTracking('properly reorders with a header', (WidgetTester tester) async {
826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842
        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')),
        );
843
        await tester.pumpAndSettle();
844 845 846 847
        expect(find.text('Header Text'), findsOneWidget);
        expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1']));
      });

848
      testWidgetsWithLeakTracking('properly reorders with a footer', (WidgetTester tester) async {
849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870
        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']));
      });

871
      testWidgetsWithLeakTracking('properly determines the horizontal drop area extents', (WidgetTester tester) async {
872
        final Widget reorderableListView = ReorderableListView(
873 874
          scrollDirection: Axis.horizontal,
          onReorder: (int oldIndex, int newIndex) { },
875
          children: const <Widget>[
876 877
            SizedBox(
              key: Key('Normal item'),
878
              width: itemHeight,
879
              child: Text('Normal item'),
880
            ),
881 882
            SizedBox(
              key: Key('Tall item'),
883
              width: itemHeight * 2,
884
              child: Text('Tall item'),
885
            ),
886 887
            SizedBox(
              key: Key('Last item'),
888
              width: itemHeight,
889
              child: Text('Last item'),
890
            ),
891 892
          ],
        );
893 894
        await tester.pumpWidget(MaterialApp(
          home: SizedBox(
895 896 897 898 899
            width: itemHeight * 10,
            child: reorderableListView,
          ),
        ));

900 901 902
        double getListWidth() {
          final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList));
          return listScrollView.geometry!.maxPaintExtent;
903 904
        }

905
        const double kDraggingListWidth = 4 * itemHeight;
906
        // Drag a normal text item
907
        expect(getListWidth(), kDraggingListWidth);
908 909 910
        TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item')));
        await tester.pump(kLongPressTimeout + kPressTimeout);
        await tester.pumpAndSettle();
911
        expect(getListWidth(), kDraggingListWidth);
912 913 914 915

        // Move it
        await drag.moveTo(tester.getCenter(find.text('Last item')));
        await tester.pumpAndSettle();
916
        expect(getListWidth(), kDraggingListWidth);
917 918 919 920

        // Drop it
        await drag.up();
        await tester.pumpAndSettle();
921
        expect(getListWidth(), kDraggingListWidth);
922 923 924 925 926

        // Drag a tall item
        drag = await tester.startGesture(tester.getCenter(find.text('Tall item')));
        await tester.pump(kLongPressTimeout + kPressTimeout);
        await tester.pumpAndSettle();
927
        expect(getListWidth(), kDraggingListWidth);
928 929 930
        // Move it
        await drag.moveTo(tester.getCenter(find.text('Last item')));
        await tester.pumpAndSettle();
931
        expect(getListWidth(), kDraggingListWidth);
932 933 934 935

        // Drop it
        await drag.up();
        await tester.pumpAndSettle();
936
        expect(getListWidth(), kDraggingListWidth);
937 938
      });

939
      testWidgetsWithLeakTracking('Horizontal drag in progress golden image', (WidgetTester tester) async {
940
        debugDisableShadows = false;
941
        final Widget reorderableListView = ReorderableListView(
942 943
          scrollDirection: Axis.horizontal,
          onReorder: (int oldIndex, int newIndex) { },
944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964
          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,
            ),
          ],
        );
965 966 967 968

        late final OverlayEntry entry;
        addTearDown(() => entry..remove()..dispose());

969
        await tester.pumpWidget(MaterialApp(
970 971
          home: Container(
            color: Colors.white,
972
            width: itemHeight * 3,
973 974 975
            // Wrap in an overlay so that the golden image includes the dragged item.
            child: Overlay(
              initialEntries: <OverlayEntry>[
976
                entry = OverlayEntry(builder: (BuildContext context) {
977 978 979 980 981 982 983
                  // 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,
                  );
984
                }),
985 986
              ],
            ),
987 988 989
          ),
        ));

990 991
        // Start dragging the second item.
        final TestGesture drag = await tester.startGesture(tester.getCenter(find.byKey(const Key('blue'))));
992
        await tester.pump(kLongPressTimeout + kPressTimeout);
993 994 995

        // Drag it left to be partially over the first item.
        await drag.moveBy(const Offset(-itemHeight / 3, 0));
996
        await tester.pumpAndSettle();
997 998 999 1000

        // 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.
1001
        await expectLater(
1002
          find.byType(Overlay).last,
1003 1004
          matchesGoldenFile('reorderable_list_test.horizontal.drop_area.png'),
        );
1005
        debugDisableShadows = true;
1006
      });
1007

1008
      testWidgetsWithLeakTracking('Preserves children states when the list parent changes the order', (WidgetTester tester) async {
1009
        _StatefulState findState(Key key) {
1010
          return find.byElementPredicate((Element element) => element.findAncestorWidgetOfExactType<_Stateful>()?.key == key)
1011 1012
              .evaluate()
              .first
1013
              .findAncestorStateOfType<_StatefulState>()!;
1014
        }
1015 1016
        await tester.pumpWidget(MaterialApp(
          home: ReorderableListView(
1017 1018
            onReorder: (int oldIndex, int newIndex) { },
            scrollDirection: Axis.horizontal,
1019
            children: <Widget>[
1020 1021 1022
              _Stateful(key: const Key('A')),
              _Stateful(key: const Key('B')),
              _Stateful(key: const Key('C')),
1023 1024 1025 1026 1027 1028 1029 1030 1031 1032
            ],
          ),
        ));
        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);

1033 1034
        await tester.pumpWidget(MaterialApp(
          home: ReorderableListView(
1035 1036
            onReorder: (int oldIndex, int newIndex) { },
            scrollDirection: Axis.horizontal,
1037
            children: <Widget>[
1038 1039 1040
              _Stateful(key: const Key('B')),
              _Stateful(key: const Key('C')),
              _Stateful(key: const Key('A')),
1041 1042 1043 1044 1045 1046 1047 1048
            ],
          ),
        ));
        // 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);
      });
1049

1050
      testWidgetsWithLeakTracking('Preserves children states when rebuilt', (WidgetTester tester) async {
1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078
        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));
      });

1079 1080 1081
      group('Accessibility (a11y/Semantics)', () {
        Map<CustomSemanticsAction, VoidCallback> getSemanticsActions(int index) {
          final Semantics semantics = find.ancestor(
1082
            of: find.byKey(Key(listItems[index])),
1083
            matching: find.byType(Semantics),
1084
          ).evaluate().first.widget as Semantics;
1085
          return semantics.properties.customSemanticsActions!;
1086 1087 1088 1089 1090 1091 1092
        }

        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');

1093
        testWidgetsWithLeakTracking('Provides the correct accessibility actions in LTR mode', (WidgetTester tester) async {
1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125
          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();
        });

1126
        testWidgetsWithLeakTracking('Provides the correct accessibility actions in Right-To-Left directionality', (WidgetTester tester) async {
1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160
          // 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();
        });

1161
        testWidgetsWithLeakTracking('First item accessibility (a11y) actions work in LTR mode', (WidgetTester tester) async {
1162 1163 1164 1165 1166 1167
          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);
1168
          firstSemanticsActions[moveToEnd]!();
1169 1170 1171 1172 1173 1174
          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);
1175
          firstSemanticsActions[moveRight]!();
1176 1177 1178 1179 1180 1181
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1']));

          handle.dispose();
        });

1182
        testWidgetsWithLeakTracking('First item accessibility (a11y) actions work in Right-To-Left directionality', (WidgetTester tester) async {
1183 1184 1185 1186 1187 1188 1189 1190
          // 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);
1191
          firstSemanticsActions[moveToEnd]!();
1192 1193 1194 1195 1196 1197
          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);
1198
          firstSemanticsActions[moveLeft]!();
1199 1200 1201 1202 1203 1204
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 3', 'Item 2', 'Item 4', 'Item 1']));

          handle.dispose();
        });

1205
        testWidgetsWithLeakTracking('Middle item accessibility (a11y) actions work in LTR mode', (WidgetTester tester) async {
1206 1207 1208 1209 1210 1211
          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);
1212
          middleSemanticsActions[moveToEnd]!();
1213 1214 1215 1216 1217 1218
          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);
1219
          middleSemanticsActions[moveRight]!();
1220 1221 1222 1223 1224 1225
          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);
1226
          middleSemanticsActions[moveLeft]!();
1227 1228 1229 1230 1231 1232
          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);
1233
          middleSemanticsActions[moveToStart]!();
1234 1235 1236 1237 1238 1239
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2']));

          handle.dispose();
        });

1240
        testWidgetsWithLeakTracking('Middle item accessibility (a11y) actions work in Right-To-Left directionality', (WidgetTester tester) async {
1241 1242 1243 1244 1245 1246 1247 1248
          // 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);
1249
          middleSemanticsActions[moveToEnd]!();
1250 1251 1252 1253 1254 1255
          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);
1256
          middleSemanticsActions[moveLeft]!();
1257 1258 1259 1260 1261 1262
          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);
1263
          middleSemanticsActions[moveRight]!();
1264 1265 1266 1267 1268 1269
          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);
1270
          middleSemanticsActions[moveToStart]!();
1271 1272 1273 1274 1275 1276
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2']));

          handle.dispose();
        });

1277
        testWidgetsWithLeakTracking('Last item accessibility (a11y) actions work in LTR mode', (WidgetTester tester) async {
1278 1279 1280 1281 1282 1283
          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);
1284
          lastSemanticsActions[moveToStart]!();
1285 1286 1287 1288 1289 1290
          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);
1291
          lastSemanticsActions[moveLeft]!();
1292 1293 1294 1295 1296 1297
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2']));

          handle.dispose();
        });

1298
        testWidgetsWithLeakTracking('Last item accessibility (a11y) actions work in Right-To-Left directionality', (WidgetTester tester) async {
1299 1300 1301 1302 1303 1304 1305 1306
          // 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);
1307
          lastSemanticsActions[moveToStart]!();
1308 1309 1310 1311 1312 1313
          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);
1314
          lastSemanticsActions[moveRight]!();
1315 1316 1317 1318 1319 1320 1321
          await tester.pumpAndSettle();
          expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 3', 'Item 2']));

          handle.dispose();
        });
      });

1322 1323
    });

1324

1325
    testWidgetsWithLeakTracking('ReorderableListView.builder asserts on negative childCount', (WidgetTester tester) async {
1326 1327 1328 1329 1330 1331 1332 1333 1334
      expect(() => ReorderableListView.builder(
        itemBuilder: (BuildContext context, int index) {
          return const SizedBox();
        },
        itemCount: -1,
        onReorder: (int from, int to) {},
      ), throwsAssertionError);
    });

1335
    testWidgetsWithLeakTracking('ReorderableListView.builder only creates the children it needs', (WidgetTester tester) async {
1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351
      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});
    });

1352
    group('Padding', () {
1353
      testWidgetsWithLeakTracking('Padding with no header & footer', (WidgetTester tester) async {
1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366
        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));
      });

1367
      testWidgetsWithLeakTracking('Padding with header or footer', (WidgetTester tester) async {
1368 1369
        const EdgeInsets padding = EdgeInsets.fromLTRB(10, 20, 30, 40);
        const Key headerKey = Key('Header');
1370
        const Key footerKey = Key('Footer');
1371 1372
        const Widget verticalHeader = SizedBox(key: headerKey, height: 10);
        const Widget horizontalHeader = SizedBox(key: headerKey, width: 10);
1373 1374
        const Widget verticalFooter = SizedBox(key: footerKey, height: 10);
        const Widget horizontalFooter = SizedBox(key: footerKey, width: 10);
1375

1376
        // Vertical Header
1377 1378 1379 1380 1381
        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));

1382 1383 1384 1385 1386 1387 1388
        // 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
1389 1390 1391 1392 1393
        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));

1394 1395 1396 1397 1398 1399 1400
        // 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
1401 1402 1403 1404 1405
        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));

1406 1407 1408 1409 1410 1411 1412
        // // 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
1413 1414 1415 1416
        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));
1417 1418 1419 1420 1421 1422

        // // 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));
1423 1424 1425
      });
    });

1426
    testWidgetsWithLeakTracking('ReorderableListView can be reversed', (WidgetTester tester) async {
1427
      final Widget reorderableListView = ReorderableListView(
1428 1429
        reverse: true,
        onReorder: (int oldIndex, int newIndex) { },
1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441
        children: const <Widget>[
          SizedBox(
            key: Key('A'),
            child: Text('A'),
          ),
          SizedBox(
            key: Key('B'),
            child: Text('B'),
          ),
          SizedBox(
            key: Key('C'),
            child: Text('C'),
1442
          ),
1443 1444 1445 1446 1447
        ],
      );
      await tester.pumpWidget(MaterialApp(
        home: reorderableListView,
      ));
1448
      expect(tester.getCenter(find.text('A')).dy, greaterThan(tester.getCenter(find.text('B')).dy));
1449
    });
1450

1451
    testWidgetsWithLeakTracking('Animation test when placing an item in place', (WidgetTester tester) async {
1452 1453
      const Key testItemKey = Key('Test item');
      final Widget reorderableListView = ReorderableListView(
1454
        onReorder: (int oldIndex, int newIndex) { },
1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500
        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);
    });
1501
    // TODO(djshuckerow): figure out how to write a test for scrolling the list.
1502

1503
    testWidgetsWithLeakTracking('ReorderableListView on desktop platforms should have drag handles', (WidgetTester tester) async {
1504 1505 1506 1507 1508 1509
      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());

1510
    testWidgetsWithLeakTracking('ReorderableListView on mobile platforms should not have drag handles', (WidgetTester tester) async {
1511 1512 1513 1514 1515 1516
      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());

1517
    testWidgetsWithLeakTracking('Vertical list renders drag handle in correct position', (WidgetTester tester) async {
1518 1519 1520 1521 1522 1523 1524 1525 1526 1527
      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);
    });

1528
    testWidgetsWithLeakTracking('Horizontal list renders drag handle in correct position', (WidgetTester tester) async {
1529 1530 1531 1532 1533 1534 1535 1536 1537
      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);
    });
1538
  });
1539

1540
  testWidgetsWithLeakTracking('ReorderableListView, can deal with the dragged item getting unmounted and rebuilt during drag', (WidgetTester tester) async {
1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556
    // 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) {
1557
          return SizedBox(
1558 1559 1560
            key: ValueKey<int>(items[index]),
            height: 100,
            child: ReorderableDragStartListener(
1561
              index: index,
1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606
              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]));
  });
1607

1608
  testWidgetsWithLeakTracking('ReorderableListView calls onReorderStart and onReorderEnd correctly', (WidgetTester tester) async {
1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683
    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));
  });

1684
  testWidgetsWithLeakTracking('ReorderableListView throws an error when key is not passed to its children', (WidgetTester tester) async {
1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698
    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.'));
  });
1699

1700
  testWidgetsWithLeakTracking('Throws an error if no overlay present', (WidgetTester tester) async {
1701 1702
    final Widget reorderableList = ReorderableListView(
      children: const <Widget>[
1703 1704 1705
        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')),
1706 1707 1708 1709 1710 1711 1712 1713 1714
      ],
      onReorder: (int oldIndex, int newIndex) { },
    );
    final Widget boilerplate = Localizations(
      locale: const Locale('en'),
      delegates: const <LocalizationsDelegate<dynamic>>[
        DefaultMaterialLocalizations.delegate,
        DefaultWidgetsLocalizations.delegate,
      ],
1715
      child: SizedBox(
1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730
        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'));
  });
1731

1732
  testWidgetsWithLeakTracking('ReorderableListView asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async {
1733 1734 1735 1736
    expect(() => ReorderableListView(
      itemExtent: 30,
      prototypeItem: const SizedBox(),
      onReorder: (int fromIndex, int toIndex) { },
1737
      children: const <Widget>[],
1738 1739 1740
    ), throwsAssertionError);
  });

1741
  testWidgetsWithLeakTracking('ReorderableListView.builder asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async {
1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760
    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);
  });

1761
  testWidgetsWithLeakTracking('if itemExtent is non-null, children have same extent in the scroll direction', (WidgetTester tester) async {
1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782
    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,
1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799
                  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);
  });

1800
  testWidgetsWithLeakTracking('if prototypeItem is non-null, children have same extent in the scroll direction', (WidgetTester tester) async {
1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818
    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()),
                        )
                    );
1819
                  },
1820 1821 1822 1823 1824 1825
                  itemCount: numbers.length,
                  prototypeItem: const SizedBox(
                    height: 30,
                    child: Text('3'),
                  ),
                  onReorder: (int oldIndex, int newIndex) {  },
1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840
                );
              },
            ),
          ),
        )
    );

    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);
  });
1841

1842
  testWidgetsWithLeakTracking('ReorderableListView auto scrolls speed is configurable', (WidgetTester tester) async {
1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859
    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();
1860
      addTearDown(scrollController.dispose);
1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923

      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);
  });

1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936
}

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
1937
  _Stateful({super.key});
1938 1939

  @override
1940
  State<StatefulWidget> createState() => _StatefulState();
1941 1942 1943
}

class _StatefulState extends State<_Stateful> {
1944
  bool? checked = false;
1945 1946 1947

  @override
  Widget build(BuildContext context) {
1948
    return SizedBox(
1949 1950
      width: 48.0,
      height: 48.0,
1951 1952
      child: Material(
        child: Checkbox(
1953
          value: checked,
1954
          onChanged: (bool? newValue) => checked = newValue,
1955 1956 1957 1958
        ),
      ),
    );
  }
1959
}