reorderable_list.dart 22.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math';

7
import 'package:flutter/widgets.dart';
8
import 'package:flutter/rendering.dart';
9

10
import 'debug.dart';
11
import 'material.dart';
12
import 'material_localizations.dart';
13

Ian Hickson's avatar
Ian Hickson committed
14 15 16
// Examples can assume:
// class MyDataObject { }

17 18 19 20 21 22
/// 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].
///
Ian Hickson's avatar
Ian Hickson committed
23 24 25
/// 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
26 27
/// [newIndex].
///
28 29
/// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE}
///
30
/// {@tool snippet}
31 32 33 34
///
/// ```dart
/// final List<MyDataObject> backingList = <MyDataObject>[/* ... */];
///
Ian Hickson's avatar
Ian Hickson committed
35
/// void handleReorder(int oldIndex, int newIndex) {
36 37 38 39 40 41 42 43
///   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);
/// }
/// ```
44
/// {@end-tool}
Ian Hickson's avatar
Ian Hickson committed
45
typedef ReorderCallback = void Function(int oldIndex, int newIndex);
46 47 48 49 50 51 52 53 54

/// A list whose items the user can interactively reorder by dragging.
///
/// This class is appropriate for views with a small number of
/// children because constructing the [List] requires doing work for every
/// child that could possibly be displayed in the list view instead of just
/// those children that are actually visible.
///
/// All [children] must have a key.
55 56
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE}
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
///
/// 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
/// List<String> _list = List.generate(5, (i) => "${i}");
///
/// Widget build(BuildContext context){
///   return ReorderableListView(
///     padding : const EdgeInsets.symmetric(horizontal:40),
///     children:[
///       for(var i=0 ; i<_list.length ; i++)
///         ListTile(
///              key:Key('$i'),
///              title: Text(_list[i]),
///         ),
///     ],
///     onReorder: (oldIndex, newIndex){
///       setState((){
///         if(oldIndex < newIndex){
///           newIndex-=1;
///         }
///         final element = _list.removeAt(oldIndex);
///         _list.insert(newIndex, element);
///       });
///     },
///   );
/// }
///
/// ```
///
///{@end-tool}
///
93 94 95 96
class ReorderableListView extends StatefulWidget {

  /// Creates a reorderable list.
  ReorderableListView({
97
    Key? key,
98
    this.header,
99 100
    required this.children,
    required this.onReorder,
101
    this.scrollController,
102 103
    this.scrollDirection = Axis.vertical,
    this.padding,
104
    this.reverse = false,
105 106 107 108 109 110
  }) : 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.',
111 112
       ),
       super(key: key);
113 114 115 116

  /// A non-reorderable header widget to show before the list.
  ///
  /// If null, no header will appear before the list.
117
  final Widget? header;
118 119 120 121 122 123 124 125 126

  /// 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;

127 128 129 130 131 132 133
  /// 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]).
134
  final ScrollController? scrollController;
135

136
  /// The amount of space by which to inset the [children].
137
  final EdgeInsets? padding;
138

139 140 141 142 143 144 145 146 147 148 149 150 151 152
  /// 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;

153 154 155 156 157
  /// 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.
Ian Hickson's avatar
Ian Hickson committed
158
  final ReorderCallback onReorder;
159 160

  @override
161
  _ReorderableListViewState createState() => _ReorderableListViewState();
162 163 164 165 166 167 168 169 170 171 172 173 174
}

// This top-level state manages an Overlay that contains the list and
// also any Draggables it creates.
//
// _ReorderableListContent manages the list itself and reorder operations.
//
// The Overlay doesn't properly keep state by building new overlay entries,
// and so we cache a single OverlayEntry for use as the list layer.
// That overlay entry then builds a _ReorderableListContent which may
// insert Draggables into the Overlay above itself.
class _ReorderableListViewState extends State<ReorderableListView> {
  // We use an inner overlay so that the dragging list item doesn't draw outside of the list itself.
175
  final GlobalKey _overlayKey = GlobalKey(debugLabel: '$ReorderableListView overlay key');
176 177

