玩玩Flutter的拖拽——實(shí)現(xiàn)一款萬能遙控器

封面

前陣子突然想到兩年前寫過的一篇博客:玩玩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ì)算以及CanvasPaint API的調(diào)用。比如畫線、圓、矩形、圓角矩形等。

代碼就不貼出來了(源碼鏈接在文末),說一下需要注意的一點(diǎn)。

  • 繪制田字格時(shí)外框?yàn)閷?shí)線,里側(cè)為虛線。Canvas 貌似沒有提供繪制虛線的方法(Android 使用 Paint.setPathEffect來更改樣式),所以只能通過循環(huán)給Path 添加虛線的路徑位置,最終調(diào)用CanvasdrawPath方法繪制。 這里我使用了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è)背景色變化。這部分可以通過BoxDecorationGestureDetector實(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)

這里就用到了今天的主角DraggableDragTarget。

  • 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)用

上面介紹了DraggableDragTarget 的作用及使用屬性。那么也就很明顯,底部的按鈕就是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)手豐衣足食。主要位置是_DragAvatarupdateDrag方法:

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ā)DragTargetonLeave。

addData(DraggableInfo info) {
  /// 避免重復(fù)添加同一按鈕,這里已重寫DraggableInfo的 == 操作符
  if (!data.contains(info)) {
    data.add(info);
  }
}

優(yōu)化

  • 對(duì)于DraggabledragAnchor屬性,是為了確定起始點(diǎn)的位置(錨點(diǎn)),有兩種模式child與pointer。
  1. DragAnchor.child就是以點(diǎn)擊點(diǎn)作為起始點(diǎn)(動(dòng)態(tài)位置)。如果feedbackchild一致,那么feedback它們將重合。

  2. 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()
  ),
)

其他

因?yàn)?code>DragTarget 的 builder 方法返回的candidateData是一個(gè)集合,所以可以同時(shí)響應(yīng)多個(gè)拖拽信息。數(shù)量上限取決于你的手機(jī)支持的多點(diǎn)觸控?cái)?shù)量。這個(gè)特點(diǎn)是Android 版本所沒有的。(雖然不知道能干什么,牛啤就完事了~~)

多點(diǎn)拖拽

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>

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容