Flutter | 事件處理

345丶發表於2021-04-01

概述

在移動端,各個平臺或者 UI 系統的事件模型都是基本一致,即:一次完整的事件分為三個階段,手指按下,移動,抬起,而其他的雙擊,拖動等都是基於這些事件的

當指標按下時,Flutter 會對應用程式執行命中測試(Hit Test) ,以確定指標與螢幕接觸的位置存在哪些 Widget,指標按下事件(以及該指標的後續事件)會被分發到由命中測試發現的最內部的元件,然後從哪裡開始,事件會在元件樹中向上冒泡,這些事件會從最內部的元件分發的元件樹的根路徑上的所有元件,這個 Web 開發瀏覽器的事件冒泡機制相似,但是 Flutter 中沒有機制取消或者停止冒泡過程,而瀏覽器是可以停止的。

注意:只有通過命中測試的元件才能觸發事件

原始指標事件處理

Flutter 中可以使用 Listener 來監聽原始觸控事件,按照<Flutter實戰> 中的分類,Listener 也是一個功能性元件,下面是 Listener 的建構函式定義:

Listener({
  Key key,
  this.onPointerDown, //手指按下回撥
  this.onPointerMove, //手指移動回撥
  this.onPointerUp,//手指抬起回撥
  this.onPointerCancel,//觸控事件取消回撥
  this.behavior = HitTestBehavior.deferToChild, //在命中測試期間如何表現
  Widget child
})
複製程式碼
  • behavior 在後面專門介紹

示例:

class EventTest extends StatefulWidget {
  @override
  _EventTestState createState() => _EventTestState();
}

class _EventTestState extends State<EventTest> {
  PointerEvent _event;

  @override
  Widget build(BuildContext context) {
    return Listener(
      child: Container(
        margin: EdgeInsets.only(top: 50),
        color: Colors.blue,
        alignment: Alignment.center,
        child: Text(_event?.toString() ?? "",
            style: TextStyle(color: Colors.white)),
      ),
      onPointerDown: (PointerDownEvent event) =>
          setState(() => {_event = event}),
      onPointerMove: (PointerMoveEvent event) =>
          setState(() => {_event = event}),
      onPointerUp: (PointerUpEvent event) => setState(() => {_event = event}),
    );
  }
}
複製程式碼

效果如下:

image-20210330215620656

手指在藍色區域內移動即可看到當前指標偏移,當觸發指標事件時,引數 PointerDownEvent,PointerMoveEvent,PointerUpEvent 都是 PointerEvent 的子類,PointerEvent 包含當前指標的一些資訊,如:

  • position:他是滑鼠相對於全域性座標的偏移
  • delta:兩次指標移動事件的距離
  • pressure:按壓力度,如果手機螢幕支援壓力感測器,此屬性才會有意義,如手機不支援,始終為 1。
  • orientation:指標移動方向,是一個角度值

上面只是一些常用屬性,除了這些還有很多其他屬性,可自行檢視 API

behavior