  // This entry contains the scrolling list itself.
178
  late OverlayEntry _listOverlayEntry;
179 180 181 182

  @override
  void initState() {
    super.initState();
183
    _listOverlayEntry = OverlayEntry(
184 185
      opaque: true,
      builder: (BuildContext context) {
186
        return _ReorderableListContent(
187 188
          header: widget.header,
          children: widget.children,
189
          scrollController: widget.scrollController,
190 191 192
          scrollDirection: widget.scrollDirection,
          onReorder: widget.onReorder,
          padding: widget.padding,
193
          reverse: widget.reverse,
194 195 196 197 198 199 200
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
201
    return Overlay(
202 203 204 205 206 207 208 209 210 211 212
      key: _overlayKey,
      initialEntries: <OverlayEntry>[
        _listOverlayEntry,
    ]);
  }
}

// This widget is responsible for the inside of the Overlay in the
// ReorderableListView.
class _ReorderableListContent extends StatefulWidget {
  const _ReorderableListContent({
213 214 215 216 217 218 219
    required this.header,
    required this.children,
    required this.scrollController,
    required this.scrollDirection,
    required this.padding,
    required this.onReorder,
    required this.reverse,
220 221
  });

222
  final Widget? header;
223
  final List<Widget> children;
224
  final ScrollController? scrollController;
225
  final Axis scrollDirection;
226
  final EdgeInsets? padding;
Ian Hickson's avatar
Ian Hickson committed
227
  final ReorderCallback onReorder;
228
  final bool reverse;
229 230

  @override
231
  _ReorderableListContentState createState() => _ReorderableListContentState();
232 233
}

234
class _ReorderableListContentState extends State<_ReorderableListContent> with TickerProviderStateMixin<_ReorderableListContent> {
235

236 237 238 239 240 241 242 243
  // 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;

  // How long an animation to reorder an element in the list takes.
244
  static const Duration _reorderAnimationDuration = Duration(milliseconds: 200);
245 246 247

  // How long an animation to scroll to an off-screen element in the
  // list takes.
248
  static const Duration _scrollAnimationDuration = Duration(milliseconds: 200);
249 250

  // Controls scrolls and measures scroll progress.
251
  late ScrollController _scrollController;
252 253

  // This controls the entrance of the dragging widget into a new place.
254
  late AnimationController _entranceController;
255 256 257

  // This controls the 'ghost' of the dragging widget, which is left behind
  // where the widget used to be.
258
  late AnimationController _ghostController;
259 260 261 262

  // The member of widget.children currently being dragged.
  //
  // Null if no drag is underway.
263
  Key? _dragging;
264 265

  // The last computed size of the feedback widget being dragged.
266
  Size? _draggingFeedbackSize;
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287

  // 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;
    }
288
    final double dropAreaWithoutMargin;
289 290
    switch (widget.scrollDirection) {
      case Axis.horizontal:
291
        dropAreaWithoutMargin = _draggingFeedbackSize!.width;
292 293
        break;
      case Axis.vertical:
294
        dropAreaWithoutMargin = _draggingFeedbackSize!.height;
295 296
        break;
    }
297
    return dropAreaWithoutMargin;
298 299 300 301 302
  }

  @override
  void initState() {
    super.initState();
303 304
    _entranceController = AnimationController(vsync: this, duration: _reorderAnimationDuration);
    _ghostController = AnimationController(vsync: this, duration: _reorderAnimationDuration);
305 306 307
    _entranceController.addStatusListener(_onEntranceStatusChanged);
  }

308 309
  @override
  void didChangeDependencies() {
310
    _scrollController = widget.scrollController ?? PrimaryScrollController.of(context) ?? ScrollController();
311 312 313
    super.didChangeDependencies();
  }

314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346
  @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;
347 348
    final RenderObject contextObject = context.findRenderObject()!;
    final RenderAbstractViewport viewport = RenderAbstractViewport.of(contextObject)!;
349 350 351 352 353 354 355 356 357 358 359 360 361 362 363
    assert(viewport != null);
    // If and only if the current scroll offset falls in-between the offsets
    // necessary to reveal the selected context at the top or bottom of the
    // screen, then it is already on-screen.
    final double margin = _dropAreaExtent;
    final double scrollOffset = _scrollController.offset;
    final double topOffset = max(
      _scrollController.position.minScrollExtent,
      viewport.getOffsetToReveal(contextObject, 0.0).offset - margin,
    );
    final double bottomOffset = min(
      _scrollController.position.maxScrollExtent,
      viewport.getOffsetToReveal(contextObject, 1.0).offset + margin,
    );
    final bool onScreen = scrollOffset <= topOffset && scrollOffset >= bottomOffset;
364

365 366 367 368 369 370 371
    // 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,
372
      ).then((void value) {
373 374 375 376 377 378 379 380 381
        setState(() {
          _scrolling = false;
        });
      });
    }
  }

  // Wraps children in Row or Column, so that the children flow in
  // the widget's scrollDirection.
