Reorderable list widget and Material demo (#18374)
diff --git a/examples/flutter_gallery/lib/demo/material/material.dart b/examples/flutter_gallery/lib/demo/material/material.dart
index 89876ab..3aab277 100644
--- a/examples/flutter_gallery/lib/demo/material/material.dart
+++ b/examples/flutter_gallery/lib/demo/material/material.dart
@@ -23,6 +23,7 @@
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';
diff --git a/examples/flutter_gallery/lib/demo/material/reorderable_list_demo.dart b/examples/flutter_gallery/lib/demo/material/reorderable_list_demo.dart
new file mode 100644
index 0000000..516aa86
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/material/reorderable_list_demo.dart
@@ -0,0 +1,192 @@
+// 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(),
+ ),
+ ),
+ );
+ }
+}
diff --git a/examples/flutter_gallery/lib/gallery/demos.dart b/examples/flutter_gallery/lib/gallery/demos.dart
index 375241d..f8713ae 100644
--- a/examples/flutter_gallery/lib/gallery/demos.dart
+++ b/examples/flutter_gallery/lib/gallery/demos.dart
@@ -266,6 +266,14 @@
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',
icon: GalleryIcons.more_vert,
diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart
index 886c9dc..b6d95a4 100644
--- a/packages/flutter/lib/material.dart
+++ b/packages/flutter/lib/material.dart
@@ -76,6 +76,7 @@
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';
diff --git a/packages/flutter/lib/src/material/reorderable_list.dart b/packages/flutter/lib/src/material/reorderable_list.dart
new file mode 100644
index 0000000..235d9c0
--- /dev/null
+++ b/packages/flutter/lib/src/material/reorderable_list.dart
@@ -0,0 +1,489 @@
+// 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,
+ );
+ });
+ }
+}
diff --git a/packages/flutter/test/material/reorderable_list_test.dart b/packages/flutter/test/material/reorderable_list_test.dart
new file mode 100644
index 0000000..48ca595
--- /dev/null
+++ b/packages/flutter/test/material/reorderable_list_test.dart
@@ -0,0 +1,437 @@
+// 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