Flutter實戰手勢番外篇之可拖拽懸浮元件

JulyYu發表於2020-02-25

前言

產品需求總是天馬行空,一天一個想法一天一個變更。本期需求中遇到一個特殊互動,產品大大希望在應用中有一個全域性浮動按鈕入口,希望使用者可以在應用每個地方都能點選進入到某一個頁面,從而增加該功能使用率。其實有點類似於在手機上增加一個快捷入口的懸浮球,將一些層級較深的功能提到一級選單,可以隨時隨地都能使用此類功能。

Flutter實戰手勢番外篇之可拖拽懸浮元件
雖然後面因為UI設計師覺得這樣的入口體驗並不友好砍掉了該需求,但前期我已經實現了Demo功能,所以還是想記錄一下該功能的實現方案。

實現方案

Draggable方式

Flutter提供了Draggable用於進行拖拽使用的元件。主要分為:child(準備拖拽的元件)、childWhenDragging(被拖拽後原點處的元件)、feedback(正在被拖拽的元件)。

Stack(
    children: <Widget>[
        Positioned(
          left: 100,
          top: 100,
          child: Draggable(
            child: Text("我只是演示使用"),
            childWhenDragging: Text("我被拉出去了?"),
            feedback: Text("我是拉出去的東西"),
          ),
          onDragEnd: (detail) {
            print(
                "Draggable onDragEnd ${detail.velocity.toString()} ${detail.offset.toString()}");
          },
          onDragCompleted: () {
            print("Draggable onDragCompleted");
          },
          onDragStarted: () {
            print("Draggable onDragStarted");
          },
          onDraggableCanceled: (Velocity velocity, Offset offset) {
            print(
                "Draggable onDraggableCanceled ${velocity.toString()} ${offset.toString()}");
          },          
        ),
    ],
),
複製程式碼

拖動過程中分為:onDragStarted(拖動開始)、onDragCompleted(拖動結束時拖拽到DragTarget)、onDraggableCanceled(拖動結束時未拖拽到DragTarget)、onDragEnd(拖動結束),拖動過程方法回撥順序如下:

Flutter實戰手勢番外篇之可拖拽懸浮元件
上述的拖拽操作結果的不同需要結合DragTarget可以體現,如拖拽到DragTarget中後抬起時觸發onDragCompleted回撥,若為拖拽到DragTarget中後抬起時觸發onDraggableCanceled回撥,通過不同的回撥結果知曉是否拖拽到DragTarget中。對於DragTarget暫時不做過多展開。

Flutter實戰手勢番外篇之可拖拽懸浮元件 Flutter實戰手勢番外篇之可拖拽懸浮元件

瞭解Draggable使用然後結合Stack和Positioned實現拖拽到全屏任意位置的效果了。

PS: 需要注意的是onDraggableCanceled的offset是globalPosition,所以需要減去全屏的TopPadding以及如果有ToolBar需要去它的高度。

double statusBarHeight = MediaQuery.of(context).padding.top;
double appBarHeight = kToolbarHeight;
Stack(
    children: <Widget>[
        Positioned(
          left: offset.dx,
          top: offset.dy,
          child: Draggable(
            child: Box(),
            childWhenDragging: Container(),
            feedback: Box(),
            onDraggableCanceled: (Velocity velocity, Offset offset) {
              //鬆手的時候
              //計算偏移量需要注意減去toobar高度和全域性topPadding高度
              setState(() {
                this.offset = Offset(
                    offset.dx, offset.dy - appBarHeight - statusBarHeight);
              });
            },
          ),
        ),
        Positioned(
          bottom: 10,
          child: Text("${offset.toString()}"),
        )
    ],
),
複製程式碼

Flutter實戰手勢番外篇之可拖拽懸浮元件

但在手勢操作中會發現正在被拖拽的元件Text非預設樣式,目前有兩種解決辦法:第一種是自定義TextStyle修改樣式;第二種是在feedback中巢狀一層Material。

feedback: Material(
    child: Text("我是拉出去的東西"),
),
複製程式碼

GestureDetector方式

GestureDetector實現方式自定義程度更高。GestureDetector具體使用已經在Flutter實戰之手勢基礎篇介紹過,有興趣可以看看。

GestureDetector結合Stack和Positioned,通過監聽手勢操作對Offset偏移量計算實現對元件進行位移和定位。主要使用GestureDetector的onPanUpdate方法,獲取到DragUpdateDetails中的delta計算出位移的dx和dy。將原有偏移量加上delta偏移量等於當前位置x,y座標點,另外結合元件自身大小和螢幕邊界值計算出最大和最小偏移量來控制元件最終可移動的最大和最小距離以防止懸浮元件超出螢幕。具體拖拽懸浮窗的詳細程式碼如下:

class AppFloatBox extends StatefulWidget {
  @override
  _AppFloatBoxState createState() => _AppFloatBoxState();
}

class _AppFloatBoxState extends State<AppFloatBox> {
  Offset offset = Offset(10, kToolbarHeight + 100);

  Offset _calOffset(Size size, Offset offset, Offset nextOffset) {
    double dx = 0;
    //水平方向偏移量不能小於0不能大於螢幕最大寬度
    if (offset.dx + nextOffset.dx <= 0) { 
      dx = 0;
    } else if (offset.dx + nextOffset.dx >= (size.width - 50)) {
      dx = size.width - 50;
    } else {
      dx = offset.dx + nextOffset.dx;
    }
    double dy = 0;
     //垂直方向偏移量不能小於0不能大於螢幕最大高度
    if (offset.dy + nextOffset.dy >= (size.height - 100)) {
      dy = size.height - 100;
    } else if (offset.dy + nextOffset.dy <= kToolbarHeight) {
      dy = kToolbarHeight;
    } else {
      dy = offset.dy + nextOffset.dy;
    }
    return Offset(
      dx,
      dy,
    );
  }

  @override
  Widget build(BuildContext context) {
    return  Positioned(
        left: offset.dx,
        top: offset.dy,
        child: GestureDetector(
          onPanUpdate: (detail) {
            setState(() {
              offset =
                  _calOffset(MediaQuery.of(context).size, offset, detail.delta);
            });
          },
          onPanEnd: (detail) {},
          child: Box()
          ),
      ),
    );
  }
}

複製程式碼

將懸浮窗元件AppFloatBox新增到Stack中,另外AppFloatBox必須在最上層否則可能會被其他元件覆蓋,整體程式碼如下:

Stack(
  fit: StackFit.expand,
  children: <Widget>[
    Container1(),
    Container2(),
    Container3(),
    AppFloatBox(), // 顯示在最上方
  ],
)
複製程式碼

Flutter實戰手勢番外篇之可拖拽懸浮元件

OverlayEntry方式(全域性模式)

介紹了以上兩種懸浮窗的實現方式但也存在弊端。如果我們需要在應用全域性中實現懸浮窗功能以上兩種方式會變得不優雅。因為Positioned依賴於Stack,需要整屏都是在Stack元件包裹下懸浮窗才能夠在全屏實現拖拽操作。若應用每個頁面都採用Stack進行佈局來管理懸浮窗會變得非常複雜和繁瑣,又或者原有專案每個頁面並不都是以Stack作為根元件的(難道還需要對全域性佈局做一次大改動?)。

所以最終採用Overlay是比較優雅和簡單的方式。實際上OverlayEntry其實類似與Stack的StatefulWidget,特點是懸浮於所有其他widget之上的元件,可以將想要的檢視疊加到全域性視窗中,因此只需要在OverlayEntry中加入想要的檢視並能一直浮現在全域性檢視了。

延用上一節的AppFloatBox,通過OverlayEntry建立AppFloatBox然後加入到Overlay中,同時可以通過呼叫OverlayEntry的remove方法直接從Overlay中移除當前元件。詳細程式碼如下:

static OverlayEntry entry;
Column(
    children: <Widget>[
      RaisedButton(
        child: Text("add"),
        onPressed: () {
          entry?.remove();
          entry = null;
          entry = OverlayEntry(builder: (context) {
            return AppFloatBox();
          });
          Overlay.of(context).insert(entry);
        },
      ),
      RaisedButton(
        child: Text("delete"),
        onPressed: () {
          entry?.remove();
          entry = null;
        },
      ),
    ],
  ),
複製程式碼

PS:如果非手動新增OverlayEntry可採用 SchedulerBinding.instance.addPostFrameCallback將懸浮窗加入到檢視中。

Flutter實戰手勢番外篇之可拖拽懸浮元件
?完整程式碼看這裡?

參考

相關文章