Unverified Commit f844fada authored by David Shuckerow's avatar David Shuckerow Committed by GitHub

Reorderable list widget and Material demo (#18374)

parent 711ecf7f
......@@ -23,6 +23,7 @@ export 'overscroll_demo.dart';
export 'page_selector_demo.dart';
export 'persistent_bottom_sheet_demo.dart';
export 'progress_indicator_demo.dart';
export 'reorderable_list_demo.dart';
export 'scrollable_tabs_demo.dart';
export 'search_demo.dart';
export 'selection_controls_demo.dart';
......
// Copyright 2018 The Chromium 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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
enum _ReorderableListType {
/// A list tile that contains a [CircleAvatar].
horizontalAvatar,
/// A list tile that contains a [CircleAvatar].
verticalAvatar,
/// A list tile that contains three lines of text and a checkbox.
threeLine,
}
class ReorderableListDemo extends StatefulWidget {
const ReorderableListDemo({ Key key }) : super(key: key);
static const String routeName = '/material/reorderable-list';
@override
_ListDemoState createState() => new _ListDemoState();
}
class _ListItem {
_ListItem(this.value, this.checkState);
final String value;
bool checkState;
}
class _ListDemoState extends State<ReorderableListDemo> {
static final GlobalKey<ScaffoldState> scaffoldKey = new GlobalKey<ScaffoldState>();
PersistentBottomSheetController<Null> _bottomSheet;
_ReorderableListType _itemType = _ReorderableListType.threeLine;
bool _reverseSort = false;
final List<_ListItem> _items = <String>[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N',
].map((String item) => new _ListItem(item, false)).toList();
void changeItemType(_ReorderableListType type) {
setState(() {
_itemType = type;
});
// Rebuild the bottom sheet to reflect the selected list view.
_bottomSheet?.setState(() { });
// Close the bottom sheet to give the user a clear view of the list.
_bottomSheet?.close();
}
void _showConfigurationSheet() {
setState(() {
_bottomSheet = scaffoldKey.currentState.showBottomSheet((BuildContext bottomSheetContext) {
return new DecoratedBox(
decoration: const BoxDecoration(
border: const Border(top: const BorderSide(color: Colors.black26)),
),
child: new ListView(
shrinkWrap: true,
primary: false,
children: <Widget>[
new RadioListTile<_ReorderableListType>(
dense: true,
title: const Text('Horizontal Avatars'),
value: _ReorderableListType.horizontalAvatar,
groupValue: _itemType,
onChanged: changeItemType,
),
new RadioListTile<_ReorderableListType>(
dense: true,
title: const Text('Vertical Avatars'),
value: _ReorderableListType.verticalAvatar,
groupValue: _itemType,
onChanged: changeItemType,
),
new RadioListTile<_ReorderableListType>(
dense: true,
title: const Text('Three-line'),
value: _ReorderableListType.threeLine,
groupValue: _itemType,
onChanged: changeItemType,
),
],
),
);
});
// Garbage collect the bottom sheet when it closes.
_bottomSheet.closed.whenComplete(() {
if (mounted) {
setState(() {
_bottomSheet = null;
});
}
});
});
}
Widget buildListTile(_ListItem item) {
const Widget secondary = const Text(
'Even more additional list item information appears on line three.',
);
Widget listTile;
switch (_itemType) {
case _ReorderableListType.threeLine:
listTile = new CheckboxListTile(
key: new Key(item.value),
isThreeLine: true,
value: item.checkState ?? false,
onChanged: (bool newValue) {
setState(() {
item.checkState = newValue;
});
},
title: new Text('This item represents ${item.value}.'),
subtitle: secondary,
secondary: const Icon(Icons.drag_handle),
);
break;
case _ReorderableListType.horizontalAvatar:
case _ReorderableListType.verticalAvatar:
listTile = new Container(
key: new Key(item.value),
height: 100.0,
width: 100.0,
child: new CircleAvatar(child: new Text(item.value),
backgroundColor: Colors.green,
),
);
break;
}
return listTile;
}
void _onReorder(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final _ListItem item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
}
@override
Widget build(BuildContext context) {
return new Scaffold(
key: scaffoldKey,
appBar: new AppBar(
title: const Text('Reorderable list'),
actions: <Widget>[
new IconButton(
icon: const Icon(Icons.sort_by_alpha),
tooltip: 'Sort',
onPressed: () {
setState(() {
_reverseSort = !_reverseSort;
_items.sort((_ListItem a, _ListItem b) => _reverseSort ? b.value.compareTo(a.value) : a.value.compareTo(b.value));
});
},
),
new IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Show menu',
onPressed: _bottomSheet == null ? _showConfigurationSheet : null,
),
],
),
body: new Scrollbar(
child: new ReorderableListView(
header: _itemType != _ReorderableListType.threeLine
? new Padding(
padding: const EdgeInsets.all(8.0),
child: new Text('Header of the list', style: Theme.of(context).textTheme.headline))
: null,
onReorder: _onReorder,
scrollDirection: _itemType == _ReorderableListType.horizontalAvatar ? Axis.horizontal : Axis.vertical,
padding: const EdgeInsets.symmetric(vertical: 8.0),
children: _items.map(buildListTile).toList(),
),
),
);
}
}
......@@ -265,6 +265,14 @@ List<GalleryDemo> _buildGalleryDemos() {
routeName: LeaveBehindDemo.routeName,
buildRoute: (BuildContext context) => const LeaveBehindDemo(),
),
new GalleryDemo(
title: 'Lists: reorderable',
subtitle: 'Reorderable lists',
icon: GalleryIcons.list_alt,
category: _kMaterialComponents,
routeName: ReorderableListDemo.routeName,
buildRoute: (BuildContext context) => const ReorderableListDemo(),
),
new GalleryDemo(
title: 'Menus',
subtitle: 'Menu buttons and simple menus',
......
......@@ -76,6 +76,7 @@ export 'src/material/radio.dart';
export 'src/material/radio_list_tile.dart';
export 'src/material/raised_button.dart';
export 'src/material/refresh_indicator.dart';
export 'src/material/reorderable_list.dart';
export 'src/material/scaffold.dart';
export 'src/material/scrollbar.dart';
export 'src/material/search.dart';
......
// Copyright 2018 The Chromium 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 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import 'material.dart';
/// The callback used by [ReorderableListView] to move an item to a new
/// position in a list.
///
/// Implementations should remove the corresponding list item at [oldIndex]
/// and reinsert it at [newIndex].
///
/// Note that if [oldIndex] is before [newIndex], removing the item at [oldIndex]
/// from the list will reduce the list's length by one. Implementations used
/// by [ReorderableListView] will need to account for this when inserting before
/// [newIndex].
///
/// Example implementation:
///
/// ```dart
/// final List<MyDataObject> backingList = <MyDataObject>[/* ... */];
///
/// void onReorder(int oldIndex, int newIndex) {
/// if (oldIndex < newIndex) {
/// // removing the item at oldIndex will shorten the list by 1.
/// newIndex -= 1;
/// }
/// final MyDataObject element = backingList.removeAt(oldIndex);
/// backingList.insert(newIndex, element);
/// }
/// ```
typedef void OnReorderCallback(int oldIndex, int newIndex);
/// A list whose items the user can interactively reorder by dragging.
///
/// This class is appropriate for views with a small number of
/// children because constructing the [List] requires doing work for every
/// child that could possibly be displayed in the list view instead of just
/// those children that are actually visible.
///
/// All [children] must have a key.
class ReorderableListView extends StatefulWidget {
/// Creates a reorderable list.
ReorderableListView({
this.header,
@required this.children,
@required this.onReorder,
this.scrollDirection = Axis.vertical,
this.padding,
}): assert(scrollDirection != null),
assert(onReorder != null),
assert(children != null),
assert(
children.every((Widget w) => w.key != null),
'All children of this widget must have a key.',
);
/// A non-reorderable header widget to show before the list.
///
/// If null, no header will appear before the list.
final Widget header;
/// The widgets to display.
final List<Widget> children;
/// The [Axis] along which the list scrolls.
///
/// List [children] can only drag along this [Axis].
final Axis scrollDirection;
/// The amount of space by which to inset the [children].
final EdgeInsets padding;
/// Called when a list child is dropped into a new position to shuffle the
/// underlying list.
///
/// This [ReorderableListView] calls [onReorder] after a list child is dropped
/// into a new position.
final OnReorderCallback onReorder;
@override
_ReorderableListViewState createState() => new _ReorderableListViewState();
}
// This top-level state manages an Overlay that contains the list and
// also any Draggables it creates.
//
// _ReorderableListContent manages the list itself and reorder operations.
//
// The Overlay doesn't properly keep state by building new overlay entries,
// and so we cache a single OverlayEntry for use as the list layer.
// That overlay entry then builds a _ReorderableListContent which may
// insert Draggables into the Overlay above itself.
class _ReorderableListViewState extends State<ReorderableListView> {
// We use an inner overlay so that the dragging list item doesn't draw outside of the list itself.
final GlobalKey _overlayKey = new GlobalKey(debugLabel: '$ReorderableListView overlay key');
// This entry contains the scrolling list itself.
OverlayEntry _listOverlayEntry;
@override
void initState() {
super.initState();
_listOverlayEntry = new OverlayEntry(
opaque: true,
builder: (BuildContext context) {
return new _ReorderableListContent(
header: widget.header,
children: widget.children,
scrollDirection: widget.scrollDirection,
onReorder: widget.onReorder,
padding: widget.padding,
);
},
);
}
@override
Widget build(BuildContext context) {
return new Overlay(
key: _overlayKey,
initialEntries: <OverlayEntry>[
_listOverlayEntry,
]);
}
}
// This widget is responsible for the inside of the Overlay in the
// ReorderableListView.
class _ReorderableListContent extends StatefulWidget {
const _ReorderableListContent({
@required this.header,
@required this.children,
@required this.scrollDirection,
@required this.padding,
@required this.onReorder,
});
final Widget header;
final List<Widget> children;
final Axis scrollDirection;
final EdgeInsets padding;
final OnReorderCallback onReorder;
@override
_ReorderableListContentState createState() => new _ReorderableListContentState();
}
class _ReorderableListContentState extends State<_ReorderableListContent> with TickerProviderStateMixin {
// The extent along the [widget.scrollDirection] axis to allow a child to
// drop into when the user reorders list children.
//
// This value is used when the extents haven't yet been calculated from
// the currently dragging widget, such as when it first builds.
static const double _defaultDropAreaExtent = 100.0;
// The additional margin to place around a computed drop area.
static const double _dropAreaMargin = 8.0;
// How long an animation to reorder an element in the list takes.
static const Duration _reorderAnimationDuration = const Duration(milliseconds: 200);
// How long an animation to scroll to an off-screen element in the
// list takes.
static const Duration _scrollAnimationDuration = const Duration(milliseconds: 200);
// Controls scrolls and measures scroll progress.
final ScrollController _scrollController = new ScrollController();
// This controls the entrance of the dragging widget into a new place.
AnimationController _entranceController;
// This controls the 'ghost' of the dragging widget, which is left behind
// where the widget used to be.
AnimationController _ghostController;
// The member of widget.children currently being dragged.
//
// Null if no drag is underway.
Key _dragging;
// The last computed size of the feedback widget being dragged.
Size _draggingFeedbackSize;
// The location that the dragging widget occupied before it started to drag.
int _dragStartIndex = 0;
// The index that the dragging widget most recently left.
// This is used to show an animation of the widget's position.
int _ghostIndex = 0;
// The index that the dragging widget currently occupies.
int _currentIndex = 0;
// The widget to move the dragging widget too after the current index.
int _nextIndex = 0;
// Whether or not we are currently scrolling this view to show a widget.
bool _scrolling = false;
double get _dropAreaExtent {
if (_draggingFeedbackSize == null) {
return _defaultDropAreaExtent;
}
double dropAreaWithoutMargin;
switch (widget.scrollDirection) {
case Axis.horizontal:
dropAreaWithoutMargin = _draggingFeedbackSize.width;
break;
case Axis.vertical:
default:
dropAreaWithoutMargin = _draggingFeedbackSize.height;
break;
}
return dropAreaWithoutMargin + _dropAreaMargin;
}
@override
void initState() {
super.initState();
_entranceController = new AnimationController(vsync: this, duration: _reorderAnimationDuration);
_ghostController = new AnimationController(vsync: this, duration: _reorderAnimationDuration);
_entranceController.addStatusListener(_onEntranceStatusChanged);
}
@override
void dispose() {
_entranceController.dispose();
_ghostController.dispose();
super.dispose();
}
// Animates the droppable space from _currentIndex to _nextIndex.
void _requestAnimationToNextIndex() {
if (_entranceController.isCompleted) {
_ghostIndex = _currentIndex;
if (_nextIndex == _currentIndex) {
return;
}
_currentIndex = _nextIndex;
_ghostController.reverse(from: 1.0);
_entranceController.forward(from: 0.0);
}
}
// Requests animation to the latest next index if it changes during an animation.
void _onEntranceStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.completed) {
setState(() {
_requestAnimationToNextIndex();
});
}
}
// Scrolls to a target context if that context is not on the screen.
void _scrollTo(BuildContext context) {
if (_scrolling)
return;
final RenderObject contextObject = context.findRenderObject();
final RenderAbstractViewport viewport = RenderAbstractViewport.of(contextObject);
assert(viewport != null);
// If and only if the current scroll offset falls in-between the offsets
// necessary to reveal the selected context at the top or bottom of the
// screen, then it is already on-screen.
final double margin = _dropAreaExtent;
final double scrollOffset = _scrollController.offset;
final double topOffset = max(
_scrollController.position.minScrollExtent,
viewport.getOffsetToReveal(contextObject, 0.0).offset - margin,
);
final double bottomOffset = min(
_scrollController.position.maxScrollExtent,
viewport.getOffsetToReveal(contextObject, 1.0).offset + margin,
);
final bool onScreen = scrollOffset <= topOffset && scrollOffset >= bottomOffset;
// If the context is off screen, then we request a scroll to make it visible.
if (!onScreen) {
_scrolling = true;
_scrollController.position.animateTo(
scrollOffset < bottomOffset ? bottomOffset : topOffset,
duration: _scrollAnimationDuration,
curve: Curves.easeInOut,
).then((Null none) {
setState(() {
_scrolling = false;
});
});
}
}
// Wraps children in Row or Column, so that the children flow in
// the widget's scrollDirection.
Widget _buildContainerForScrollDirection({List<Widget> children}) {
switch (widget.scrollDirection) {
case Axis.horizontal:
return new Row(children: children);
case Axis.vertical:
default:
return new Column(children: children);
}
}
// Wraps one of the widget's children in a DragTarget and Draggable.
// Handles up the logic for dragging and reordering items in the list.
Widget _wrap(Widget toWrap, int index, BoxConstraints constraints) {
assert(toWrap.key != null);
// We create a global key based on both the child key and index
// so that when we reorder the list, a key doesn't get created twice.
final GlobalObjectKey keyIndexGlobalKey = new GlobalObjectKey(toWrap.key);
// We pass the toWrapWithGlobalKey into the Draggable so that when a list
// item gets dragged, the accessibility framework can preserve the selected
// state of the dragging item.
final Widget toWrapWithGlobalKey = new KeyedSubtree(key: keyIndexGlobalKey, child: toWrap);
// Starts dragging toWrap.
void onDragStarted() {
setState(() {
_dragging = toWrap.key;
_dragStartIndex = index;
_ghostIndex = index;
_currentIndex = index;
_entranceController.value = 1.0;
_draggingFeedbackSize = keyIndexGlobalKey.currentContext.size;
});
}
// Drops toWrap into the last position it was hovering over.
void onDragEnded() {
setState(() {
if (_dragStartIndex != _currentIndex)
widget.onReorder(_dragStartIndex, _currentIndex);
// Animates leftover space in the drop area closed.
// TODO(djshuckerow): bring the animation in line with the Material
// specifications.
_ghostController.reverse(from: 0.1);
_entranceController.reverse(from: 0.1);
_dragging = null;
});
}
Widget buildDragTarget(BuildContext context, List<Key> acceptedCandidates, List<dynamic> rejectedCandidates) {
// We build the draggable inside of a layout builder so that we can
// constrain the size of the feedback dragging widget.
Widget child = new LongPressDraggable<Key>(
maxSimultaneousDrags: 1,
axis: widget.scrollDirection,
data: toWrap.key,
ignoringFeedbackSemantics: false,
feedback: new Container(
alignment: Alignment.topLeft,
// These constraints will limit the cross axis of the drawn widget.
constraints: constraints,
child: new Material(
elevation: 6.0,
child: toWrapWithGlobalKey,
),
),
child: _dragging == toWrap.key ? const SizedBox() : toWrapWithGlobalKey,
childWhenDragging: const SizedBox(),
dragAnchor: DragAnchor.child,
onDragStarted: onDragStarted,
// When the drag ends inside a DragTarget widget, the drag
// succeeds, and we reorder the widget into position appropriately.
onDragCompleted: onDragEnded,
// When the drag does not end inside a DragTarget widget, the
// drag fails, but we still reorder the widget to the last position it
// had been dragged to.
onDraggableCanceled: (Velocity velocity, Offset offset) {
onDragEnded();
},
);
// The target for dropping at the end of the list doesn't need to be
// draggable.
if (index >= widget.children.length) {
child = toWrap;
}
// Determine the size of the drop area to show under the dragging widget.
Widget spacing;
switch (widget.scrollDirection) {
case Axis.horizontal:
spacing = new SizedBox(width: _dropAreaExtent);
break;
case Axis.vertical:
default:
spacing = new SizedBox(height: _dropAreaExtent);
break;
}
// We open up a space under where the dragging widget currently is to
// show it can be dropped.
if (_currentIndex == index) {
return _buildContainerForScrollDirection(children: <Widget>[
new SizeTransition(
sizeFactor: _entranceController,
axis: widget.scrollDirection,
child: spacing
),
child,
]);
}
// We close up the space under where the dragging widget previously was
// with the ghostController animation.
if (_ghostIndex == index) {
return _buildContainerForScrollDirection(children: <Widget>[
new SizeTransition(
sizeFactor: _ghostController,
axis: widget.scrollDirection,
child: spacing,
),
child,
]);
}
return child;
}
// We wrap the drag target in a Builder so that we can scroll to its specific context.
return new KeyedSubtree(
key: new Key('#$ReorderableListView|KeyedSubtree|${toWrap.key}'),
child:new Builder(builder: (BuildContext context) {
return new DragTarget<Key>(
builder: buildDragTarget,
onWillAccept: (Key toAccept) {
setState(() {
_nextIndex = index;
_requestAnimationToNextIndex();
});
_scrollTo(context);
// If the target is not the original starting point, then we will accept the drop.
return _dragging == toAccept && toAccept != toWrap.key;
},
onAccept: (Key accepted) {},
onLeave: (Key leaving) {},
);
}),
);
}
@override
Widget build(BuildContext context) {
// We use the layout builder to constrain the cross-axis size of dragging child widgets.
return new LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
final List<Widget> wrappedChildren = <Widget>[];
if (widget.header != null) {
wrappedChildren.add(widget.header);
}
for (int i = 0; i < widget.children.length; i += 1) {
wrappedChildren.add(_wrap(widget.children[i], i, constraints));
}
const Key endWidgetKey = const Key('DraggableList - End Widget');
Widget finalDropArea;
switch (widget.scrollDirection) {
case Axis.horizontal:
finalDropArea = new SizedBox(
key: endWidgetKey,
width: _defaultDropAreaExtent,
height: constraints.maxHeight,
);
break;
case Axis.vertical:
default:
finalDropArea = new SizedBox(
key: endWidgetKey,
height: _defaultDropAreaExtent,
width: constraints.maxWidth,
);
break;
}
wrappedChildren.add(_wrap(
finalDropArea,
widget.children.length,
constraints),
);
return new SingleChildScrollView(
scrollDirection: widget.scrollDirection,
child: _buildContainerForScrollDirection(children: wrappedChildren),
padding: widget.padding,
controller: _scrollController,
);
});
}
}
// Copyright 2018 The Chromium 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_test/flutter_test.dart';
import 'package:flutter/material.dart';
void main() {
group('$ReorderableListView', () {
const double itemHeight = 48.0;
const List<String> originalListItems = const <String>['Item 1', 'Item 2', 'Item 3', 'Item 4'];
List<String> listItems;
void onReorder(int oldIndex, int newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final String element = listItems.removeAt(oldIndex);
listItems.insert(newIndex, element);
}
Widget listItemToWidget(String listItem) {
return new SizedBox(
key: new Key(listItem),
height: itemHeight,
width: itemHeight,
child: new Text(listItem),
);
}
Widget build({Widget header, Axis scrollDirection = Axis.vertical}) {
return new MaterialApp(
home: new SizedBox(
height: itemHeight * 10,
width: itemHeight * 10,
child: new ReorderableListView(
header: header,
children: listItems.map(listItemToWidget).toList(),
scrollDirection: scrollDirection,
onReorder: onReorder,
),
),
);
}
setUp(() {
// Copy the original list into listItems.
listItems = originalListItems.toList();
});
group('in vertical mode', () {
testWidgets('reorders its contents only when a drag finishes', (WidgetTester tester) async {
await tester.pumpWidget(build());
expect(listItems, orderedEquals(originalListItems));
final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 1')));
await tester.pump(kLongPressTimeout + kPressTimeout);
expect(listItems, orderedEquals(originalListItems));
await drag.moveTo(tester.getCenter(find.text('Item 4')));
expect(listItems, orderedEquals(originalListItems));
await drag.up();
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 1', 'Item 4']));
});
testWidgets('allows reordering from the very top to the very bottom', (WidgetTester tester) async {
await tester.pumpWidget(build());
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 1')),
tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2),
);
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
});
testWidgets('allows reordering from the very bottom to the very top', (WidgetTester tester) async {
await tester.pumpWidget(build());
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 4')),
tester.getCenter(find.text('Item 1')),
);
expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3']));
});
testWidgets('allows reordering inside the middle of the widget', (WidgetTester tester) async {
await tester.pumpWidget(build());
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 3')),
tester.getCenter(find.text('Item 2')),
);
expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4']));
});
testWidgets('properly reorders with a header', (WidgetTester tester) async {
await tester.pumpWidget(build(header: const Text('Header Text')));
expect(find.text('Header Text'), findsOneWidget);
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 1')),
tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2),
);
expect(find.text('Header Text'), findsOneWidget);
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
});
testWidgets('properly determines the vertical drop area extents', (WidgetTester tester) async {
final Widget reorderableListView = new ReorderableListView(
children: const <Widget>[
const SizedBox(
key: const Key('Normal item'),
height: itemHeight,
child: const Text('Normal item'),
),
const SizedBox(
key: const Key('Tall item'),
height: itemHeight * 2,
child: const Text('Tall item'),
),
const SizedBox(
key: const Key('Last item'),
height: itemHeight,
child: const Text('Last item'),
)
],
scrollDirection: Axis.vertical,
onReorder: (int oldIndex, int newIndex) {},
);
await tester.pumpWidget(new MaterialApp(
home: new SizedBox(
height: itemHeight * 10,
child: reorderableListView,
),
));
Element getContentElement() {
final SingleChildScrollView listScrollView = find.byType(SingleChildScrollView).evaluate().first.widget;
final Widget scrollContents = listScrollView.child;
final Element contentElement = find.byElementPredicate((Element element) => element.widget == scrollContents).evaluate().first;
return contentElement;
}
const double kNonDraggingListHeight = 292.0;
// The list view pads the drop area by 8dp.
const double kDraggingListHeight = 300.0;
// Drag a normal text item
expect(getContentElement().size.height, kNonDraggingListHeight);
TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item')));
await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle();
expect(getContentElement().size.height, kDraggingListHeight);
// Move it
await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle();
expect(getContentElement().size.height, kDraggingListHeight);
// Drop it
await drag.up();
await tester.pumpAndSettle();
expect(getContentElement().size.height, kNonDraggingListHeight);
// Drag a tall item
drag = await tester.startGesture(tester.getCenter(find.text('Tall item')));
await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle();
expect(getContentElement().size.height, kDraggingListHeight);
// Move it
await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle();
expect(getContentElement().size.height, kDraggingListHeight);
// Drop it
await drag.up();
await tester.pumpAndSettle();
expect(getContentElement().size.height, kNonDraggingListHeight);
});
testWidgets('Preserves children states when the list parent changes the order', (WidgetTester tester) async {
_StatefulState findState(Key key) {
return find.byElementPredicate((Element element) => element.ancestorWidgetOfExactType(_Stateful)?.key == key)
.evaluate()
.first
.ancestorStateOfType(const TypeMatcher<_StatefulState>());
}
await tester.pumpWidget(new MaterialApp(
home: new ReorderableListView(
children: <Widget>[
new _Stateful(key: const Key('A')),
new _Stateful(key: const Key('B')),
new _Stateful(key: const Key('C')),
],
onReorder: (int oldIndex, int newIndex) {},
),
));
await tester.tap(find.byKey(const Key('A')));
await tester.pumpAndSettle();
// Only the 'A' widget should be checked.
expect(findState(const Key('A')).checked, true);
expect(findState(const Key('B')).checked, false);
expect(findState(const Key('C')).checked, false);
await tester.pumpWidget(new MaterialApp(
home: new ReorderableListView(
children: <Widget>[
new _Stateful(key: const Key('B')),
new _Stateful(key: const Key('C')),
new _Stateful(key: const Key('A')),
],
onReorder: (int oldIndex, int newIndex) {},
),
));
// Only the 'A' widget should be checked.
expect(findState(const Key('B')).checked, false);
expect(findState(const Key('C')).checked, false);
expect(findState(const Key('A')).checked, true);
});
});
group('in horizontal mode', () {
testWidgets('allows reordering from the very top to the very bottom', (WidgetTester tester) async {
await tester.pumpWidget(build(scrollDirection: Axis.horizontal));
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 1')),
tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0),
);
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
});
testWidgets('allows reordering from the very bottom to the very top', (WidgetTester tester) async {
await tester.pumpWidget(build(scrollDirection: Axis.horizontal));
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 4')),
tester.getCenter(find.text('Item 1')),
);
expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3']));
});
testWidgets('allows reordering inside the middle of the widget', (WidgetTester tester) async {
await tester.pumpWidget(build(scrollDirection: Axis.horizontal));
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 3')),
tester.getCenter(find.text('Item 2')),
);
expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4']));
});
testWidgets('properly reorders with a header', (WidgetTester tester) async {
await tester.pumpWidget(build(header: const Text('Header Text'), scrollDirection: Axis.horizontal));
expect(find.text('Header Text'), findsOneWidget);
expect(listItems, orderedEquals(originalListItems));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 1')),
tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0),
);
await tester.pumpAndSettle();
expect(find.text('Header Text'), findsOneWidget);
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
await tester.pumpWidget(build(header: const Text('Header Text'), scrollDirection: Axis.horizontal));
await longPressDrag(
tester,
tester.getCenter(find.text('Item 4')),
tester.getCenter(find.text('Item 3')),
);
expect(find.text('Header Text'), findsOneWidget);
expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1']));
});
testWidgets('properly determines the horizontal drop area extents', (WidgetTester tester) async {
final Widget reorderableListView = new ReorderableListView(
children: const <Widget>[
const SizedBox(
key: const Key('Normal item'),
width: itemHeight,
child: const Text('Normal item'),
),
const SizedBox(
key: const Key('Tall item'),
width: itemHeight * 2,
child: const Text('Tall item'),
),
const SizedBox(
key: const Key('Last item'),
width: itemHeight,
child: const Text('Last item'),
)
],
scrollDirection: Axis.horizontal,
onReorder: (int oldIndex, int newIndex) {},
);
await tester.pumpWidget(new MaterialApp(
home: new SizedBox(
width: itemHeight * 10,
child: reorderableListView,
),
));
Element getContentElement() {
final SingleChildScrollView listScrollView = find.byType(SingleChildScrollView).evaluate().first.widget;
final Widget scrollContents = listScrollView.child;
final Element contentElement = find.byElementPredicate((Element element) => element.widget == scrollContents).evaluate().first;
return contentElement;
}
const double kNonDraggingListWidth = 292.0;
// The list view pads the drop area by 8dp.
const double kDraggingListWidth = 300.0;
// Drag a normal text item
expect(getContentElement().size.width, kNonDraggingListWidth);
TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item')));
await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle();
expect(getContentElement().size.width, kDraggingListWidth);
// Move it
await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle();
expect(getContentElement().size.width, kDraggingListWidth);
// Drop it
await drag.up();
await tester.pumpAndSettle();
expect(getContentElement().size.width, kNonDraggingListWidth);
// Drag a tall item
drag = await tester.startGesture(tester.getCenter(find.text('Tall item')));
await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle();
expect(getContentElement().size.width, kDraggingListWidth);
// Move it
await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle();
expect(getContentElement().size.width, kDraggingListWidth);
// Drop it
await drag.up();
await tester.pumpAndSettle();
expect(getContentElement().size.width, kNonDraggingListWidth);
});
testWidgets('Preserves children states when the list parent changes the order', (WidgetTester tester) async {
_StatefulState findState(Key key) {
return find.byElementPredicate((Element element) => element.ancestorWidgetOfExactType(_Stateful)?.key == key)
.evaluate()
.first
.ancestorStateOfType(const TypeMatcher<_StatefulState>());
}
await tester.pumpWidget(new MaterialApp(
home: new ReorderableListView(
children: <Widget>[
new _Stateful(key: const Key('A')),
new _Stateful(key: const Key('B')),
new _Stateful(key: const Key('C')),
],
onReorder: (int oldIndex, int newIndex) {},
scrollDirection: Axis.horizontal,
),
));
await tester.tap(find.byKey(const Key('A')));
await tester.pumpAndSettle();
// Only the 'A' widget should be checked.
expect(findState(const Key('A')).checked, true);
expect(findState(const Key('B')).checked, false);
expect(findState(const Key('C')).checked, false);
await tester.pumpWidget(new MaterialApp(
home: new ReorderableListView(
children: <Widget>[
new _Stateful(key: const Key('B')),
new _Stateful(key: const Key('C')),
new _Stateful(key: const Key('A')),
],
onReorder: (int oldIndex, int newIndex) {},
scrollDirection: Axis.horizontal,
),
));
// 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);
});
});
// TODO(djshuckerow): figure out how to write a test for scrolling the list.
});
}
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
// ignore:prefer_const_constructors_in_immutables
_Stateful({Key key}) : super(key: key);
@override
State<StatefulWidget> createState() => new _StatefulState();
}
class _StatefulState extends State<_Stateful> {
bool checked = false;
@override
Widget build(BuildContext context) {
return new Container(
width: 48.0,
height: 48.0,
child: new Material(
child: new Checkbox(
value: checked,
onChanged: (bool newValue) => checked = newValue,
),
),
);
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment