前阵子突然想到两年前写过的一篇博客:玩玩Android的拖拽——实现一款万能遥控器,就想着用Flutter
来复刻一下。顺便练习一下Flutter
里的拖拽Widget。
先给大家看看最终的实现效果以及与Android版的对比(个人觉得还原度很高,甚至Flutter版的更好):
Android | Flutter |
---|---|
因为有之前Android
版本的实现经验,所以省了不少时间,当然也踩了不少坑,前前后后用了3天时间。下面我来介绍下实现流程。
整个UI分为上下两部分,上半部分为手机(遥控器),下半部分是遥控按钮的选择菜单。
使用CustomPainter
来画一个手机外观。这部分都是各种位置计算以及Canvas
和 Paint
API的调用。比如画线、圆、矩形、圆角矩形等。
代码就不贴出来了(源码地址在文末),说一下需要注意的一点。
Canvas
貌似没有提供绘制虚线的方法(Android 使用 Paint.setPathEffect
来更改样式),所以只能通过循环给Path
添加虚线的路径位置,最终调用Canvas
的 drawPath
方法绘制。 这里我使用了path_drawing库来实现,它封装了这一循环操作,便于使用。 // 虚线段长4,间隔4
Path _dashPath = dashPath(_mPath, dashArray: CircularIntervalList<double>(<double>[4, 4]));
canvas.drawPath(_dashPath, _mPhonePaint);
这部分很简单,一个PageView
,里面用GridView
排列好对应的按钮。为了方便实现底部指示器效果,我这里使用了flutter_swiper
来替代PageView
实现。
按钮的素材图片本身是没有圆形边框的。其次按钮的按下时会有一个背景色变化。这部分可以通过BoxDecoration
和GestureDetector
实现。大致代码如下:
class _DraggableButtonState extends State<DraggableButton> {
Color _color = Colors.transparent;
@override
Widget build(BuildContext context) {
Widget child = Image.asset('assets/image.png', width: 48 / 2, height: 48 / 2,);
child = Container(
alignment: Alignment.center,
height: 48,
width: 48,
decoration: BoxDecoration(
color: _color,
borderRadius: BorderRadius.circular(48 / 2), // 圆角
border: Border.all(color: Colours.circleBorder, width: 0.4), // 边框
),
child: child,
);
return Center(
child: GestureDetector(
child: child,
onTapDown: (_) {
/// 按下按钮背景变化
setState(() {
_color = Colours.pressed;
});
},
onTapUp: (_) {
setState(() {
_color = Colors.transparent;
});
},
onTapCancel: () {
setState(() {
_color = Colors.transparent;
});
},
),
);
}
}
这里就用到了今天的主角Draggable
与DragTarget
。
Draggable
: 可拖动Widget。属性 | 类型 | 说明 |
---|---|---|
child | Widget | 拖动的Widget |
feedback | Widget | 拖动时,在手指指针下显示的Widget |
data | T | 传递的信息 |
axis | Axis | 可以限制拖动方向,水平或垂直 |
childWhenDragging | Widget | 拖动时child的样式 |
dragAnchor | DragAnchor | 拖动时起始点位置(后面会说到) |
affinity | Axis | 手势冲突时,指定以何种拖动方向触发 |
maxSimultaneousDrags | int | 指定最多可同时拖动的数量 |
onDragStarted | void Function() | 拖动开始 |
onDraggableCanceled | void Function(Velocity velocity, Offset offset) | 拖动取消,指没有被DragTarget 控件接受时结束拖动 |
onDragEnd | void Function(DraggableDetails details) | 拖动结束 |
onDragCompleted | void Function() | 拖动完成,与取消情况相反 |
DragTarget
:用于接收Draggable
传递的数据。属性 | 类型 | 说明 |
---|---|---|
builder | Widget Function(BuildContext context, List candidateData, List rejectedData) | 可通过回调的数据构建Widget |
onWillAccept | bool Function(T data) | 判断是否接受Draggable 传递的数据 |
onAccept | void Function(T data) | 拖动结束,接收数据时调用 |
onLeave | void Function(T data) | Draggable 离开DragTarget 区域时调用 |
上面介绍了Draggable
与DragTarget
的作用及使用属性。那么也就很明显,底部的按钮就是Draggable
,上半部的手机屏幕就是DragTarget
。
不过这里有个问题,Draggable
没有提供拖动中的回调(无法获取实时位置),DragTarget
也没有提供Draggable
在区域中拖动的回调。这导致我们无法实时在手机屏幕上显示“指示投影”。
所以这里只能拷出源码修改,自己动手丰衣足食。主要位置是_DragAvatar
的 updateDrag
方法:
void updateDrag(Offset globalPosition) {
_lastOffset = globalPosition - dragStartPoint;
....
final List<_DragTargetState<T>> targets = _getDragTargets(result.path).toList();
bool listsMatch = false;
if (targets.length >= _enteredTargets.length && _enteredTargets.isNotEmpty) {
listsMatch = true;
final Iterator<_DragTargetState<T>> iterator = targets.iterator;
for (int i = 0; i < _enteredTargets.length; i += 1) {
iterator.moveNext();
if (iterator.current != _enteredTargets[i]) {
listsMatch = false;
break;
}
/// TODO 修改处 给DragTargetState添加didDrag方法,回调有Draggable拖动。
_enteredTargets[i].didDrag(this);
}
}
/// TODO 修改处 给Draggable添加onDrag回调方法,返回拖动中位置
if (onDrag != null) {
onDrag(_lastOffset);
}
....
}
详细的改动源码里有注释,这里就不全部贴出了。这下万事俱备,开搞!!
class DraggableInfo {
String id;
String text;
String img;
/// 拖动类型
DraggableType type;
/// 记录拖动位置
double dx = 0;
double dy = 0;
DraggableInfo(this.id, this.text, this.img, this.type);
setOffset(double dx, double dy) {
this.dx = dx;
this.dy = dy;
}
@override
String toString() {
return '$runtimeType(id: $id, text: $text, img: $img, type: $type, dx: $dx, dy: $dy)';
}
@override
// ignore: hash_and_equals 以id作为唯一标识
bool operator == (other) => other is DraggableInfo && id == other.id;
}
enum DraggableType {
/// 1 * 1 文字
text,
/// 1 * 1 图片
imageOneToOne,
/// 1 * 2 图片
imageOneToTwo,
/// 3 * 3 图片
imageThreeToThree,
}
因为这里的触发拖动是长按,所以使用LongPressDraggable
,用法与Draggable
一致。将上面的按钮完善一下:
var child; /// 自定义按钮
LongPressDraggable<DraggableInfo>(
data: draggableInfo,
dragAnchor: MyDragAnchor.center,
/// 最多拖动一个
maxSimultaneousDrags: 1,
/// 拖动控件时的样式,这里添加一个透明度
feedback: Opacity(
opacity: 0.5,
child: child,
),
child: child,
onDragStarted: () {
/// 开始拖动
},
/// 拖动中实时位置回调
onDrag: (offset) {
/// 返回点为拖动目标左上角位置(相对于全屏),将位置保存。
widget.data.setOffset(offset.dx, offset.dy);
},
),
使用DragTarget
来进行拖动数据的更新。
GlobalKey<PanelViewState> _panelGlobalKey = GlobalKey();
DragTarget<DraggableInfo>(
builder: (context, candidateData, rejectedData) {
return PanelView( /// 所有的接收数据处理
key: _panelGlobalKey,
dropShadowData: candidateData, /// 指示投影数据
);
},
onAccept: (data) {
/// 目标被区域接收
_panelGlobalKey.currentState.addData(data);
},
onLeave: (data) {
/// 目标移出区域
_panelGlobalKey.currentState.removeData(data);
},
onDrag: (data) {
/// 监测到有目标在拖动,绘制指示投影。
setState(() {
});
},
onWillAccept: (data) {
/// 判断目标是否可以被接收
return data != null;
},
),
大小主要分为三种:1 * 1, 1 * 2, 3 * 3,需要通过传递的DraggableType
来确定大小。
拖动返回的位置是相对于全屏的,所以需要globalToLocal
转换一下。
Rect computeSize(BuildContext context, DraggableInfo info) {
/// gridSize为一个田字格大小
double width = widget.gridSize;
double height = widget.gridSize;
if (info.type == DraggableType.imageOneToTwo) {
width = widget.gridSize;
height = widget.gridSize * 2;
} else if (info.type == DraggableType.imageThreeToThree) {
width = widget.gridSize * 3;
height = widget.gridSize * 3;
}
RenderBox box = context.findRenderObject();
// 将全局坐标转换为当前Widget的本地坐标。
Offset center = box.globalToLocal(Offset(info.dx, info.dy));
return Rect.fromCenter(
center: center,
width: width,
height: height,
);
}
我们拖动中的位置和释放时的位置都不一定准确的放在田字格中,所以我们要修正位置(包括边界超出的处理)。修正位置也可以让“指示投影”给予用户良好的引导。
Rect adjustPosition(DraggableInfo info, Rect mRect) {
// 最小单元格宽高
double size = widget.gridSize / 2;
double left, top, right, bottom;
// 修正x坐标
double offsetX = mRect.left % size;
if (offsetX < size / 2) {
left = mRect.left - offsetX;
} else {
left = mRect.left - offsetX + size;
}
// 修正Y坐标
double offsetY = mRect.top % size;
if (offsetY < size / 2) {
top = mRect.top - offsetY;
} else {
top = mRect.top - offsetY + size;
}
right = left + mRect.width;
bottom = top + mRect.height;
//超出边界部分修正
//因为DragTarget判断长宽大于一半进入就算进入接收区域,也就是面积最小进入四分之一
if (top < 0) {
top = 0;
bottom = top + mRect.height;
}
if (left < 0) {
left = 0;
right = left + mRect.width;
}
if (bottom > widget.gridSize * 7) {
bottom = widget.gridSize * 7;
top = bottom - mRect.height;
}
if (right > widget.gridSize * 4) {
right = widget.gridSize * 4;
left = right - mRect.width;
}
return Rect.fromLTRB(left, top, right, bottom);
}
经过这两步,我们的布局边界效果如下:
避免拖动按钮造成重叠,我们需要逐一对比Rect
。
/// 判断当前Rect是否有重叠
bool isOverlap(Rect rect, List<Rect> mRectList) {
for (int i = 0; i < mRectList.length; i++) {
if (isRectOverlap(mRectList[i], rect)) {
return true;
}
}
return false;
}
/// 判断两Rect是否重叠(摩根定理)
bool isRectOverlap(Rect oldRect, Rect newRect) {
return (
oldRect.right > newRect.left &&
newRect.right > oldRect.left &&
oldRect.bottom > newRect.top &&
newRect.bottom > oldRect.top
);
}
有重叠的,我们显示一个空Widget。
通过上面的三步处理,我们计算出正确的Rect
。最终使用Stack
显示出来。
/// 保存放置按钮的Rect
List<Rect> rectList = List();
/// 放置的按钮
List<Widget> children= List.generate(data.length, (index) {
/// 计算位置及大小
Rect rect = computeSize(context, data[index]);
/// 修正
rect = adjustPosition(data[index], rect);
rectList.add(rect);
/// 是否重叠
bool overlap = isOverlap(rect, rectList);
if (overlap) {
return const SizedBox.shrink();
}
/// 涉及widget移动、删除,注意添加key
var button = DraggableButton(
key: ObjectKey(data[index]),
onDragStarted: () {
/// 开始拖动时,移除面板上的拖动按钮
removeData(data[index]);
},
);
return Positioned.fromRect(
rect: rect,
child: Center(
child: button,
),
);
});
return Stack(
children: children,
);
这里需要注意两点:
因为二次拖动时(已放置的按钮,再次长按拖动)涉及Widget删除,为了避免错乱,Draggable
按钮一定要添加key。具体原因及原理见:说说Flutter中最熟悉的陌生人 —— Key
注意避免重复添加同一按钮。因为二次拖动时不一定会触发DragTarget
的onLeave
。
addData(DraggableInfo info) {
/// 避免重复添加同一按钮,这里已重写DraggableInfo的 == 操作符
if (!data.contains(info)) {
data.add(info);
}
}
Draggable
的dragAnchor
属性,是为了确定起始点的位置(锚点),有两种模式child与pointer。DragAnchor.child
就是以点击点作为起始点(动态位置)。如果feedback
与child
一致,那么feedback
它们将重合。
DragAnchor.pointer
就是以按钮的左上角(Offset.zero
)作为起始点(固定位置)。也就是feedback
的左上角将是点击点的位置。
很遗憾这两种都不是Android原版的效果,原效果以点击点作为feedback
的中心点(大家可以仔细观察上面的GIF)。所以我添加了一个锚点类型center
,让点击点作为feedback
的中心点。也就是x,y各偏移长宽的一半。
LongPressDraggable<DraggableInfo>(
onDragStarted: () {
/// 开始拖动
Vibrate.feedback(FeedbackType.light);
},
....
),
setState
而造成CustomPainter
的不断重绘,这里需要使用RepaintBoundary
。具体原因及原理见:说说Flutter中的RepaintBoundaryRepaintBoundary(
child: CustomPaint(
/// 绘制手机外形
painter: PhoneView()
),
)
因为DragTarget
的 builder
方法返回的candidateData
是一个集合,所以可以同时响应多个拖拽信息。数量上限取决于你的手机支持的多点触控数量。这个特点是Android 版本所没有的。(虽然不知道能干什么,牛啤就完事了~~)
PS:
本篇虽然看似是一个UI效果实现,但其实也是之前的“说说”系列的一个实践总结。上面文章中也有提到过:
说说Flutter中的RepaintBoundary
说说Flutter中的Semantics
说说Flutter中最熟悉的陌生人 —— Key
没有上面的这三篇作为基础,那么也无法有这样的完成度,推荐大家阅读。
到这里我就将整个实现的重点说完了,其他的计算细节这里就不说了,可以去看看源码。奉上Github地址,有兴趣的可以跑起来玩玩。记得不要白嫖,来个素质三连哦(star、fork、文章点赞)。
我在这里提前感谢大家了,你的支持就是我最大的动力!!