他決定子元件如何響應命中測試,他的值為 HitTestBehavior,是一個列舉類,有三個列舉值

  • deferToChild:子元件會一個一個的進行命中測試,如果子元件中有測試通過的,則當前元件通過,這意味著指標事件作用於子元件時,其父級元件也肯定可以接收到事件

  • opaque:在命中測試時,將當前元件當初不透明處理(即使本身是透明的),最終的效果相當於當前 Widget 的整個區域都是點選區域。栗子:

    Listener(
        child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(300.0, 150.0)),
            child: Center(child: Text("Box A")),
        ),
        //behavior: HitTestBehavior.opaque,
        onPointerDown: (event) => print("down A")
    ),
    複製程式碼

    上例子,只有點選文字區域才會觸發點選事件,因為 deferToChild 會去子元件判斷是否命中測試,該例中子元件就是 Text("Box A") 。

    如果想讓整個 300x150 的區域都能點選,我們可以將 behavior 設為 HitTestBehavior.opaque。

    注意:該屬性不能用於在元件樹中攔截(忽略)事件,他只是決定命中測試時的元件大小

  • translucent:當元件點選透明區域時,可以對自身邊界及底部可視區域都進行命中測試。這意味著點選頂部元件透明區域時,頂部元件和底部元件都可以接收到事件,例如:

    Stack(
      children: <Widget>[
        Listener(
          child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(300.0, 200.0)),
            child: DecoratedBox(
                decoration: BoxDecoration(color: Colors.blue)),
          ),
          onPointerDown: (event) => print("down0"),
        ),
        Listener(
          child: ConstrainedBox(
            constraints: BoxConstraints.tight(Size(200.0, 100.0)),
            child: Center(child: Text("左上角200*100範圍內非文字區域點選")),
          ),
          onPointerDown: (event) => print("down1"),
          //behavior: HitTestBehavior.translucent, //放開此行註釋後可以"點透"
        )
      ],
    )
    複製程式碼

    上慄中,當註釋掉最後一行程式碼,在左上角200x100 範圍內非文字區域點選時(頂部元件透明區域),控制檯只會列印 down0,也就是說頂部沒有接收到事件,只有底部接收到了

    當放開註釋後,再點選時頂部和底部都會接收到事件

忽略 PinterEvent

如果我們不想讓某個子樹響應 PointerEvent ,則可以使用 IgnorePointerAbsorbPointer,這兩個元件都能阻止子樹接受指標事件,不同之處在於 AbsorbPointer 會參與命中測試,而 IgnorePointer 本身不會參與,這就意味著 AbsorbPointer 本身是可以接受指標事件的(但其子樹不行),而 IngorePointer 不可以,例:

Listener(
  child: AbsorbPointer(
    child: Listener(
      child: Container(
        color: Colors.red,
        width: 200.0,
        height: 100.0,
      ),
      onPointerDown: (event)=>print("in"),
    ),
  ),
  onPointerDown: (event)=>print("up"),
)
複製程式碼

點選 Container 時,由於他在 AbsorbPointer 子樹上,所以不會響應指標事件,

但是 AbsorbPoniter 本身是可以接受指標事件的,所以會輸出 up,如果將 AbsorbPointer 換成 IgnorePointer,那麼兩個都不會輸出;

手勢識別

GestuerDetector

GestureDetector 是一個用於手勢識別的功能性元件,我們可以通過它來識別各種手勢

GestureDetector 實際上是指標事件的語義化封裝,下面我們來看一下各種手勢識別。

點選,雙擊,長按

我們通過 GestureDetector 對 Container 進行手勢識別,觸發相應事件後,在 Container 上顯示事件名,如下:

class _EventTestState extends State<EventTest> {
  //事件名稱
  String _operation = "";

  @override
  Widget build(BuildContext context) {
    return Center(
      child: GestureDetector(
        child: Container(
          width: 200,
          color: Colors.blue,
          alignment: Alignment.center,
          height: 100,
          child: Text(_operation, style: TextStyle(color: Colors.white,fontSize: 20)),
        ),
        onTap: () => upDateText("tap"), //單擊
        onDoubleTap: () => upDateText("doubleTap"), //雙擊
        onLongPress: () => upDateText("longPress"), //長按
      ),
    );
  }

  void upDateText(String text) {
    setState(() {
      _operation = text;
    });
  }
}
複製程式碼

345

注意:當同時監聽 onTop 和 onDoubleTap 時,當使用者觸發 tap 事件時,會有 200 毫秒的延時,這是因為可能會再次點選觸發雙擊事件

如果只監聽了 onTap,則不會有延時

拖動,滑動

一次完整的手勢過程是指使用者手指按下到抬起的整個過程,期間,使用者按下後可能會移動,也可能不移動。

