引言
有過移動端開發經驗的同學都知道,移動端的觸控事件是由手指按下、手指移動、手指抬起這些基本事件組成的。
在Flutter
中,一切皆Widget
。Widget
本身並不具備識別觸控事件的功能。能識別觸控事件的Widget
,必須經由Listener
或GestureDetector
組裝起來。
而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提供了多種觸控事件的監聽,但我們經常用到的是onPointerDown
、onPointerMove
、onPointerUp
,分別對應手指按下、手指移動、手指抬起這三個觸控事件。
child
屬性表示被包裝的Widget
。
behavior
屬性,這是Listener
很重要的一個屬性,也是本節著重討論的,但是現在還輪不到他出場,在理解behavior
屬性之前,我們必須要認識一個概念,叫做命中測試(Hit Test)。
命中測試
當手指按下時,Flutter
會執行命中測試,它經歷了以下這幾步:
1、從最底層的Widget
開始執行命中測試,是否命中取決於hitTestChildren
方法(它的children Widget
是否命中測試)或hitTestSelf
方法是否返回true
。
2、迴圈最底層Widget
的children 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
中,每一個Widget
實際上會對應一個RenderObject
。對於上面程式碼來說,上圖為Widget
和RenderObject
的對應關係。
1、當點選了Text
時,它的命中測試列表是這樣的:
RenderParagraph
->RenderPositionedBox
->RenderConstrainedBox
->RenderPointerListener
,所以RenderPointerListener
的handleEvent
方法會被執行,最終在控制檯會列印onPointerDown。
注意:觸控事件會迴圈命中測試列表,並分別執行它們的
handleEvent
方法。Flutter
中幾乎所有Widget
對應的RenderObject
都是直接或者間接繼承自RenderBox
,而RenderBox
繼承了HitTestTarget,並重寫了handleEvent
方法。
2、當點選了Text
以外的區域時,它的命中測試列表就沒有RenderPointerListener
了。為什麼呢???
Text
以外的區域是ConstrainedBox
的(為什麼不是Center
,因為Center
的功能是幫助Text
定位,它的區域和Text
是一致的)。那ConstrainedBox
對應的RenderConstrainedBox
命中測試了麼?很顯然是沒有的。
因為ConstrainedBox
只有一個child
,就是Center
。Center
對應的RenderPositionedBox
沒有命中測試,導致RenderConstrainedBox
的hitTestChildren
返回false
,而它的hitTestSelf
也返回false
,所以RenderConstrainedBox
沒有命中測試。
而Listener
也只有一個child
,那就是ConstrainedBox
,既然RenderConstrainedBox
沒有命中測試,那麼RenderPointerListener
相應的就沒有命中測試,所以命中測試列表中是沒有RenderPointerListener
的。
所以控制檯並不會列印onPointerDown。
說明:命中測試方法是
RenderBox
(RenderObject
的子類)的hitTest
方法。
上面的例子使用的behavior
屬性是預設的HitTestBehavior.deferToChild
,如果修改一下behavior
屬性會有什麼奇妙的效果呢?
behavior屬性
behavior
表示命中測試(Hit Test)過程中的表現策略。它是一個列舉,提供了三個值,分別是HitTestBehavior.deferToChild
、HitTestBehavior.opaque
、HitTestBehavior.translucent
。
上面說到過,命中測試,就是看RenderBox
的hitTest
的返回值,如Listener
的hitTest
方法如下。
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.deferToChild:Listener
是否命中測試,取決於子child
是否命中測試,這是預設behavior
的預設值。
HitTestBehavior.opaque:當Listener
的子child
沒有命中測試時,該屬性值保證hitTestSelf
返回true
,即保證Listener
所在區域能響應觸控事件。
HitTestBehavior.translucent:當Listener
的子child
沒有命中測試時,並且hitTestSelf
返回false
時,該屬性值可以保證Listener
所在的區域能響應觸控事件(加入到命中測試列表),但是hitTest
方法返回值還是false
,這不能改變。
一個例子
上面那個例子,我們將Listener
的behavior
屬性修改為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
)
],
),
複製程式碼
它的展示效果如上圖所示。
上圖為Widget
與RenderObject
的對應關係。
1、behavior
為預設HitTestBehavior.deferToChild
屬性時,當點選了Text
以外的區域,它的命中測試列表是這樣的:
RenderDecoratedBox
->RenderConstrainedBox
->RenderPointerListener
->RenderStack
。
RenderStack
的hitTestChildren
會先找Stack
中最上層的child
,看它是否命中測試。很顯然,第一個child
,即第二個Listener
沒有命中測試。
然後它再去找第二個child
,即第一個Listener
是否命中測試。這裡的第一個Listener
包含的Container
設定了color
屬性,所以Container
這裡對應的是RenderDecoratedBox
,它通過了命中測試,相應的Listener
也通過了命中測試。
所以控制檯會只列印onPointerDown1。
2、將註釋2關閉,註釋1開啟,behavior
為HitTestBehavior.opaque
屬性時,當點選了Text
以外的區域,它的命中測試列表是這樣的:
RenderPointerListener
->RenderStack
。
RenderStack
的hitTestChildren
會先找Stack
中最上層的child
,看它是否命中測試。第一個child
,即第二個Listener
加上了HitTestBehavior.opaque
屬性後,通過了命中測試。
這個時候RenderStack
的hitTestChildren
直接返回了true
,它並不會再去檢測第二個child
,即第一個Listener
是否命中測試。
所以控制檯只會列印onPointerDown2。
3、將註釋1關閉,註釋2開啟,behavior
為HitTestBehavior.translucent
屬性時,當點選了Text
以外的區域,它的命中測試列表是這樣的:
RenderPointerListener
->RenderDecoratedBox
->RenderConstrainedBox
->RenderPointerListener
->RenderStack
。
RenderStack
的hitTestChildren
會先找Stack
中最上層的child
,看它是否命中測試。第一個child
,即第二個Listener
加上了HitTestBehavior.translucent
屬性後,通過了命中測試,加入命中測試列表。但必須注意的是,雖然通過了命中測試,但是該RenderPointerListener的hitTest方法返回false。
然後RenderStack會再去找第二個child
,即第一個Listener
是否命中測試。由上面的分析可知,它是通過了命中測試的。因此整個命中測試列表就是:
RenderPointerListener
->RenderDecoratedBox
->RenderConstrainedBox
->RenderPointerListener
->RenderStack
。
所以控制檯會先列印onPointerDown2,然後再列印onPointerDown1。
總結
Flutter
的Listener
元件是一切可觸控Widget
的包裝元件,在觸控事件確定怎麼樣傳遞時,需要對Widget
進行命中測試。Listener
提供了behavior
屬性,可靈活的改變Listener
在命中測試時的表現,提供多種不一樣的觸控表現。