| // 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. |
| /// |
| /// This class is appropriate for views with a small number of |
| /// children because constructing the [List] requires doing work for every |
| /// child that could possibly be displayed in the list view instead of just |
| /// those children that are actually visible. |
| /// |
| /// All list items must have a key. |
| /// |
| /// {@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 --template=stateful_widget_scaffold} |
| /// |
| /// ```dart |
| /// final List<int> _items = List<int>.generate(50, (int index) => index); |
| /// |
| /// @override |
| /// Widget build(BuildContext context){ |
| /// final ColorScheme colorScheme = Theme.of(context).colorScheme; |
| /// final Color oddItemColor = colorScheme.primary.withOpacity(0.05); |
| /// final Color evenItemColor = colorScheme.primary.withOpacity(0.15); |
| /// |
| /// return ReorderableListView( |
| /// padding: const EdgeInsets.symmetric(horizontal: 40), |
| /// children: <Widget>[ |
| /// for (int index = 0; index < _items.length; index++) |
| /// ListTile( |
| /// key: Key('$index'), |
| /// tileColor: _items[index].isOdd ? oddItemColor : evenItemColor, |
| /// title: Text('Item ${_items[index]}'), |
| /// ), |
| /// ], |
| /// onReorder: (int oldIndex, int newIndex) { |
| /// setState(() { |
| /// if (oldIndex < newIndex) { |
| /// newIndex -= 1; |
| /// } |
| /// final int item = _items.removeAt(oldIndex); |
| /// _items.insert(newIndex, item); |
| /// }); |
| /// }, |
| /// ); |
| /// } |
| /// |
| /// ``` |
| /// |
| ///{@end-tool} |
| class ReorderableListView extends StatefulWidget { |
| /// Creates a reorderable list from a pre-built list of widgets. |
| /// |
| /// See also: |
| /// |
| /// * [ReorderableListView.builder], which allows you to build a reorderable |
| /// list where the items are built as needed when scrolling the list. |
| ReorderableListView({ |
| Key? key, |
| required List<Widget> children, |
| required this.onReorder, |
| this.itemExtent, |
| this.prototypeItem, |
| this.proxyDecorator, |
| this.buildDefaultDragHandles = true, |
| this.padding, |
| this.header, |
| 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, |
| super(key: key); |
| |
| /// 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 --template=stateful_widget_material} |
| /// |
| /// ```dart |
| /// final List<int> _items = List<int>.generate(50, (int index) => index); |
| /// |
| /// @override |
| /// Widget build(BuildContext context) { |
| /// final ColorScheme colorScheme = Theme.of(context).colorScheme; |
| /// final Color oddItemColor = colorScheme.primary.withOpacity(0.05); |
| /// final Color evenItemColor = colorScheme.primary.withOpacity(0.15); |
| /// |
| /// return ReorderableListView.builder( |
| /// padding: const EdgeInsets.symmetric(horizontal: 40), |
| /// itemCount:_items.length, |
| /// itemBuilder: (BuildContext context, int index) { |
| /// return ListTile( |
| /// key: Key('$index'), |
| /// tileColor: _items[index].isOdd ? oddItemColor : evenItemColor, |
| /// title: Text('Item ${_items[index]}'), |
| /// ); |
| /// }, |
| /// onReorder: (int oldIndex, int newIndex) { |
| /// setState(() { |
| /// if (oldIndex < newIndex) { |
| /// newIndex -= 1; |
| /// } |
| /// final int item = _items.removeAt(oldIndex); |
| /// _items.insert(newIndex, item); |
| /// }); |
| /// }, |
| /// ); |
| /// } |
| /// |
| /// ``` |
| /// {@end-tool} |
| /// See also: |
| /// |
| /// * [ReorderableListView], which allows you to build a reorderable |
| /// list with all the items passed into the constructor. |
| const ReorderableListView.builder({ |
| Key? key, |
| required this.itemBuilder, |
| required this.itemCount, |
| required this.onReorder, |
| this.itemExtent, |
| this.prototypeItem, |
| this.proxyDecorator, |
| this.buildDefaultDragHandles = true, |
| this.padding, |
| this.header, |
| 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), |
| super(key: key); |
| |
| /// {@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.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 --template=stateful_widget_scaffold} |
| /// |
| /// ```dart |
| /// final List<int> _items = List<int>.generate(50, (int index) => index); |
| /// |
| /// @override |
| /// Widget build(BuildContext context){ |
| /// final ColorScheme colorScheme = Theme.of(context).colorScheme; |
| /// final Color oddItemColor = colorScheme.primary.withOpacity(0.05); |
| /// final Color 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: (int oldIndex, int newIndex) { |
| /// setState(() { |
| /// if (oldIndex < newIndex) { |
| /// newIndex -= 1; |
| /// } |
| /// final int item = _items.removeAt(oldIndex); |
| /// _items.insert(newIndex, item); |
| /// }); |
| /// }, |
| /// ); |
| /// } |
| /// ``` |
| ///{@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; |
| |
| /// {@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.fuchsia: |
| 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: |
| 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 we can't just apply the padding to the list, |
| // so we break it up into padding for the header and padding for the list. |
| final EdgeInsets padding = widget.padding ?? EdgeInsets.zero; |
| late final EdgeInsets headerPadding; |
| late final EdgeInsets listPadding; |
| |
| if (widget.header == null) { |
| headerPadding = EdgeInsets.zero; |
| listPadding = padding; |
| } else { |
| switch (widget.scrollDirection) { |
| case Axis.horizontal: |
| if (widget.reverse) { |
| // Header on the right |
| headerPadding = EdgeInsets.fromLTRB(0, padding.top, padding.right, padding.bottom); |
| listPadding = EdgeInsets.fromLTRB(padding.left, padding.top, 0, padding.bottom); |
| } else { |
| // Header on the left |
| headerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, 0, padding.bottom); |
| listPadding = EdgeInsets.fromLTRB(0, padding.top, padding.right, padding.bottom); |
| } |
| break; |
| case Axis.vertical: |
| if (widget.reverse) { |
| // Header on the bottom |
| headerPadding = EdgeInsets.fromLTRB(padding.left, 0, padding.right, padding.bottom); |
| listPadding = EdgeInsets.fromLTRB(padding.left, padding.top, padding.right, 0); |
| } else { |
| // Header on the top |
| headerPadding = EdgeInsets.fromLTRB(padding.left, padding.top, padding.right, 0); |
| listPadding = 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, |
| proxyDecorator: widget.proxyDecorator ?? _proxyDecorator, |
| ), |
| ), |
| ], |
| ); |
| } |
| } |
| |
| // 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 => hashValues(subKey, state); |
| } |