深入理解Flutter的Listener元件

AndroidHint發表於2020-03-01

引言

有過移動端開發經驗的同學都知道,移動端的觸控事件是由手指按下、手指移動、手指抬起這些基本事件組成的。

Flutter中,一切皆WidgetWidget本身並不具備識別觸控事件的功能。能識別觸控事件的Widget,必須經由ListenerGestureDetector組裝起來。

GestureDetector本質上還是由Listener組成的,所以我們先認識一下Listener

Listener

Listener在功能劃分上屬於功能型Widget,主要提供原始觸控事件的監聽。下面看一下它的建構函式:

const Listener({
    Key key,
    this.onPointerDown,
    this.onPointerMove,
    this.onPointerEnter,
    this.onPointerExit,
    this.onPointerHover,
    this.onPointerUp,
    this.onPointerCancel,
    this.onPointerSignal,
    this.behavior = HitTestBehavior.deferToChild,
    Widget child,
 })
複製程式碼

從建構函式中可以知道,Listener提供了多種觸控事件的監聽,但我們經常用到的是onPointerDownonPointerMoveonPointerUp,分別對應手指按下手指移動手指抬起這三個觸控事件。

child屬性表示被包裝的Widget

behavior屬性,這是Listener很重要的一個屬性,也是本節著重討論的,但是現在還輪不到他出場,在理解behavior屬性之前,我們必須要認識一個概念,叫做命中測試(Hit Test)

命中測試

當手指按下時,Flutter會執行命中測試,它經歷了以下這幾步:

1、從最底層的Widget開始執行命中測試,是否命中取決於hitTestChildren方法(它的children Widget是否命中測試)或hitTestSelf方法是否返回true

2、迴圈最底層Widgetchildren Widget,分別執行child Widget的命中測試。child Widget是否命中也取決於hitTestChidren方法(它的children Widget是否命中測試)或hitTestSelf方法是否返回true

3、從下往上遞迴地執行命中測試,直到找到最上層的一個命中測試的Widget,將它加入命中測試列表。由於它已命中測試,那麼它的父Widget也命中了測試,將父Widget也加入命中測試列表。以此類推,直到將所有命中測試的Widget加入命中測試列表。

一個例子

為了更加形象的理解命中測試這個概念,我們看一下下面的例子。

Listener(
    child: ConstrainedBox(
      constraints: BoxConstraints.tight(Size(200, 200)),
      child: Center(
        child: Text('click me'),
      )
    ),
    onPointerDown: (event) => print("onPointerDown")
)
複製程式碼

深入理解Flutter的Listener元件
它的展示效果如上圖所示。

深入理解Flutter的Listener元件

Flutter中,每一個Widget實際上會對應一個RenderObject。對於上面程式碼來說,上圖為WidgetRenderObject的對應關係。

1、當點選了Text時,它的命中測試列表是這樣的: RenderParagraph->RenderPositionedBox->RenderConstrainedBox->RenderPointerListener,所以RenderPointerListenerhandleEvent方法會被執行,最終在控制檯會列印onPointerDown

注意:觸控事件會迴圈命中測試列表,並分別執行它們的handleEvent方法。Flutter中幾乎所有Widget對應的RenderObject都是直接或者間接繼承自RenderBox,而RenderBox繼承了HitTestTarget,並重寫了handleEvent方法。

2、當點選了Text以外的區域時,它的命中測試列表就沒有RenderPointerListener了。為什麼呢???

Text以外的區域是ConstrainedBox的(為什麼不是Center,因為Center的功能是幫助Text定位,它的區域和Text是一致的)。那ConstrainedBox對應的RenderConstrainedBox命中測試了麼?很顯然是沒有的。

因為ConstrainedBox只有一個child,就是CenterCenter對應的RenderPositionedBox沒有命中測試,導致RenderConstrainedBoxhitTestChildren返回false,而它的hitTestSelf也返回false,所以RenderConstrainedBox沒有命中測試。

Listener也只有一個child,那就是ConstrainedBox,既然RenderConstrainedBox沒有命中測試,那麼RenderPointerListener相應的就沒有命中測試,所以命中測試列表中是沒有RenderPointerListener的。

所以控制檯並不會列印onPointerDown

說明:命中測試方法是RenderBoxRenderObject的子類)的hitTest方法。

上面的例子使用的behavior屬性是預設的HitTestBehavior.deferToChild,如果修改一下behavior屬性會有什麼奇妙的效果呢?

behavior屬性

behavior表示命中測試(Hit Test)過程中的表現策略。它是一個列舉,提供了三個值,分別是HitTestBehavior.deferToChildHitTestBehavior.opaqueHitTestBehavior.translucent

