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

封面

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

先給大家看看最終的實現效果以及與Android版的對比(個人覺得還原度很高,甚至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、文章點贊)。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章