前言
在 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 (横轴子元素最大长度算法)
-
当知道横轴子元素的数量是固定时,使用
SliverGridDelegateWithFixedCrossAxisCount
const sliverGridDelegate = SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, // 横轴方向子元素的数量 mainAxisSpacing: 15, // 主轴方向两子元素之间间距为 15 crossAxisSpacing: 10, // 横轴方向两子元素之间间距为 10 childAspectRatio: 1, // 子元素横轴方向大小 ÷ 子元素主轴方向大小 );
- Axis.vertical 图示
- Axis.horizontal 图示
-
当知道横轴子元素最大宽度时,则使用
SliverGridDelegateWithMaxCrossAxisExtent
const sliverGridDelegate = SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 84.0, // 子元素横轴大小不超过 84.0 mainAxisSpacing: 15, // 主轴方向两子元素之间间距为 15 crossAxisSpacing: 10, // 横轴方向两子元素之间间距为 10 childAspectRatio: 1, // 子元素横轴方向大小 ÷ 子元素主轴方向大小 );
与
SliverGridDelegateWithFixedCrossAxisCount
相比,它保持了子元素的大小,尽可能不会受设备屏幕大小的影响。那它 GridView 和 Grid Item 的大小是如何计算呢?其实只要计算出横轴子元素的数量,后面就跟SliverGridDelegateWithFixedCrossAxisCount
完全一致。那如何计算 crossAxisCount 数量,可参考图示:-
Axis.vertical
final itemWidth = maxCrossAxisExtent + crossAxisSpacing; final totalWidth = GridView.width + crossAxisSpacing; crossAxisCount = (totalWidth / itemWidth).ceil();
-
Axis.horizontal
final itemHeight = maxCrossAxisExtent + crossAxisSpacing; final totalHeight = GridView.height + crossAxisSpacing; crossAxisCount = (totalHeight / itemHeight).ceil();
-
-
在上述两个小节中,我们图示讲解 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 = 0
和crossAxisSpacing = 0
时
- 当
mainAxisSpacing != 0
和crossAxisSpacing != 0
时
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 图示说明
DragGrid
组件
在之前的章节中,我们对 GridView、SlideTransition、LongPressDraggable 等官方组件分别进行了讲解和说明,也有了一定的理解。接下来我们就逐步开发一个 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
(动画), 有 initState
、dispose
以及 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 时的 OffsetcreateGridItemFadeAnimation:
创建 FadeAnimation 动画 (使用 tween)createGridItemSlideAnimation:
创建 SlideAnimation 动画 (使用 tween)startRunDragGridSlideAnimations:
计算 newItems 和 oldItems 之间的动画过渡
-
判断拖动中的 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; }
-
获取 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; }
-
根据 feedback 左上角偏移量(dx/dy), 计算 移动 feedback 到哪个位置 (下标index), 图示如下:
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; }
-
计算 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; }
-
计算 新的 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(); } }
-
在
_DragGridState
的 initState 中监听 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
前往