Flutter - 优雅地实现一个可拖动排序的Wrap

824 阅读3分钟

flutter_sortable_wrap 主要用到了这三个组件 Wrap, Draggable, DragTarget 来实现

核心思路:

  • 静止状态时,所有 children 都是 Draggable,因此此时每个 child 都能拖动
  • 拖动状态时,所有 children 都是 DragTarget,因此此时每个 child 都能被击中

先看效果:

Kapture 2023-03-22 at 17.59.11.gif

主要原理:

1. Draggable 组件提供了 onDragStartedonDragEnd 等事件回调能力。

因此,静止状态 变到 拖动状态 的时机就选在 onDragStarted 里,需要把所有 Wrapchildren 都变了,那么就需要做一次 setState:

/// A. Return the widget in dragging mode [拖动状态]
if (isDragging) {
  if (isDraggingMe(element)) {
    return IgnorePointer(ignoring: true, child: Opacity(opacity: 0.2, child: element.view));
  } else {
    return SortableItem(key: ValueKey(index), element: element, onEventHit: eventDoRollingInDragging);
  }
}

/// B. Return the widget in idle mode [静止状态]
return Draggable<SortableElement>(
    ...
    onDragStarted: onDragStarted,
    ...
);

// 做一次 setState 换掉所有 [idle mode] 的 children,进入 [dragging mode]
void onDragStarted() {
    setState(() => draggingElement = element);
}

2. DragTarget 组件提供了 onWillAcceptonAccept 等被手指移到其上方被击中的事件回调能力。

因此,我们就可以在 onWillAccept 里处理动画过渡、修正位置等逻辑。

谨记,动画是花里胡哨点缀用的,数据和逻辑都不要跟动画耦合,不要依赖动画的开始或结束来更新数据和逻辑

源码分析:

flutter_sortable_wrap Github 的代码量不多,大家对照着源码,这里简单讲一下:

  1. SortableWrapWrap 组件的封装,此组件是对外使用的
  2. SortableItemDragTarget 组件的封装,用来被击中及做动画的
  3. SortableElement 是数据封装类,每个 SortableItem 绑定一个 SortableElement 数据,它包含了位置索引 originalIndexpreservedIndex 等数据

那么,在拖动过程中,当 SortableElement 组件被击中,动画及数据就在 onWillAccept -> onEventHit -> eventDoRollingInDragging 方法里处理了,也即处理被击中的 Widget 的动画是向左还是向右移动,若是跨行拖动时就克隆多一个 幽灵Ghost Widget 来左入/右入,核心代码:

// 当 DragTarget 组件被碰中的事件回调,处理位置数据并做动画效果
/// Events
void eventDoRollingInDragging(SortableItemState beHitItemState, SortableElement holdingElement) {
  assert(draggingElement != null, 'Dragging status is a mess now, please check it out.');
  assert(draggingElement == holdingElement, 'Got a different dragging view, please check it out.');

  SortableElement dragging = draggingElement!;
  SortableElement element = beHitItemState.widget.element;

  int toIndex = animationElements.indexOf(element);
  int draggingIndex = animationElements.indexOf(dragging);
  // 是否在同一行拖动,如果不是,就是跨行拖动了
  bool isDraggingInSameRow = toIndex ~/ elementCountPerRow == draggingIndex ~/ elementCountPerRow;
    
  // 判断是拖动是向左还是向右: 若拖动向右,底下被碰中的 DragTarget 组件就要做向左动画,反之亦然
  /// To lower index means user dragging to left, user dragging to left or top, the hit target should animate to right
  bool isDraggingToLowerIndex = toIndex < draggingIndex;
  int i = isDraggingToLowerIndex ? draggingIndex - 1 : draggingIndex + 1;
  for (; isDraggingToLowerIndex ? i >= toIndex : i <= toIndex; isDraggingToLowerIndex ? i-- : i++) {
    SortableElement e = animationElements[i];

    /// Swap the index in cached data
    int sourceIndex = i;
    int destinationIndex = isDraggingToLowerIndex ? i + 1 : i - 1;
    // 这里已经是更新数据,别等动画的结束,也即就算没有动画,数据也是正确的
    animationElements.swap(sourceIndex, destinationIndex);

    // 开始做动画
    /// Handle animation by corresponding item's state
    SortableItemState itemState = e.state;
    itemState.sourceIndex = sourceIndex;
    itemState.destinationIndex = destinationIndex;
    itemState.startAnimation(isDraggingInSameRow);
  }

  // 既然数据变了,当然就要 setState 了,不要等动画结束才做
  /// Make sure you see the right thing on the right position
  setState(() {});
}

OK,有什么不明白的欢迎评论~