上面說到過,命中測試,就是看RenderBoxhitTest的返回值,如ListenerhitTest方法如下。

bool hitTest(BoxHitTestResult result, { Offset position }) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
}

bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
複製程式碼

HitTestBehavior.deferToChildListener是否命中測試,取決於子child是否命中測試,這是預設behavior的預設值。

HitTestBehavior.opaque:當Listener的子child沒有命中測試時,該屬性值保證hitTestSelf返回true,即保證Listener所在區域能響應觸控事件。

HitTestBehavior.translucent:當Listener的子child沒有命中測試時,並且hitTestSelf返回false時,該屬性值可以保證Listener所在的區域能響應觸控事件(加入到命中測試列表),但是hitTest方法返回值還是false,這不能改變。

一個例子

上面那個例子,我們將Listenerbehavior屬性修改為HitTestBehavior.opaque

Listener(
    child: ConstrainedBox(
      constraints: BoxConstraints.tight(Size(200, 200)),
      child: Center(
        child: Text('click me'),
      )
    ),
    behavior: HitTestBehavior.opaque, //顯性的修改behavior屬性
    onPointerDown: (event) => print("onPointerDown")
)
複製程式碼

當我們再次點選Text以外的區域時,可以發現命中列表中加入了RenderPointerListener

因為當RenderPointerListener執行hitTestSelf時,判斷behavior如果為HitTestBehavior.opaque,則返回true。也就是說RenderPointerListener符合命中測試。

所以,我們能看到控制檯將會列印onPointerDown

另一個例子

為了更深入的理解behavior屬性,我們再來看另外一個例子。

Stack(
  children: <Widget>[
    Listener(
      child: ConstrainedBox(
        constraints: BoxConstraints.tight(Size(400, 200)),
        child: Container(
          color: Colors.blue,
        )
      ),
      onPointerDown: (event) => print("onPointerDown1"),
    ),
    Listener(
      child: ConstrainedBox(
        constraints: BoxConstraints.tight(Size(400, 200)),
        child: Center(child: Text("dont click me")),
      ),
      onPointerDown: (event) => print("onPointerDown2"),
//    behavior: HitTestBehavior.opaque, //註釋1
//    behavior: HitTestBehavior.translucent,  //註釋2
    )
  ],
),
複製程式碼

深入理解Flutter的Listener元件
它的展示效果如上圖所示。
深入理解Flutter的Listener元件
上圖為WidgetRenderObject的對應關係。

1、behavior為預設HitTestBehavior.deferToChild屬性時,當點選了Text以外的區域,它的命中測試列表是這樣的: RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack

RenderStackhitTestChildren會先找Stack中最上層的child,看它是否命中測試。很顯然,第一個child,即第二個Listener沒有命中測試。

然後它再去找第二個child,即第一個Listener是否命中測試。這裡的第一個Listener包含的Container設定了color屬性,所以Container這裡對應的是RenderDecoratedBox,它通過了命中測試,相應的Listener也通過了命中測試。

所以控制檯會只列印onPointerDown1

2、將註釋2關閉,註釋1開啟,behaviorHitTestBehavior.opaque屬性時,當點選了Text以外的區域,它的命中測試列表是這樣的: RenderPointerListener->RenderStack

RenderStackhitTestChildren會先找Stack中最上層的child,看它是否命中測試。第一個child,即第二個Listener加上了HitTestBehavior.opaque屬性後,通過了命中測試。

這個時候RenderStackhitTestChildren直接返回了true,它並不會再去檢測第二個child,即第一個Listener是否命中測試。

所以控制檯只會列印onPointerDown2

3、將註釋1關閉,註釋2開啟,behaviorHitTestBehavior.translucent屬性時,當點選了Text以外的區域,它的命中測試列表是這樣的: RenderPointerListener->RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack

RenderStackhitTestChildren會先找Stack中最上層的child,看它是否命中測試。第一個child,即第二個Listener加上了HitTestBehavior.translucent屬性後,通過了命中測試,加入命中測試列表。但必須注意的是,雖然通過了命中測試,但是該RenderPointerListener的hitTest方法返回false

然後RenderStack會再去找第二個child,即第一個Listener是否命中測試。由上面的分析可知,它是通過了命中測試的。因此整個命中測試列表就是: RenderPointerListener->RenderDecoratedBox->RenderConstrainedBox->RenderPointerListener->RenderStack

所以控制檯會先列印onPointerDown2,然後再列印onPointerDown1

總結

FlutterListener元件是一切可觸控Widget的包裝元件,在觸控事件確定怎麼樣傳遞時,需要對Widget進行命中測試。Listener提供了behavior屬性,可靈活的改變Listener在命中測試時的表現,提供多種不一樣的觸控表現。

相關文章