// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; import 'debug.dart'; import 'material.dart'; import 'material_localizations.dart'; // Examples can assume: // class MyDataObject { } /// 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]. /// /// 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]. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE} /// /// {@tool snippet} /// /// ```dart /// final List<MyDataObject> backingList = <MyDataObject>[/* ... */]; /// /// void handleReorder(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); /// } /// ``` /// {@end-tool} typedef ReorderCallback = void Function(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. /// /// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE} class ReorderableListView extends StatefulWidget { /// Creates a reorderable list. ReorderableListView({ Key key, this.header, @required this.children, @required this.onReorder, this.scrollController, this.scrollDirection = Axis.vertical, this.padding, this.reverse = false, }) : 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.', ), super(key: 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; /// Creates a [ScrollPosition] to manage and determine which portion /// of the content is visible in the scroll view. /// /// This can be used in many ways, such as setting an initial scroll offset, /// (via [ScrollController.initialScrollOffset]), reading the current scroll position /// (via [ScrollController.offset]), or changing it (via [ScrollController.jumpTo] or /// [ScrollController.animateTo]). final ScrollController scrollController; /// The amount of space by which to inset the [children]. final EdgeInsets padding; /// Whether the scroll view scrolls in the reading direction. /// /// For example, if the reading direction is left-to-right and /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from /// left to right when [reverse] is false and from right to left when /// [reverse] is true. /// /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view /// scrolls from top to bottom when [reverse] is false and from bottom to top /// when [reverse] is true. /// /// Defaults to false. final bool reverse; /// 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 ReorderCallback onReorder; @override _ReorderableListViewState createState() => _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 = GlobalKey(debugLabel: '$ReorderableListView overlay key'); // This entry contains the scrolling list itself. OverlayEntry _listOverlayEntry; @override void initState() { super.initState(); _listOverlayEntry = OverlayEntry( opaque: true, builder: (BuildContext context) { return _ReorderableListContent( header: widget.header, children: widget.children, scrollController: widget.scrollController, scrollDirection: widget.scrollDirection, onReorder: widget.onReorder, padding: widget.padding, reverse: widget.reverse, ); }, ); } @override Widget build(BuildContext context) { return 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.scrollController, @required this.scrollDirection, @required this.padding, @required this.onReorder, @required this.reverse, }); final Widget header; final List<Widget> children; final ScrollController scrollController; final Axis scrollDirection; final EdgeInsets padding; final ReorderCallback onReorder; final bool reverse; @override _ReorderableListContentState createState() => _ReorderableListContentState(); } class _ReorderableListContentState extends State<_ReorderableListContent> with TickerProviderStateMixin<_ReorderableListContent> { // 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 = Duration(milliseconds: 200); // How long an animation to scroll to an off-screen element in the // list takes. static const Duration _scrollAnimationDuration = Duration(milliseconds: 200); // Controls scrolls and measures scroll progress. ScrollController _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 = AnimationController(vsync: this, duration: _reorderAnimationDuration); _ghostController = AnimationController(vsync: this, duration: _reorderAnimationDuration); _entranceController.addStatusListener(_onEntranceStatusChanged); } @override void didChangeDependencies() { _scrollController = widget.scrollController ?? PrimaryScrollController.of(context) ?? ScrollController(); super.didChangeDependencies(); } @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((void value) { 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 Row(children: children); case Axis.vertical: default: return 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); final GlobalObjectKey keyIndexGlobalKey = 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. // Starts dragging toWrap. void onDragStarted() { setState(() { _dragging = toWrap.key; _dragStartIndex = index; _ghostIndex = index; _currentIndex = index; _entranceController.value = 1.0; _draggingFeedbackSize = keyIndexGlobalKey.currentContext.size; }); } // Places the value from startIndex one space before the element at endIndex. void reorder(int startIndex, int endIndex) { setState(() { if (startIndex != endIndex) widget.onReorder(startIndex, endIndex); // 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; }); } // Drops toWrap into the last position it was hovering over. void onDragEnded() { reorder(_dragStartIndex, _currentIndex); } Widget wrapWithSemantics() { // First, determine which semantics actions apply. final Map<CustomSemanticsAction, VoidCallback> semanticsActions = <CustomSemanticsAction, VoidCallback>{}; // Create the appropriate semantics actions. void moveToStart() => reorder(index, 0); void moveToEnd() => reorder(index, widget.children.length); void moveBefore() => reorder(index, index - 1); // To move after, we go to index+2 because we are moving it to the space // before index+2, which is after the space at index+1. void moveAfter() => reorder(index, index + 2); final MaterialLocalizations localizations = MaterialLocalizations.of(context); // If the item can move to before its current position in the list. if (index > 0) { semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToStart)] = moveToStart; String reorderItemBefore = localizations.reorderItemUp; if (widget.scrollDirection == Axis.horizontal) { reorderItemBefore = Directionality.of(context) == TextDirection.ltr ? localizations.reorderItemLeft : localizations.reorderItemRight; } semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = moveBefore; } // If the item can move to after its current position in the list. if (index < widget.children.length - 1) { String reorderItemAfter = localizations.reorderItemDown; if (widget.scrollDirection == Axis.horizontal) { reorderItemAfter = Directionality.of(context) == TextDirection.ltr ? localizations.reorderItemRight : localizations.reorderItemLeft; } semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = moveAfter; semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToEnd)] = moveToEnd; } // We pass toWrap with a GlobalKey into the Draggable so that when a list // item gets dragged, the accessibility framework can preserve the selected // state of the dragging item. // // We also apply the relevant custom accessibility actions for moving the item // up, down, to the start, and to the end of the list. return KeyedSubtree( key: keyIndexGlobalKey, child: MergeSemantics( child: Semantics( customSemanticsActions: semanticsActions, child: toWrap, ), ), ); } Widget buildDragTarget(BuildContext context, List<Key> acceptedCandidates, List<dynamic> rejectedCandidates) { final Widget toWrapWithSemantics = wrapWithSemantics(); // We build the draggable inside of a layout builder so that we can // constrain the size of the feedback dragging widget. Widget child = LongPressDraggable<Key>( maxSimultaneousDrags: 1, axis: widget.scrollDirection, data: toWrap.key, ignoringFeedbackSemantics: false, feedback: Container( alignment: Alignment.topLeft, // These constraints will limit the cross axis of the drawn widget. constraints: constraints, child: Material( elevation: 6.0, child: toWrapWithSemantics, ), ), child: _dragging == toWrap.key ? const SizedBox() : toWrapWithSemantics, 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 = SizedBox(width: _dropAreaExtent); break; case Axis.vertical: default: spacing = 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>[ 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>[ 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 Builder(builder: (BuildContext context) { return 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: (Object leaving) { }, ); }); } @override Widget build(BuildContext context) { assert(debugCheckHasMaterialLocalizations(context)); // We use the layout builder to constrain the cross-axis size of dragging child widgets. return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { const Key endWidgetKey = Key('DraggableList - End Widget'); Widget finalDropArea; switch (widget.scrollDirection) { case Axis.horizontal: finalDropArea = SizedBox( key: endWidgetKey, width: _defaultDropAreaExtent, height: constraints.maxHeight, ); break; case Axis.vertical: default: finalDropArea = SizedBox( key: endWidgetKey, height: _defaultDropAreaExtent, width: constraints.maxWidth, ); break; } return SingleChildScrollView( scrollDirection: widget.scrollDirection, padding: widget.padding, controller: _scrollController, reverse: widget.reverse, child: _buildContainerForScrollDirection( children: <Widget>[ if (widget.reverse) _wrap(finalDropArea, widget.children.length, constraints), if (widget.header != null) widget.header, for (int i = 0; i < widget.children.length; i += 1) _wrap(widget.children[i], i, constraints), if (!widget.reverse) _wrap(finalDropArea, widget.children.length, constraints), ], ), ); }); } }