382
  Widget _buildContainerForScrollDirection({ required List<Widget> children }) {
383 384
    switch (widget.scrollDirection) {
      case Axis.horizontal:
385
        return Row(children: children);
386
      case Axis.vertical:
387
        return Column(children: children);
388 389 390 391 392 393 394
    }
  }

  // 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);
395
    final _ReorderableListViewChildGlobalKey keyIndexGlobalKey = _ReorderableListViewChildGlobalKey(toWrap.key!, this);
396 397 398 399 400 401 402 403 404 405 406 407
    // 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;
408
        _draggingFeedbackSize = keyIndexGlobalKey.currentContext!.size;
409 410 411
      });
    }

412 413
    // Places the value from startIndex one space before the element at endIndex.
    void reorder(int startIndex, int endIndex) {
414
      setState(() {
415 416
        if (startIndex != endIndex)
          widget.onReorder(startIndex, endIndex);
417
        // Animates leftover space in the drop area closed.
418 419
        _ghostController.reverse(from: 0);
        _entranceController.reverse(from: 0);
420 421 422 423
        _dragging = null;
      });
    }

424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440
    // 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);

441
      final MaterialLocalizations localizations = MaterialLocalizations.of(context);
442 443 444

      // If the item can move to before its current position in the list.
      if (index > 0) {
445
        semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToStart)] = moveToStart;
446 447 448 449 450 451
        String reorderItemBefore = localizations.reorderItemUp;
        if (widget.scrollDirection == Axis.horizontal) {
          reorderItemBefore = Directionality.of(context) == TextDirection.ltr
              ? localizations.reorderItemLeft
              : localizations.reorderItemRight;
        }
452
        semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = moveBefore;
453 454 455 456 457 458 459 460 461 462
      }

      // If the item can move to after its current position in the list.
      if (index < widget.children.length - 1) {
        String reorderItemAfter = localizations.reorderItemDown;
        if (widget.scrollDirection == Axis.horizontal) {
          reorderItemAfter = Directionality.of(context) == TextDirection.ltr
              ? localizations.reorderItemRight
              : localizations.reorderItemLeft;
        }
463 464
        semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = moveAfter;
        semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToEnd)] = moveToEnd;
465 466 467 468 469 470 471 472
      }

      // 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.
473
      return KeyedSubtree(
474
        key: keyIndexGlobalKey,
475 476
        child: MergeSemantics(
          child: Semantics(
477 478 479 480 481
            customSemanticsActions: semanticsActions,
            child: toWrap,
          ),
        ),
      );
482 483
    }

