前陣子突然想到兩年前寫過的一篇博客:玩玩Android的拖拽——實(shí)現(xiàn)一款萬能遙控器,就想著用Flutter來復(fù)刻一下。順便練習(xí)一下Flutter里的拖拽Widget。
先給大家康康最終的實(shí)現(xiàn)效果及對(duì)比(個(gè)人覺得還原度很高,甚至Flutter版的更好):
| Android | Flutter |
|---|---|
![]() Android
|
![]() Flutter
|
因?yàn)橛兄?code>Android版本的實(shí)現(xiàn)經(jīng)驗(yàn),所以省了不少時(shí)間,當(dāng)然也踩了不少坑,前前后后用了3天時(shí)間。下面我來介紹下實(shí)現(xiàn)流程。
UI實(shí)現(xiàn)
整個(gè)UI分為上下兩部分,上半部分為手機(jī)(遙控器),下半部分是遙控按鈕的選擇菜單。
手機(jī)
使用CustomPainter來畫一個(gè)手機(jī)外觀。這部分都是各種位置計(jì)算以及Canvas 和 Paint API的調(diào)用。比如畫線、圓、矩形、圓角矩形等。
代碼就不貼出來了(源碼鏈接在文末),說一下需要注意的一點(diǎn)。
- 繪制田字格時(shí)外框?yàn)閷?shí)線,里側(cè)為虛線。
Canvas貌似沒有提供繪制虛線的方法(Android 使用Paint.setPathEffect來更改樣式),所以只能通過循環(huán)給Path添加虛線的路徑位置,最終調(diào)用Canvas的drawPath方法繪制。 這里我使用了path_drawing庫來實(shí)現(xiàn),它封裝了這一循環(huán)操作,便于使用。
// 虛線段長4,間隔4
Path _dashPath = dashPath(_mPath, dashArray: CircularIntervalList<double>(<double>[4, 4]));
canvas.drawPath(_dashPath, _mPhonePaint);
遙控按鈕的選擇菜單
這部分很簡單,一個(gè)PageView,里面用GridView排列好對(duì)應(yīng)的按鈕。為了方便實(shí)現(xiàn)底部指示器效果,我這里使用了flutter_swiper來替代PageView實(shí)現(xiàn)。
按鈕
按鈕的素材圖片本身是沒有圓形邊框的。其次按鈕的按下時(shí)會(huì)有一個(gè)背景色變化。這部分可以通過BoxDecoration和GestureDetector實(shí)現(xiàn)。大致代碼如下:
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;
});
},
),
);
}
}
拖動(dòng)實(shí)現(xiàn)
這里就用到了今天的主角Draggable與DragTarget。
-
Draggable: 可拖動(dòng)Widget。
| 屬性 | 類型 | 說明 |
|---|---|---|
| child | Widget | 拖動(dòng)的Widget |
| feedback | Widget | 拖動(dòng)時(shí),在手指指針下顯示的Widget |
| data | T | 傳遞的信息 |
| axis | Axis | 可以限制拖動(dòng)方向,水平或垂直 |
| childWhenDragging | Widget | 拖動(dòng)時(shí)child的樣式 |
| dragAnchor | DragAnchor | 拖動(dòng)時(shí)起始點(diǎn)位置(后面會(huì)說到) |
| affinity | Axis | 手勢沖突時(shí),指定以何種拖動(dòng)方向觸發(fā) |
| maxSimultaneousDrags | int | 指定最多可同時(shí)拖動(dòng)的數(shù)量 |
| onDragStarted | void Function() | 拖動(dòng)開始 |
| onDraggableCanceled | void Function(Velocity velocity, Offset offset) | 拖動(dòng)取消,指沒有被DragTarget控件接受時(shí)結(jié)束拖動(dòng) |
| onDragEnd | void Function(DraggableDetails details) | 拖動(dòng)結(jié)束 |
| onDragCompleted | void Function() | 拖動(dòng)完成,與取消情況相反 |
-
DragTarget:用于接收Draggable傳遞的數(shù)據(jù)。
| 屬性 | 類型 | 說明 |
|---|---|---|
| builder | Widget Function(BuildContext context, List<T> candidateData, List<dynamic> rejectedData) | 可通過回調(diào)的數(shù)據(jù)構(gòu)建Widget |
| onWillAccept | bool Function(T data) | 判斷是否接受Draggable傳遞的數(shù)據(jù) |
| onAccept | void Function(T data) | 拖動(dòng)結(jié)束,接收數(shù)據(jù)時(shí)調(diào)用 |
| onLeave | void Function(T data) |
Draggable離開DragTarget區(qū)域時(shí)調(diào)用 |
上面介紹了Draggable與DragTarget 的作用及使用屬性。那么也就很明顯,底部的按鈕就是Draggable,上半部的手機(jī)屏幕就是DragTarget。
不過這里有個(gè)問題,Draggable沒有提供拖動(dòng)中的回調(diào)(無法獲取實(shí)時(shí)位置),DragTarget也沒有提供Draggable在區(qū)域中拖動(dòng)的回調(diào)。這導(dǎo)致我們無法實(shí)時(shí)在手機(jī)屏幕上顯示“指示投影”。
所以這里只能拷出源碼修改,自己動(dòng)手豐衣足食。主要位置是_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方法,回調(diào)有Draggable拖動(dòng)。
_enteredTargets[i].didDrag(this);
}
}
/// TODO 修改處 給Draggable添加onDrag回調(diào)方法,返回拖動(dòng)中位置
if (onDrag != null) {
onDrag(_lastOffset);
}
....
}
詳細(xì)的改動(dòng)源碼里有注釋,這里就不全部貼出了。這下萬事俱備,開搞!!
定義拖動(dòng)傳遞的數(shù)據(jù)對(duì)象
class DraggableInfo {
String id;
String text;
String img;
/// 拖動(dòng)類型
DraggableType type;
/// 記錄拖動(dòng)位置
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作為唯一標(biāo)識(shí)
bool operator == (other) => other is DraggableInfo && id == other.id;
}
enum DraggableType {
/// 1 * 1 文字
text,
/// 1 * 1 圖片
imageOneToOne,
/// 1 * 2 圖片
imageOneToTwo,
/// 3 * 3 圖片
imageThreeToThree,
}
拖動(dòng)按鈕
因?yàn)檫@里的觸發(fā)拖動(dòng)是長按,所以使用LongPressDraggable,用法與Draggable一致。將上面的按鈕完善一下:
var child; /// 自定義按鈕
LongPressDraggable<DraggableInfo>(
data: draggableInfo,
dragAnchor: MyDragAnchor.center,
/// 最多拖動(dòng)一個(gè)
maxSimultaneousDrags: 1,
/// 拖動(dòng)控件時(shí)的樣式,這里添加一個(gè)透明度
feedback: Opacity(
opacity: 0.5,
child: child,
),
child: child,
onDragStarted: () {
/// 開始拖動(dòng)
},
/// 拖動(dòng)中實(shí)時(shí)位置回調(diào)
onDrag: (offset) {
/// 返回點(diǎn)為拖動(dòng)目標(biāo)左上角位置(相對(duì)于全屏),將位置保存。
widget.data.setOffset(offset.dx, offset.dy);
},
),
接收拖動(dòng)
使用DragTarget來進(jìn)行拖動(dòng)數(shù)據(jù)的更新。
GlobalKey<PanelViewState> _panelGlobalKey = GlobalKey();
DragTarget<DraggableInfo>(
builder: (context, candidateData, rejectedData) {
return PanelView( /// 所有的接收數(shù)據(jù)處理
key: _panelGlobalKey,
dropShadowData: candidateData, /// 指示投影數(shù)據(jù)
);
},
onAccept: (data) {
/// 目標(biāo)被區(qū)域接收
_panelGlobalKey.currentState.addData(data);
},
onLeave: (data) {
/// 目標(biāo)移出區(qū)域
_panelGlobalKey.currentState.removeData(data);
},
onDrag: (data) {
/// 監(jiān)測到有目標(biāo)在拖動(dòng),繪制指示投影。
setState(() {
});
},
onWillAccept: (data) {
/// 判斷目標(biāo)是否可以被接收
return data != null;
},
),
數(shù)據(jù)處理
確定位置與大小
大小主要分為三種:1 * 1, 1 * 2, 3 * 3,需要通過傳遞的
DraggableType來確定大小。拖動(dòng)返回的位置是相對(duì)于全屏的,所以需要
globalToLocal轉(zhuǎn)換一下。
Rect computeSize(BuildContext context, DraggableInfo info) {
/// gridSize為一個(gè)田字格大小
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();
// 將全局坐標(biāo)轉(zhuǎn)換為當(dāng)前Widget的本地坐標(biāo)。
Offset center = box.globalToLocal(Offset(info.dx, info.dy));
return Rect.fromCenter(
center: center,
width: width,
height: height,
);
}
修正位置
我們拖動(dòng)中的位置和釋放時(shí)的位置都不一定準(zhǔn)確的放在田字格中,所以我們要修正位置(包括邊界超出的處理)。修正位置也可以讓“指示投影”給予用戶良好的引導(dǎo)。
Rect adjustPosition(DraggableInfo info, Rect mRect) {
// 最小單元格寬高
double size = widget.gridSize / 2;
double left, top, right, bottom;
// 修正x坐標(biāo)
double offsetX = mRect.left % size;
if (offsetX < size / 2) {
left = mRect.left - offsetX;
} else {
left = mRect.left - offsetX + size;
}
// 修正Y坐標(biāo)
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;
//超出邊界部分修正
//因?yàn)镈ragTarget判斷長寬大于一半進(jìn)入就算進(jìn)入接收區(qū)域,也就是面積最小進(jìn)入四分之一
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);
}
經(jīng)過這兩步,我們的布局邊界效果如下:
避免重疊
避免拖動(dòng)按鈕造成重疊,我們需要逐一對(duì)比Rect。
/// 判斷當(dāng)前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
);
}
有重疊的,我們顯示一個(gè)空Widget。
通過上面的三步處理,我們計(jì)算出正確的Rect。最終使用Stack顯示出來。
/// 保存放置按鈕的Rect
List<Rect> rectList = List();
/// 放置的按鈕
List<Widget> children= List.generate(data.length, (index) {
/// 計(jì)算位置及大小
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移動(dòng)、刪除,注意添加key
var button = DraggableButton(
key: ObjectKey(data[index]),
onDragStarted: () {
/// 開始拖動(dòng)時(shí),移除面板上的拖動(dòng)按鈕
removeData(data[index]);
},
);
return Positioned.fromRect(
rect: rect,
child: Center(
child: button,
),
);
});
return Stack(
children: children,
);
這里需要注意兩點(diǎn):
因?yàn)?strong>二次拖動(dòng)時(shí)(已放置的按鈕,再次長按拖動(dòng))涉及Widget刪除,為了避免錯(cuò)亂,
Draggable按鈕一定要添加key。具體原因及原理見:說說Flutter中最熟悉的陌生人 —— Key注意避免重復(fù)添加同一按鈕。因?yàn)槎瓮蟿?dòng)時(shí)不一定會(huì)觸發(fā)
DragTarget的onLeave。
addData(DraggableInfo info) {
/// 避免重復(fù)添加同一按鈕,這里已重寫DraggableInfo的 == 操作符
if (!data.contains(info)) {
data.add(info);
}
}
優(yōu)化
- 對(duì)于
Draggable的dragAnchor屬性,是為了確定起始點(diǎn)的位置(錨點(diǎn)),有兩種模式child與pointer。
DragAnchor.child就是以點(diǎn)擊點(diǎn)作為起始點(diǎn)(動(dòng)態(tài)位置)。如果feedback與child一致,那么feedback它們將重合。-
DragAnchor.pointer就是以按鈕的左上角(Offset.zero)作為起始點(diǎn)(固定位置)。也就是feedback的左上角將是點(diǎn)擊點(diǎn)的位置。很遺憾這兩種都不是Android原版的效果,原效果以點(diǎn)擊點(diǎn)作為
feedback的中心點(diǎn)(大家可以仔細(xì)觀察上面的GIF)。所以我添加了一個(gè)錨點(diǎn)類型center,讓點(diǎn)擊點(diǎn)作為feedback的中心點(diǎn)。也就是x,y各偏移長寬的一半。
- 在開始拖動(dòng)時(shí),我們可以添加一個(gè)振動(dòng)反饋。這里可以使用flutter_vibrate庫來實(shí)現(xiàn)。
LongPressDraggable<DraggableInfo>(
onDragStarted: () {
/// 開始拖動(dòng)
Vibrate.feedback(FeedbackType.light);
},
....
),
- 為了避免因拖動(dòng)按鈕時(shí)調(diào)用
setState而造成CustomPainter的不斷重繪,這里需要使用RepaintBoundary。具體原因及原理見:說說Flutter中的RepaintBoundary
RepaintBoundary(
child: CustomPaint(
/// 繪制手機(jī)外形
painter: PhoneView()
),
)
- 語義添加, 詳情見說說Flutter中的Semantics。
其他
因?yàn)?code>DragTarget 的 builder 方法返回的candidateData是一個(gè)集合,所以可以同時(shí)響應(yīng)多個(gè)拖拽信息。數(shù)量上限取決于你的手機(jī)支持的多點(diǎn)觸控?cái)?shù)量。這個(gè)特點(diǎn)是Android 版本所沒有的。(雖然不知道能干什么,牛啤就完事了~~)

PS:
本篇雖然看似是一個(gè)UI效果實(shí)現(xiàn),但其實(shí)也是之前的“說說”系列的一個(gè)實(shí)踐總結(jié)。上面文章中也有提到過:
沒有上面的這三篇作為基礎(chǔ),那么也無法有這樣的完成度,推薦大家閱讀。
到這里我就將整個(gè)實(shí)現(xiàn)的重點(diǎn)說完了,其他的計(jì)算細(xì)節(jié)這里就不說了,可以去看看源碼。奉上Github地址,有興趣的可以跑起來玩玩。記得不要白嫖,來個(gè)素質(zhì)三連哦(star、fork、文章點(diǎn)贊)。
我在這里提前感謝大家了,你的支持就是我最大的動(dòng)力?。?/p>

