在手機、平板上,主要是透過觸控螢幕進行操作,在 Flutter 中,除了一些按鈕等元件,大部份元件本身不能傾聽操作相關事件,然而,實際上也可以為這類元件加上一些事件傾聽。
Flutter 系統將相關事件分為兩個層次:原始指標事件(Raw pointer events)與手勢(Gestures)。
這邊談一下原始指標事件,指標描述的是手指或觸控筆在螢幕上的位置,想傾聽指標的相關事件,可以透過 Listerner 類別,就撰寫本文的時間點,可以傾聽的事件有:
onPointerCancel
onPointerDown
onPointerMove
onPointerSignal
onPointerUp
這就是原始指標事件之所以「原始」的原因,因為不包含一些較高階的手勢操作,像是拖曳、雙連觸(Double tap)等較高級的事件;手機、平板可以接上滑鼠,早期的 Listerner
可以傾聽滑鼠事件,不過現在相關的事件已經被廢棄,職責分給了 MouseRegion
。
Listerner
本身是個 Widget
,有個 child
可以指定子元件,因此最簡單的傾聽範例就是:
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Openhome.cc')),
body: Center(
child: Listener(
child: Container(
width: 300,
height: 200,
color: Colors.blue,
),
onPointerDown: (event) => print('Down: ${event.position.dx}, ${event.position.dy}'),
onPointerMove: (event) => print('Move ${event.position.dx}, ${event.position.dy}'),
onPointerUp: (event) => print('Up ${event.position.dx}, ${event.position.dy}'),
)
)
),
)
);
這個範例很簡單,就不貼操作示範的圖了。看來是包在想傾聽的子元件上,就可以傾聽該元件的原始事件,只不過,實際的 UI 不會這麼單純,來進一步看看,若在 Container
中設個子元件並加上另一個 Listener
會如何?
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Openhome.cc')),
body: Center(
child: Listener(
child: Container(
width: 300,
height: 200,
color: Colors.blue,
child: Center(
// 加上個 Listener,傾聽其子元件
child: Listener(
child: Text('按我'),
onPointerDown: (_) => print('Inner Down'),
),
),
),
onPointerDown: (_) => print('Outer Down'),
)
)
),
)
);
來看一下執行結果,分點在文字與其外圍點點看:
在點選文字時,Container
父元件 Listener
也被觸發了,這令人聯想到 DOM 事件浮昇,不過在 Flutter 中正確的說法是,「子元件命中測試(hit test)」通過,因而父裔元件的「Listener
的命中測試」就通過,不過這只是 Listener
預設的命中測試行為,這個行為可以藉由 behavior
來控制,預設是 HitTestBehavior.deferToChild
,另外還可以指定為 HitTestBehavior.opaque
或 HitTestBehavior.translucent
。
命中測試的實現,是在 RenderProxyBoxWithHitTestBehavior
的原始碼中:
@override
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;
}
@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;
hitTest
用來進行命中測試,如果指標位置(position
)在 Listener
範圍(size
)之內(也就是 size.contains(position)
該行),hitTestChildren
測試子元件是否命中,也就是指標是否在子元件繪製範圍內,子元件必須是可見的,透明的子元件不會命中。
因此上一個範例中,若將 Text('按我')
改為 Container(width: 50, height: 30)
,怎麼按中間位置,都不會顯示「Inner Down」的訊息,只會顯示「Outer Down」的訊息。
從原始碼中可以看到,如果 hitTestChildren
結果是 false
,然而 behavior
是 HitTestBehavior.opaque
的話,目前 Listener
命中測試也會通過,什麼時候會想要這種行為呢?來看看以下的情況:
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Openhome.cc')),
body: Center(
child: Container(
width: 300,
height: 200,
color: Colors.blue,
child: Listener(
// behavior: HitTestBehavior.opaque,
child: Center(
child: Text('按我'),
),
onPointerDown: (_) => print('Down'),
),
)
)
),
)
);
在這個範例中,Listener
的範圍與 Container
相同,如果你在 Text
繪製範圍內按下,子元件命中測試通過,因而父裔的 Listener
的命中測試通過,如果不是點在 Text
上,子元件命中測試不通過,就不會顯示「Down」的訊息。
若是將註解符號去除,若不是點在 Text
上,子元件命中測試不通過,然而因為 behavior
是 HitTestBehavior.opaque
,從方才的 hitTest
來看,Listener
可以通過命中測試,這時就會顯示「Down」的訊息。
簡單來說,opaque 代表著不透明,HitTestBehavior.opaque
也就是將 Listener
當成是個不透明元件,在指標落於 Listener
範圍時,就視為命中測試,因此底下的範例中,Listener
雖然沒有子元件,只要在藍色方塊中按下,都會顯示「Down」的訊息。
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Openhome.cc')),
body: Center(
child: Container(
width: 300,
height: 200,
color: Colors.blue,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) => print('Down'),
),
)
)
),
)
);
在方才的 hitTest
中可以看到,如果 hitTarget
為 true
,result
會收集命中測試的物件,被收集的物件會被發送事件。
如果 hitTarget
為 false
,然而 behavior
是 HitTestBehavior.translucent
,也會收集物件,這時意謂著就算 Listener
命中測試失敗,只要 behavior
是 HitTestBehavior.translucent
,一樣可以收到事件。
來看看底下的範例:
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Openhome.cc')),
body: Center(
child: Container(
width: 300,
height: 200,
color: Colors.blue,
child: Listener(
behavior: HitTestBehavior.translucent,
child: Center(
child: Text('按我'),
),
onPointerDown: (_) => print('Down'),
),
)
)
),
)
);
嗯?跟更之前的範例相同?只不過 HitTestBehavior.opaque
改為 HitTestBehavior.translucent
?就執行效果來看,兩者是相同的,然而意義不同,因為 HitTestBehavior.translucent
無論如何都會收到事件通知(就算命中測試失敗),這就像是把 Listener
當成一個透明膜,只要指標在 Listener
範圍中,就可以傾聽事件(無論命中測試成功或失敗)。
來看另一個堆疊元件的例子:
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Openhome.cc')),
body: Center(
child: Stack(
children: [
Listener(
child: Container(
width: 300,
height: 200,
color: Colors.blue,
),
onPointerDown: (event) => print("堆疊底部"),
),
Listener(
onPointerDown: (event) => print("有一層膜"),
// behavior: HitTestBehavior.translucent,
)
],
)
)
),
)
);
就這個例子來說,按藍色範圍,只會顯示「堆疊底部」,然而,若去掉註解符號,堆疊頂端罩的膜就能收到事件,按藍色範圍,就會顯示「有一層膜」與「堆疊底部」的訊息。
有的時候,會想要讓子元件無法參與指標事件,這時可以透過 IgnorePointer
和 AbsorbPointer
,兩者的差別在於,前者不能參與命中測試而後者可以,例如:
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Openhome.cc')),
body: Center(
child: Listener(
child: AbsorbPointer(
child: Listener(
child: Container(
width: 300,
height: 200,
color: Colors.blue,
),
onPointerDown: (_) => print('Container'),
)
),
onPointerDown: (_) => print('AbsorbPointer'),
)
)
),
)
);
上例中,點選藍色範圍,只會出現「AbsorbPointer」訊息,因為 AbsorbPointer
不會有指標事件,如果將 AbsorbPointer
改為 IgnorePointer
,就怎麼點都不會有訊息了。