Unverified Commit 8529e5a0 authored by Darren Austin's avatar Darren Austin Committed by GitHub

New Reorderable list widgets (#74299)

Introduced new widget/ReorderableList and widget/SliverReorderableList widgets.
parent c2cd4ef3
...@@ -51,7 +51,6 @@ bool debugCheckHasMaterial(BuildContext context) { ...@@ -51,7 +51,6 @@ bool debugCheckHasMaterial(BuildContext context) {
return true; return true;
} }
/// Asserts that the given context has a [Localizations] ancestor that contains /// Asserts that the given context has a [Localizations] ancestor that contains
/// a [MaterialLocalizations] delegate. /// a [MaterialLocalizations] delegate.
/// ///
......
...@@ -2,47 +2,16 @@ ...@@ -2,47 +2,16 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:math'; import 'dart:ui' show lerpDouble;
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'debug.dart'; import 'debug.dart';
import 'icons.dart';
import 'material.dart'; import 'material.dart';
import 'material_localizations.dart'; import 'material_localizations.dart';
import 'theme.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. /// A list whose items the user can interactively reorder by dragging.
/// ///
...@@ -62,25 +31,30 @@ typedef ReorderCallback = void Function(int oldIndex, int newIndex); ...@@ -62,25 +31,30 @@ typedef ReorderCallback = void Function(int oldIndex, int newIndex);
/// {@tool dartpad --template=stateful_widget_scaffold} /// {@tool dartpad --template=stateful_widget_scaffold}
/// ///
/// ```dart /// ```dart
/// List<String> _list = List.generate(5, (i) => "${i}"); /// final List<int> _items = List<int>.generate(50, (int index) => index);
/// ///
/// Widget build(BuildContext context){ /// Widget build(BuildContext context){
/// final ColorScheme colorScheme = Theme.of(context).colorScheme;
/// final oddItemColor = colorScheme.primary.withOpacity(0.05);
/// final evenItemColor = colorScheme.primary.withOpacity(0.15);
///
/// return ReorderableListView( /// return ReorderableListView(
/// padding : const EdgeInsets.symmetric(horizontal:40), /// padding: const EdgeInsets.symmetric(horizontal: 40),
/// children:[ /// children: <Widget>[
/// for(var i=0 ; i<_list.length ; i++) /// for (int index = 0; index < _items.length; index++)
/// ListTile( /// ListTile(
/// key:Key('$i'), /// key: Key('$index'),
/// title: Text(_list[i]), /// tileColor: _items[index].isOdd ? oddItemColor : evenItemColor,
/// title: Text('Item ${_items[index]}'),
/// ), /// ),
/// ], /// ],
/// onReorder: (oldIndex, newIndex){ /// onReorder: (int oldIndex, int newIndex) {
/// setState((){ /// setState(() {
/// if(oldIndex < newIndex){ /// if (oldIndex < newIndex) {
/// newIndex-=1; /// newIndex -= 1;
/// } /// }
/// final element = _list.removeAt(oldIndex); /// final int item = _items.removeAt(oldIndex);
/// _list.insert(newIndex, element); /// _items.insert(newIndex, item);
/// }); /// });
/// }, /// },
/// ); /// );
...@@ -89,9 +63,7 @@ typedef ReorderCallback = void Function(int oldIndex, int newIndex); ...@@ -89,9 +63,7 @@ typedef ReorderCallback = void Function(int oldIndex, int newIndex);
/// ``` /// ```
/// ///
///{@end-tool} ///{@end-tool}
///
class ReorderableListView extends StatefulWidget { class ReorderableListView extends StatefulWidget {
/// Creates a reorderable list. /// Creates a reorderable list.
ReorderableListView({ ReorderableListView({
Key? key, Key? key,
...@@ -102,6 +74,8 @@ class ReorderableListView extends StatefulWidget { ...@@ -102,6 +74,8 @@ class ReorderableListView extends StatefulWidget {
this.scrollDirection = Axis.vertical, this.scrollDirection = Axis.vertical,
this.padding, this.padding,
this.reverse = false, this.reverse = false,
this.buildDefaultDragHandles = true,
this.proxyDecorator,
}) : assert(scrollDirection != null), }) : assert(scrollDirection != null),
assert(onReorder != null), assert(onReorder != null),
assert(children != null), assert(children != null),
...@@ -109,6 +83,7 @@ class ReorderableListView extends StatefulWidget { ...@@ -109,6 +83,7 @@ class ReorderableListView extends StatefulWidget {
children.every((Widget w) => w.key != null), children.every((Widget w) => w.key != null),
'All children of this widget must have a key.', 'All children of this widget must have a key.',
), ),
assert(buildDefaultDragHandles != null),
super(key: key); super(key: key);
/// A non-reorderable header widget to show before the list. /// A non-reorderable header widget to show before the list.
...@@ -157,23 +132,94 @@ class ReorderableListView extends StatefulWidget { ...@@ -157,23 +132,94 @@ class ReorderableListView extends StatefulWidget {
/// into a new position. /// into a new position.
final ReorderCallback onReorder; final ReorderCallback onReorder;
/// If true: on desktop platforms, a drag handle is stacked over the
/// center of each item's trailing edge; on mobile platforms, a long
/// press anywhere on the item starts a drag.
///
/// The default desktop drag handle is just an [Icons.drag_handle]
/// wrapped by a [ReorderableDragStartListener]. On mobile
/// platforms, the entire item is wrapped with a
/// [ReorderableDelayedDragStartListener].
///
/// To change the appearance or the layout of the drag handles, make
/// this parameter false and wrap each list item, or a widget within
/// each list item, with [ReorderableDragStartListener] or
/// [ReorderableDelayedDragStartListener], or a custom subclass
/// of [ReorderableDragStartListener].
///
/// The following sample specifies `buildDefaultDragHandles: false`, and
/// uses a [Card] at the leading edge of each item for the item's drag handle.
///
/// {@tool dartpad --template=stateful_widget_scaffold}
///
/// ```dart
/// final List<int> _items = List<int>.generate(50, (int index) => index);
///
/// Widget build(BuildContext context){
/// final ColorScheme colorScheme = Theme.of(context).colorScheme;
/// final oddItemColor = colorScheme.primary.withOpacity(0.05);
/// final evenItemColor = colorScheme.primary.withOpacity(0.15);
///
/// return ReorderableListView(
/// buildDefaultDragHandles: false,
/// children: <Widget>[
/// for (int index = 0; index < _items.length; index++)
/// Container(
/// key: Key('$index'),
/// color: _items[index].isOdd ? oddItemColor : evenItemColor,
/// child: Row(
/// children: <Widget>[
/// Container(
/// width: 64,
/// height: 64,
/// padding: const EdgeInsets.all(8),
/// child: ReorderableDragStartListener(
/// index: index,
/// child: Card(
/// color: colorScheme.primary,
/// elevation: 2,
/// ),
/// ),
/// ),
/// Text('Item ${_items[index]}'),
/// ],
/// ),
/// ),
/// ],
/// onReorder: (oldIndex, newIndex) {
/// setState(() {
/// if (oldIndex < newIndex) {
/// newIndex -= 1;
/// }
/// final int item = _items.removeAt(oldIndex);
/// _items.insert(newIndex, item);
/// });
/// },
/// );
/// }
/// ```
///{@end-tool}
final bool buildDefaultDragHandles;
/// A callback that allows the app to add an animated decoration around
/// an item when it is being dragged.
///
/// If this is null, a default decoration of a Material widget with
/// an animated elevation will be used.
final ReorderItemProxyDecorator? proxyDecorator;
@override @override
_ReorderableListViewState createState() => _ReorderableListViewState(); _ReorderableListViewState createState() => _ReorderableListViewState();
} }
// This top-level state manages an Overlay that contains the list and // This top-level state manages an Overlay that contains the list and
// also any Draggables it creates. // also any items being dragged on top fo the list.
//
// _ReorderableListContent manages the list itself and reorder operations.
// //
// The Overlay doesn't properly keep state by building new overlay entries, // 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. // and so we cache a single OverlayEntry for use as the list layer.
// That overlay entry then builds a _ReorderableListContent which may // That overlay entry then builds a _ReorderableListContent which may
// insert Draggables into the Overlay above itself. // insert items being dragged into the Overlay above itself.
class _ReorderableListViewState extends State<ReorderableListView> { 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. // This entry contains the scrolling list itself.
late OverlayEntry _listOverlayEntry; late OverlayEntry _listOverlayEntry;
...@@ -188,26 +234,35 @@ class _ReorderableListViewState extends State<ReorderableListView> { ...@@ -188,26 +234,35 @@ class _ReorderableListViewState extends State<ReorderableListView> {
children: widget.children, children: widget.children,
scrollController: widget.scrollController, scrollController: widget.scrollController,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
onReorder: widget.onReorder,
padding: widget.padding, padding: widget.padding,
onReorder: widget.onReorder,
reverse: widget.reverse, reverse: widget.reverse,
buildDefaultDragHandles: widget.buildDefaultDragHandles,
proxyDecorator: widget.proxyDecorator,
); );
}, },
); );
} }
@override
void didUpdateWidget(ReorderableListView oldWidget) {
super.didUpdateWidget(oldWidget);
// As this depends on pretty much everything, it
// is ok to mark this as dirty unconditionally.
_listOverlayEntry.markNeedsBuild();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context));
return Overlay( return Overlay(
key: _overlayKey,
initialEntries: <OverlayEntry>[ initialEntries: <OverlayEntry>[
_listOverlayEntry, _listOverlayEntry
]); ],
);
} }
} }
// This widget is responsible for the inside of the Overlay in the
// ReorderableListView.
class _ReorderableListContent extends StatefulWidget { class _ReorderableListContent extends StatefulWidget {
const _ReorderableListContent({ const _ReorderableListContent({
required this.header, required this.header,
...@@ -217,6 +272,8 @@ class _ReorderableListContent extends StatefulWidget { ...@@ -217,6 +272,8 @@ class _ReorderableListContent extends StatefulWidget {
required this.padding, required this.padding,
required this.onReorder, required this.onReorder,
required this.reverse, required this.reverse,
required this.buildDefaultDragHandles,
required this.proxyDecorator,
}); });
final Widget? header; final Widget? header;
...@@ -226,401 +283,188 @@ class _ReorderableListContent extends StatefulWidget { ...@@ -226,401 +283,188 @@ class _ReorderableListContent extends StatefulWidget {
final EdgeInsets? padding; final EdgeInsets? padding;
final ReorderCallback onReorder; final ReorderCallback onReorder;
final bool reverse; final bool reverse;
final bool buildDefaultDragHandles;
final ReorderItemProxyDecorator? proxyDecorator;
@override @override
_ReorderableListContentState createState() => _ReorderableListContentState(); _ReorderableListContentState createState() => _ReorderableListContentState();
} }
class _ReorderableListContentState extends State<_ReorderableListContent> with TickerProviderStateMixin<_ReorderableListContent> { class _ReorderableListContentState extends State<_ReorderableListContent> {
Widget _wrapWithSemantics(Widget child, int index) {
// The extent along the [widget.scrollDirection] axis to allow a child to void reorder(int startIndex, int endIndex) {
// drop into when the user reorders list children. if (startIndex != endIndex)
// widget.onReorder(startIndex, endIndex);
// 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;
// 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.
late ScrollController _scrollController;
// This controls the entrance of the dragging widget into a new place.
late AnimationController _entranceController;
// This controls the 'ghost' of the dragging widget, which is left behind
// where the widget used to be.
late 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;
}
final double dropAreaWithoutMargin;
switch (widget.scrollDirection) {
case Axis.horizontal:
dropAreaWithoutMargin = _draggingFeedbackSize!.width;
break;
case Axis.vertical:
dropAreaWithoutMargin = _draggingFeedbackSize!.height;
break;
} }
return dropAreaWithoutMargin;
}
@override
void initState() {
super.initState();
_entranceController = AnimationController(vsync: this, duration: _reorderAnimationDuration);
_ghostController = AnimationController(vsync: this, duration: _reorderAnimationDuration);
_entranceController.addStatusListener(_onEntranceStatusChanged);
}
@override // First, determine which semantics actions apply.
void didChangeDependencies() { final Map<CustomSemanticsAction, VoidCallback> semanticsActions = <CustomSemanticsAction, VoidCallback>{};
_scrollController = widget.scrollController ?? PrimaryScrollController.of(context) ?? ScrollController();
super.didChangeDependencies(); // Create the appropriate semantics actions.
} void moveToStart() => reorder(index, 0);
void moveToEnd() => reorder(index, widget.children.length);
@override void moveBefore() => reorder(index, index - 1);
void dispose() { // To move after, we go to index+2 because we are moving it to the space
_entranceController.dispose(); // before index+2, which is after the space at index+1.
_ghostController.dispose(); void moveAfter() => reorder(index, index + 2);
super.dispose();
} final MaterialLocalizations localizations = MaterialLocalizations.of(context);
// Animates the droppable space from _currentIndex to _nextIndex. // If the item can move to before its current position in the list.
void _requestAnimationToNextIndex() { if (index > 0) {
if (_entranceController.isCompleted) { semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToStart)] = moveToStart;
_ghostIndex = _currentIndex; String reorderItemBefore = localizations.reorderItemUp;
if (_nextIndex == _currentIndex) { if (widget.scrollDirection == Axis.horizontal) {
return; reorderItemBefore = Directionality.of(context) == TextDirection.ltr
? localizations.reorderItemLeft
: localizations.reorderItemRight;
} }
_currentIndex = _nextIndex; semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = moveBefore;
_ghostController.reverse(from: 1.0);
_entranceController.forward(from: 0.0);
} }
}
// Requests animation to the latest next index if it changes during an animation. // If the item can move to after its current position in the list.
void _onEntranceStatusChanged(AnimationStatus status) { if (index < widget.children.length - 1) {
if (status == AnimationStatus.completed) { String reorderItemAfter = localizations.reorderItemDown;
setState(() { if (widget.scrollDirection == Axis.horizontal) {
_requestAnimationToNextIndex(); reorderItemAfter = Directionality.of(context) == TextDirection.ltr
}); ? localizations.reorderItemRight
: localizations.reorderItemLeft;
}
semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = moveAfter;
semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToEnd)] = moveToEnd;
} }
}
// Scrolls to a target context if that context is not on the screen. // We pass toWrap with a GlobalKey into the item so that when it
void _scrollTo(BuildContext context) { // gets dragged, the accessibility framework can preserve the selected
if (_scrolling) // state of the dragging item.
return; //
final RenderObject contextObject = context.findRenderObject()!; // We also apply the relevant custom accessibility actions for moving the item
final RenderAbstractViewport viewport = RenderAbstractViewport.of(contextObject)!; // up, down, to the start, and to the end of the list.
assert(viewport != null); return MergeSemantics(
// If and only if the current scroll offset falls in-between the offsets child: Semantics(
// necessary to reveal the selected context at the top or bottom of the customSemanticsActions: semanticsActions,
// screen, then it is already on-screen. child: child,
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({ required List<Widget> children }) {
switch (widget.scrollDirection) {
case Axis.horizontal:
return Row(children: children);
case Axis.vertical:
return Column(children: children);
}
} }
// Wraps one of the widget's children in a DragTarget and Draggable. Widget _itemBuilder(BuildContext context, int index) {
// Handles up the logic for dragging and reordering items in the list. final Widget item = widget.children[index];
Widget _wrap(Widget toWrap, int index, BoxConstraints constraints) {
assert(toWrap.key != null); // TODO(goderbauer): The semantics stuff should probably happen inside
final _ReorderableListViewChildGlobalKey keyIndexGlobalKey = _ReorderableListViewChildGlobalKey(toWrap.key!, this); // _ReorderableItem so the widget versions can have them as well.
// We pass the toWrapWithGlobalKey into the Draggable so that when a list final Widget itemWithSemantics = _wrapWithSemantics(item, index);
// item gets dragged, the accessibility framework can preserve the selected final Key itemGlobalKey = _ReorderableListViewChildGlobalKey(item.key!, this);
// state of the dragging item.
if (widget.buildDefaultDragHandles) {
// Starts dragging toWrap. switch (Theme.of(context).platform) {
void onDragStarted() { case TargetPlatform.fuchsia:
setState(() { case TargetPlatform.linux:
_dragging = toWrap.key; case TargetPlatform.windows:
_dragStartIndex = index; case TargetPlatform.macOS:
_ghostIndex = index; return Stack(
_currentIndex = index; key: itemGlobalKey,
_entranceController.value = 1.0; children: <Widget>[
_draggingFeedbackSize = keyIndexGlobalKey.currentContext!.size; itemWithSemantics,
}); Positioned.directional(
} textDirection: Directionality.of(context),
top: 0,
// Places the value from startIndex one space before the element at endIndex. bottom: 0,
void reorder(int startIndex, int endIndex) { end: 8,
setState(() { child: Align(
if (startIndex != endIndex) alignment: AlignmentDirectional.centerEnd,
widget.onReorder(startIndex, endIndex); child: ReorderableDragStartListener(
// Animates leftover space in the drop area closed. index: index,
_ghostController.reverse(from: 0); child: const Icon(Icons.drag_handle),
_entranceController.reverse(from: 0); ),
_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. case TargetPlatform.iOS:
if (index < widget.children.length - 1) { case TargetPlatform.android:
String reorderItemAfter = localizations.reorderItemDown; return ReorderableDelayedDragStartListener(
if (widget.scrollDirection == Axis.horizontal) { key: itemGlobalKey,
reorderItemAfter = Directionality.of(context) == TextDirection.ltr index: index,
? localizations.reorderItemRight child: itemWithSemantics,
: 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) { return KeyedSubtree(
final Widget toWrapWithSemantics = wrapWithSemantics(); key: itemGlobalKey,
child: itemWithSemantics,
// 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.
final Widget spacing;
switch (widget.scrollDirection) {
case Axis.horizontal:
spacing = SizedBox(width: _dropAreaExtent);
break;
case Axis.vertical:
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. Widget _proxyDecorator(Widget child, int index, Animation<double> animation) {
return Builder(builder: (BuildContext context) { return AnimatedBuilder(
return DragTarget<Key>( animation: animation,
builder: buildDragTarget, builder: (BuildContext context, Widget? child) {
onWillAccept: (Key? toAccept) { final double animValue = Curves.easeInOut.transform(animation.value);
setState(() { final double elevation = lerpDouble(0, 6, animValue)!;
_nextIndex = index; return Material(
_requestAnimationToNextIndex(); child: child,
}); elevation: elevation,
_scrollTo(context); );
// If the target is not the original starting point, then we will accept the drop. },
return _dragging == toAccept && toAccept != toWrap.key; child: child,
}, );
onAccept: (Key accepted) { },
onLeave: (Object? leaving) { },
);
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context)); // If there is a header we can't just apply the padding to the list,
// We use the layout builder to constrain the cross-axis size of dragging child widgets. // so we wrap the CustomScrollView in the padding for the top, left and right
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { // and only add the padding from the bottom to the sliver list (or the equivalent
const Key endWidgetKey = Key('DraggableList - End Widget'); // for other axis directions).
final Widget finalDropArea; final EdgeInsets padding = widget.padding ?? const EdgeInsets.all(0);
switch (widget.scrollDirection) { late EdgeInsets outerPadding;
case Axis.horizontal: late EdgeInsets listPadding;
finalDropArea = SizedBox( switch (widget.scrollDirection) {
key: endWidgetKey,
width: _defaultDropAreaExtent,
height: constraints.maxHeight,
);
break;
case Axis.vertical:
finalDropArea = SizedBox(
key: endWidgetKey,
height: _defaultDropAreaExtent,
width: constraints.maxWidth,
);
break;
}
// If the reorderable list only has one child element, reordering case Axis.horizontal:
// should not be allowed. if (widget.reverse) {
final bool hasMoreThanOneChildElement = widget.children.length > 1; outerPadding = EdgeInsets.fromLTRB(0, padding.top, padding.right, padding.bottom);
listPadding = EdgeInsets.fromLTRB(padding.left, 0, 0, 0);
} else {
outerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, 0, padding.bottom);
listPadding = EdgeInsets.fromLTRB(0, 0, padding.right, 0);
}
break;
case Axis.vertical:
if (widget.reverse) {
outerPadding = EdgeInsets.fromLTRB(padding.left, 0, padding.right, padding.bottom);
listPadding = EdgeInsets.fromLTRB(0, padding.top, 0, 0);
} else {
outerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, padding.right, 0);
listPadding = EdgeInsets.fromLTRB(0, 0, 0, padding.bottom);
}
break;
}
return SingleChildScrollView( return Padding(
padding: outerPadding,
child: CustomScrollView(
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
padding: widget.padding,
controller: _scrollController,
reverse: widget.reverse, reverse: widget.reverse,
child: _buildContainerForScrollDirection( controller: widget.scrollController,
children: <Widget>[ slivers: <Widget>[
if (widget.reverse && hasMoreThanOneChildElement) _wrap(finalDropArea, widget.children.length, constraints), if (widget.header != null)
if (widget.header != null) widget.header!, SliverToBoxAdapter(child: widget.header!),
for (int i = 0; i < widget.children.length; i += 1) _wrap(widget.children[i], i, constraints), SliverPadding(
if (!widget.reverse && hasMoreThanOneChildElement) _wrap(finalDropArea, widget.children.length, constraints), padding: listPadding,
], sliver: SliverReorderableList(
), itemBuilder: _itemBuilder,
); itemCount: widget.children.length,
}); onReorder: widget.onReorder,
proxyDecorator: widget.proxyDecorator ?? _proxyDecorator,
),
),
],
),
);
} }
} }
...@@ -631,12 +475,10 @@ class _ReorderableListContentState extends State<_ReorderableListContent> with T ...@@ -631,12 +475,10 @@ class _ReorderableListContentState extends State<_ReorderableListContent> with T
// of the objects used to generate widgets. // of the objects used to generate widgets.
@optionalTypeArgs @optionalTypeArgs
class _ReorderableListViewChildGlobalKey extends GlobalObjectKey { class _ReorderableListViewChildGlobalKey extends GlobalObjectKey {
const _ReorderableListViewChildGlobalKey(this.subKey, this.state) : super(subKey); const _ReorderableListViewChildGlobalKey(this.subKey, this.state) : super(subKey);
final Key subKey; final Key subKey;
final State state;
final _ReorderableListContentState state;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
......
...@@ -11,6 +11,7 @@ import 'basic.dart'; ...@@ -11,6 +11,7 @@ import 'basic.dart';
import 'framework.dart'; import 'framework.dart';
import 'localizations.dart'; import 'localizations.dart';
import 'media_query.dart'; import 'media_query.dart';
import 'overlay.dart';
import 'table.dart'; import 'table.dart';
// Any changes to this file should be reflected in the debugAssertAllWidgetVarsUnset() // Any changes to this file should be reflected in the debugAssertAllWidgetVarsUnset()
...@@ -363,6 +364,40 @@ bool debugCheckHasWidgetsLocalizations(BuildContext context) { ...@@ -363,6 +364,40 @@ bool debugCheckHasWidgetsLocalizations(BuildContext context) {
return true; return true;
} }
/// Asserts that the given context has an [Overlay] ancestor.
///
/// To call this function, use the following pattern, typically in the
/// relevant Widget's build method:
///
/// ```dart
/// assert(debugCheckHasOverlay(context));
/// ```
///
/// Does nothing if asserts are disabled. Always returns true.
bool debugCheckHasOverlay(BuildContext context) {
assert(() {
if (context.widget is! Overlay && context.findAncestorWidgetOfExactType<Overlay>() == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('No Overlay widget found.'),
ErrorDescription(
'${context.widget.runtimeType} widgets require an Overlay '
'widget ancestor.\n'
'An overlay lets widgets float on top of other widget children.'
),
ErrorHint(
'To introduce an Overlay widget, you can either directly '
'include one, or use a widget that contains an Overlay itself, '
'such as a Navigator, WidgetApp, MaterialApp, or CupertinoApp.',
),
...context.describeMissingAncestor(expectedAncestorType: Overlay)
]
);
}
return true;
}());
return true;
}
/// Returns true if none of the widget library debug variables have been changed. /// Returns true if none of the widget library debug variables have been changed.
/// ///
/// This function is used by the test framework to ensure that debug variables /// This function is used by the test framework to ensure that debug variables
......
// 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/gestures.dart';
import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'debug.dart';
import 'framework.dart';
import 'inherited_theme.dart';
import 'overlay.dart';
import 'scroll_controller.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_view.dart';
import 'scrollable.dart';
import 'sliver.dart';
import 'ticker_provider.dart';
import 'transitions.dart';
// Examples can assume:
// class MyDataObject {}
/// A callback used by [ReorderableList] to report that a list item has moved
/// to a new position in the 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 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}
///
/// See also:
///
/// * [ReorderableList], a widget list that allows the user to reorder
/// its items.
/// * [SliverReorderableList], a sliver list that allows the user to reorder
/// its items.
/// * [ReorderableListView], a material design list that allows the user to
/// reorder its items.
typedef ReorderCallback = void Function(int oldIndex, int newIndex);
/// Signature for the builder callback used to decorate the dragging item in
/// [ReorderableList] and [SliverReorderableList].
///
/// The [child] will be the item that is being dragged, and [index] is the
/// position of the item in the list.
///
/// The [animation] will be driven from 0 to 1.0 while the item is being picked
/// up during a drag operation, and reversed from 1.0 to 0 when the item is
/// dropped. This can be used to animate properties of the proxy like an
/// elevation or border.
///
/// The returned value will typically be the [child] wrapped in other widgets.
typedef ReorderItemProxyDecorator = Widget Function(Widget child, int index, Animation<double> animation);
/// A scrolling container that allows the user to interactively reorder the
/// list items.
///
/// This widget is similar to one created by [ListView.builder], and uses
/// an [IndexedWidgetBuilder] to create each item.
///
/// It is up to the application to wrap each child (or an internal part of the
/// child such as a drag handle) with a drag listener that will recognize
/// the start of an item drag and then start the reorder by calling
/// [ReorderableListState.startItemDragReorder]. This is most easily achieved
/// by wrapping each child in a [ReorderableDragStartListener] or a
/// [ReorderableDelayedDragStartListener]. These will take care of recognizing
/// the start of a drag gesture and call the list state's
/// [ReorderableListState.startItemDragReorder] method.
///
/// This widget's [ReorderableListState] can be used to manually start an item
/// reorder, or cancel a current drag. To refer to the
/// [ReorderableListState] either provide a [GlobalKey] or use the static
/// [ReorderableList.of] method from an item's build method.
///
/// See also:
///
/// * [SliverReorderableList], a sliver list that allows the user to reorder
/// its items.
/// * [ReorderableListView], a material design list that allows the user to
/// reorder its items.
class ReorderableList extends StatefulWidget {
/// Creates a scrolling container that allows the user to interactively
/// reorder the list items.
///
/// The [itemCount] must be greater than or equal to zero.
const ReorderableList({
Key? key,
required this.itemBuilder,
required this.itemCount,
required this.onReorder,
this.proxyDecorator,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.controller,
this.primary,
this.physics,
this.shrinkWrap = false,
this.padding,
}) : assert(itemCount >= 0),
super(key: key);
/// Called, as needed, to build list item widgets.
///
/// List items are only built when they're scrolled into view.
///
/// The [IndexedWidgetBuilder] index parameter indicates the item's
/// position in the list. The value of the index parameter will be between
/// zero and one less than [itemCount]. All items in the list must have a
/// unique [Key], and should have some kind of listener to start the drag
/// (usually a [ReorderableDragStartListener] or
/// [ReorderableDelayedDragStartListener]).
final IndexedWidgetBuilder itemBuilder;
/// The number of items in the list.
final int itemCount;
/// A callback used by the list to report that a list item has been dragged
/// to a new location in the list and the application should update the order
/// of the items.
final ReorderCallback onReorder;
/// A callback that allows the app to add an animated decoration around
/// an item when it is being dragged.
final ReorderItemProxyDecorator? proxyDecorator;
/// The axis along which the list of items scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// 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;
/// An object that can be used to control the scroll view's scroll offset.
///
/// Must be null if [primary] is true.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
final ScrollController? controller;
/// Whether this is the primary scroll view associated with the parent
/// [PrimaryScrollController].
///
/// On iOS, this identifies the scroll view that will scroll to top in
/// response to a tap in the status bar.
///
/// Defaults to true when [scrollDirection] is [Axis.vertical] and
/// [controller] is null.
final bool? primary;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions.
final ScrollPhysics? physics;
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// If the scroll view does not shrink wrap, then the scroll view will expand
/// to the maximum allowed size in the [scrollDirection]. If the scroll view
/// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must
/// be true.
///
/// Shrink wrapping the content of the scroll view is significantly more
/// expensive than expanding to the maximum allowed size because the content
/// can expand and contract during scrolling, which means the size of the
/// scroll view needs to be recomputed whenever the scroll position changes.
///
/// Defaults to false.
final bool shrinkWrap;
/// The amount of space by which to inset the children.
final EdgeInsetsGeometry? padding;
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [ReorderableList] item widgets that
/// insert or remove items in response to user input.
///
/// If no [ReorderableList] surrounds the given context, then this function
/// will assert in debug mode and throw an exception in release mode.
///
/// See also:
///
/// * [maybeOf], a similar function that will return null if no
/// [ReorderableList] ancestor is found.
static ReorderableListState of(BuildContext context) {
assert(context != null);
final ReorderableListState? result = context.findAncestorStateOfType<ReorderableListState>();
assert((){
if (result == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'ReorderableList.of() called with a context that does not contain a ReorderableList.'),
ErrorDescription(
'No ReorderableList ancestor could be found starting from the context that was passed to ReorderableList.of().'),
ErrorHint(
'This can happen when the context provided is from the same StatefulWidget that '
'built the ReorderableList. Please see the ReorderableList documentation for examples '
'of how to refer to an ReorderableListState object:'
' https://api.flutter.dev/flutter/widgets/ReorderableListState-class.html'
),
context.describeElement('The context used was')
]);
}
return true;
}());
return result!;
}
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [ReorderableList] item widgets that insert
/// or remove items in response to user input.
///
/// If no [ReorderableList] surrounds the context given, then this function will
/// return null.
///
/// See also:
///
/// * [of], a similar function that will throw if no [ReorderableList] ancestor
/// is found.
static ReorderableListState? maybeOf(BuildContext context) {
assert(context != null);
return context.findAncestorStateOfType<ReorderableListState>();
}
@override
ReorderableListState createState() => ReorderableListState();
}
/// The state for a list that allows the user to interactively reorder
/// the list items.
///
/// An app that needs to start a new item drag or cancel an existing one
/// can refer to the [ReorderableList]'s state with a global key:
///
/// ```dart
/// GlobalKey<ReorderableListState> listKey = GlobalKey<ReorderableListState>();
/// ...
/// ReorderableList(key: listKey, ...);
/// ...
/// listKey.currentState.cancelReorder();
/// ```
class ReorderableListState extends State<ReorderableList> {
final GlobalKey<SliverReorderableListState> _sliverReorderableListKey = GlobalKey();
/// Initiate the dragging of the item at [index] that was started with
/// the pointer down [event].
///
/// The given [recognizer] will be used to recognize and start the drag
/// item tracking and lead to either an item reorder, or a cancelled drag.
/// The list will take ownership of the returned recognizer and will dispose
/// it when it is no longer needed.
///
/// Most applications will not use this directly, but will wrap the item
/// (or part of the item, like a drag handle) in either a
/// [ReorderableDragStartListener] or [ReorderableDelayedDragStartListener]
/// which call this for the application.
void startItemDragReorder({
required int index,
required PointerDownEvent event,
required MultiDragGestureRecognizer<MultiDragPointerState> recognizer,
}) {
_sliverReorderableListKey.currentState!.startItemDragReorder(index: index, event: event, recognizer: recognizer);
}
/// Cancel any item drag in progress.
///
/// This should be called before any major changes to the item list
/// occur so that any item drags will not get confused by
/// changes to the underlying list.
///
/// If no drag is active, this will do nothing.
void cancelReorder() {
_sliverReorderableListKey.currentState!.cancelReorder();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
controller: widget.controller,
primary: widget.primary,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
slivers: <Widget>[
SliverPadding(
padding: widget.padding ?? const EdgeInsets.all(0),
sliver: SliverReorderableList(
key: _sliverReorderableListKey,
itemBuilder: widget.itemBuilder,
itemCount: widget.itemCount,
onReorder: widget.onReorder,
proxyDecorator: widget.proxyDecorator,
),
),
],
);
}
}
/// A sliver list that allows the user to interactively reorder the list items.
///
/// It is up to the application to wrap each child (or an internal part of the
/// child) with a drag listener that will recognize the start of an item drag
/// and then start the reorder by calling
/// [SliverReorderableListState.startItemDragReorder]. This is most easily
/// achieved by wrapping each child in a [ReorderableDragStartListener] or
/// a [ReorderableDelayedDragStartListener]. These will take care of
/// recognizing the start of a drag gesture and call the list state's start
/// item drag method.
///
/// This widget's [SliverReorderableListState] can be used to manually start an item
/// reorder, or cancel a current drag that's already underway. To refer to the
/// [SliverReorderableListState] either provide a [GlobalKey] or use the static
/// [SliverReorderableList.of] method from an item's build method.
///
/// See also:
///
/// * [ReorderableList], a regular widget list that allows the user to reorder
/// its items.
/// * [ReorderableListView], a material design list that allows the user to
/// reorder its items.
class SliverReorderableList extends StatefulWidget {
/// Creates a sliver list that allows the user to interactively reorder its
/// items.
///
/// The [itemCount] must be greater than or equal to zero.
const SliverReorderableList({
Key? key,
required this.itemBuilder,
required this.itemCount,
required this.onReorder,
this.proxyDecorator,
}) : assert(itemCount >= 0),
super(key: key);
/// Called, as needed, to build list item widgets.
///
/// List items are only built when they're scrolled into view.
///
/// The [IndexedWidgetBuilder] index parameter indicates the item's
/// position in the list. The value of the index parameter will be between
/// zero and one less than [itemCount]. All items in the list should have a
/// unique [Key], and should have some kind of listener to start the drag
/// (usually a [ReorderableDragStartListener] or
/// [ReorderableDelayedDragStartListener]).
final IndexedWidgetBuilder itemBuilder;
/// The number of items in the list.
final int itemCount;
/// A callback used by the list to report that a list item has been dragged
/// to a new location in the list and the application should update the order
/// of the items.
final ReorderCallback onReorder;
/// A callback that allows the app to add an animated decoration around
/// an item when it is being dragged.
final ReorderItemProxyDecorator? proxyDecorator;
@override
SliverReorderableListState createState() => SliverReorderableListState();
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [SliverReorderableList] item widgets to
/// start or cancel an item drag operation.
///
/// If no [SliverReorderableList] surrounds the context given, this function
/// will assert in debug mode and throw an exception in release mode.
///
/// See also:
///
/// * [maybeOf], a similar function that will return null if no
/// [SliverReorderableList] ancestor is found.
static SliverReorderableListState of(BuildContext context) {
assert(context != null);
final SliverReorderableListState? result = context.findAncestorStateOfType<SliverReorderableListState>();
assert((){
if (result == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'SliverReorderableList.of() called with a context that does not contain a SliverReorderableList.'),
ErrorDescription(
'No SliverReorderableList ancestor could be found starting from the context that was passed to SliverReorderableList.of().'),
ErrorHint(
'This can happen when the context provided is from the same StatefulWidget that '
'built the SliverReorderableList. Please see the SliverReorderableList documentation for examples '
'of how to refer to an SliverReorderableList object:'
' https://api.flutter.dev/flutter/widgets/SliverReorderableListState-class.html'
),
context.describeElement('The context used was')
]);
}
return true;
}());
return result!;
}
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// This method is typically used by [SliverReorderableList] item widgets that
/// insert or remove items in response to user input.
///
/// If no [SliverReorderableList] surrounds the context given, this function
/// will return null.
///
/// See also:
///
/// * [of], a similar function that will throw if no [SliverReorderableList]
/// ancestor is found.
static SliverReorderableListState? maybeOf(BuildContext context) {
assert(context != null);
return context.findAncestorStateOfType<SliverReorderableListState>();
}
}
/// The state for a sliver list that allows the user to interactively reorder
/// the list items.
///
/// An app that needs to start a new item drag or cancel an existing one
/// can refer to the [SliverReorderableList]'s state with a global key:
///
/// ```dart
/// GlobalKey<SliverReorderableListState> listKey = GlobalKey<SliverReorderableListState>();
/// ...
/// SliverReorderableList(key: listKey, ...);
/// ...
/// listKey.currentState.cancelReorder();
/// ```
///
/// [ReorderableDragStartListener] and [ReorderableDelayedDragStartListener]
/// refer to their [SliverReorderableList] with the static
/// [SliverReorderableList.of] method.
class SliverReorderableListState extends State<SliverReorderableList> with TickerProviderStateMixin {
// Map of index -> child state used manage where the dragging item will need
// to be inserted.
final Map<int, _ReorderableItemState> _items = <int, _ReorderableItemState>{};
bool _reorderingDrag = false;
bool _autoScrolling = false;
OverlayEntry? _overlayEntry;
_ReorderableItemState? _dragItem;
_DragInfo? _dragInfo;
int? _insertIndex;
Offset? _finalDropPosition;
MultiDragGestureRecognizer<MultiDragPointerState>? _recognizer;
late ScrollableState _scrollable;
Axis get _scrollDirection => axisDirectionToAxis(_scrollable.axisDirection);
bool get _reverse =>
_scrollable.axisDirection == AxisDirection.up ||
_scrollable.axisDirection == AxisDirection.left;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_scrollable = Scrollable.of(context)!;
}
@override
void didUpdateWidget(covariant SliverReorderableList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.itemCount != oldWidget.itemCount) {
cancelReorder();
}
}
@override
void dispose() {
_dragInfo?.dispose();
super.dispose();
}
/// Initiate the dragging of the item at [index] that was started with
/// the pointer down [event].
///
/// The given [recognizer] will be used to recognize and start the drag
/// item tracking and lead to either an item reorder, or a cancelled drag.
///
/// Most applications will not use this directly, but will wrap the item
/// (or part of the item, like a drag handle) in either a
/// [ReorderableDragStartListener] or [ReorderableDelayedDragStartListener]
/// which call this method when they detect the gesture that triggers a drag
/// start.
void startItemDragReorder({
required int index,
required PointerDownEvent event,
required MultiDragGestureRecognizer<MultiDragPointerState> recognizer,
}) {
assert(0 <= index && index < widget.itemCount);
setState(() {
if (_reorderingDrag) {
cancelReorder();
}
if (_items.containsKey(index)) {
_dragItem = _items[index]!;
_recognizer = recognizer
..onStart = _dragStart
..addPointer(event);
} else {
// TODO(darrenaustin): Can we handle this better, maybe scroll to the item?
throw Exception('Attempting to start a drag on a non-visible item');
}
});
}
/// Cancel any item drag in progress.
///
/// This should be called before any major changes to the item list
/// occur so that any item drags will not get confused by
/// changes to the underlying list.
///
/// If a drag operation is in progress, this will immediately reset
/// the list to back to its pre-drag state.
///
/// If no drag is active, this will do nothing.
void cancelReorder() {
_dragReset();
}
void _registerItem(_ReorderableItemState item) {
_items[item.index] = item;
}
void _unregisterItem(int index, _ReorderableItemState item) {
final _ReorderableItemState? currentItem = _items[index];
if (currentItem == item) {
_items.remove(index);
}
}
Drag? _dragStart(Offset position) {
assert(_reorderingDrag == false);
final _ReorderableItemState item = _dragItem!;
_insertIndex = item.index;
_reorderingDrag = true;
_dragInfo = _DragInfo(
item: item,
initialPosition: position,
scrollDirection: _scrollDirection,
onUpdate: _dragUpdate,
onCancel: _dragCancel,
onEnd: _dragEnd,
onDropCompleted: _dropCompleted,
proxyDecorator: widget.proxyDecorator,
tickerProvider: this,
);
final OverlayState overlay = Overlay.of(context)!;
assert(_overlayEntry == null);
_overlayEntry = OverlayEntry(builder: _dragInfo!.createProxy);
overlay.insert(_overlayEntry!);
_dragInfo!.startDrag();
item.dragging = true;
for (final _ReorderableItemState childItem in _items.values) {
if (childItem == item || !childItem.mounted)
continue;
childItem.updateForGap(_insertIndex!, _dragInfo!.itemExtent, false, _reverse);
}
return _dragInfo;
}
void _dragUpdate(_DragInfo item, Offset position, Offset delta) {
setState(() {
_overlayEntry?.markNeedsBuild();
_dragUpdateItems();
_autoScrollIfNecessary();
});
}
void _dragCancel(_DragInfo item) {
_dragReset();
}
void _dragEnd(_DragInfo item) {
setState(() {
if (_insertIndex! < widget.itemCount - 1) {
// Find the location of the item we want to insert before
_finalDropPosition = _itemOffsetAt(_insertIndex!);
} else {
// Inserting into the last spot on the list. If it's the only spot, put
// it back where it was. Otherwise, grab the second to last and move
// down by the gap.
final int itemIndex = _items.length > 1 ? _insertIndex! - 1 : _insertIndex!;
if (_reverse) {
_finalDropPosition = _itemOffsetAt(itemIndex) - _extentOffset(item.itemExtent, _scrollDirection);
} else {
_finalDropPosition = _itemOffsetAt(itemIndex) + _extentOffset(item.itemExtent, _scrollDirection);
}
}
});
}
void _dropCompleted() {
final int fromIndex = _dragItem!.index;
final int toIndex = _insertIndex!;
if (fromIndex != toIndex) {
widget.onReorder.call(fromIndex, toIndex);
}
_dragReset();
}
void _dragReset() {
setState(() {
if (_reorderingDrag) {
_reorderingDrag = false;
_dragItem!.dragging = false;
_dragItem = null;
_dragInfo?.dispose();
_dragInfo = null;
_resetItemGap();
_recognizer?.dispose();
_recognizer = null;
_overlayEntry?.remove();
_overlayEntry = null;
_finalDropPosition = null;
}
});
}
void _resetItemGap() {
for (final _ReorderableItemState item in _items.values) {
item.resetGap();
}
}
void _dragUpdateItems() {
assert(_reorderingDrag);
assert(_dragItem != null);
assert(_dragInfo != null);
final _ReorderableItemState gapItem = _dragItem!;
final double gapExtent = _dragInfo!.itemExtent;
final double proxyItemStart = _offsetExtent(_dragInfo!.dragPosition - _dragInfo!.dragOffset, _scrollDirection);
final double proxyItemEnd = proxyItemStart + gapExtent;
// Find the new index for inserting the item being dragged.
int newIndex = _insertIndex!;
for (final _ReorderableItemState item in _items.values) {
if (item == gapItem || !item.mounted)
continue;
final Rect geometry = item.targetGeometry();
final double itemStart = _scrollDirection == Axis.vertical ? geometry.top : geometry.left;
final double itemExtent = _scrollDirection == Axis.vertical ? geometry.height : geometry.width;
final double itemEnd = itemStart + itemExtent;
final double itemMiddle = itemStart + itemExtent / 2;
if (_reverse) {
if (itemEnd >= proxyItemEnd && proxyItemEnd >= itemMiddle) {
// The start of the proxy is in the beginning half of the item, so
// we should swap the item with the gap and we are done looking for
// the new index.
newIndex = item.index;
break;
} else if (itemMiddle >= proxyItemStart && proxyItemStart >= itemStart) {
// The end of the proxy is in the ending half of the item, so
// we should swap the item with the gap and we are done looking for
// the new index.
newIndex = item.index + 1;
break;
} else if (itemStart > proxyItemEnd && newIndex < (item.index + 1)) {
newIndex = item.index + 1;
} else if (proxyItemStart > itemEnd && newIndex > item.index) {
newIndex = item.index;
}
} else {
if (itemStart <= proxyItemStart && proxyItemStart <= itemMiddle) {
// The start of the proxy is in the beginning half of the item, so
// we should swap the item with the gap and we are done looking for
// the new index.
newIndex = item.index;
break;
} else if (itemMiddle <= proxyItemEnd && proxyItemEnd <= itemEnd) {
// The end of the proxy is in the ending half of the item, so
// we should swap the item with the gap and we are done looking for
// the new index.
newIndex = item.index + 1;
break;
} else if (itemEnd < proxyItemStart && newIndex < (item.index + 1)) {
newIndex = item.index + 1;
} else if (proxyItemEnd < itemStart && newIndex > item.index) {
newIndex = item.index;
}
}
}
if (newIndex != _insertIndex) {
_insertIndex = newIndex;
for (final _ReorderableItemState item in _items.values) {
if (item == gapItem || !item.mounted)
continue;
item.updateForGap(newIndex, gapExtent, true, _reverse);
}
}
}
Future<void> _autoScrollIfNecessary() async {
if (!_autoScrolling && _dragInfo != null && _dragInfo!.scrollable != null) {
final ScrollPosition position = _dragInfo!.scrollable!.position;
double? newOffset;
const Duration duration = Duration(milliseconds: 14);
const double step = 1.0;
const double overDragMax = 20.0;
const double overDragCoef = 10;
final RenderBox scrollRenderBox = _dragInfo!.scrollable!.context.findRenderObject()! as RenderBox;
final Offset scrollOrigin = scrollRenderBox.localToGlobal(Offset.zero);
final double scrollStart = _offsetExtent(scrollOrigin, _scrollDirection);
final double scrollEnd = scrollStart + _sizeExtent(scrollRenderBox.size, _scrollDirection);
final double proxyStart = _offsetExtent(_dragInfo!.dragPosition - _dragInfo!.dragOffset, _scrollDirection);
final double proxyEnd = proxyStart + _dragInfo!.itemExtent;
if (_reverse) {
if (proxyEnd > scrollEnd && position.pixels > position.minScrollExtent) {
final double overDrag = max(proxyEnd - scrollEnd, overDragMax);
newOffset = max(position.minScrollExtent, position.pixels - step * overDrag / overDragCoef);
} else if (proxyStart < scrollStart && position.pixels < position.maxScrollExtent) {
final double overDrag = max(scrollStart - proxyStart, overDragMax);
newOffset = min(position.maxScrollExtent, position.pixels + step * overDrag / overDragCoef);
}
} else {
if (proxyStart < scrollStart && position.pixels > position.minScrollExtent) {
final double overDrag = max(scrollStart - proxyStart, overDragMax);
newOffset = max(position.minScrollExtent, position.pixels - step * overDrag / overDragCoef);
} else if (proxyEnd > scrollEnd && position.pixels < position.maxScrollExtent) {
final double overDrag = max(proxyEnd - scrollEnd, overDragMax);
newOffset = min(position.maxScrollExtent, position.pixels + step * overDrag / overDragCoef);
}
}
if (newOffset != null && (newOffset - position.pixels).abs() >= 1.0) {
_autoScrolling = true;
await position.animateTo(newOffset,
duration: duration,
curve: Curves.linear
);
_autoScrolling = false;
if (_dragItem != null) {
_dragUpdateItems();
_autoScrollIfNecessary();
}
}
}
}
Offset _itemOffsetAt(int index) {
final RenderBox itemRenderBox = _items[index]!.context.findRenderObject()! as RenderBox;
return itemRenderBox.localToGlobal(Offset.zero);
}
Widget _itemBuilder(BuildContext context, int index) {
if (_dragInfo != null && index >= widget.itemCount) {
switch (_scrollDirection) {
case Axis.horizontal:
return SizedBox(width: _dragInfo!.itemExtent);
case Axis.vertical:
return SizedBox(height: _dragInfo!.itemExtent);
}
}
final Widget child = widget.itemBuilder(context, index);
assert(child.key != null, 'All list items must have a key');
final OverlayState overlay = Overlay.of(context)!;
return _ReorderableItem(
key: _ReorderableItemGlobalKey(child.key!, index, this),
index: index,
child: child,
capturedThemes: InheritedTheme.capture(from: context, to: overlay.context),
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasOverlay(context));
return SliverList(
// When dragging, the dragged item is still in the list but has been replaced
// by a zero height SizedBox, so that the gap can move around. To make the
// list extent stable we add a dummy entry to the end.
delegate: SliverChildBuilderDelegate(_itemBuilder, childCount: widget.itemCount + (_reorderingDrag ? 1 : 0)),
);
}
}
class _ReorderableItem extends StatefulWidget {
const _ReorderableItem({
required Key key,
required this.index,
required this.child,
required this.capturedThemes,
}) : super(key: key);
final int index;
final Widget child;
final CapturedThemes capturedThemes;
@override
_ReorderableItemState createState() => _ReorderableItemState();
}
class _ReorderableItemState extends State<_ReorderableItem> {
late SliverReorderableListState _listState;
Offset _startOffset = Offset.zero;
Offset _targetOffset = Offset.zero;
AnimationController? _offsetAnimation;
Key get key => widget.key!;
int get index => widget.index;
bool get dragging => _dragging;
set dragging(bool dragging) {
if (mounted) {
setState(() {
_dragging = dragging;
});
}
}
bool _dragging = false;
@override
void initState() {
_listState = SliverReorderableList.of(context);
_listState._registerItem(this);
super.initState();
}
@override
void dispose() {
_offsetAnimation?.dispose();
_listState._unregisterItem(index, this);
super.dispose();
}
@override
void didUpdateWidget(covariant _ReorderableItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.index != widget.index) {
_listState._unregisterItem(oldWidget.index, this);
_listState._registerItem(this);
}
}
@override
Widget build(BuildContext context) {
if (_dragging) {
return const SizedBox();
}
_listState._registerItem(this);
return Transform(
transform: Matrix4.translationValues(offset.dx, offset.dy, 0.0),
child: widget.child,
);
}
@override
void deactivate() {
_listState._unregisterItem(index, this);
super.deactivate();
}
Offset get offset {
if (_offsetAnimation != null) {
final double animValue = Curves.easeInOut.transform(_offsetAnimation!.value);
return Offset.lerp(_startOffset, _targetOffset, animValue)!;
}
return _targetOffset;
}
void updateForGap(int gapIndex, double gapExtent, bool animate, bool reverse) {
final Offset newTargetOffset = (gapIndex <= index)
? _extentOffset(reverse ? -gapExtent : gapExtent, _listState._scrollDirection)
: Offset.zero;
if (newTargetOffset != _targetOffset) {
_targetOffset = newTargetOffset;
if (animate) {
if (_offsetAnimation == null) {
_offsetAnimation = AnimationController(
vsync: _listState,
duration: const Duration(milliseconds: 250),
)
..addListener(rebuild)
..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
_startOffset = _targetOffset;
_offsetAnimation!.dispose();
_offsetAnimation = null;
}
})
..forward();
} else {
_startOffset = offset;
_offsetAnimation!.forward(from: 0.0);
}
} else {
if (_offsetAnimation != null) {
_offsetAnimation!.dispose();
_offsetAnimation = null;
}
_startOffset = _targetOffset;
}
rebuild();
}
}
void resetGap() {
if (_offsetAnimation != null) {
_offsetAnimation!.dispose();
_offsetAnimation = null;
}
_startOffset = Offset.zero;
_targetOffset = Offset.zero;
rebuild();
}
Rect targetGeometry() {
final RenderBox itemRenderBox = context.findRenderObject()! as RenderBox;
final Offset itemPosition = itemRenderBox.localToGlobal(Offset.zero) + _targetOffset;
return itemPosition & itemRenderBox.size;
}
void rebuild() {
if (mounted) {
setState(() {});
}
}
}
/// A wrapper widget that will recognize the start of a drag on the wrapped
/// widget by a [PointerDownEvent], and immediately initiate dragging the
/// wrapped item to a new location in a reorderable list.
///
/// See also:
///
/// * [ReorderableDelayedDragStartListener], a similar wrapper that will
/// only recognize the start after a long press event.
/// * [ReorderableList], a widget list that allows the user to reorder
/// its items.
/// * [SliverReorderableList], a sliver list that allows the user to reorder
/// its items.
/// * [ReorderableListView], a material design list that allows the user to
/// reorder its items.
class ReorderableDragStartListener extends StatelessWidget {
/// Creates a listener for a drag immediately following a pointer down
/// event over the given child widget.
///
/// This is most commonly used to wrap part of a list item like a drag
/// handle.
const ReorderableDragStartListener({
Key? key,
required this.child,
required this.index,
}) : super(key: key);
/// The widget for which the application would like to respond to a tap and
/// drag gesture by starting a reordering drag on a reorderable list.
final Widget child;
/// The index of the associated item that will be dragged in the list.
final int index;
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (PointerDownEvent event) => _startDragging(context, event),
child: child,
);
}
/// Provides the gesture recognizer used to indicate the start of a reordering
/// drag operation.
///
/// By default this returns an [ImmediateMultiDragGestureRecognizer] but
/// subclasses can use this to customize the drag start gesture.
@protected
MultiDragGestureRecognizer<MultiDragPointerState> createRecognizer() {
return ImmediateMultiDragGestureRecognizer(debugOwner: this);
}
void _startDragging(BuildContext context, PointerDownEvent event) {
final SliverReorderableListState? list = SliverReorderableList.maybeOf(context);
list?.startItemDragReorder(
index: index,
event: event,
recognizer: createRecognizer()
);
}
}
/// A wrapper widget that will recognize the start of a drag operation by
/// looking for a long press event. Once it is recognized, it will start
/// a drag operation on the wrapped item in the reorderable list.
///
/// See also:
///
/// * [ReorderableDragStartListener], a similar wrapper that will
/// recognize the start of the drag immediately after a pointer down event.
/// * [ReorderableList], a widget list that allows the user to reorder
/// its items.
/// * [SliverReorderableList], a sliver list that allows the user to reorder
/// its items.
/// * [ReorderableListView], a material design list that allows the user to
/// reorder its items.
class ReorderableDelayedDragStartListener extends ReorderableDragStartListener {
/// Creates a listener for an drag following a long press event over the
/// given child widget.
///
/// This is most commonly used to wrap an entire list item in a reorderable
/// list.
const ReorderableDelayedDragStartListener({
Key? key,
required Widget child,
required int index,
}) : super(key: key, child: child, index: index);
@override
MultiDragGestureRecognizer<MultiDragPointerState> createRecognizer() {
return DelayedMultiDragGestureRecognizer(debugOwner: this);
}
}
typedef _DragItemUpdate = void Function(_DragInfo item, Offset position, Offset delta);
typedef _DragItemCallback = void Function(_DragInfo item);
class _DragInfo extends Drag {
_DragInfo({
required this.item,
Offset initialPosition = Offset.zero,
this.scrollDirection = Axis.vertical,
this.onUpdate,
this.onEnd,
this.onCancel,
this.onDropCompleted,
this.proxyDecorator,
required this.tickerProvider,
}) {
final RenderBox itemRenderBox = item.context.findRenderObject()! as RenderBox;
dragPosition = initialPosition;
dragOffset = itemRenderBox.globalToLocal(initialPosition);
itemSize = item.context.size!;
itemExtent = _sizeExtent(itemSize, scrollDirection);
scrollable = Scrollable.of(item.context);
}
final _ReorderableItemState item;
final Axis scrollDirection;
final _DragItemUpdate? onUpdate;
final _DragItemCallback? onEnd;
final _DragItemCallback? onCancel;
final VoidCallback? onDropCompleted;
final ReorderItemProxyDecorator? proxyDecorator;
final TickerProvider tickerProvider;
late Offset dragPosition;
late Offset dragOffset;
late Size itemSize;
late double itemExtent;
ScrollableState? scrollable;
AnimationController? _proxyAnimation;
void dispose() {
_proxyAnimation?.dispose();
}
void startDrag() {
_proxyAnimation = AnimationController(
vsync: tickerProvider,
duration: const Duration(milliseconds: 250),
)
..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.dismissed) {
_dropCompleted();
}
})
..forward();
}
@override
void update(DragUpdateDetails details) {
final Offset delta = _restrictAxis(details.delta, scrollDirection);
dragPosition += delta;
onUpdate?.call(this, dragPosition, details.delta);
}
@override
void end(DragEndDetails details) {
_proxyAnimation!.reverse();
onEnd?.call(this);
}
@override
void cancel() {
_proxyAnimation?.dispose();
_proxyAnimation = null;
onCancel?.call(this);
}
void _dropCompleted() {
_proxyAnimation?.dispose();
_proxyAnimation = null;
onDropCompleted?.call();
}
Widget createProxy(BuildContext context) {
return item.widget.capturedThemes.wrap(
_DragItemProxy(
item: item,
size: itemSize,
animation: _proxyAnimation!,
position: dragPosition - dragOffset - _overlayOrigin(context),
proxyDecorator: proxyDecorator,
)
);
}
}
Offset _overlayOrigin(BuildContext context) {
final OverlayState overlay = Overlay.of(context)!;
final RenderBox overlayBox = overlay.context.findRenderObject()! as RenderBox;
return overlayBox.localToGlobal(Offset.zero);
}
class _DragItemProxy extends StatelessWidget {
const _DragItemProxy({
Key? key,
required this.item,
required this.position,
required this.size,
required this.animation,
required this.proxyDecorator,
}) : super(key: key);
final _ReorderableItemState item;
final Offset position;
final Size size;
final AnimationController animation;
final ReorderItemProxyDecorator? proxyDecorator;
@override
Widget build(BuildContext context) {
final Widget child = item.widget.child;
final Widget proxyChild = proxyDecorator?.call(child, item.index, animation.view) ?? child;
final Offset overlayOrigin = _overlayOrigin(context);
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget? child) {
Offset effectivePosition = position;
final Offset? dropPosition = item._listState._finalDropPosition;
if (dropPosition != null) {
effectivePosition = Offset.lerp(dropPosition - overlayOrigin, effectivePosition, Curves.easeOut.transform(animation.value))!;
}
return Positioned(
child: SizedBox(
width: size.width,
height: size.height,
child: child,
),
left: effectivePosition.dx,
top: effectivePosition.dy,
);
},
child: proxyChild,
);
}
}
double _sizeExtent(Size size, Axis scrollDirection) {
switch (scrollDirection) {
case Axis.horizontal:
return size.width;
case Axis.vertical:
return size.height;
}
}
double _offsetExtent(Offset offset, Axis scrollDirection) {
switch (scrollDirection) {
case Axis.horizontal:
return offset.dx;
case Axis.vertical:
return offset.dy;
}
}
Offset _extentOffset(double extent, Axis scrollDirection) {
switch (scrollDirection) {
case Axis.horizontal:
return Offset(extent, 0.0);
case Axis.vertical:
return Offset(0.0, extent);
}
}
Offset _restrictAxis(Offset offset, Axis scrollDirection) {
switch (scrollDirection) {
case Axis.horizontal:
return Offset(offset.dx, 0.0);
case Axis.vertical:
return Offset(0.0, offset.dy);
}
}
// A global key that takes its identity from the object and uses a value of a
// particular type to identify itself.
//
// The difference with GlobalObjectKey is that it uses [==] instead of [identical]
// of the objects used to generate widgets.
@optionalTypeArgs
class _ReorderableItemGlobalKey extends GlobalObjectKey {
const _ReorderableItemGlobalKey(this.subKey, this.index, this.state) : super(subKey);
final Key subKey;
final int index;
final SliverReorderableListState state;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType)
return false;
return other is _ReorderableItemGlobalKey
&& other.subKey == subKey
&& other.index == index
&& other.state == state;
}
@override
int get hashCode => hashValues(subKey, index, state);
}
...@@ -83,6 +83,7 @@ export 'src/widgets/platform_view.dart'; ...@@ -83,6 +83,7 @@ export 'src/widgets/platform_view.dart';
export 'src/widgets/preferred_size.dart'; export 'src/widgets/preferred_size.dart';
export 'src/widgets/primary_scroll_controller.dart'; export 'src/widgets/primary_scroll_controller.dart';
export 'src/widgets/raw_keyboard_listener.dart'; export 'src/widgets/raw_keyboard_listener.dart';
export 'src/widgets/reorderable_list.dart';
export 'src/widgets/restoration.dart'; export 'src/widgets/restoration.dart';
export 'src/widgets/restoration_properties.dart'; export 'src/widgets/restoration_properties.dart';
export 'src/widgets/router.dart'; export 'src/widgets/router.dart';
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
...@@ -90,6 +91,7 @@ void main() { ...@@ -90,6 +91,7 @@ void main() {
await drag.moveTo(tester.getCenter(find.text('Item 4'))); await drag.moveTo(tester.getCenter(find.text('Item 4')));
expect(listItems, orderedEquals(originalListItems)); expect(listItems, orderedEquals(originalListItems));
await drag.up(); await drag.up();
await tester.pumpAndSettle();
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 1', 'Item 4'])); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 1', 'Item 4']));
}); });
...@@ -101,6 +103,7 @@ void main() { ...@@ -101,6 +103,7 @@ void main() {
tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 1')),
tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2),
); );
await tester.pumpAndSettle();
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
}); });
...@@ -112,6 +115,7 @@ void main() { ...@@ -112,6 +115,7 @@ void main() {
tester.getCenter(find.text('Item 4')), tester.getCenter(find.text('Item 4')),
tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 1')),
); );
await tester.pumpAndSettle();
expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3']));
}); });
...@@ -123,6 +127,7 @@ void main() { ...@@ -123,6 +127,7 @@ void main() {
tester.getCenter(find.text('Item 3')), tester.getCenter(find.text('Item 3')),
tester.getCenter(find.text('Item 2')), tester.getCenter(find.text('Item 2')),
); );
await tester.pumpAndSettle();
expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4'])); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4']));
}); });
...@@ -135,6 +140,7 @@ void main() { ...@@ -135,6 +140,7 @@ void main() {
tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 1')),
tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2), tester.getCenter(find.text('Item 4')) + const Offset(0.0, itemHeight * 2),
); );
await tester.pumpAndSettle();
expect(find.text('Header Text'), findsOneWidget); expect(find.text('Header Text'), findsOneWidget);
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
}); });
...@@ -168,45 +174,43 @@ void main() { ...@@ -168,45 +174,43 @@ void main() {
), ),
)); ));
Element getContentElement() { double getListHeight() {
final SingleChildScrollView listScrollView = tester.widget(find.byType(SingleChildScrollView)); final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList));
final Widget scrollContents = listScrollView.child!; return listScrollView.geometry!.maxPaintExtent;
final Element contentElement = tester.element(find.byElementPredicate((Element element) => element.widget == scrollContents));
return contentElement;
} }
const double kDraggingListHeight = 292.0; const double kDraggingListHeight = 4 * itemHeight;
// Drag a normal text item // Drag a normal text item
expect(getContentElement().size!.height, kDraggingListHeight); expect(getListHeight(), kDraggingListHeight);
TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item'))); TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item')));
await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.height, kDraggingListHeight); expect(getListHeight(), kDraggingListHeight);
// Move it // Move it
await drag.moveTo(tester.getCenter(find.text('Last item'))); await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.height, kDraggingListHeight); expect(getListHeight(), kDraggingListHeight);
// Drop it // Drop it
await drag.up(); await drag.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.height, kDraggingListHeight); expect(getListHeight(), kDraggingListHeight);
// Drag a tall item // Drag a tall item
drag = await tester.startGesture(tester.getCenter(find.text('Tall item'))); drag = await tester.startGesture(tester.getCenter(find.text('Tall item')));
await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.height, kDraggingListHeight); expect(getListHeight(), kDraggingListHeight);
// Move it // Move it
await drag.moveTo(tester.getCenter(find.text('Last item'))); await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.height, kDraggingListHeight); expect(getListHeight(), kDraggingListHeight);
// Drop it // Drop it
await drag.up(); await drag.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.height, kDraggingListHeight); expect(getListHeight(), kDraggingListHeight);
}); });
testWidgets('Vertical drop area golden', (WidgetTester tester) async { testWidgets('Vertical drop area golden', (WidgetTester tester) async {
...@@ -344,8 +348,8 @@ void main() { ...@@ -344,8 +348,8 @@ void main() {
} }
await tester.pumpWidget(buildWithScrollController(primary)); await tester.pumpWidget(buildWithScrollController(primary));
SingleChildScrollView scrollView = tester.widget( Scrollable scrollView = tester.widget(
find.byType(SingleChildScrollView), find.byType(Scrollable),
); );
expect(scrollView.controller, primary); expect(scrollView.controller, primary);
...@@ -353,7 +357,7 @@ void main() { ...@@ -353,7 +357,7 @@ void main() {
final ScrollController primary2 = ScrollController(); final ScrollController primary2 = ScrollController();
await tester.pumpWidget(buildWithScrollController(primary2)); await tester.pumpWidget(buildWithScrollController(primary2));
scrollView = tester.widget( scrollView = tester.widget(
find.byType(SingleChildScrollView), find.byType(Scrollable),
); );
expect(scrollView.controller, primary2); expect(scrollView.controller, primary2);
}); });
...@@ -367,7 +371,7 @@ void main() { ...@@ -367,7 +371,7 @@ void main() {
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: SizedBox( body: SizedBox(
height: 200, height: 150,
child: ReorderableListView( child: ReorderableListView(
scrollController: customController, scrollController: customController,
onReorder: (int oldIndex, int newIndex) { }, onReorder: (int oldIndex, int newIndex) { },
...@@ -414,6 +418,9 @@ void main() { ...@@ -414,6 +418,9 @@ void main() {
// First 20.0 px always ignored, so scroll offset is only // First 20.0 px always ignored, so scroll offset is only
// shifted by 80.0. // shifted by 80.0.
// Final offset: 40.0 + 80.0 = 120.0 // Final offset: 40.0 + 80.0 = 120.0
// The total distance available to scroll is 300.0 - 150.0 = 150.0, or
// height of the ReorderableListView minus height of the SizedBox. Since
// The final offset is less than this, it's not limited.
expect(customController.offset, 120.0); expect(customController.offset, 120.0);
}); });
...@@ -446,11 +453,6 @@ void main() { ...@@ -446,11 +453,6 @@ void main() {
} catch (e) { } catch (e) {
fail('Expected no error, but got $e'); fail('Expected no error, but got $e');
} }
// Expect that we have build *a* ScrollController for use in the view.
final SingleChildScrollView scrollView = tester.widget(
find.byType(SingleChildScrollView),
);
expect(scrollView.controller, isNotNull);
}); });
group('Accessibility (a11y/Semantics)', () { group('Accessibility (a11y/Semantics)', () {
...@@ -675,6 +677,7 @@ void main() { ...@@ -675,6 +677,7 @@ void main() {
tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 1')),
tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0), tester.getCenter(find.text('Item 4')) + const Offset(itemHeight * 2, 0.0),
); );
await tester.pumpAndSettle();
expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1'])); expect(listItems, orderedEquals(<String>['Item 2', 'Item 3', 'Item 4', 'Item 1']));
}); });
...@@ -686,6 +689,7 @@ void main() { ...@@ -686,6 +689,7 @@ void main() {
tester.getCenter(find.text('Item 4')), tester.getCenter(find.text('Item 4')),
tester.getCenter(find.text('Item 1')), tester.getCenter(find.text('Item 1')),
); );
await tester.pumpAndSettle();
expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3'])); expect(listItems, orderedEquals(<String>['Item 4', 'Item 1', 'Item 2', 'Item 3']));
}); });
...@@ -697,6 +701,7 @@ void main() { ...@@ -697,6 +701,7 @@ void main() {
tester.getCenter(find.text('Item 3')), tester.getCenter(find.text('Item 3')),
tester.getCenter(find.text('Item 2')), tester.getCenter(find.text('Item 2')),
); );
await tester.pumpAndSettle();
expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4'])); expect(listItems, orderedEquals(<String>['Item 1', 'Item 3', 'Item 2', 'Item 4']));
}); });
...@@ -718,6 +723,7 @@ void main() { ...@@ -718,6 +723,7 @@ void main() {
tester.getCenter(find.text('Item 4')), tester.getCenter(find.text('Item 4')),
tester.getCenter(find.text('Item 3')), tester.getCenter(find.text('Item 3')),
); );
await tester.pumpAndSettle();
expect(find.text('Header Text'), findsOneWidget); expect(find.text('Header Text'), findsOneWidget);
expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1'])); expect(listItems, orderedEquals(<String>['Item 2', 'Item 4', 'Item 3', 'Item 1']));
}); });
...@@ -751,45 +757,43 @@ void main() { ...@@ -751,45 +757,43 @@ void main() {
), ),
)); ));
Element getContentElement() { double getListWidth() {
final SingleChildScrollView listScrollView = tester.widget(find.byType(SingleChildScrollView)); final RenderSliverList listScrollView = tester.renderObject(find.byType(SliverList));
final Widget scrollContents = listScrollView.child!; return listScrollView.geometry!.maxPaintExtent;
final Element contentElement = tester.element(find.byElementPredicate((Element element) => element.widget == scrollContents));
return contentElement;
} }
const double kDraggingListWidth = 292.0; const double kDraggingListWidth = 4 * itemHeight;
// Drag a normal text item // Drag a normal text item
expect(getContentElement().size!.width, kDraggingListWidth); expect(getListWidth(), kDraggingListWidth);
TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item'))); TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Normal item')));
await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.width, kDraggingListWidth); expect(getListWidth(), kDraggingListWidth);
// Move it // Move it
await drag.moveTo(tester.getCenter(find.text('Last item'))); await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.width, kDraggingListWidth); expect(getListWidth(), kDraggingListWidth);
// Drop it // Drop it
await drag.up(); await drag.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.width, kDraggingListWidth); expect(getListWidth(), kDraggingListWidth);
// Drag a tall item // Drag a tall item
drag = await tester.startGesture(tester.getCenter(find.text('Tall item'))); drag = await tester.startGesture(tester.getCenter(find.text('Tall item')));
await tester.pump(kLongPressTimeout + kPressTimeout); await tester.pump(kLongPressTimeout + kPressTimeout);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.width, kDraggingListWidth); expect(getListWidth(), kDraggingListWidth);
// Move it // Move it
await drag.moveTo(tester.getCenter(find.text('Last item'))); await drag.moveTo(tester.getCenter(find.text('Last item')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.width, kDraggingListWidth); expect(getListWidth(), kDraggingListWidth);
// Drop it // Drop it
await drag.up(); await drag.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(getContentElement().size!.width, kDraggingListWidth); expect(getListWidth(), kDraggingListWidth);
}); });
testWidgets('Horizontal drop area golden', (WidgetTester tester) async { testWidgets('Horizontal drop area golden', (WidgetTester tester) async {
...@@ -1171,7 +1175,7 @@ void main() { ...@@ -1171,7 +1175,7 @@ void main() {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: reorderableListView, home: reorderableListView,
)); ));
expect(tester.getCenter(find.text('A')).dy, lessThan(tester.getCenter(find.text('B')).dy)); expect(tester.getCenter(find.text('A')).dy, greaterThan(tester.getCenter(find.text('B')).dy));
}); });
testWidgets('Animation test when placing an item in place', (WidgetTester tester) async { testWidgets('Animation test when placing an item in place', (WidgetTester tester) async {
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
void main() {
testWidgets('SliverReorderableList, drag and drop, fixed height items', (WidgetTester tester) async {
final List<int> items = List<int>.generate(8, (int index) => index);
Future<void> pressDragRelease(Offset start, Offset delta) async {
final TestGesture drag = await tester.startGesture(start);
await tester.pump(kPressTimeout);
await drag.moveBy(delta);
await tester.pump(kPressTimeout);
await drag.up();
await tester.pumpAndSettle();
}
void check({ List<int> visible = const <int>[], List<int> hidden = const <int>[] }) {
for (final int i in visible) {
expect(find.text('item $i'), findsOneWidget);
}
for (final int i in hidden) {
expect(find.text('item $i'), findsNothing);
}
}
// The SliverReorderableList is 800x600, 8 items, each item is 800x100 with
// an "item $index" text widget at the item's origin. Drags are initiated by
// a simple press on the text widget.
await tester.pumpWidget(TestList(items: items));
check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]);
// Drag item 0 downwards less than halfway and let it snap back. List
// should remain as it is.
await pressDragRelease(const Offset(12, 50), const Offset(12, 60));
check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]);
expect(tester.getTopLeft(find.text('item 0')), Offset.zero);
expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 100));
expect(items, orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7]));
// Drag item 0 downwards more than halfway to displace item 1.
await pressDragRelease(tester.getCenter(find.text('item 0')), const Offset(0, 151));
check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]);
expect(tester.getTopLeft(find.text('item 1')), Offset.zero);
expect(tester.getTopLeft(find.text('item 0')), const Offset(0, 100));
expect(items, orderedEquals(<int>[1, 0, 2, 3, 4, 5, 6, 7]));
// Drag item 0 back to where it was.
await pressDragRelease(tester.getCenter(find.text('item 0')), const Offset(0, -51));
check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]);
expect(tester.getTopLeft(find.text('item 0')), Offset.zero);
expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 100));
expect(items, orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7]));
// Drag item 1 to item 3
await pressDragRelease(tester.getCenter(find.text('item 1')), const Offset(0, 251));
check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]);
expect(tester.getTopLeft(find.text('item 0')), Offset.zero);
expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 300));
expect(tester.getTopLeft(find.text('item 3')), const Offset(0, 200));
expect(items, orderedEquals(<int>[0, 2, 3, 1, 4, 5, 6, 7]));
// Drag item 1 back to where it was
await pressDragRelease(tester.getCenter(find.text('item 1')), const Offset(0, -200));
check(visible: <int>[0, 1, 2, 3, 4, 5], hidden: <int>[6, 7]);
expect(tester.getTopLeft(find.text('item 0')), Offset.zero);
expect(tester.getTopLeft(find.text('item 1')), const Offset(0, 100));
expect(tester.getTopLeft(find.text('item 3')), const Offset(0, 300));
expect(items, orderedEquals(<int>[0, 1, 2, 3, 4, 5, 6, 7]));
});
testWidgets('SliverReorderableList, items inherit DefaultTextStyle, IconTheme', (WidgetTester tester) async {
const Color textColor = Color(0xffffffff);
const Color iconColor = Color(0xff0000ff);
TextStyle getIconStyle() {
return tester.widget<RichText>(
find.descendant(
of: find.byType(Icon),
matching: find.byType(RichText),
),
).text.style!;
}
TextStyle getTextStyle() {
return tester.widget<RichText>(
find.descendant(
of: find.text('item 0'),
matching: find.byType(RichText),
),
).text.style!;
}
// This SliverReorderableList has just one item: "item 0".
await tester.pumpWidget(
TestList(
items: List<int>.from(<int>[0]),
textColor: textColor,
iconColor: iconColor,
),
);
expect(tester.getTopLeft(find.text('item 0')), Offset.zero);
expect(getIconStyle().color, iconColor);
expect(getTextStyle().color, textColor);
// Dragging item 0 causes it to be reparented in the overlay. The item
// should still inherit the IconTheme and DefaultTextStyle because they are
// InheritedThemes.
final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('item 0')));
await tester.pump(kPressTimeout);
await drag.moveBy(const Offset(0, 50));
await tester.pump(kPressTimeout);
expect(tester.getTopLeft(find.text('item 0')), const Offset(0, 50));
expect(getIconStyle().color, iconColor);
expect(getTextStyle().color, textColor);
// Drag is complete, item 0 returns to where it was.
await drag.up();
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('item 0')), Offset.zero);
expect(getIconStyle().color, iconColor);
expect(getTextStyle().color, textColor);
});
}
class TestList extends StatefulWidget {
const TestList({
Key? key,
this.textColor,
this.iconColor,
required this.items,
}) : super(key: key);
final List<int> items;
final Color? textColor;
final Color? iconColor;
@override
_TestListState createState() => _TestListState();
}
class _TestListState extends State<TestList> {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: DefaultTextStyle(
style: TextStyle(color: widget.textColor),
child: IconTheme(
data: IconThemeData(color: widget.iconColor),
child: StatefulBuilder(
builder: (BuildContext outerContext, StateSetter setState) {
final List<int> items = widget.items;
return CustomScrollView(
slivers: <Widget>[
SliverReorderableList(
itemBuilder: (BuildContext context, int index) {
return Container(
key: ValueKey<int>(items[index]),
height: 100,
color: items[index].isOdd ? Colors.red : Colors.green,
child: ReorderableDragStartListener(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('item ${items[index]}'),
const Icon(Icons.drag_handle),
],
),
index: index,
),
);
},
itemCount: items.length,
onReorder: (int fromIndex, int toIndex) {
setState(() {
if (toIndex > fromIndex) {
toIndex -= 1;
}
items.insert(toIndex, items.removeAt(fromIndex));
});
},
),
],
);
}
),
),
),
),
);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment