图文详解 - 如何开发 Draggable GridView 组件

240 阅读15分钟

DragGrid.gif

前言

UI 布局 中,网格布局是很常见的一种方式。它是一种将 页面/区域 划分为等大小网格的方法,每个元素都位于其所属的行和列之中。这种布局简单易用,适用于需要清晰展示信息的项目。

Flutter App 中,我们通常使用 GridView 来实现一个网格布局。但美中不足的是,它并不支持每个 Grid Item 元素拖动,也就无法手动拖动调整排序。但这类需求比较常见,所以我开发了 DragGrid 组件 (基于 LongPressDraggable + GridView 的实现)。

接下来,本篇文章主要对 DragGrid 组件逐一讲解说明,希望大家都有所收获。

  • 如何理解 GridView 布局算法 (SliverGridDelegateWithFixedCrossAxisCount)
  • 如何理解 GridView 布局算法 (SliverGridDelegateWithMaxCrossAxisExtent)
  • 如何获取 GridView 整体布局大小
  • 如何计算 GridView 每个Item大小
  • 如何计算 DragItem 拖动时的位置
  • 如何实现 DragItem 拖动时的动画
  • 如何实现 GridController

官方组件

在开发 DragGrid 组件中,我们用到了 Flutter 一些官方组件,为了更容易理解 DragGrid 组件的实现,我们对官方组件进行简单说明。

  • GridView (网格组件)
  • FadeTransition (渐入渐出)
  • SlideTransition (滑动动画)
  • LongPressDraggable (长按拖动)

GridView

网格组件 GridView 作为 DragGrid 组件中的基础组件,理解它是十分重要的。因为它涉及了布局、动画、定位等各方面的计算。那如何理解它,我们可以从它的如下 API 去理解

  • scrollDirection (布局方向)
    • Axis.vertical (主轴为竖直方向,默认)
    • Axis.horizontal (主轴为横向方向)
  • gridDelegate (布局算法, 必须指定)
    • SliverGridDelegateWithFixedCrossAxisCount (横轴固定数量子元素算法)
    • SliverGridDelegateWithMaxCrossAxisExtent (横轴子元素最大长度算法)
  1. 当知道横轴子元素的数量是固定时,使用 SliverGridDelegateWithFixedCrossAxisCount

      const sliverGridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 4, // 横轴方向子元素的数量
        mainAxisSpacing: 15, // 主轴方向两子元素之间间距为 15
        crossAxisSpacing: 10, // 横轴方向两子元素之间间距为 10
        childAspectRatio: 1, // 子元素横轴方向大小 ÷ 子元素主轴方向大小
      );
    
    • Axis.vertical 图示

    GridView (Axis.vertical).jpg

    • Axis.horizontal 图示

    GridView (Axis.horizontal).jpg

  2. 当知道横轴子元素最大宽度时,则使用 SliverGridDelegateWithMaxCrossAxisExtent

      const sliverGridDelegate = SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 84.0, // 子元素横轴大小不超过 84.0
        mainAxisSpacing: 15, // 主轴方向两子元素之间间距为 15
        crossAxisSpacing: 10, // 横轴方向两子元素之间间距为 10
        childAspectRatio: 1, // 子元素横轴方向大小 ÷ 子元素主轴方向大小
      );
    

    SliverGridDelegateWithFixedCrossAxisCount 相比,它保持了子元素的大小,尽可能不会受设备屏幕大小的影响。那它 GridViewGrid Item 的大小是如何计算呢?其实只要计算出横轴子元素的数量,后面就跟 SliverGridDelegateWithFixedCrossAxisCount 完全一致。那如何计算 crossAxisCount 数量,可参考图示:

    • Axis.vertical GridView (Axis.vertical crossAxisCount).jpg

        final itemWidth = maxCrossAxisExtent + crossAxisSpacing;
        final totalWidth = GridView.width + crossAxisSpacing;
        crossAxisCount = (totalWidth / itemWidth).ceil();
      
    • Axis.horizontal GridView (Axis.horizontal crossAxisCount).jpg

        final itemHeight = maxCrossAxisExtent + crossAxisSpacing;
        final totalHeight = GridView.height + crossAxisSpacing;
        crossAxisCount = (totalHeight / itemHeight).ceil();
      
  3. 在上述两个小节中,我们图示讲解 GridView 的布局算法,但它们都有一个前提,就是已经知道了横轴方向的尺寸,那我们如何在 Flutter 中获取它的尺寸,我们可以借助于如下这个API,在绘制帧的 postFrame 回调函数中获取。具体如下:

      WidgetsBinding.instance.addPostFrameCallback((callback) {
        renderBox = (context.findRenderObject() as RenderBox);
        offset = renderBox.localToGlobal(Offset.zero);
        viewSize = context.size!; 
      });
    
    • offset: GridView 相对于可视窗口的偏移量 (类似 Web 端的 event.clientX/clientY)
    • viewSize: GridView 尺寸大小 (不过主轴方向大小, 还是通过计算比较准确)

FadeTransition

渐入渐出的动画,比较简单。这里主要是当我们通过 gridController 新增一个 GridItem 时,防止新老位置的 GridItem 重叠而提供的渐入渐出的一个效果。

   // 动画 opacity: 0 -> 1
   Tween<double> tween = Tween(
     begin: 0.0,
     end: 1.0,
   );

   final fadeAnimation = tween.animate(
     CurvedAnimation(
       parent: AnimationController(),
       curve: Curves.easeOut,
     ),
   );
   
   // 使用 FadeTransition
   return FadeTransition(
     opacity: fadeAnimation, 
     child: GridCustomItem,
   );

SlideTransition

滑动过渡动画,这里动画主要是当我们拖动 GridItem 占据另一个 GridItem 时,那个被占据的 GridItem 进行滑动过渡。

   // Grid Item 从右往左滑动
   Tween<Offset> tween = Tween(
     begin: const Offset(1.0, 0.0),
     end: const Offset(0.0, 0.0),
   );

   final slideTransition = tween.animate(
     CurvedAnimation(
       parent: AnimationController(),
       curve: Curves.easeOut,
     ),
   );
   
   return SlideTransition(
     position: slideAnimation,
     child: GridCustomItem,
   );

这里我们对 Offset 偏移量,简单说明下,它基于 Grid Item 自身大小为单位,图示如下:

  • mainAxisSpacing = 0crossAxisSpacing = 0

GridView (Axis.vertical SlideTransition).jpg

  • mainAxisSpacing != 0crossAxisSpacing != 0

GridView (Axis.vertical SlideTransition2).jpg

LongPressDraggable

原先的 GridView 并不能拖放 Grid Item,这里我们选择 LongPressDraggable 组件提供拖放 Grid Item 的相关能力。

  LongPressDraggable(
    data: index, // 与拖拽项关联的数据
    child: child, // 当拖拽项未被拖拽时显示的 widget
    feedback: feedback, // 用户拖拽时显示的反馈 widget
    dragAnchorStrategy: (draggable, context, position) {
      // 参数表示 feedback 视图的展示方式
      return Offset(
        gridItem.width / 2 + 16.0,
        gridItem.height / 2 + 16.0,
      );
    }, 
    onDragStarted: () {
      // 开始拖动
    },
    onDragUpdate: (details) {
      // 正在拖动中
      
      // 定位信息
      details.globalPosition.dx // (相当于 Web 端的 Mouse 事件中 clientX)
      details.globalPosition.dy // (相当于 Web 端的 Mouse 事件中 clientY)
    },
    onDragEnd: (details) {
      // 结束拖动
      
      // 定位信息 (details.offset.dx/dy + dragAnchorStrategy offset.dx/dy)
      details.offset.dx + (gridItem.width / 2 + 16.0) // (== details.globalPosition.dx)
      details.offset.dy + (gridItem.height / 2 + 16.0) // (== details.globalPosition.dy)
    },
  );

有关 dragAnchorStrategy 图示说明

GridView (dragAnchorStrategy).jpg


DragGrid 组件

在之前的章节中,我们对 GridViewSlideTransitionLongPressDraggable 等官方组件分别进行了讲解和说明,也有了一定的理解。接下来我们就逐步开发一个 DragGrid 组件。

DragGrid 配置项

DragGrid 组件是由 GridView + SlideTransition + LongPressDraggable 封装而成,我们可以梳理并拟定组件所需的配置项:

    /// 定义 sortChanger 类型
    typedef SortChanger<T> = void Function(
      List<T> list,
    );

    /// 定义 GridView itemBuilder 类型
    typedef ItemBuilder<T> = Widget Function(
      BuildContext context,
      T item,
      int index,
    );

    /// 定义 itemListChanger 类型
    typedef ItemListChanger<T> = void Function(
      List<T> list,
    );

    /// 定义 isItemListChanged 类型
    typedef IsItemListChanged<T> = bool Function(
      List<T> newItemList,
      List<T> oldItemList,
    );
    
    /// GridController 定义 (基于 ChangeNotifier 观察者模式)
    class GridController<T> extends ChangeNotifier {
      // 将在 GridController API 定义 小节中 进行完善
      
      /// update: 用来从 DragGrid 获取最新的 itemList Api
      /// append: 手动追加新 Grid Item, 会重新渲染 DragGrid,并触发 itemListChanger
      /// remove: 手动移除某个 Grid Item, 会重新渲染 DragGrid,并触发 itemListChanger
      /// insert: 手动插入某个 Grid Item, 会重新渲染 DragGrid,并触发 itemListChanger
      /// reset: 手动重置 itemList, 会重新渲染 DragGrid,并触发 itemListChanger
      void update(List<T> itemList) {}
      void append({required T item, bool animation = true}) { /* ... */ }
      void remove({required int index, bool animation = true}) { /* ... */ }
      void insert({int index = 0, required T item, bool animation = true}) { /* ... */ }
      void reset({required List<T> itemList, bool animation = true}) { /* ... */ }
    }

    /// 定义 DragGrid 组件,设定所需配置项
    class DragGrid<T> extends StatefulWidget {
      const DragGrid({
        super.key,
        this.enable = true,
        this.padding = EdgeInsets.zero,
        this.animation = true,
        this.duration = const Duration(milliseconds: 500),
        this.shrinkWrap = true,
        this.crossCount,
        this.onDragEnd,
        this.onDragUpdate,
        this.onDragStarted,
        this.direction = Axis.vertical,
        this.scrollPhysics = const NeverScrollableScrollPhysics(),
        this.scrollController,
        this.gridController,
        this.sortChanger,
        this.itemListChanger,
        this.isItemListChanged,
        required this.sliverGridDelegate,
        required this.itemBuilder,
        required this.itemList,
      });

      /// LongPressDraggable 组件开始拖动事件
      final VoidCallback? onDragStarted;

      /// LongPressDraggable 组件拖动中事件
      final DragUpdateCallback? onDragUpdate;

      /// LongPressDraggable 组件拖动结束事件
      final DragEndCallback? onDragEnd;

      /// itemList 在拖动中排序发生更改时触发
      final SortChanger<T>? sortChanger;

      /// 拖动结束后 或 gridController 手动更新 itemList 时触发
      final ItemListChanger<T>? itemListChanger;

      /// 在外部 Wiget 变更时,是否需要重新渲染 DragGrid (用于 didUpdateWidget 中)
      final IsItemListChanged<T>? isItemListChanged;

      /// GridView 组件 GridController 配置项
      final GridController<T>? gridController;

      /// GridView 组件 ScrollController 配置项
      final ScrollController? scrollController;

      /// GridView 组件 SliverGridDelegate 配置项
      /// 1. SliverGridDelegateWithFixedCrossAxisCount
      /// 2. SliverGridDelegateWithMaxCrossAxisExtent
      final SliverGridDelegate sliverGridDelegate;

      /// GridView 组件 physics 配置项
      final ScrollPhysics scrollPhysics;

      /// GridView 组件 item builder 配置项
      final ItemBuilder<T> itemBuilder;

      /// GridView 组件 padding 配置项
      final EdgeInsets padding;

      /// 定义 SideAnimation/FadeAnimation 时长
      final Duration duration;

      /// 记录横轴方向子元素数量 (默认: 从 sliverGridDelegate 获取, 不建议手动指定)
      final int? crossCount;

      /// GridView 组件 scrollDirection (default: Axis.vertical) 配置项
      final Axis direction;

      /// 记录 Grid Items 数据
      final List<T> itemList;

      /// GridView shrinkWrap 配置项
      final bool shrinkWrap;

      /// 是否启用拖动动画
      final bool animation;

      /// 是否允许拖动
      final bool enable;

      @override
      State<DragGrid<T>> createState() => _DragGridState<T>();
    }

_DragGridState 钩子函数

_DragGridState 继承于 State 并实现 TickerProviderStateMixin (动画), 有 initStatedispose 以及 didUpdateWidget 这些钩子函数。

  • didUpdateWidget: State对象与新Widget对象关联, 检查新widget是否与旧widget不同函数
  • initState: 初始化时回调处理
  • dispose: 销毁时回调处理
class _DragGridState<T> extends State<DragGrid<T>> with TickerProviderStateMixin {
  late final onDragEnd = widget.onDragEnd;
  late final onDragUpdate = widget.onDragUpdate;
  late final onDragStarted = widget.onDragStarted;

  late final padding = widget.padding;
  late final duration = widget.duration;
  late final physics = widget.scrollPhysics;
  late final shrinkWrap = widget.shrinkWrap;
  late final itemBuilder = widget.itemBuilder;
  late final gridController = widget.gridController ?? GridController();
  late final scrollController = widget.scrollController ?? ScrollController();
  late final sliverGridDelegate = widget.sliverGridDelegate;
  late final isItemListChanged = widget.isItemListChanged;
  late final itemListChanger = widget.itemListChanger;
  late final sortChanger = widget.sortChanger;

  late List<Animation<Offset>?> slideAnimations = []; // 记录每个 GridItem slideAnimations 情况
  late List<Animation<double>?> fadeAnimations = []; // 记录每个 GridItem slideAnimations 情况
  late AnimationController animateController; // 控制 slideAnimations/fadeAnimations 动画启动和停止及其监听

  int? currentIndex; // 动画拖动中,当时所占的位置,即下标 (index)
  
  late int crossCount; // GridView 横轴数量
  double mainAxisSpacing = 0.0;
  double crossAxisSpacing = 0.0;
  double childAspectRatio = 1.0;
  double maxCrossAxisExtent = 0.0;
  Axis direction = Axis.vertical;

  late bool enable = widget.enable;
  late bool animation = widget.animation;
  late int itemCount = widget.itemList.length;
  late List<T> renderItems = [...widget.itemList]; // 记录 itemList 数据源 
  late List<T> renderCache = [...widget.itemList]; // 备份 itemList 数据源 

  late RenderBox renderBox;
  late Offset scroll = Offset.zero; // GridView 所处滚动区域的滚动量
  late Offset offset = Offset.zero; // GridView 在全局可视窗口内的偏移量
  late Size itemSize = Size.infinite; // GridView, 每个 item 大小
  late Size gridSize = Size.infinite; // GridView, 自身大小 (计算所得)
  late Size viewSize = Size.infinite; // GridView 父级 RenderBox 大小

  @override
  void initState() {
    super.initState();
    
    // GridController 同步最新 itemList
    gridController.update(renderItems);

    // 获取 SliverGridDelegateWithFixedCrossAxisCount 布局信息
    if (sliverGridDelegate is SliverGridDelegateWithFixedCrossAxisCount) {
      mainAxisSpacing = (sliverGridDelegate as dynamic).mainAxisSpacing;
      crossAxisSpacing = (sliverGridDelegate as dynamic).crossAxisSpacing;
      childAspectRatio = (sliverGridDelegate as dynamic).childAspectRatio;
      crossCount = (sliverGridDelegate as dynamic).crossAxisCount;
      crossCount = widget.crossCount ?? crossCount;
      direction = widget.direction;
    }

    // 获取 SliverGridDelegateWithMaxCrossAxisExtent 布局信息
    if (sliverGridDelegate is SliverGridDelegateWithMaxCrossAxisExtent) {
      mainAxisSpacing = (sliverGridDelegate as dynamic).mainAxisSpacing;
      crossAxisSpacing = (sliverGridDelegate as dynamic).crossAxisSpacing;
      childAspectRatio = (sliverGridDelegate as dynamic).childAspectRatio;
      maxCrossAxisExtent = (sliverGridDelegate as dynamic).maxCrossAxisExtent;
      direction = widget.direction;
    }

    // 初始化 animateController
    animateController = AnimationController(
      duration: duration,
      vsync: this,
    );

    // 监听 animation 动画完成后,重置处理
    animateController.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        slideAnimations.clear();
        fadeAnimations.clear();
        currentIndex = null;
      }
    });

    // 如果存在滚动区域,监听滚动事件,实时更新滚动量
    scrollController.addListener(() {
      scroll = Offset.zero;

      for (final position in scrollController.positions) {
        if (position.axis == Axis.vertical) {
          scroll += Offset(0, position.pixels);
        }
        if (position.axis == Axis.horizontal) {
          scroll += Offset(position.pixels, 0);
        }
      }
    });

    // WidgetsBinding.instance.addPostFrameCallback 处理
    // 在帧绘制的 postFrame 回调函数中,获取布局信息 (尺寸、偏移量)
    callOnceFrameCallback();
  }

  @override
  dispose() {
    super.dispose();
    animateController.dispose(); // 消除动画控制器
  }

  @override
  void didUpdateWidget(covariant DragGrid<T> oldWidget) {
    super.didUpdateWidget(oldWidget);

    late final newItemList = widget.itemList;
    late final oldItemList = oldWidget.itemList;
    late final isEnableChanged = oldWidget.enable != widget.enable;
    late final isLengthChanged = oldItemList.length != newItemList.length;
    late final isAnimationChanged = oldWidget.animation != widget.animation;
    late final isDirectionChanged = oldWidget.direction != widget.direction;

    late final isHasItemChanged = isItemListChanged?.call(
      newItemList,
      oldItemList,
    );

    // 比对 old Widget 和 new Widget, 是否需要渲染 DragGrid 组件
    if (isDirectionChanged == true || isAnimationChanged == true ||
        isHasItemChanged == true || isEnableChanged == true ||
        isLengthChanged == true) {
      setState(() {
        enable = widget.enable;
        animation = widget.animation;
        direction = widget.direction;
        itemCount = newItemList.length;
        renderItems = [...newItemList];
        renderCache = [...newItemList];

        currentIndex = null;
        gridController.update(renderItems); // 更新 itemList
        callOnceFrameCallback(); // 更新布局信息
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    // UI Wiget 部分后续完善
    return GridView.builder(...);
  }

  // 帧绘制后回调处理,更新布局信息
  void callOnceFrameCallback() {
    WidgetsBinding.instance.addPostFrameCallback((callback) {
      renderBox = (context.findRenderObject() as RenderBox);
      offset = renderBox.localToGlobal(Offset.zero);
      viewSize = context.size!;

      if (sliverGridDelegate is SliverGridDelegateWithMaxCrossAxisExtent) {
        if (direction == Axis.vertical) {
          final itemWidth = maxCrossAxisExtent + crossAxisSpacing;
          final totalWidth = context.size!.width + crossAxisSpacing;
          crossCount = widget.crossCount ?? (totalWidth / itemWidth).ceil();
        }

        if (direction == Axis.horizontal) {
          final itemHeight = maxCrossAxisExtent + crossAxisSpacing;
          final totalHeight = context.size!.height + crossAxisSpacing;
          crossCount = widget.crossCount ?? (totalHeight / itemHeight).ceil();
        }
      }

      // getDragGridSize: 获取 GridView 大小,将在 _DragGridState 核心逻辑 小节中说明
      gridSize = getDragGridSize(
        total: itemCount,
        viewSize: context.size!,
        crossCount: crossCount,
        childAspectRatio: childAspectRatio,
        crossAxisSpacing: crossAxisSpacing,
        mainAxisSpacing: mainAxisSpacing,
        direction: direction,
      );

      // getGridItemSize: 获取 Grid Item 大小,将在 _DragGridState 核心逻辑 小节中说明
      itemSize = getGridItemSize(
        total: itemCount,
        gridSize: gridSize,
        crossCount: crossCount,
        crossAxisSpacing: crossAxisSpacing,
        mainAxisSpacing: mainAxisSpacing,
        direction: direction,
      );

      // 获取新的布局信息后,重新渲染绘制
      setState(() {});
    });
  }
}

_DragGridState 核心逻辑

为了减少 _DragGridState 核心处理逻辑的代码量,我们提取封装了如下 API:

  • isOutbounding: Grid Item 拖动移动中,是否超出了 GridView 边界
  • getGridItemSize: 获取 GridView 每个 Item 大小尺寸
  • getDragGridSize: 获取 DragGrid/GridView 大小尺寸
  • getAnimationTargetIndex: 计算 Grid Item 拖动中,移动到哪个位置 (即下标 index)
  • getScrollControllerOffset: 计算 Grid Item 拖动中,整个 GridView 内是否滚动及其滚动量
  • getAnimationGridItemOffset: 计算 SlideAnimation 动画 begin 时的 Offset
  • createGridItemFadeAnimation: 创建 FadeAnimation 动画 (使用 tween)
  • createGridItemSlideAnimation: 创建 SlideAnimation 动画 (使用 tween)
  • startRunDragGridSlideAnimations: 计算 newItems 和 oldItems 之间的动画过渡
  1. 判断拖动中的 Grid Item 是否超出 GridView 边界

        bool isOutbounding({
          required double dx, // 实时移动偏移量(类似于 web 端 clientX)
          required double dy, // 实时移动偏移量(类似于 web 端 clientY)
          required Offset offset, // GridView Offset 偏移量
          required Offset scroll, // GridView 父层 scrollable 滚动量
          required Size itemSize,
          required Size gridSize,
        }) {
          final tx = dx + scroll.dx;
          final ty = dy + scroll.dy;
    
          // 计算左上角
          final left = tx - offset.dx - itemSize.width / 2 - 16.0;
          final top = ty - offset.dy - itemSize.height / 2 - 16.0;
    
          // 是否在边界之内
          final inx = left > -itemSize.width && left < gridSize.width;
          final iny = top > -itemSize.height && top < gridSize.height;
    
          return !inx || !iny;
        }
    
  2. 获取 DragGrid/GridView 以及 Grid Item 尺寸大小

        // 计算 GridView 自身大小
        Size getDragGridSize({
          required int total, // items 数量
          required Size viewSize,
          required double childAspectRatio,
          required double crossAxisSpacing,
          required double mainAxisSpacing,
          required int crossCount,
          required Axis direction,
        }) {
          int line = 1;
          double width = 0.0;
          double height = 0.0;
          double ctxWidth = viewSize.width;
          double ctxHeight = viewSize.height;
    
          line = (total ~/ crossCount);
          line += (total % crossCount > 0 ? 1 : 0);
    
          if (direction == Axis.vertical) {
            width = (ctxWidth - crossAxisSpacing * (crossCount - 1)) / crossCount;
            height = width / childAspectRatio * line + mainAxisSpacing * (line - 1);
            return Size(ctxWidth, height);
          }
    
          if (direction == Axis.horizontal) {
            height = (ctxHeight - crossAxisSpacing * (crossCount - 1)) / crossCount;
            width = height / childAspectRatio * line + mainAxisSpacing * (line - 1);
            return Size(width, ctxHeight);
          }
    
          return Size.zero;
        }
    
        // 计算 GridView Item 大小
        Size getGridItemSize({
          required int total, // items 数量
          required Size gridSize,
          required double crossAxisSpacing,
          required double mainAxisSpacing,
          required int crossCount,
          required Axis direction,
        }) {
          int line = 1;
    
          if (total > 0) {
            line = (total ~/ crossCount);
            line += (total % crossCount > 0 ? 1 : 0);
          }
    
          if (direction == Axis.vertical) {
            return Size(
              (gridSize.width - crossAxisSpacing * (crossCount - 1)) / crossCount,
              (gridSize.height - mainAxisSpacing * (line - 1)) / line,
            );
          }
    
          if (direction == Axis.horizontal) {
            return Size(
              (gridSize.width - mainAxisSpacing * (line - 1)) / line,
              (gridSize.height - crossAxisSpacing * (crossCount - 1)) / crossCount,
            );
          }
    
          return Size.zero;
        }
    
  3. 根据 feedback 左上角偏移量(dx/dy), 计算 移动 feedback 到哪个位置 (下标index), 图示如下: GridView (targetIndex).jpg

        int getAnimationTargetIndex({
          required double dx, // 实时移动偏移量(类似于 web 端 clientX)
          required double dy, // 实时移动偏移量(类似于 web 端 clientY)
          required int total, // items 数量
          required Size itemSize, // Grid item 大小
          required Offset offset, // GridView Offset 偏移量
          required Offset scroll, // GridView 父层 scrollable 滚动量
          required double crossAxisSpacing,
          required double mainAxisSpacing,
          required Axis direction,
          required int crossCount,
        }) {
          int topIndex = 0;
          int leftIndex = 0;
          int targetIndex = 0;
    
          double newTop = 0;
          double newLeft = 0;
          double newWidth = 0;
          double newHeight = 0;
    
          final tx = dx + scroll.dx;
          final ty = dy + scroll.dy;
          
          // 获取左上角位置
          final left = tx - offset.dx - itemSize.width / 2 - 16.0;
          final top = ty - offset.dy - itemSize.height / 2 - 16.0;
    
          if (direction == Axis.vertical) {
            newTop = top + mainAxisSpacing / 2;
            newLeft = left + crossAxisSpacing / 2;
            newWidth = itemSize.width + crossAxisSpacing;
            newHeight = itemSize.height + mainAxisSpacing;
    
            topIndex = max((newTop / newHeight).round(), 0);
            leftIndex = max((newLeft / newWidth).round(), 0);
            targetIndex = leftIndex + crossCount * topIndex;
          }
    
          if (direction == Axis.horizontal) {
            newTop = top + crossAxisSpacing / 2;
            newLeft = left + mainAxisSpacing / 2;
            newWidth = itemSize.width + mainAxisSpacing;
            newHeight = itemSize.height + crossAxisSpacing;
    
            topIndex = max((newTop / newHeight).round(), 0);
            leftIndex = max((newLeft / newWidth).round(), 0);
            targetIndex = topIndex + crossCount * leftIndex;
          }
    
          if (targetIndex >= total) {
            targetIndex = total - 1;
          }
    
          if (targetIndex < 0) {
            targetIndex = 0;
          }
    
          return targetIndex;
        }
    
  4. 计算 Grid Item 拖动中,整个 GridView 内是否滚动及其滚动量

        double getScrollControllerOffset({
          required double dx, // 实时移动偏移量(类似于 web 端 clientX)
          required double dy, // 实时移动偏移量(类似于 web 端 clientY)
          required Offset delta, // 拖动中的位置方向信息
          required Offset offset, // GridView Offset 偏移量
          required Offset scroll, // GridView 父层 scrollable 滚动量
          required Size itemSize,
          required Size viewSize,
          required Axis direction,
          required ScrollPhysics physics,
          required ScrollController scrollController,
        }) {
          // 如果不允许滚动,则返回 0.0,即没有滚动需求
          if (physics == const NeverScrollableScrollPhysics()) {
            return 0.0;
          }
    
          final tx = dx + scroll.dx;
          final ty = dy + scroll.dy;
          
          // 左上角位置信息
          final left = tx - offset.dx - itemSize.width / 2 - 16.0;
          final top = ty - offset.dy - itemSize.height / 2 - 16.0;
    
          final inx1 = left > scroll.dx;
          final iny1 = top > scroll.dy;
          final inx2 = left + itemSize.width < viewSize.width;
          final iny2 = top + itemSize.height < viewSize.height;
          final scrollExtent = scrollController.position.maxScrollExtent;
          final scollOffset = scrollController.offset;
    
          // 处理滚动
          if (direction == Axis.vertical) {
            if (!iny1 && scollOffset > 0 && delta.dy <= 0) {
              return -min(scollOffset, 5.0);
            }
    
            if (!iny2 && scollOffset < scrollExtent && delta.dy >= 0) {
              return min(scrollExtent - scollOffset, 5.0);
            }
          }
    
          if (direction == Axis.horizontal) {
            if (!inx1 && scollOffset > 0 && delta.dx <= 0) {
              return -min(scollOffset, 5.0);
            }
    
            if (!inx2 && scollOffset < scrollExtent && delta.dx >= 0) {
              return min(scrollExtent - scollOffset, 5.0);
            }
          }
    
          return 0.0;
        }
    
  5. 计算 新的 itemList 和 老的 itemList 之间的动画过渡

         /// 新的 itemList 和 老的 itemList 之间的动画过渡
         void startRunDragGridSlideAnimations<T>({
           required bool enable,
           required Size itemSize,
           required List<T> oldItems,
           required List<T> newItems,
           required AnimationController animateController,
           required List<Animation<Offset>?> slideAnimations,
           required List<Animation<double>?> fadeAnimations,
           required double crossAxisSpacing,
           required double mainAxisSpacing,
           required Axis direction,
           required int crossCount,
         }) {
           if (enable) {
             fadeAnimations.clear();
             slideAnimations.clear();
    
             // slideAnimations
             for (final end in newItems.asMap().keys) {
               int from = oldItems.indexOf(newItems[end]);
    
               slideAnimations.add(createGridItemSlideAnimation(
                 enable: enable,
                 itemSize: itemSize,
                 direction: direction,
                 crossCount: crossCount,
                 animateController: animateController,
                 crossAxisSpacing: crossAxisSpacing,
                 mainAxisSpacing: mainAxisSpacing,
                 from: from != -1 ? from : end,
                 end: end,
               ));
             }
    
             // fadeAnimations
             for (final obj in newItems.asMap().entries) {
               if (obj.key >= oldItems.length) {
                 fadeAnimations.add(null);
                 continue;
               }
    
               if (oldItems.any((old) => old == obj.value)) {
                 fadeAnimations.add(null);
                 continue;
               }
    
               fadeAnimations.add(createGridItemFadeAnimation(
                 animateController: animateController,
                 enable: enable,
               ));
             }
    
             animateController.reset();
             animateController.forward();
           }
         }
    
         /// 创建 GridItem SlideAnimation
         Animation<Offset>? createGridItemSlideAnimation({
           required int end,
           required int from,
           required bool enable,
           required Size itemSize,
           required Axis direction,
           required int crossCount,
           required double mainAxisSpacing,
           required double crossAxisSpacing,
           required AnimationController animateController,
         }) {
           if (enable && from != end) {
             Tween<Offset> tween = Tween(
               begin: getAnimationGridItemOffset(
                 crossAxisSpacing: crossAxisSpacing,
                 mainAxisSpacing: mainAxisSpacing,
                 crossCount: crossCount,
                 direction: direction,
                 itemSize: itemSize,
                 from: from,
                 end: end,
               ),
               end: const Offset(0.0, 0.0),
             );
    
             return tween.animate(
               CurvedAnimation(
                 parent: animateController,
                 curve: Curves.easeOut,
               ),
             );
           }
    
           return null;
         }
    
         /// 创建 GridItem FadeAnimation
         Animation<double>? createGridItemFadeAnimation({
           required bool enable,
           required AnimationController animateController,
         }) {
           if (enable) {
             Tween<double> tween = Tween(
               begin: 0.0,
               end: 1.0,
             );
    
             return tween.animate(
               CurvedAnimation(
                 parent: animateController,
                 curve: Curves.easeOut,
               ),
             );
           }
    
           return null;
         }
    
         /// 计算 GridItem Offset (from -> end)
         Offset getAnimationGridItemOffset({
           required double crossAxisSpacing,
           required double mainAxisSpacing,
           required Axis direction,
           required int crossCount,
           required Size itemSize,
           required int from,
           required int end,
         }) {
           int sx = 0;
           int ex = 0;
           int sy = 0;
           int ey = 0;
    
           double kw = 1.0;
           double kh = 1.0;
    
           if (direction == Axis.vertical) {
             sx = from % crossCount;
             ex = end % crossCount;
             sy = from ~/ crossCount;
             ey = end ~/ crossCount;
    
             kw = (itemSize.width + crossAxisSpacing) / itemSize.width;
             kh = (itemSize.height + mainAxisSpacing) / itemSize.height;
           }
    
           if (direction == Axis.horizontal) {
             sx = from ~/ crossCount;
             ex = end ~/ crossCount;
             sy = from % crossCount;
             ey = end % crossCount;
    
             kw = (itemSize.width + mainAxisSpacing) / itemSize.width;
             kh = (itemSize.height + crossAxisSpacing) / itemSize.height;
           }
    
           return Offset(
             (sx - ex).toDouble() * kw,
             (sy - ey).toDouble() * kh,
           );
         }
    

_DragGridState UI Wiget

梳理开发完 _DragGridState 核心处理逻辑,我们接下来把 UI Wiget 开发完

    class _DragGridState<T> extends State<DragGrid<T>> with TickerProviderStateMixin {
      //...
  
      @override
      Widget build(BuildContext context) {
        return GridView.builder(
          padding: padding,
          physics: physics,
          itemCount: itemCount,
          shrinkWrap: shrinkWrap,
          scrollDirection: direction,
          controller: scrollController,
          gridDelegate: sliverGridDelegate,
          itemBuilder: (context, index) {
            if (!enable) {
              // 如果不启用,则直接渲染 GridItem
              return itemBuilder(
                context,
                renderItems[index],
                index,
              );
            }

            final draggable = SizedBox(
              width: itemSize.width,
              height: itemSize.height,
              child: itemBuilder(context, renderItems[index], index),
            );

            // 长按组件封装
            return LongPressDraggable(
              data: index,
              feedback: draggable,
              child: DragTarget<int>(
                builder: (context, candidates, rejects) {
                  Animation<Offset>? slideAnimation;
                  Animation<double>? fadeAnimation;
                  Widget target = Container();
                  
                  // 当某个 GridItem 被长按拖动时,直接渲染 Container()
                  if (currentIndex != index) {
                    target = draggable;
                  }

                  if (animation && fadeAnimations.isNotEmpty) {
                    fadeAnimation = fadeAnimations[index];
                  }

                  if (animation && slideAnimations.isNotEmpty) {
                    slideAnimation = slideAnimations[index];
                  }
                  
                  // 是否使用 SlideTransition
                  if (animation && slideAnimation != null) {
                    return SlideTransition(position: slideAnimation, child: target);
                  }

                  // 是否使用 FadeTransition
                  if (animation && fadeAnimation != null) {
                    return FadeTransition(opacity: fadeAnimation, child: target);
                  }

                  return target;
                },
              ),
              dragAnchorStrategy: (draggable, context, position) {
                 // feedback 展现形式,位于手指按下中心左上角偏移 Offset(16.0, 16.0)
                return Offset(
                  itemSize.width / 2 + 16.0,
                  itemSize.height / 2 + 16.0,
                );
              },
              onDragStarted: () {
                onDragStarted?.call(); // 这里一般调用震动插件优化拖动体验
                currentIndex = index; // 记录按下时的 Grid Item Index
              },
              onDragUpdate: (details) {
                onDragUpdate?.call(details);
                
                // 是否超出边界
                final isOutbounded = isOutbounding(
                  dx: details.globalPosition.dx,
                  dy: details.globalPosition.dy,
                  offset: offset,
                  scroll: scroll,
                  itemSize: itemSize,
                  gridSize: gridSize,
                );

                // 计算 目标 Index
                final targetIndex = getAnimationTargetIndex(
                  dx: details.globalPosition.dx,
                  dy: details.globalPosition.dy,
                  total: itemCount,
                  offset: offset,
                  scroll: scroll,
                  itemSize: itemSize,
                  crossAxisSpacing: crossAxisSpacing,
                  mainAxisSpacing: mainAxisSpacing,
                  crossCount: crossCount,
                  direction: direction,
                );

                // 计算 GridView 是否滚动
                final scollOffset = getScrollControllerOffset(
                  dx: details.globalPosition.dx,
                  dy: details.globalPosition.dy,
                  delta: details.delta,
                  offset: offset,
                  scroll: scroll,
                  itemSize: itemSize,
                  viewSize: viewSize,
                  direction: direction,
                  scrollController: scrollController,
                  physics: physics,
                );

                if (isOutbounded) {
                  return;
                }

                if (scollOffset != 0.0) {
                  // 需要滚动
                  scrollController.jumpTo(scrollController.offset + scollOffset);
                }

                // 处理被占用的 Grid item 位置信息
                if (currentIndex != null && currentIndex != targetIndex) {
                  slideAnimations.clear();
                  fadeAnimations.clear();

                  setState(() {
                    final tempItems = [...renderItems];
                    final dragItem = renderItems.removeAt(currentIndex!);

                    // 更新 GridView itemList
                    renderItems.insert(
                      targetIndex,
                      dragItem,
                    );

                    if (enable && animation) {
                      // 根据新老 GridView itemList 位置信息,指定滑动动画
                      startRunDragGridSlideAnimations(
                        enable: enable,
                        itemSize: itemSize,
                        oldItems: tempItems,
                        newItems: renderItems,
                        direction: direction,
                        crossCount: crossCount,
                        mainAxisSpacing: mainAxisSpacing,
                        crossAxisSpacing: crossAxisSpacing,
                        animateController: animateController,
                        slideAnimations: slideAnimations,
                        fadeAnimations: fadeAnimations,
                      );
                    }

                    sortChanger?.call([...renderItems]);
                  });
                }

                // 拖动中实时更新 currentIndex
                currentIndex = targetIndex;
              },
              onDragEnd: (details) {
                onDragEnd?.call(details);

                final isOutbounded = isOutbounding(
                  // offset + dragAnchorStrategy
                  dx: details.offset.dx + (itemSize.width / 2 + 16.0),
                  dy: details.offset.dy + (itemSize.height / 2 + 16.0),
                  offset: offset,
                  scroll: scroll,
                  itemSize: itemSize,
                  gridSize: gridSize,
                );

                // 如果没有超出边界,则使用拖动时的 新 itemList
                if (!isOutbounded) {
                  renderCache = [...renderItems];
                  renderItems = [...renderCache];
                }

                // 如果超出边界,则从 renderCache 恢复,老的 itemList
                if (isOutbounded) {
                  renderItems = [...renderCache];
                }

                // 更新渲染并执行 itemListChanger 回调
                setState(() {
                  if (currentIndex != null && currentIndex != index) {
                    itemListChanger?.call([...renderItems]);
                  }

                  currentIndex = null;
                });
              },
            );
          },
        );
      }

      // ...
    }

GridController API 定义

到目前为止,一个可拖动的 GridView 已经实现了。不过有时候我们也需要组件外部触发更新 GridView itemList 排序 (例,点击 Button 更新排序),那么我们把 GridController 继续完善

  • 定义 GridController,完善更新排序 API 逻辑

        /// Dragging GridView Controller
        class GridController<T> extends ChangeNotifier {
          /// 记录 Grid itemList (用来更新排序)
          List<T> _itemList = [];
    
          /// 记录是否启用动画
          bool _animation = true;
    
          /// Getter get _itemList
          get itemList => _itemList;
    
          /// Getter get _animation
          get animation => _animation;
    
          /// 用来从 DragGrid 获取最新的 itemList Api
          void update(List<T> itemList) {
            _itemList = [...itemList];
          }
    
          /// 手动追加新 Grid Item, 会重新渲染 DragGrid,并触发 itemListChanger
          void append({required T item, bool animation = true}) {
            _itemList.removeWhere((opt) => opt == item);
            _animation = animation;
            _itemList.add(item);
            notifyListeners();
          }
    
          /// 手动移除某个 Grid Item, 会重新渲染 DragGrid,并触发 itemListChanger
          void remove({required int index, bool animation = true}) {
            if (index < 0) {
              index = itemList.length + index;
            }
    
            if (index >= 0 && index < _itemList.length) {
              _itemList.removeAt(index);
              _animation = animation;
              notifyListeners();
            }
          }
    
          /// 手动插入某个 Grid Item, 会重新渲染 DragGrid,并触发 itemListChanger
          void insert({int index = 0, required T item, bool animation = true}) {
            if (index < 0) {
              index = itemList.length + index;
            }
    
            if (index >= 0 && index < _itemList.length) {
              _itemList.removeWhere((opt) => opt == item);
              _itemList.insert(index, item);
              _animation = animation;
              notifyListeners();
            }
          }
    
          /// 手动重置 itemList, 会重新渲染 DragGrid,并触发 itemListChanger
          void reset({required List<T> itemList, bool animation = true}) {
            _animation = animation;
            _itemList = itemList;
            notifyListeners();
          }
        }
    
  • _DragGridStateinitState 中监听 gridController 变更事件, 并执行渲染相关组件

        class _DragGridState<T> extends State<DragGrid<T>> with TickerProviderStateMixin {
          @override
          void initState() {
            //...
    
            gridController.addListener(() {
              // 不启用动画
              if (!gridController.animation) {
                setState(() {
                  renderItems = [...gridController.itemList];
                  renderCache = [...gridController.itemList];
                  itemListChanger?.call([...renderItems]);
                });
                return;
              }
    
              // 启用动画
              startRunDragGridSlideAnimations(
                enable: enable,
                itemSize: itemSize,
                oldItems: renderItems,
                newItems: [...gridController.itemList],
                direction: direction,
                crossCount: crossCount,
                animateController: animateController,
                crossAxisSpacing: crossAxisSpacing,
                mainAxisSpacing: mainAxisSpacing,
                slideAnimations: slideAnimations,
                fadeAnimations: fadeAnimations,
              );
    
              setState(() {
                renderItems = [...gridController.itemList];
                renderCache = [...gridController.itemList];
                itemListChanger?.call([...renderItems]);
              });
            });
          }
        }
    

DragGrid 完整源码

到这一步,DragGrid 组件的开发,已经全部梳理讲解完了,希望大家都有所收获吧。 完整源码

如果有发现 Bug ,请提交一个 issues。如果你愿意自己修复或增强东西,非常欢迎你提 PR


相关资源

Github 库

https://github.com/flutter-library-provider/DragGrid 前往

Pub 官方库

https://pub.dev/packages/drag_grid 前往