blob: cb79aebf6bcf0d7b31779c1810d9e4eb0281633b [file] [log] [blame]
// 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:ui' show lerpDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'debug.dart';
import 'icons.dart';
import 'material.dart';
import 'material_localizations.dart';
import 'theme.dart';
/// A list whose items the user can interactively reorder by dragging.
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE}
///
/// This sample shows by dragging the user can reorder the items of the list.
/// The [onReorder] parameter is required and will be called when a child
/// widget is dragged to a new position.
///
/// {@tool dartpad}
///
/// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.0.dart **
/// {@end-tool}
///
/// By default, on [TargetPlatformVariant.desktop] platforms each item will
/// have a drag handle added on top of it that will allow the user to grab it
/// to move the item. On [TargetPlatformVariant.mobile], no drag handle will be
/// added, but when the user long presses anywhere on the item it will start
/// moving the item. Displaying drag handles can be controlled with
/// [ReorderableListView.buildDefaultDragHandles].
///
/// All list items must have a key.
///
/// This example demonstrates using the [proxyDecorator] callback to customize
/// the appearance of a list item while it's being dragged.
/// {@tool dartpad}
///
/// While a drag is underway, the widget returned by the [proxyDecorator]
/// serves as a "proxy" (a substitute) for the item in the list. The proxy is
/// created with the original list item as its child. The [proxyDecorator]
/// in this example is similar to the default one except that it changes the
/// proxy item's background color.
///
/// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.1.dart **
/// {@end-tool}
class ReorderableListView extends StatefulWidget {
/// Creates a reorderable list from a pre-built list of widgets.
///
/// This constructor is appropriate for lists with a small number of
/// children because constructing the [List] requires doing work for every
/// child that could possibly be displayed in the list view instead of just
/// those children that are actually visible.
///
/// See also:
///
/// * [ReorderableListView.builder], which allows you to build a reorderable
/// list where the items are built as needed when scrolling the list.
ReorderableListView({
super.key,
required List<Widget> children,
required this.onReorder,
this.onReorderStart,
this.onReorderEnd,
this.itemExtent,
this.prototypeItem,
this.proxyDecorator,
this.buildDefaultDragHandles = true,
this.padding,
this.header,
this.footer,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.scrollController,
this.primary,
this.physics,
this.shrinkWrap = false,
this.anchor = 0.0,
this.cacheExtent,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
}) : assert(scrollDirection != null),
assert(onReorder != null),
assert(children != null),
assert(
itemExtent == null || prototypeItem == null,
'You can only pass itemExtent or prototypeItem, not both',
),
assert(
children.every((Widget w) => w.key != null),
'All children of this widget must have a key.',
),
assert(buildDefaultDragHandles != null),
itemBuilder = ((BuildContext context, int index) => children[index]),
itemCount = children.length;
/// Creates a reorderable list from widget items that are created on demand.
///
/// This constructor is appropriate for list views with a large number of
/// children because the builder is called only for those children
/// that are actually visible.
///
/// The `itemBuilder` callback will be called only with indices greater than
/// or equal to zero and less than `itemCount`.
///
/// The `itemBuilder` should always return a non-null widget, and actually
/// create the widget instances when called. Avoid using a builder that
/// returns a previously-constructed widget; if the list view's children are
/// created in advance, or all at once when the [ReorderableListView] itself
/// is created, it is more efficient to use the [ReorderableListView]
/// constructor. Even more efficient, however, is to create the instances
/// on demand using this constructor's `itemBuilder` callback.
///
/// This example creates a list using the
/// [ReorderableListView.builder] constructor. Using the [IndexedWidgetBuilder], The
/// list items are built lazily on demand.
/// {@tool dartpad}
///
/// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.reorderable_list_view_builder.0.dart **
/// {@end-tool}
/// See also:
///
/// * [ReorderableListView], which allows you to build a reorderable
/// list with all the items passed into the constructor.
const ReorderableListView.builder({
super.key,
required this.itemBuilder,
required this.itemCount,
required this.onReorder,
this.onReorderStart,
this.onReorderEnd,
this.itemExtent,
this.prototypeItem,
this.proxyDecorator,
this.buildDefaultDragHandles = true,
this.padding,
this.header,
this.footer,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.scrollController,
this.primary,
this.physics,
this.shrinkWrap = false,
this.anchor = 0.0,
this.cacheExtent,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
}) : assert(scrollDirection != null),
assert(itemCount >= 0),
assert(onReorder != null),
assert(
itemExtent == null || prototypeItem == null,
'You can only pass itemExtent or prototypeItem, not both',
),
assert(buildDefaultDragHandles != null);
/// {@macro flutter.widgets.reorderable_list.itemBuilder}
final IndexedWidgetBuilder itemBuilder;
/// {@macro flutter.widgets.reorderable_list.itemCount}
final int itemCount;
/// {@macro flutter.widgets.reorderable_list.onReorder}
final ReorderCallback onReorder;
/// {@macro flutter.widgets.reorderable_list.onReorderStart}
final void Function(int index)? onReorderStart;
/// {@macro flutter.widgets.reorderable_list.onReorderEnd}
final void Function(int index)? onReorderEnd;
/// {@macro flutter.widgets.reorderable_list.proxyDecorator}
final ReorderItemProxyDecorator? proxyDecorator;
/// 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}
///
///
/// ** See code in examples/api/lib/material/reorderable_list/reorderable_list_view.build_default_drag_handles.0.dart **
///{@end-tool}
final bool buildDefaultDragHandles;
/// {@macro flutter.widgets.reorderable_list.padding}
final EdgeInsets? padding;
/// A non-reorderable header item to show before the items of the list.
///
/// If null, no header will appear before the list.
final Widget? header;
/// A non-reorderable footer item to show after the items of the list.
///
/// If null, no footer will appear after the list.
final Widget? footer;
/// {@macro flutter.widgets.scroll_view.scrollDirection}
final Axis scrollDirection;
/// {@macro flutter.widgets.scroll_view.reverse}
final bool reverse;
/// {@macro flutter.widgets.scroll_view.controller}
final ScrollController? scrollController;
/// {@macro flutter.widgets.scroll_view.primary}
/// Defaults to true when [scrollDirection] is [Axis.vertical] and
/// [scrollController] is null.
final bool? primary;
/// {@macro flutter.widgets.scroll_view.physics}
final ScrollPhysics? physics;
/// {@macro flutter.widgets.scroll_view.shrinkWrap}
final bool shrinkWrap;
/// {@macro flutter.widgets.scroll_view.anchor}
final double anchor;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
final double? cacheExtent;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@macro flutter.widgets.scroll_view.keyboardDismissBehavior}
///
/// The default is [ScrollViewKeyboardDismissBehavior.manual]
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String? restorationId;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// {@macro flutter.widgets.list_view.itemExtent}
final double? itemExtent;
/// {@macro flutter.widgets.list_view.prototypeItem}
final Widget? prototypeItem;
@override
State<ReorderableListView> createState() => _ReorderableListViewState();
}
class _ReorderableListViewState extends State<ReorderableListView> {
Widget _wrapWithSemantics(Widget child, int index) {
void reorder(int startIndex, int endIndex) {
if (startIndex != endIndex) {
widget.onReorder(startIndex, endIndex);
}
}
// 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.itemCount);
void moveBefore() => reorder(index, index - 1);
// To move after, we go to index+2 because we are moving it to the space
// before index+2, which is after the space at index+1.
void moveAfter() => reorder(index, index + 2);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
// If the item can move to before its current position in the list.
if (index > 0) {
semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToStart)] = moveToStart;
String reorderItemBefore = localizations.reorderItemUp;
if (widget.scrollDirection == Axis.horizontal) {
reorderItemBefore = Directionality.of(context) == TextDirection.ltr
? localizations.reorderItemLeft
: localizations.reorderItemRight;
}
semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = moveBefore;
}
// If the item can move to after its current position in the list.
if (index < widget.itemCount - 1) {
String reorderItemAfter = localizations.reorderItemDown;
if (widget.scrollDirection == Axis.horizontal) {
reorderItemAfter = Directionality.of(context) == TextDirection.ltr
? localizations.reorderItemRight
: localizations.reorderItemLeft;
}
semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = moveAfter;
semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToEnd)] = moveToEnd;
}
// We pass toWrap with a GlobalKey into the item so that when it
// 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 MergeSemantics(
child: Semantics(
customSemanticsActions: semanticsActions,
child: child,
),
);
}
Widget _itemBuilder(BuildContext context, int index) {
final Widget item = widget.itemBuilder(context, index);
assert(() {
if (item.key == null) {
throw FlutterError(
'Every item of ReorderableListView must have a key.',
);
}
return true;
}());
// TODO(goderbauer): The semantics stuff should probably happen inside
// _ReorderableItem so the widget versions can have them as well.
final Widget itemWithSemantics = _wrapWithSemantics(item, index);
final Key itemGlobalKey = _ReorderableListViewChildGlobalKey(item.key!, this);
if (widget.buildDefaultDragHandles) {
switch (Theme.of(context).platform) {
case TargetPlatform.linux:
case TargetPlatform.windows:
case TargetPlatform.macOS:
switch (widget.scrollDirection) {
case Axis.horizontal:
return Stack(
key: itemGlobalKey,
children: <Widget>[
itemWithSemantics,
Positioned.directional(
textDirection: Directionality.of(context),
start: 0,
end: 0,
bottom: 8,
child: Align(
alignment: AlignmentDirectional.bottomCenter,
child: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
),
),
],
);
case Axis.vertical:
return Stack(
key: itemGlobalKey,
children: <Widget>[
itemWithSemantics,
Positioned.directional(
textDirection: Directionality.of(context),
top: 0,
bottom: 0,
end: 8,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
),
),
],
);
}
case TargetPlatform.iOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
return ReorderableDelayedDragStartListener(
key: itemGlobalKey,
index: index,
child: itemWithSemantics,
);
}
}
return KeyedSubtree(
key: itemGlobalKey,
child: itemWithSemantics,
);
}
Widget _proxyDecorator(Widget child, int index, Animation<double> animation) {
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget? child) {
final double animValue = Curves.easeInOut.transform(animation.value);
final double elevation = lerpDouble(0, 6, animValue)!;
return Material(
elevation: elevation,
child: child,
);
},
child: child,
);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterialLocalizations(context));
assert(debugCheckHasOverlay(context));
// If there is a header or footer we can't just apply the padding to the list,
// so we break it up into padding for the header, footer and padding for the list.
final EdgeInsets padding = widget.padding ?? EdgeInsets.zero;
late final EdgeInsets headerPadding;
late final EdgeInsets footerPadding;
late final EdgeInsets listPadding;
if (widget.header == null && widget.footer == null) {
headerPadding = EdgeInsets.zero;
footerPadding = EdgeInsets.zero;
listPadding = padding;
} else if (widget.header != null || widget.footer != null) {
switch (widget.scrollDirection) {
case Axis.horizontal:
if (widget.reverse) {
headerPadding = EdgeInsets.fromLTRB(0, padding.top, padding.right, padding.bottom);
listPadding = EdgeInsets.fromLTRB(widget.footer != null ? 0 : padding.left, padding.top, widget.header != null ? 0 : padding.right, padding.bottom);
footerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, 0, padding.bottom);
} else {
headerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, 0, padding.bottom);
listPadding = EdgeInsets.fromLTRB(widget.header != null ? 0 : padding.left, padding.top, widget.footer != null ? 0 : padding.right, padding.bottom);
footerPadding = EdgeInsets.fromLTRB(0, padding.top, padding.right, padding.bottom);
}
break;
case Axis.vertical:
if (widget.reverse) {
headerPadding = EdgeInsets.fromLTRB(padding.left, 0, padding.right, padding.bottom);
listPadding = EdgeInsets.fromLTRB(padding.left, widget.footer != null ? 0 : padding.top, padding.right, widget.header != null ? 0 : padding.bottom);
footerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, padding.right, 0);
} else {
headerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, padding.right, 0);
listPadding = EdgeInsets.fromLTRB(padding.left, widget.header != null ? 0 : padding.top, padding.right, widget.footer != null ? 0 : padding.bottom);
footerPadding = EdgeInsets.fromLTRB(padding.left, 0, padding.right, padding.bottom);
}
break;
}
}
return CustomScrollView(
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
controller: widget.scrollController,
primary: widget.primary,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
anchor: widget.anchor,
cacheExtent: widget.cacheExtent,
dragStartBehavior: widget.dragStartBehavior,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
restorationId: widget.restorationId,
clipBehavior: widget.clipBehavior,
slivers: <Widget>[
if (widget.header != null)
SliverPadding(
padding: headerPadding,
sliver: SliverToBoxAdapter(child: widget.header),
),
SliverPadding(
padding: listPadding,
sliver: SliverReorderableList(
itemBuilder: _itemBuilder,
itemExtent: widget.itemExtent,
prototypeItem: widget.prototypeItem,
itemCount: widget.itemCount,
onReorder: widget.onReorder,
onReorderStart: widget.onReorderStart,
onReorderEnd: widget.onReorderEnd,
proxyDecorator: widget.proxyDecorator ?? _proxyDecorator,
),
),
if (widget.footer != null)
SliverPadding(
padding: footerPadding,
sliver: SliverToBoxAdapter(child: widget.footer),
),
],
);
}
}
// 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 _ReorderableListViewChildGlobalKey extends GlobalObjectKey {
const _ReorderableListViewChildGlobalKey(this.subKey, this.state) : super(subKey);
final Key subKey;
final State state;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is _ReorderableListViewChildGlobalKey
&& other.subKey == subKey
&& other.state == state;
}
@override
int get hashCode => Object.hash(subKey, state);
}