GestureDetector 對拖動和滑動事件時沒有區分的,他們本質是一樣的。

GestureDetector 會把要監聽的元件的原點(左上角)作為本次手勢的原點,當監聽元件上手指按下時,手勢識別就會開始。例:

class _EventTestState extends State<EventTest> with SingleTickerProviderStateMixin {

  double _top = 100.0; //距離頂部的偏移
  double _left = 100.0; //距離左邊的偏移
  @override
  Widget build(BuildContext context) {

    return Scaffold(
      body: Stack(
        children: <Widget>[
          Positioned(
            top: _top,
            left: _left,
            child: GestureDetector(
              child: CircleAvatar(child: Text("A")),
              //手指按下回撥
              onPanDown: (DragDownDetails e) {
                print('使用者手指按下 ${e.globalPosition}');
              },
              //手指滑動回撥
              onPanUpdate: (DragUpdateDetails e) {
                //滑動時,更新偏移
                print('滑動');
                setState(() {
                  _left += e.delta.dx;
                  _top += e.delta.dy;
                });
              },
              onPanEnd: (DragEndDetails e) {
                //滑動結束,列印 x,y軸速度
                print(e.velocity);
              },
            ),
          )
        ],
      ),
    );
  }
}
複製程式碼
  • globalPosition:此屬性為使用者按下時相對於螢幕(非父元件)原點的偏移
  • delta:當使用者在螢幕上滑動時,會觸發多次 Update 事件,dalta 指一次 Update 事件滑動的偏移量
  • velocity:該屬性代表使用者抬起時的滑動速度(包含x,y兩個軸的),上例中沒有處理抬起的速度,常見的效果是根據抬起手指的速度做一個減速動畫

效果如下:

345
I/flutter ( 8239): 使用者手指按下 Offset(134.9, 280.7)
I/flutter ( 8239): 滑動
I/chatty  ( 8239): uid=10152(com.flutter.flutter_study) 1.ui identical 302 lines
I/flutter ( 8239): 滑動
I/flutter ( 8239): Velocity(-59.6, 244.0)
複製程式碼
單一方向拖動

在很多場景中,我們只需要沿著一個方向來拖動,如一個垂直方向的列表

GestureDetector 支援特定方向的手勢事件,例如:

Positioned(
  top: _top,
  child: GestureDetector(
    child: CircleAvatar(child: Text("A")),
    //手指按下回撥
    onPanDown: (DragDownDetails e) {
      print('使用者手指按下 ${e.globalPosition}');
    },
    onVerticalDragUpdate: (DragUpdateDetails e) {
      setState(() {
        _top += e.delta.dy;
      });
    },
    onPanEnd: (DragEndDetails e) {
      //滑動結束,列印 x,y軸速度
      print(e.velocity);
    },
  ),
)
複製程式碼

修改滑動的那個例子如上即可

縮放

GestureDetector 可以監聽縮放事件,如下:

Center(
  child: GestureDetector(
    child: Image.asset("./images/avatar.jpg", width: _width),
    onScaleUpdate: (ScaleUpdateDetails details) {
      setState(() {
        //縮放倍數在 0.8 到 10 倍之間
        _width = 100 * details.scale.clamp(.8, 10.0);
      });
    },
  ),
);
複製程式碼
345

上例比較簡單,實際中我們可能還需要一些其他功能,如雙擊放大縮小,執行動畫等,有興趣的可以先嚐試一下

GestureRecognizer

getstureDetector 內部是使用一個或者多個 GestureRecognizer 來識別各種手勢的,而 GestureRecognizer 的作用就是通過 Listener 將原始指標轉換為語義手勢

GestureRecognizer 是一個抽象類,一種手勢對應一個子類,Flutter 實現了豐富的手勢識別器,我們可以直接使用。

例如:

