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

5 6
// @dart = 2.8

7 8
import 'dart:math';

9
import 'package:flutter/widgets.dart';
10
import 'package:flutter/rendering.dart';
11

12
import 'debug.dart';
13
import 'material.dart';
14
import 'material_localizations.dart';
15

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

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

/// 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.
57 58
///
/// {@youtube 560 315 https://www.youtube.com/watch?v=3fB1mxOsqJE}
59 60 61 62
class ReorderableListView extends StatefulWidget {

  /// Creates a reorderable list.
  ReorderableListView({
63
    Key key,
64 65 66
    this.header,
    @required this.children,
    @required this.onReorder,
67
    this.scrollController,
68 69
    this.scrollDirection = Axis.vertical,
    this.padding,
70
    this.reverse = false,
71 72 73 74 75 76
  }) : 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.',
77 78
       ),
       super(key: key);
79 80 81 82 83 84 85 86 87 88 89 90 91 92

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

93 94 95 96 97 98 99 100 101
  /// 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;

102 103 104
  /// The amount of space by which to inset the [children].
  final EdgeInsets padding;

105 106 107 108 109 110 111 112 113 114 115 116 117 118
  /// 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;

119 120 121 122 123
  /// 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
124
  final ReorderCallback onReorder;
125 126

  @override
127
  _ReorderableListViewState createState() => _ReorderableListViewState();
128 129 130 131 132 133 134 135 136 137 138 139 140
}

// 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.
141
  final GlobalKey _overlayKey = GlobalKey(debugLabel: '$ReorderableListView overlay key');
142 143 144 145 146 147 148

  // This entry contains the scrolling list itself.
  OverlayEntry _listOverlayEntry;

  @override
  void initState() {
    super.initState();
149
    _listOverlayEntry = OverlayEntry(
150 151
      opaque: true,
      builder: (BuildContext context) {
152
        return _ReorderableListContent(
153 154
          header: widget.header,
          children: widget.children,
155
          scrollController: widget.scrollController,
156 157 158
          scrollDirection: widget.scrollDirection,
          onReorder: widget.onReorder,
          padding: widget.padding,
159
          reverse: widget.reverse,
160 161 162 163 164 165 166
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
167
    return Overlay(
168 169 170 171 172 173 174 175 176 177 178 179 180
      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,
181
    @required this.scrollController,
182 183 184
    @required this.scrollDirection,
    @required this.padding,
    @required this.onReorder,
185
    @required this.reverse,
186 187 188 189
  });

  final Widget header;
  final List<Widget> children;
190
  final ScrollController scrollController;
191 192
  final Axis scrollDirection;
  final EdgeInsets padding;
Ian Hickson's avatar
Ian Hickson committed
193
  final ReorderCallback onReorder;
194
  final bool reverse;
195 196

  @override
197
  _ReorderableListContentState createState() => _ReorderableListContentState();
198 199
}

200
class _ReorderableListContentState extends State<_ReorderableListContent> with TickerProviderStateMixin<_ReorderableListContent> {
201

202 203 204 205 206 207 208 209
  // 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.
210
  static const Duration _reorderAnimationDuration = Duration(milliseconds: 200);
211 212 213

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

  // Controls scrolls and measures scroll progress.
217
  ScrollController _scrollController;
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263

  // 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;
    }
264
    return dropAreaWithoutMargin;
265 266 267 268 269
  }

  @override
  void initState() {
    super.initState();
270 271
    _entranceController = AnimationController(vsync: this, duration: _reorderAnimationDuration);
    _ghostController = AnimationController(vsync: this, duration: _reorderAnimationDuration);
272 273 274
    _entranceController.addStatusListener(_onEntranceStatusChanged);
  }

275 276
  @override
  void didChangeDependencies() {
277
    _scrollController = widget.scrollController ?? PrimaryScrollController.of(context) ?? ScrollController();
278 279 280
    super.didChangeDependencies();
  }

281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330
  @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 RenderObject contextObject = context.findRenderObject();
    final RenderAbstractViewport 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 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;
331

332 333 334 335 336 337 338
    // 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,
339
      ).then((void value) {
340 341 342 343 344 345 346 347 348
        setState(() {
          _scrolling = false;
        });
      });
    }
  }

  // Wraps children in Row or Column, so that the children flow in
  // the widget's scrollDirection.
349
  Widget _buildContainerForScrollDirection({ List<Widget> children }) {
350 351
    switch (widget.scrollDirection) {
      case Axis.horizontal:
352
        return Row(children: children);
353 354
      case Axis.vertical:
      default:
355
        return Column(children: children);
356 357 358 359 360 361 362
    }
  }

  // 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);
363
    final _ReorderableListViewChildGlobalKey keyIndexGlobalKey = _ReorderableListViewChildGlobalKey(toWrap.key, this);
364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379
    // 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;
      });
    }

