前陣子突然想到兩年前寫過的一篇部落格:玩玩Android的拖拽——實現一款萬能遙控器,就想著用Flutter
來複刻一下。順便練習一下Flutter
裡的拖拽Widget。
先給大家康康最終的實現效果及對比(個人覺得還原度很高,甚至Flutter版的更好):
Android | Flutter |
---|---|
因為有之前Android
版本的實現經驗,所以省了不少時間,當然也踩了不少坑,前前後後用了3天時間。下面我來介紹下實現流程。
UI實現
整個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各偏移長寬的一半。
- 在開始拖動時,我們可以新增一個振動反饋。這裡可以使用flutter_vibrate庫來實現。
LongPressDraggable<DraggableInfo>(
onDragStarted: () {
/// 開始拖動
Vibrate.feedback(FeedbackType.light);
},
....
),
複製程式碼
- 為了避免因拖動按鈕時呼叫
setState
而造成CustomPainter
的不斷重繪,這裡需要使用RepaintBoundary
。具體原因及原理見:說說Flutter中的RepaintBoundary
RepaintBoundary(
child: CustomPaint(
/// 繪製手機外形
painter: PhoneView()
),
)
複製程式碼
- 語義新增, 詳情見說說Flutter中的Semantics。
其他
因為DragTarget
的 builder
方法返回的candidateData
是一個集合,所以可以同時響應多個拖拽資訊。數量上限取決於你的手機支援的多點觸控數量。這個特點是Android 版本所沒有的。(雖然不知道能幹什麼,牛啤就完事了~~)
PS:
本篇雖然看似是一個UI效果實現,但其實也是之前的“說說”系列的一個實踐總結。上面文章中也有提到過:
沒有上面的這三篇作為基礎,那麼也無法有這樣的完成度,推薦大家閱讀。
到這裡我就將整個實現的重點說完了,其他的計算細節這裡就不說了,可以去看看原始碼。奉上Github地址,有興趣的可以跑起來玩玩。記得不要白嫖,來個素質三連哦(star、fork、文章點贊)。
我在這裡提前感謝大家了,你的支援就是我最大的動力!!