我們要給一段富文字 (RichText) ,的不同部分新增事件處理器,但是 TextSpan 並不是一個 widget,所以不能用 GestureDetector。但是 TextSpan 有一個 Recongizer 屬性,他可以接收一個 GestureRecognizer。

bool _toggle = false; //變色開關
TapGestureRecognizer _recognizer = TapGestureRecognizer();

Widget bothDirectionTest() {
  return Center(
    child: Text.rich(TextSpan(children: [
      TextSpan(text: "你好世界"),
      TextSpan(
          text: "點選變色",
          style: TextStyle(
              fontSize: 30, color: _toggle ? Colors.red : Colors.yellow),
          recognizer: _recognizer
            ..onTap = () {
              setState(() {
                _toggle = !_toggle;
              });
            }),
      TextSpan(text: "你好世界")
    ])),
  );
}
@override
void dispose() {
    //用到GestureRecognizer的話一定要呼叫其dispose方法釋放資源
    _recognizer.dispose();
    super.dispose();
}

複製程式碼

注意:使用 GestureRecognizer 之後,一定要呼叫其 dispose 方法來釋放資源(主要是取消內部的計時器),執行效果如下:

345

手勢競爭與衝突

競爭

如在上例中,同時監聽水平方向和垂直方向的拖動事件,那麼斜著滑動時那個方向會生效? 實際上取決於第一次移動時兩個軸上的位移分量,那個軸的大,那麼哪個軸就會在本次滑動事件中勝出

實際上 Flutter 中引入了一個 Arenal 的概念,直譯為 競技場 的意思,每一個手勢識別器(GestureRecognizer) 都是一個競爭者(GestureArenaMember),當發生滑動事件時,他們都要在 競技場 去競爭本次事件的處理權,而最終只有一個競爭者會勝出。

例如有一個 ListView,他的第一個子元件也是 ListView,如果滑動子 ListView,父 ListView 會動嗎?答案肯定是不會動的,這時只有子 ListView 會動,這是因為子 LsitView 貨到了滑動事件的處理權。

示例

var _top1 = 100.0;
var _left1 = 100.0;

Widget bothDirection() {
  return Stack(
    children: [
      Positioned(
        top: _top1,
        left: _left1,
        child: GestureDetector(
          child: CircleAvatar(child: Text("A")),
          onVerticalDragUpdate: (DragUpdateDetails details) {
            setState(() {
              _top1 += details.delta.dy;
            });
          },
          onHorizontalDragUpdate: (DragUpdateDetails details) {
            setState(() {
              _left1 += details.delta.dx;
            });
          },
        ),
      )
    ],
  );
}
複製程式碼

執行之後,每次拖動只會沿著一個方向移動,而競爭者發生在手指按下後首次移動時

上例中獲勝的條件是,首次移動時的位置在水平和垂直方向上分量大的一個獲勝

手勢衝突

由於手勢競爭最終只有一個勝出者,所以,當有多個手勢識別器時,可能會產生衝突;

例如有一個 Widget,可以左右拖動,現在我們也想檢測它上面手指按下和抬起的事件,如下:

var _left2 = 100.0;
Widget flictTest() {
  return Stack(
    children: [
      Positioned(
        left: _left2,
        top: 100,
        child: GestureDetector(
          child: CircleAvatar(child: Text("A")),
          onHorizontalDragUpdate: (DragUpdateDetails details) {
            setState(() {
              _left2 += details.delta.dx;
            });
          },
          onHorizontalDragEnd: (details) {
            print('onHorizontalDragEnd');
          },
          onTapDown: (details) {
            print('down');
          },
          onTapUp: (details) {
            print('up');
          },
        ),
      )
    ],
  );
}
複製程式碼

拖動後,日誌如下:

0I/flutter ( 4315): down
I/flutter ( 4315): onHorizontalDragEnd
複製程式碼