380 381
    // Places the value from startIndex one space before the element at endIndex.
    void reorder(int startIndex, int endIndex) {
382
      setState(() {
383 384
        if (startIndex != endIndex)
          widget.onReorder(startIndex, endIndex);
385 386 387 388 389 390 391
        // Animates leftover space in the drop area closed.
        _ghostController.reverse(from: 0.1);
        _entranceController.reverse(from: 0.1);
        _dragging = null;
      });
    }

392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412
    // 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) {
413
        semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToStart)] = moveToStart;
414 415 416 417 418 419
        String reorderItemBefore = localizations.reorderItemUp;
        if (widget.scrollDirection == Axis.horizontal) {
          reorderItemBefore = Directionality.of(context) == TextDirection.ltr
              ? localizations.reorderItemLeft
              : localizations.reorderItemRight;
        }
420
        semanticsActions[CustomSemanticsAction(label: reorderItemBefore)] = moveBefore;
421 422 423 424 425 426 427 428 429 430
      }

      // 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;
        }
431 432
        semanticsActions[CustomSemanticsAction(label: reorderItemAfter)] = moveAfter;
        semanticsActions[CustomSemanticsAction(label: localizations.reorderItemToEnd)] = moveToEnd;
433 434 435 436 437 438 439 440
      }

      // 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.
441
      return KeyedSubtree(
442
        key: keyIndexGlobalKey,
443 444
        child: MergeSemantics(
          child: Semantics(
445 446 447 448 449
            customSemanticsActions: semanticsActions,
            child: toWrap,
          ),
        ),
      );
450 451
    }

452
    Widget buildDragTarget(BuildContext context, List<Key> acceptedCandidates, List<dynamic> rejectedCandidates) {
453 454
      final Widget toWrapWithSemantics = wrapWithSemantics();

455 456
      // We build the draggable inside of a layout builder so that we can
      // constrain the size of the feedback dragging widget.
457
      Widget child = LongPressDraggable<Key>(
458 459 460 461
        maxSimultaneousDrags: 1,
        axis: widget.scrollDirection,
        data: toWrap.key,
        ignoringFeedbackSemantics: false,
462
        feedback: Container(
463 464 465
          alignment: Alignment.topLeft,
          // These constraints will limit the cross axis of the drawn widget.
          constraints: constraints,
466
          child: Material(
467
            elevation: 6.0,
468
            child: toWrapWithSemantics,
469 470
          ),
        ),
471
        child: _dragging == toWrap.key ? const SizedBox() : toWrapWithSemantics,
472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495
        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:
496
          spacing = SizedBox(width: _dropAreaExtent);
497 498 499
          break;
        case Axis.vertical:
        default:
500
          spacing = SizedBox(height: _dropAreaExtent);
501 502 503 504 505 506 507
          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>[
508
          SizeTransition(
509 510
            sizeFactor: _entranceController,
            axis: widget.scrollDirection,
511
            child: spacing,
512 513 514 515 516 517 518 519
          ),
          child,
        ]);
      }
      // We close up the space under where the dragging widget previously was
      // with the ghostController animation.
      if (_ghostIndex == index) {
        return _buildContainerForScrollDirection(children: <Widget>[
520
          SizeTransition(
521 522 523 524 525 526 527 528 529 530 531
            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.
532 533
    return Builder(builder: (BuildContext context) {
      return DragTarget<Key>(
534 535 536 537 538 539 540 541 542 543
        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;
        },
544
        onAccept: (Key accepted) { },
545
        onLeave: (Object leaving) { },
546 547
      );
    });
548 549 550 551
  }

  @override
  Widget build(BuildContext context) {
552
    assert(debugCheckHasMaterialLocalizations(context));
553
    // We use the layout builder to constrain the cross-axis size of dragging child widgets.
554
    return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
555 556 557 558 559 560 561 562
      const Key endWidgetKey = Key('DraggableList - End Widget');
      Widget finalDropArea;
      switch (widget.scrollDirection) {
        case Axis.horizontal:
          finalDropArea = SizedBox(
            key: endWidgetKey,
            width: _defaultDropAreaExtent,
            height: constraints.maxHeight,
563
          );
564 565 566 567 568 569 570
          break;
        case Axis.vertical:
        default:
          finalDropArea = SizedBox(
            key: endWidgetKey,
            height: _defaultDropAreaExtent,
            width: constraints.maxWidth,
571
          );
572 573
          break;
      }
574 575 576 577 578

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

579 580 581 582 583
      return SingleChildScrollView(
        scrollDirection: widget.scrollDirection,
        padding: widget.padding,
        controller: _scrollController,
        reverse: widget.reverse,
584 585
        child: _buildContainerForScrollDirection(
          children: <Widget>[
586
            if (widget.reverse && hasMoreThanOneChildElement) _wrap(finalDropArea, widget.children.length, constraints),
587 588
            if (widget.header != null) widget.header,
            for (int i = 0; i < widget.children.length; i += 1) _wrap(widget.children[i], i, constraints),
589
            if (!widget.reverse && hasMoreThanOneChildElement) _wrap(finalDropArea, widget.children.length, constraints),
590 591
          ],
        ),
592
      );
593 594 595
    });
  }
}
596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622

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