484
    Widget buildDragTarget(BuildContext context, List<Key?> acceptedCandidates, List<dynamic> rejectedCandidates) {
485 486
      final Widget toWrapWithSemantics = wrapWithSemantics();

487 488
      // We build the draggable inside of a layout builder so that we can
      // constrain the size of the feedback dragging widget.
489
      Widget child = LongPressDraggable<Key>(
490 491 492 493
        maxSimultaneousDrags: 1,
        axis: widget.scrollDirection,
        data: toWrap.key,
        ignoringFeedbackSemantics: false,
494
        feedback: Container(
495 496 497
          alignment: Alignment.topLeft,
          // These constraints will limit the cross axis of the drawn widget.
          constraints: constraints,
498
          child: Material(
499
            elevation: 6.0,
500
            child: toWrapWithSemantics,
501 502
          ),
        ),
503
        child: _dragging == toWrap.key ? const SizedBox() : toWrapWithSemantics,
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524
        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.
525
      final Widget spacing;
526 527
      switch (widget.scrollDirection) {
        case Axis.horizontal:
528
          spacing = SizedBox(width: _dropAreaExtent);
529 530
          break;
        case Axis.vertical:
531
          spacing = SizedBox(height: _dropAreaExtent);
532 533 534 535 536 537 538
          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>[
539
          SizeTransition(
540 541
            sizeFactor: _entranceController,
            axis: widget.scrollDirection,
542
            child: spacing,
543 544 545 546 547 548 549 550
          ),
          child,
        ]);
      }
      // We close up the space under where the dragging widget previously was
      // with the ghostController animation.
      if (_ghostIndex == index) {
        return _buildContainerForScrollDirection(children: <Widget>[
551
          SizeTransition(
552 553 554 555 556 557 558 559 560 561 562
            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.
563 564
    return Builder(builder: (BuildContext context) {
      return DragTarget<Key>(
565
        builder: buildDragTarget,
566
        onWillAccept: (Key? toAccept) {
567 568 569 570 571 572 573 574
          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;
        },
575
        onAccept: (Key accepted) { },
576
        onLeave: (Object? leaving) { },
577 578
      );
    });
579 580 581 582
  }

  @override
  Widget build(BuildContext context) {
583
    assert(debugCheckHasMaterialLocalizations(context));
584
    // We use the layout builder to constrain the cross-axis size of dragging child widgets.
585
    return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
586
      const Key endWidgetKey = Key('DraggableList - End Widget');
587
      final Widget finalDropArea;
588 589 590 591 592 593
      switch (widget.scrollDirection) {
        case Axis.horizontal:
          finalDropArea = SizedBox(
            key: endWidgetKey,
            width: _defaultDropAreaExtent,
            height: constraints.maxHeight,
594
          );
595 596 597 598 599 600
          break;
        case Axis.vertical:
          finalDropArea = SizedBox(
            key: endWidgetKey,
            height: _defaultDropAreaExtent,
            width: constraints.maxWidth,
601
          );
602 603
          break;
      }
604 605 606 607 608

      // If the reorderable list only has one child element, reordering
      // should not be allowed.
      final bool hasMoreThanOneChildElement = widget.children.length > 1;

609 610 611 612 613
      return SingleChildScrollView(
        scrollDirection: widget.scrollDirection,
        padding: widget.padding,
        controller: _scrollController,
        reverse: widget.reverse,
614 615
        child: _buildContainerForScrollDirection(
          children: <Widget>[
616
            if (widget.reverse && hasMoreThanOneChildElement) _wrap(finalDropArea, widget.children.length, constraints),
617
            if (widget.header != null) widget.header!,
618
            for (int i = 0; i < widget.children.length; i += 1) _wrap(widget.children[i], i, constraints),
619
            if (!widget.reverse && hasMoreThanOneChildElement) _wrap(finalDropArea, widget.children.length, constraints),
620 621
          ],
        ),
622
      );
623 624 625
    });
  }
}
626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652

// 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 _ReorderableListContentState 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);
}