玩玩Flutter的拖拽——實現一款萬能遙控器

唯鹿發表於2020-04-01

封面

前陣子突然想到兩年前寫過的一篇部落格:玩玩Android的拖拽——實現一款萬能遙控器,就想著用Flutter來複刻一下。順便練習一下Flutter裡的拖拽Widget。

先給大家康康最終的實現效果及對比(個人覺得還原度很高,甚至Flutter版的更好):

Android Flutter
Android
Flutter

因為有之前Android版本的實現經驗,所以省了不少時間,當然也踩了不少坑,前前後後用了3天時間。下面我來介紹下實現流程。

UI實現

整個UI分為上下兩部分,上半部分為手機(遙控器),下半部分是遙控按鈕的選擇選單。

手機

使用CustomPainter來畫一個手機外觀。這部分都是各種位置計算以及CanvasPaint API的呼叫。比如畫線、圓、矩形、圓角矩形等。

程式碼就不貼出來了(原始碼連結在文末),說一下需要注意的一點。

  • 繪製田字格時外框為實線,裡側為虛線。Canvas 貌似沒有提供繪製虛線的方法(Android 使用 Paint.setPathEffect來更改樣式),所以只能通過迴圈給Path 新增虛線的路徑位置,最終呼叫CanvasdrawPath方法繪製。 這裡我使用了path_drawing庫來實現,它封裝了這一迴圈操作,便於使用。
  // 虛線段長4,間隔4
  Path _dashPath = dashPath(_mPath, dashArray: CircularIntervalList<double>(<double>[4, 4]));
  canvas.drawPath(_dashPath, _mPhonePaint);
複製程式碼

遙控按鈕的選擇選單

這部分很簡單,一個PageView,裡面用GridView排列好對應的按鈕。為了方便實現底部指示器效果,我這裡使用了flutter_swiper來替代PageView實現。

按鈕

按鈕的素材圖片本身是沒有圓形邊框的。其次按鈕的按下時會有一個背景色變化。這部分可以通過BoxDecorationGestureDetector實現。大致程式碼如下:

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;
          });
        },
      ),
    );
  }
}

複製程式碼

拖動實現

這裡就用到了今天的主角DraggableDragTarget

  • 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區域時呼叫

上面介紹了DraggableDragTarget 的作用及使用屬性。那麼也就很明顯,底部的按鈕就是Draggable,上半部的手機螢幕就是DragTarget

不過這裡有個問題,Draggable沒有提供拖動中的回撥(無法獲取實時位置),DragTarget也沒有提供Draggable在區域中拖動的回撥。這導致我們無法實時在手機螢幕上顯示“指示投影”。

指示投影

所以這裡只能拷出原始碼修改,自己動手豐衣足食。主要位置是_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方法,回撥有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

  • 注意避免重複新增同一按鈕。因為二次拖動時不一定會觸發DragTargetonLeave

addData(DraggableInfo info) {
  /// 避免重複新增同一按鈕,這裡已重寫DraggableInfo的 == 操作符
  if (!data.contains(info)) {
    data.add(info);
  }
}
複製程式碼

優化

  • 對於DraggabledragAnchor屬性,是為了確定起始點的位置(錨點),有兩種模式child與pointer。
  1. DragAnchor.child就是以點選點作為起始點(動態位置)。如果feedbackchild一致,那麼feedback它們將重合。

  2. 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()
  ),
)
複製程式碼

其他

因為DragTargetbuilder 方法返回的candidateData是一個集合,所以可以同時響應多個拖拽資訊。數量上限取決於你的手機支援的多點觸控數量。這個特點是Android 版本所沒有的。(雖然不知道能幹什麼,牛啤就完事了~~)

多點拖拽

PS:

本篇雖然看似是一個UI效果實現,但其實也是之前的“說說”系列的一個實踐總結。上面文章中也有提到過:

沒有上面的這三篇作為基礎,那麼也無法有這樣的完成度,推薦大家閱讀


到這裡我就將整個實現的重點說完了,其他的計算細節這裡就不說了,可以去看看原始碼。奉上Github地址,有興趣的可以跑起來玩玩。記得不要白嫖,來個素質三連哦(star、fork、文章點贊)。

我在這裡提前感謝大家了,你的支援就是我最大的動力!!

相關文章