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