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

前言

產品需求老是天馬行空,一天一個想法一天一個變動。本期需求中遇到一個特殊交互,產品大大但願在應用中有一個全局浮動按鈕入口,但願用戶能夠在應用每一個地方都能點擊進入到某一個頁面,從而增長該功能使用率。其實有點相似於在手機上增長一個快捷入口的懸浮球,將一些層級較深的功能提到一級菜單,能夠隨時隨地都能使用此類功能。html

雖而後面由於UI設計師以爲這樣的入口體驗並不友好砍掉了該需求,但前期我已經實現了Demo功能,因此仍是想記錄一下該功能的實現方案。

實現方案

Draggable方式

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

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(拖動結束),拖動過程方法回調順序以下:github

上述的拖拽操做結果的不一樣須要結合DragTarget能夠體現,如拖拽到DragTarget中後擡起時觸發onDragCompleted回調,若爲拖拽到DragTarget中後擡起時觸發onDraggableCanceled回調,經過不一樣的回調結果知曉是否拖拽到DragTarget中。對於DragTarget暫時不作過多展開。

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

PS: 須要注意的是onDraggableCanceled的offset是globalPosition,因此須要減去全屏的TopPadding以及若是有ToolBar須要去它的高度。bash

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()}"),
        )
    ],
),
複製代碼

但在手勢操做中會發現正在被拖拽的組件Text非默認樣式,目前有兩種解決辦法:第一種是自定義TextStyle修改樣式;第二種是在feedback中嵌套一層Material。app

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

GestureDetector方式

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

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必須在最上層不然可能會被其餘組件覆蓋,總體代碼以下:post

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

OverlayEntry方式(全局模式)

介紹了以上兩種懸浮窗的實現方式但也存在弊端。若是咱們須要在應用全局中實現懸浮窗功能以上兩種方式會變得不優雅。由於Positioned依賴於Stack,須要整屏都是在Stack組件包裹下懸浮窗纔可以在全屏實現拖拽操做。若應用每一個頁面都採用Stack進行佈局來管理懸浮窗會變得很是複雜和繁瑣,又或者原有項目每一個頁面並不都是以Stack做爲根組件的(難道還須要對全局佈局作一次大改動?)。ui

因此最終採用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將懸浮窗加入到視圖中。

🚀完整代碼看這裏🚀

參考

相關文章
相關標籤/搜索