我們發現沒有列印 up,這是因為拖動時,在按下手指沒有移動時,拖動手勢還沒有完整的語義,此時 TapDown 手勢勝出,此時列印 down,而拖動時,拖動手勢勝出,當抬起時, onHorizontalDragEnd 和 onTap 發生衝突,但是應為是在拖動的語義中,所以 onHorizeontalDragend 勝出,所以就會列印 onHorizontalDragEnd。

如果我們的邏輯程式碼中,對手指的按下和抬起時強依賴的,例如輪播元件,我們希望按下時暫停輪播,抬起時恢復輪播。但是由於輪播元件中本身可能已經處理了拖動手勢,甚至支援了縮放手勢,這時外部如果再用 onTapDown,onTap 來監聽是不行的。

這個時候就可以同個 Listener 監聽原始指標事件就行:

Listener(
    child: GestureDetector(
      child: CircleAvatar(child: Text("A")),
      onHorizontalDragUpdate: (DragUpdateDetails details) {
        setState(() {
          _left2 += details.delta.dx;
        });
      },
      onHorizontalDragEnd: (details) {
        print('onHorizontalDragEnd');
      },
    ),
    onPointerDown: (details){
      print('onPointerDown');
    },
    onPointerUp: (details){
      print('onPointerUp');
    },
  ),
)
複製程式碼

手勢衝突只是手勢級別的,而手勢是對原始指標的語義化識別,所以在遇到複雜的衝突場景時,都可以通過 Listener 直接識別原始指標事件來解決衝突

事件匯流排

在 App 中,我們經常需要一個廣播機制,用以誇頁面事件通知,例如登出登入時,某些頁面可能需要進行狀態更新。這個時候一個事件匯流排便會非常有用;

事件匯流排通常實現了訂閱者模式,訂閱者包含訂閱者和釋出者兩個角色,可以通過事件匯流排來觸發事件和監聽事件;

程式碼如下:

typedef void EventCallback(arg);

class EventBus {
  //私有構造
  EventBus._internal();

  static EventBus _singleton = new EventBus._internal();

  //工廠建構函式
  factory EventBus() => _singleton;

  //儲存時間訂閱者佇列,key:事件名(id),value:對應的實際訂閱者佇列
  var _eMap = new Map<Object, List<EventCallback>>();

  ///新增訂閱者
  void on(eventName, EventCallback f) {
    if (eventName == null || f == null) return;
    _eMap[eventName] ??= [];
    _eMap[eventName].add(f);
  }

  ///移除訂閱者
  void off(eventName, [EventCallback f]) {
    var list = _eMap[eventName];
    if (eventName == null || list == null) return;
    if (f == null) {
      _eMap[eventName] = null;
    } else {
      list.remove(f);
    }
  }
	
  ///觸發訂閱者
  void emit(eventName, [arg]) {
    var list = _eMap[eventName];
    if (list == null) return;
    int len = list.length - 1;
    for (var i = len; i > -1; i--) {
      list[i](arg);
    }
  }
}

///定義一個 top-level,全域性變數,頁面引入該檔案之後可以直接使用 bug
var bus = new EventBus();
複製程式碼

使用如下:

//監聽登入失效
bus.on(Event.LOGIN_OUT, (arg) {
  SpUtil.putString(Application.accessToken, null);
  Application.router.navigateTo(context, Routes.login, clearStack: true);
});

//觸發失效事件
bus.emit(Event.LOGIN_OUT, null);
複製程式碼

注意:Dart 中實現點了模式的標準做法就是使用 static 變數 + 工廠建構函式的方式,這樣就可以保證 new EventBus() 始終返回都是同一個例項

事件匯流排常用於元件之間的狀態共享,但是關於元件之間的狀態共享也有一些專門的包,如 redux,以及 Provider。

對於一些簡單的應用,事件匯流排總是奏議滿足業務需求,如果覺得使用狀態管理包的話,一定要想清楚 APP 是否有必要使用它,防止化簡為繁的過度設計。


參考

參考自 Flutter實戰

相關文章