// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('negative itemCount should assert', (WidgetTester tester) async { final List<int> items = <int>[1, 2, 3]; await tester.pumpWidget(MaterialApp( home: StatefulBuilder( builder: (BuildContext outerContext, StateSetter setState) { return CustomScrollView( slivers: <Widget>[ SliverReorderableList( itemCount: -1, onReorder: (int fromIndex, int toIndex) { setState(() { if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); }); }, itemBuilder: (BuildContext context, int index) { return SizedBox( height: 100, child: Text('item ${items[index]}'), ); }, ), ], ); }, ), )); expect(tester.takeException(), isA<AssertionError>()); }); testWidgets('zero itemCount should not build widget', (WidgetTester tester) async { final List<int> items = <int>[1, 2, 3]; await tester.pumpWidget(MaterialApp( home: StatefulBuilder( builder: (BuildContext outerContext, StateSetter setState) { return CustomScrollView( slivers: <Widget>[ SliverFixedExtentList( itemExtent: 50.0, delegate: SliverChildListDelegate(<Widget>[ const Text('before'), ]), ), SliverReorderableList( itemCount: 0, onReorder: (int fromIndex, int toIndex) { setState(() { if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); }); }, itemBuilder: (BuildContext context, int index) { return SizedBox( height: 100, child: Text('item ${items[index]}'), ); }, ), SliverFixedExtentList( itemExtent: 50.0, delegate: SliverChildListDelegate(<Widget>[ const Text('after'), ]), ), ], ); }, ), )); expect(find.text('before'), findsOneWidget); expect(find.byType(SliverReorderableList), findsNothing); expect(find.text('after'), findsOneWidget); }); testWidgets('SliverReorderableList, drag and drop, fixed height items', (WidgetTester tester) async { final List<int> items = List<int>.generate(8, (int index) => index); Future<void> pressDragRelease(Offset start, Offset delta) async { final TestGesture drag = await tester.startGesture(start); await tester.pump(kPressTimeout); await drag.moveBy(delta); await tester.pump(kPressTimeout); await drag.up(); await tester.pumpAndSettle(); } void check({ List<int> visible = const <int>[], List<int> hidden = const <int>[] }) { for (final int i in visible) { expect(find.text('item $i'), findsOneWidget); } for (final int i in hidden) { expect(find.text('item $i'), findsNothing); } } // The SliverReorderableList 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(TestList(items: items)); check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]); // Drag item 0 downwards less than halfway and let it snap back. List // should remain as it is. await pressDragRelease(const Offset(12, 50), const Offset(12, 60)); check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]); expect(tester.getTopLeft(find.text('item 0')), Offset.zero); expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 100)); expect(items, orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7])); // Drag item 0 downwards more than halfway to displace item 1. await pressDragRelease(tester.getCenter(find.text('item 0')), const Offset(0, 51)); check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]); expect(tester.getTopLeft(find.text('item 1')), Offset.zero); expect(tester.getTopLeft(find.text('item 0')), const Offset(0, 100)); expect(items, orderedEquals(<int>[1, 0, 2, 3, 4, 5, 6, 7])); // Drag item 0 back to where it was. await pressDragRelease(tester.getCenter(find.text('item 0')), const Offset(0, -51)); check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]); expect(tester.getTopLeft(find.text('item 0')), Offset.zero); expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 100)); expect(items, orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7])); // Drag item 1 to item 3 await pressDragRelease(tester.getCenter(find.text('item 1')), const Offset(0, 151)); check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]); expect(tester.getTopLeft(find.text('item 0')), Offset.zero); expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 300)); expect(tester.getTopLeft(find.text('item 3')), const Offset(0, 200)); expect(items, orderedEquals(<int>[0, 2, 3, 1, 4, 5, 6, 7])); // Drag item 1 back to where it was await pressDragRelease(tester.getCenter(find.text('item 1')), const Offset(0, -200)); check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]); expect(tester.getTopLeft(find.text('item 0')), Offset.zero); expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 100)); expect(tester.getTopLeft(find.text('item 3')), const Offset(0, 300)); expect(items, orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7])); }); testWidgets('SliverReorderableList, items inherit DefaultTextStyle, IconTheme', (WidgetTester tester) async { const Color textColor = Color(0xffffffff); const Color iconColor = Color(0xff0000ff); TextStyle getIconStyle() { return tester.widget<RichText>( find.descendant( of: find.byType(Icon), matching: find.byType(RichText), ), ).text.style!; } TextStyle getTextStyle() { return tester.widget<RichText>( find.descendant( of: find.text('item 0'), matching: find.byType(RichText), ), ).text.style!; } // This SliverReorderableList has just one item: "item 0". await tester.pumpWidget( TestList( items: List<int>.from(<int>[0]), textColor: textColor, iconColor: iconColor, ), ); expect(tester.getTopLeft(find.text('item 0')), Offset.zero); expect(getIconStyle().color, iconColor); expect(getTextStyle().color, textColor); // Dragging item 0 causes it to be reparented in the overlay. The item // should still inherit the IconTheme and DefaultTextStyle because they are // InheritedThemes. final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); await tester.pump(kPressTimeout); await drag.moveBy(const Offset(0, 50)); await tester.pump(kPressTimeout); expect(tester.getTopLeft(find.text('item 0')), const Offset(0, 50)); expect(getIconStyle().color, iconColor); expect(getTextStyle().color, textColor); // Drag is complete, item 0 returns to where it was. await drag.up(); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('item 0')), Offset.zero); expect(getIconStyle().color, iconColor); expect(getTextStyle().color, textColor); }); testWidgets('SliverReorderableList - custom proxyDecorator', (WidgetTester tester) async { const ValueKey<String> fadeTransitionKey = ValueKey<String>('reordered-fade'); await tester.pumpWidget( TestList( items: List<int>.from(<int>[0, 1, 2, 3]), proxyDecorator: ( Widget child, int index, Animation<double> animation, ) { return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { final Tween<double> fadeValues = Tween<double>(begin: 1.0, end: 0.5); final Animation<double> fadeAnimation = animation.drive(fadeValues); return FadeTransition( key: fadeTransitionKey, opacity: fadeAnimation, child: child, ); }, child: child, ); }, ), ); Finder getItemFadeTransition() => find.byKey(fadeTransitionKey); expect(getItemFadeTransition(), findsNothing); // Start gesture on first item final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); await tester.pump(kPressTimeout); // Drag enough for transition animation defined in proxyDecorator to start. await drag.moveBy(const Offset(0, 50)); await tester.pump(); // At the start, opacity should be at 1.0. expect(getItemFadeTransition(), findsOneWidget); FadeTransition fadeTransition = tester.widget(getItemFadeTransition()); expect(fadeTransition.opacity.value, 1.0); // Let animation run halfway. await tester.pump(const Duration(milliseconds: 125)); fadeTransition = tester.widget(getItemFadeTransition()); expect(fadeTransition.opacity.value, greaterThan(0.5)); expect(fadeTransition.opacity.value, lessThan(1.0)); // Allow animation to run to the end. await tester.pumpAndSettle(); expect(find.byKey(fadeTransitionKey), findsOneWidget); fadeTransition = tester.widget(getItemFadeTransition()); expect(fadeTransition.opacity.value, 0.5); // Finish reordering. await drag.up(); await tester.pumpAndSettle(); expect(getItemFadeTransition(), findsNothing); }); testWidgets('ReorderableList supports items with nested list views without throwing layout exception.', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( builder: (BuildContext context, Widget? child) { return MediaQuery( // Ensure there is always a top padding to simulate a phone with // safe area at the top. If the nested list doesn't have the // padding removed before it is put into the overlay it will // overflow the layout by the top padding. data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(top: 50)), child: child!, ); }, home: Scaffold( appBar: AppBar(title: const Text('Nested Lists')), body: ReorderableList( itemCount: 10, itemBuilder: (BuildContext context, int index) { return ReorderableDragStartListener( index: index, key: ValueKey<int>(index), child: Column( children: <Widget>[ ListView( shrinkWrap: true, physics: const ClampingScrollPhysics(), children: const <Widget>[ Text('Other data'), Text('Other data'), Text('Other data'), ], ), ], ), ); }, 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(kPressTimeout); // Drag enough for move to start await drag.moveBy(const Offset(0, 50)); await tester.pumpAndSettle(); // There shouldn't be a layout overflow exception. expect(tester.takeException(), isNull); }); testWidgets('ReorderableList supports items with nested list views without throwing layout exception.', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/83224. await tester.pumpWidget( MaterialApp( builder: (BuildContext context, Widget? child) { return MediaQuery( // Ensure there is always a top padding to simulate a phone with // safe area at the top. If the nested list doesn't have the // padding removed before it is put into the overlay it will // overflow the layout by the top padding. data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(top: 50)), child: child!, ); }, home: Scaffold( appBar: AppBar(title: const Text('Nested Lists')), body: ReorderableList( itemCount: 10, itemBuilder: (BuildContext context, int index) { return ReorderableDragStartListener( index: index, key: ValueKey<int>(index), child: Column( children: <Widget>[ ListView( shrinkWrap: true, physics: const ClampingScrollPhysics(), children: const <Widget>[ Text('Other data'), Text('Other data'), Text('Other data'), ], ), ], ), ); }, 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(kPressTimeout); // Drag enough for move to start. await drag.moveBy(const Offset(0, 50)); await tester.pumpAndSettle(); // There shouldn't be a layout overflow exception. expect(tester.takeException(), isNull); }); testWidgets('SliverReorderableList - properly animates the drop at starting position in a reversed list', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/84625 final List<int> items = List<int>.generate(8, (int index) => index); Future<void> pressDragRelease(Offset start, Offset delta) async { final TestGesture drag = await tester.startGesture(start); await tester.pump(kPressTimeout); await drag.moveBy(delta); await tester.pumpAndSettle(); await drag.up(); await tester.pump(); } // The TestList is 800x600 SliverReorderableList with 8 items 800x100 each. // Each item has a text widget with 'item $index' that can be moved by a // press and drag gesture. For this test we are reversing the order so // the first item is at the bottom. await tester.pumpWidget(TestList(items: items, reverse: true)); expect(tester.getTopLeft(find.text('item 0')), const Offset(0, 500)); expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 400)); // Drag item 0 downwards off the edge and let it snap back. It should // smoothly animate back up. await pressDragRelease(tester.getCenter(find.text('item 0')), const Offset(0, 50)); expect(tester.getTopLeft(find.text('item 0')), const Offset(0, 550)); expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 400)); // After the first several frames we should be moving closer to the final position, // not further away as was the case with the original bug. await tester.pump(const Duration(milliseconds: 10)); expect(tester.getTopLeft(find.text('item 0')).dy, lessThan(550)); // Sample the middle (don't use exact values as it depends on the internal // curve being used). await tester.pump(const Duration(milliseconds: 125)); expect(tester.getTopLeft(find.text('item 0')).dy, lessThan(550)); // Wait for it to finish, it should be back to the original position await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('item 0')), const Offset(0, 500)); }); testWidgets('ReorderableList asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async { final List<int> numbers = <int>[0,1,2]; expect(() => ReorderableList( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(numbers[index]), height: 20 + numbers[index] * 10, child: ReorderableDragStartListener( index: index, child: Text(numbers[index].toString()), ) ); }, itemCount: numbers.length, itemExtent: 30, prototypeItem: const SizedBox(), onReorder: (int fromIndex, int toIndex) { }, ), throwsAssertionError); }); testWidgets('SliverReorderableList asserts on both non-null itemExtent and prototypeItem', (WidgetTester tester) async { final List<int> numbers = <int>[0,1,2]; expect(() => SliverReorderableList( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(numbers[index]), height: 20 + numbers[index] * 10, child: ReorderableDragStartListener( index: index, child: Text(numbers[index].toString()), ) ); }, itemCount: numbers.length, itemExtent: 30, prototypeItem: const SizedBox(), onReorder: (int fromIndex, int toIndex) { }, ), throwsAssertionError); }); testWidgets('if itemExtent is non-null, children have same extent in the scroll direction', (WidgetTester tester) async { final List<int> numbers = <int>[0,1,2]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return ReorderableList( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(numbers[index]), // children with different heights height: 20 + numbers[index] * 10, child: ReorderableDragStartListener( index: index, child: Text(numbers[index].toString()), ) ); }, itemCount: numbers.length, itemExtent: 30, onReorder: (int fromIndex, int toIndex) { if (fromIndex < toIndex) { toIndex--; } final int value = numbers.removeAt(fromIndex); numbers.insert(toIndex, value); }, ); }, ), ), ) ); final double item0Height = tester.getSize(find.text('0').hitTestable()).height; final double item1Height = tester.getSize(find.text('1').hitTestable()).height; final double item2Height = tester.getSize(find.text('2').hitTestable()).height; expect(item0Height, 30.0); expect(item1Height, 30.0); expect(item2Height, 30.0); }); testWidgets('if prototypeItem is non-null, children have same extent in the scroll direction', (WidgetTester tester) async { final List<int> numbers = <int>[0,1,2]; await tester.pumpWidget( MaterialApp( home: Scaffold( body: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return ReorderableList( itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(numbers[index]), // children with different heights height: 20 + numbers[index] * 10, child: ReorderableDragStartListener( index: index, child: Text(numbers[index].toString()), ) ); }, itemCount: numbers.length, prototypeItem: const SizedBox( height: 30, child: Text('3'), ), onReorder: (int oldIndex, int newIndex) { }, ); }, ), ), ) ); final double item0Height = tester.getSize(find.text('0').hitTestable()).height; final double item1Height = tester.getSize(find.text('1').hitTestable()).height; final double item2Height = tester.getSize(find.text('2').hitTestable()).height; expect(item0Height, 30.0); expect(item1Height, 30.0); expect(item2Height, 30.0); }); group('ReorderableDragStartListener', () { testWidgets('It should allow the item to be dragged when enabled is true', (WidgetTester tester) async { const int itemCount = 5; int onReorderCallCount = 0; final List<int> items = List<int>.generate(itemCount, (int index) => index); void handleReorder(int fromIndex, int toIndex) { onReorderCallCount += 1; if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); } // The list has five elements of height 100 await tester.pumpWidget( MaterialApp( home: ReorderableList( itemCount: itemCount, itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(items[index]), height: 100, child: ReorderableDragStartListener( index: index, child: Text('item ${items[index]}'), ), ); }, onReorder: handleReorder, ), ), ); // Start gesture on first item final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); await tester.pump(kPressTimeout); // Drag enough to move down the first item await drag.moveBy(const Offset(0, 50)); await tester.pump(); await drag.up(); await tester.pumpAndSettle(); expect(onReorderCallCount, 1); expect(items, orderedEquals(<int>[1, 0, 2, 3, 4])); }); testWidgets('It should allow the item to be dragged when enabled is true', (WidgetTester tester) async { const int itemCount = 5; int onReorderCallCount = 0; final List<int> items = List<int>.generate(itemCount, (int index) => index); void handleReorder(int fromIndex, int toIndex) { onReorderCallCount += 1; if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); } // The list has five elements of height 100 await tester.pumpWidget( MaterialApp( home: ReorderableList( itemCount: itemCount, itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(items[index]), height: 100, child: ReorderableDragStartListener( index: index, enabled: false, child: Text('item ${items[index]}'), ), ); }, onReorder: handleReorder, ), ), ); // Start gesture on first item final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); await tester.pump(kLongPressTimeout); // Drag enough to move down the first item await drag.moveBy(const Offset(0, 150)); await tester.pump(); await drag.up(); await tester.pumpAndSettle(); expect(onReorderCallCount, 0); expect(items, orderedEquals(<int>[0, 1, 2, 3, 4])); }); }); group('ReorderableDelayedDragStartListener', () { testWidgets('It should allow the item to be dragged when enabled is true', (WidgetTester tester) async { const int itemCount = 5; int onReorderCallCount = 0; final List<int> items = List<int>.generate(itemCount, (int index) => index); void handleReorder(int fromIndex, int toIndex) { onReorderCallCount += 1; if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); } // The list has five elements of height 100 await tester.pumpWidget( MaterialApp( home: ReorderableList( itemCount: itemCount, itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(items[index]), height: 100, child: ReorderableDelayedDragStartListener( index: index, child: Text('item ${items[index]}'), ), ); }, onReorder: handleReorder, ), ), ); await tester.pumpAndSettle(); // Start gesture on first item final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); await tester.pump(kLongPressTimeout); // Drag enough to move down the first item await drag.moveBy(const Offset(0, 50)); await tester.pump(); await drag.up(); await tester.pumpAndSettle(); expect(onReorderCallCount, 1); expect(items, orderedEquals(<int>[1, 0, 2, 3, 4])); }); testWidgets('It should allow the item to be dragged when enabled is true', (WidgetTester tester) async { const int itemCount = 5; int onReorderCallCount = 0; final List<int> items = List<int>.generate(itemCount, (int index) => index); void handleReorder(int fromIndex, int toIndex) { onReorderCallCount += 1; if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); } // The list has five elements of height 100 await tester.pumpWidget( MaterialApp( home: ReorderableList( itemCount: itemCount, itemBuilder: (BuildContext context, int index) { return SizedBox( key: ValueKey<int>(items[index]), height: 100, child: ReorderableDelayedDragStartListener( index: index, enabled: false, child: Text('item ${items[index]}'), ), ); }, onReorder: handleReorder, ), ), ); // Start gesture on first item final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0'))); await tester.pump(kLongPressTimeout); // Drag enough to move down the first item await drag.moveBy(const Offset(0, 50)); await tester.pump(); await drag.up(); await tester.pumpAndSettle(); expect(onReorderCallCount, 0); expect(items, orderedEquals(<int>[0, 1, 2, 3, 4])); }); }); } class TestList extends StatefulWidget { const TestList({ Key? key, this.textColor, this.iconColor, this.proxyDecorator, required this.items, this.reverse = false, }) : super(key: key); final List<int> items; final Color? textColor; final Color? iconColor; final ReorderItemProxyDecorator? proxyDecorator; final bool reverse; @override State<TestList> createState() => _TestListState(); } class _TestListState extends State<TestList> { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: DefaultTextStyle( style: TextStyle(color: widget.textColor), child: IconTheme( data: IconThemeData(color: widget.iconColor), child: StatefulBuilder( builder: (BuildContext outerContext, StateSetter setState) { final List<int> items = widget.items; return CustomScrollView( reverse: widget.reverse, slivers: <Widget>[ SliverReorderableList( 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) { setState(() { if (toIndex > fromIndex) { toIndex -= 1; } items.insert(toIndex, items.removeAt(fromIndex)); }); }, proxyDecorator: widget.proxyDecorator, ), ], ); }, ), ), ), ), ); } }