Created
August 13, 2020 21:20
-
-
Save csells/0fbfd605659c9d57bcf02843b6bc366c to your computer and use it in GitHub Desktop.
flutter-long-press-vs-short-press
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:math'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| void main() => runApp(App()); | |
| class App extends StatelessWidget { | |
| static const title = 'Flutter longpress'; | |
| @override | |
| Widget build(BuildContext context) => MaterialApp( | |
| title: title, | |
| theme: ThemeData( | |
| primarySwatch: Colors.blue, | |
| visualDensity: VisualDensity.adaptivePlatformDensity, | |
| ), | |
| home: HomePage(), | |
| ); | |
| } | |
| class HomePage extends StatefulWidget { | |
| @override | |
| _HomePageState createState() => _HomePageState(); | |
| } | |
| class _HomePageState extends State<HomePage> { | |
| final letters1 = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']; | |
| final letters2 = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; | |
| final controller1 = ScrollController(); | |
| final controller2 = ScrollController(); | |
| @override | |
| Widget build(BuildContext context) => Scaffold( | |
| appBar: AppBar(title: Text(App.title)), | |
| body: Row( | |
| children: [ | |
| Expanded( | |
| child: ReorderableListView( | |
| children: [ | |
| for (final letter in letters1) Text('long click: $letter', key: Key('$letter-1')), | |
| ], | |
| onReorder: (oldIndex, newIndex) => _reorder(letters1, oldIndex, newIndex), | |
| scrollController: controller1, | |
| ), | |
| ), | |
| Expanded( | |
| child: MyReorderableListView( | |
| children: [ | |
| for (final letter in letters2) Text('short click: $letter', key: Key('$letter-2')), | |
| ], | |
| onReorder: (oldIndex, newIndex) => _reorder(letters2, oldIndex, newIndex), | |
| scrollController: controller2, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| void _reorder(List<String> letters, int oldIndex, int newIndex) { | |
| setState( | |
| () { | |
| if (newIndex > oldIndex) newIndex -= 1; | |
| letters.insert(newIndex, letters.removeAt(oldIndex)); | |
| }, | |
| ); | |
| } | |
| } | |
| class MyReorderableListView extends StatefulWidget { | |
| /// Creates a reorderable list. | |
| MyReorderableListView({ | |
| Key key, | |
| this.header, | |
| @required this.children, | |
| @required this.onReorder, | |
| this.scrollController, | |
| this.scrollDirection = Axis.vertical, | |
| this.padding, | |
| this.reverse = false, | |
| }) : assert(scrollDirection != null), | |
| assert(onReorder != null), | |
| assert(children != null), | |
| assert( | |
| children.every((Widget w) => w.key != null), | |
| 'All children of this widget must have a key.', | |
| ), | |
| super(key: key); | |
| /// A non-reorderable header widget to show before the list. | |
| /// | |
| /// If null, no header will appear before the list. | |
| final Widget header; | |
| /// The widgets to display. | |
| final List<Widget> children; | |
| /// The [Axis] along which the list scrolls. | |
| /// | |
| /// List [children] can only drag along this [Axis]. | |
| final Axis scrollDirection; | |
| /// Creates a [ScrollPosition] to manage and determine which portion | |
| /// of the content is visible in the scroll view. | |
| /// | |
| /// This can be used in many ways, such as setting an initial scroll offset, | |
| /// (via [ScrollController.initialScrollOffset]), reading the current scroll position | |
| /// (via [ScrollController.offset]), or changing it (via [ScrollController.jumpTo] or | |
| /// [ScrollController.animateTo]). | |
| final ScrollController scrollController; | |
| /// The amount of space by which to inset the [children]. | |
| final EdgeInsets padding; | |
| /// Whether the scroll view scrolls in the reading direction. | |
| /// | |
| /// For example, if the reading direction is left-to-right and | |
| /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from | |
| /// left to right when [reverse] is false and from right to left when | |
| /// [reverse] is true. | |
| /// | |
| /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view | |
| /// scrolls from top to bottom when [reverse] is false and from bottom to top | |
| /// when [reverse] is true. | |
| /// | |
| /// Defaults to false. | |
| final bool reverse; | |
| /// Called when a list child is dropped into a new position to shuffle the | |
| /// underlying list. | |
| /// | |
| /// This [ReorderableListView] calls [onReorder] after a list child is dropped | |
| /// into a new position. | |
| final ReorderCallback onReorder; | |
| @override | |
| _ReorderableListViewState createState() => _ReorderableListViewState(); | |
| } | |
| // This top-level state manages an Overlay that contains the list and | |
| // also any Draggables it creates. | |
| // | |
| // _ReorderableListContent manages the list itself and reorder operations. | |
| // | |
| // The Overlay doesn't properly keep state by building new overlay entries, | |
| // and so we cache a single OverlayEntry for use as the list layer. | |
| // That overlay entry then builds a _ReorderableListContent which may | |
| // insert Draggables into the Overlay above itself. | |
| class _ReorderableListViewState extends State<MyReorderableListView> { | |
| // We use an inner overlay so that the dragging list item doesn't draw outside of the list itself. | |
| final GlobalKey _overlayKey = GlobalKey(debugLabel: '$MyReorderableListView overlay key'); | |
| // This entry contains the scrolling list itself. | |
| OverlayEntry _listOverlayEntry; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _listOverlayEntry = OverlayEntry( | |
| opaque: true, | |
| builder: (BuildContext context) { | |
| return _ReorderableListContent( | |
| header: widget.header, | |
| children: widget.children, | |
| scrollController: widget.scrollController, | |
| scrollDirection: widget.scrollDirection, | |
| onReorder: widget.onReorder, | |
| padding: widget.padding, | |
| reverse: widget.reverse, | |
| ); | |
| }, | |
| ); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Overlay(key: _overlayKey, initialEntries: <OverlayEntry>[ | |
| _listOverlayEntry, | |
| ]); | |
| } | |
| } | |
| // This widget is responsible for the inside of the Overlay in the | |
| // ReorderableListView. | |
| class _ReorderableListContent extends StatefulWidget { | |
| const _ReorderableListContent({ | |
| @required this.header, | |
| @required this.children, | |
| @required this.scrollController, | |
| @required this.scrollDirection, | |
| @required this.padding, | |
| @required this.onReorder, | |
| @required this.reverse, | |
| }); | |
| final Widget header; | |
| final List<Widget> children; | |
| final ScrollController scrollController; | |
| final Axis scrollDirection; | |
| final EdgeInsets padding; | |
| final ReorderCallback onReorder; | |
| final bool reverse; | |
| @override | |
| _ReorderableListContentState createState() => _ReorderableListContentState(); | |
| } | |
| class _ReorderableListContentState extends State<_ReorderableListContent> | |
| with TickerProviderStateMixin<_ReorderableListContent> { | |
| // The extent along the [widget.scrollDirection] axis to allow a child to | |
| // drop into when the user reorders list children. | |
| // | |
| // This value is used when the extents haven't yet been calculated from | |
| // the currently dragging widget, such as when it first builds. | |
| static const double _defaultDropAreaExtent = 100.0; | |
| // The additional margin to place around a computed drop area. | |
| static const double _dropAreaMargin = 8.0; | |
| // How long an animation to reorder an element in the list takes. | |
| static const Duration _reorderAnimationDuration = Duration(milliseconds: 200); | |
| // How long an animation to scroll to an off-screen element in the | |
| // list takes. | |
| static const Duration _scrollAnimationDuration = Duration(milliseconds: 200); | |
| // Controls scrolls and measures scroll progress. | |
| ScrollController _scrollController; | |
| // This controls the entrance of the dragging widget into a new place. | |
| AnimationController _entranceController; | |
| // This controls the 'ghost' of the dragging widget, which is left behind | |
| // where the widget used to be. | |
| AnimationController _ghostController; | |
| // The member of widget.children currently being dragged. | |
| // | |
| // Null if no drag is underway. | |
| Key _dragging; | |
| // The last computed size of the feedback widget being dragged. | |
| Size _draggingFeedbackSize; | |
| // The location that the dragging widget occupied before it started to drag. | |
| int _dragStartIndex = 0; | |
| // The index that the dragging widget most recently left. | |
| // This is used to show an animation of the widget's position. | |
| int _ghostIndex = 0; | |
| // The index that the dragging widget currently occupies. | |
| int _currentIndex = 0; | |
| // The widget to move the dragging widget too after the current index. | |
| int _nextIndex = 0; | |
| // Whether or not we are currently scrolling this view to show a widget. | |
| bool _scrolling = false; | |
| double get _dropAreaExtent { | |
| if (_draggingFeedbackSize == null) { | |
| return _defaultDropAreaExtent; | |
| } | |
| double dropAreaWithoutMargin; | |
| switch (widget.scrollDirection) { | |
| case Axis.horizontal: | |
| dropAreaWithoutMargin = _draggingFeedbackSize.width; | |
| break; | |
| case Axis.vertical: | |
| default: | |
| dropAreaWithoutMargin = _draggingFeedbackSize.height; | |
| break; | |
| } | |
| return dropAreaWithoutMargin + _dropAreaMargin; | |
| } | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _entranceController = AnimationController(vsync: this, duration: _reorderAnimationDuration); | |
| _ghostController = AnimationController(vsync: this, duration: _reorderAnimationDuration); | |
| _entranceController.addStatusListener(_onEntranceStatusChanged); | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| _scrollController = widget.scrollController ?? PrimaryScrollController.of(context) ?? ScrollController(); | |
| super.didChangeDependencies(); | |
| } | |
| @override | |
| void dispose() { | |
| _entranceController.dispose(); | |
| _ghostController.dispose(); | |
| super.dispose(); | |
| } | |
| // Animates the droppable space from _currentIndex to _nextIndex. | |
| void _requestAnimationToNextIndex() { | |
| if (_entranceController.isCompleted) { | |
| _ghostIndex = _currentIndex; | |
| if (_nextIndex == _currentIndex) { | |
| return; | |
| } | |
| _currentIndex = _nextIndex; | |
| _ghostController.reverse(from: 1.0); | |
| _entranceController.forward(from: 0.0); | |
| } | |
| } | |
| // Requests animation to the latest next index if it changes during an animation. | |
| void _onEntranceStatusChanged(AnimationStatus status) { | |
| if (status == AnimationStatus.completed) { | |
| setState(() { | |
| _requestAnimationToNextIndex(); | |
| }); | |
| } | |
| } | |
| // Scrolls to a target context if that context is not on the screen. | |
| void _scrollTo(BuildContext context) { | |
| if (_scrolling) { | |
| return; | |
| } | |
| final contextObject = context.findRenderObject(); | |
| final viewport = RenderAbstractViewport.of(contextObject); | |
| assert(viewport != null); | |
| // If and only if the current scroll offset falls in-between the offsets | |
| // necessary to reveal the selected context at the top or bottom of the | |
| // screen, then it is already on-screen. | |
| final margin = _dropAreaExtent; | |
| final scrollOffset = _scrollController.offset; | |
| final topOffset = max( | |
| _scrollController.position.minScrollExtent, | |
| viewport.getOffsetToReveal(contextObject, 0.0).offset - margin, | |
| ); | |
| final bottomOffset = min( | |
| _scrollController.position.maxScrollExtent, | |
| viewport.getOffsetToReveal(contextObject, 1.0).offset + margin, | |
| ); | |
| final onScreen = scrollOffset <= topOffset && scrollOffset >= bottomOffset; | |
| // If the context is off screen, then we request a scroll to make it visible. | |
| if (!onScreen) { | |
| _scrolling = true; | |
| _scrollController.position | |
| .animateTo( | |
| scrollOffset < bottomOffset ? bottomOffset : topOffset, | |
| duration: _scrollAnimationDuration, | |
| curve: Curves.easeInOut, | |
| ) | |
| .then((void value) { | |
| setState(() { | |
| _scrolling = false; | |
| }); | |
| }); | |
| } | |
| } | |
| // Wraps children in Row or Column, so that the children flow in | |
| // the widget's scrollDirection. | |
| Widget _buildContainerForScrollDirection({List<Widget> children}) { | |
| switch (widget.scrollDirection) { | |
| case Axis.horizontal: | |
| return Row(children: children); | |
| case Axis.vertical: | |
| default: | |
| return Column(children: children); | |
| } | |
| } | |
| // Wraps one of the widget's children in a DragTarget and Draggable. | |
| // Handles up the logic for dragging and reordering items in the list. | |
| Widget _wrap(Widget toWrap, int index, BoxConstraints constraints) { | |
| assert(toWrap.key != null); | |
| final keyIndexGlobalKey = GlobalObjectKey(toWrap.key); | |
| // We pass the toWrapWithGlobalKey into the Draggable so that when a list | |
| // item gets dragged, the accessibility framework can preserve the selected | |
| // state of the dragging item. | |
| // Starts dragging toWrap. | |
| void onDragStarted() { | |
| setState(() { | |
| _dragging = toWrap.key; | |
| _dragStartIndex = index; | |
| _ghostIndex = index; | |
| _currentIndex = index; | |
| _entranceController.value = 1.0; | |
| _draggingFeedbackSize = keyIndexGlobalKey.currentContext.size; | |
| }); | |
| } | |
| // Places the value from startIndex one space before the element at endIndex. | |
| void reorder(int startIndex, int endIndex) { | |
| setState(() { | |
| if (startIndex != endIndex) widget.onReorder(startIndex, endIndex); | |
| // Animates leftover space in the drop area closed. | |
| _ghostController.reverse(from: 0.1); | |
| _entranceController.reverse(from: 0.1); | |
| _dragging = null; | |
| }); | |
| } | |
| // Drops toWrap into the last position it was hovering over. | |
| void onDragEnded() { | |
| reorder(_dragStartIndex, _currentIndex); | |
| } | |
| Widget wrapWithSemantics() { | |
| // First, determine which semantics actions apply. | |
| final 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 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; | |
| var reorderItemBefore = localizations.reorderItemUp; | |
| if (widget.scrollDirection == Axis.horizontal) { | |
| reorderItemBefore = Directionality.of(context) == TextDirection.ltr | |
| ? localizations.reorderItemLeft | |
| : localizations.reorderItemRight; | |
| } | |
| semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = moveBefore; | |
| } | |
| // If the item can move to after its current position in the list. | |
| if (index < widget.children.length - 1) { | |
| var reorderItemAfter = localizations.reorderItemDown; | |
| if (widget.scrollDirection == Axis.horizontal) { | |
| reorderItemAfter = Directionality.of(context) == TextDirection.ltr | |
| ? localizations.reorderItemRight | |
| : localizations.reorderItemLeft; | |
| } | |
| semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = moveAfter; | |
| semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToEnd)] = moveToEnd; | |
| } | |
| // We pass toWrap with a GlobalKey into the Draggable so that when a list | |
| // item gets dragged, the accessibility framework can preserve the selected | |
| // state of the dragging item. | |
| // | |
| // We also apply the relevant custom accessibility actions for moving the item | |
| // up, down, to the start, and to the end of the list. | |
| return KeyedSubtree( | |
| key: keyIndexGlobalKey, | |
| child: MergeSemantics( | |
| child: Semantics( | |
| customSemanticsActions: semanticsActions, | |
| child: toWrap, | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget buildDragTarget(BuildContext context, List<Key> acceptedCandidates, List<dynamic> rejectedCandidates) { | |
| final toWrapWithSemantics = wrapWithSemantics(); | |
| // We build the draggable inside of a layout builder so that we can | |
| // constrain the size of the feedback dragging widget. | |
| // NOTE: allow drag 'n' drop w/o longpress | |
| // Widget child = LongPressDraggable<Key>( | |
| Widget child = Draggable<Key>( | |
| maxSimultaneousDrags: 1, | |
| axis: widget.scrollDirection, | |
| data: toWrap.key, | |
| ignoringFeedbackSemantics: false, | |
| feedback: Container( | |
| alignment: Alignment.topLeft, | |
| // These constraints will limit the cross axis of the drawn widget. | |
| constraints: constraints, | |
| child: Material( | |
| elevation: 6.0, | |
| child: toWrapWithSemantics, | |
| ), | |
| ), | |
| child: _dragging == toWrap.key ? const SizedBox() : toWrapWithSemantics, | |
| childWhenDragging: const SizedBox(), | |
| dragAnchor: DragAnchor.child, | |
| onDragStarted: onDragStarted, | |
| // When the drag ends inside a DragTarget widget, the drag | |
| // succeeds, and we reorder the widget into position appropriately. | |
| onDragCompleted: onDragEnded, | |
| // When the drag does not end inside a DragTarget widget, the | |
| // drag fails, but we still reorder the widget to the last position it | |
| // had been dragged to. | |
| onDraggableCanceled: (Velocity velocity, Offset offset) { | |
| onDragEnded(); | |
| }, | |
| ); | |
| // The target for dropping at the end of the list doesn't need to be | |
| // draggable. | |
| if (index >= widget.children.length) { | |
| child = toWrap; | |
| } | |
| // Determine the size of the drop area to show under the dragging widget. | |
| Widget spacing; | |
| switch (widget.scrollDirection) { | |
| case Axis.horizontal: | |
| spacing = SizedBox(width: _dropAreaExtent); | |
| break; | |
| case Axis.vertical: | |
| default: | |
| spacing = SizedBox(height: _dropAreaExtent); | |
| break; | |
| } | |
| // We open up a space under where the dragging widget currently is to | |
| // show it can be dropped. | |
| if (_currentIndex == index) { | |
| return _buildContainerForScrollDirection(children: <Widget>[ | |
| SizeTransition( | |
| sizeFactor: _entranceController, | |
| axis: widget.scrollDirection, | |
| child: spacing, | |
| ), | |
| child, | |
| ]); | |
| } | |
| // We close up the space under where the dragging widget previously was | |
| // with the ghostController animation. | |
| if (_ghostIndex == index) { | |
| return _buildContainerForScrollDirection(children: <Widget>[ | |
| SizeTransition( | |
| sizeFactor: _ghostController, | |
| axis: widget.scrollDirection, | |
| child: spacing, | |
| ), | |
| child, | |
| ]); | |
| } | |
| return child; | |
| } | |
| // We wrap the drag target in a Builder so that we can scroll to its specific context. | |
| return Builder(builder: (BuildContext context) { | |
| return DragTarget<Key>( | |
| builder: buildDragTarget, | |
| onWillAccept: (Key toAccept) { | |
| setState(() { | |
| _nextIndex = index; | |
| _requestAnimationToNextIndex(); | |
| }); | |
| _scrollTo(context); | |
| // If the target is not the original starting point, then we will accept the drop. | |
| return _dragging == toAccept && toAccept != toWrap.key; | |
| }, | |
| onAccept: (Key accepted) {}, | |
| onLeave: (Object leaving) {}, | |
| ); | |
| }); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| assert(debugCheckHasMaterialLocalizations(context)); | |
| // We use the layout builder to constrain the cross-axis size of dragging child widgets. | |
| return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { | |
| const endWidgetKey = Key('DraggableList - End Widget'); | |
| Widget finalDropArea; | |
| switch (widget.scrollDirection) { | |
| case Axis.horizontal: | |
| finalDropArea = SizedBox( | |
| key: endWidgetKey, | |
| width: _defaultDropAreaExtent, | |
| height: constraints.maxHeight, | |
| ); | |
| break; | |
| case Axis.vertical: | |
| default: | |
| finalDropArea = SizedBox( | |
| key: endWidgetKey, | |
| height: _defaultDropAreaExtent, | |
| width: constraints.maxWidth, | |
| ); | |
| break; | |
| } | |
| // If the reorderable list only has one child element, reordering | |
| // should not be allowed. | |
| final hasMoreThanOneChildElement = widget.children.length > 1; | |
| return SingleChildScrollView( | |
| scrollDirection: widget.scrollDirection, | |
| padding: widget.padding, | |
| controller: _scrollController, | |
| reverse: widget.reverse, | |
| child: _buildContainerForScrollDirection( | |
| children: <Widget>[ | |
| if (widget.reverse && hasMoreThanOneChildElement) _wrap(finalDropArea, widget.children.length, constraints), | |
| if (widget.header != null) widget.header, | |
| for (int i = 0; i < widget.children.length; i += 1) _wrap(widget.children[i], i, constraints), | |
| if (!widget.reverse && hasMoreThanOneChildElement) | |
| _wrap(finalDropArea, widget.children.length, constraints), | |
| ], | |
| ), | |
| ); | |
| }); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment