HarmonyOS自定義控件之觸摸事件與事件分發(fā)
51CTO和華為官方合作共建的鴻蒙技術(shù)社區(qū)
觸摸事件
如何監(jiān)聽觸摸事件
HarmonyOS中可以通過Listener的方式:
- setTouchEventListener(new TouchEventListener() {
- @Override
- public boolean onTouchEvent(Component component, TouchEvent touchEvent) {
- return false;
- }
- });
注意:setTouchEventListener會(huì)被覆蓋
常用的觸摸事件的類型
這里我們對(duì)比其他主流系統(tǒng)中MotionEvent與HarmonyOS中TouchEvent來方便理解與記憶。
MotionEvent的常用的事件類型與HarmonyOS中的TouchEvent類型基本可以對(duì)應(yīng)起來:
- MotionEvent.ACTION_CANCEL -> TouchEvent.CANCEL
- MotionEvent.ACTION_HOVER_ENTER -> TouchEvent.HOVER_POINTER_ENTER
- MotionEvent.ACTION_HOVER_EXIT -> TouchEvent.HOVER_POINTER_EXIT
- MotionEvent.ACTION_HOVER_MOVE -> TouchEvent.HOVER_POINTER_MOVE
- MotionEvent.ACTION_POINTER_DOWN -> TouchEvent.OTHER_POINT_DOWN
- MotionEvent.ACTION_POINTER_UP -> TouchEvent.OTHER_POINT_UP
- MotionEvent.ACTION_MOVE -> TouchEvent.POINT_MOVE
- MotionEvent.ACTION_DOWN -> TouchEvent.PRIMARY_POINT_DOWN
- MotionEvent.ACTION_UP -> TouchEvent.PRIMARY_POINT_UP
常用的Api
獲取事件類型
- touchEvent.getAction() == TouchEvent.PRIMARY_POINT_DOWN
獲取手指相對(duì)于屏幕的x、y坐標(biāo)
- touchEvent.getPointerScreenPosition(touchEvent.getIndex()).getX();
- touchEvent.getPointerScreenPosition(touchEvent.getIndex()).getY();
獲取手指相對(duì)于父控件的x、y坐標(biāo)
- touchEvent.getPointerPosition(touchEvent.getIndex()).getX();
- touchEvent.getPointerPosition(touchEvent.getIndex()).getY();
getPointerScreenPosition與getPointerPosition的區(qū)別
前者是相對(duì)的屏幕的坐標(biāo),而后者是相對(duì)于父控件的坐標(biāo)。如果在手指滑動(dòng)過程中,對(duì)該控件做了位移,那么getPointerPosition獲取的坐標(biāo)將會(huì)是手指本身坐標(biāo)加上控件的位移量,導(dǎo)致位移異常。
這里建議,如果需要根據(jù)坐標(biāo)來計(jì)算,都使用getPointerScreenPosition比較保險(xiǎn)。
總結(jié)
TouchEvent提供了基礎(chǔ)api,但是沒有MotionEvent內(nèi)一些比較高階的api,比如obtain等。接下來我們來關(guān)注更為重要的事件分發(fā)。
事件分發(fā)
事件分發(fā)是一套比較重要同時(shí)也比較復(fù)雜的機(jī)制,如果不熟悉這套機(jī)制,那么在遇到稍微復(fù)雜的滑動(dòng)失效問題就會(huì)覺得手足無措。在這里通過打印日志的方式來摸索HarmonyOS上的事件的傳遞機(jī)制。
HarmonyOS中事件的傳遞機(jī)制
首先,我們通過打印日志的方式,來摸索觸摸事件是如何在Component中傳遞的。經(jīng)過實(shí)驗(yàn),發(fā)現(xiàn)如下幾條規(guī)律:
- 事件首先會(huì)傳遞到最底層的目標(biāo)控件,而非頂層的父控件
- 如果目標(biāo)控件不處理該事件,即onTouchEvent返回false,那么事件冒泡到父控件
- 如果目標(biāo)控件處理了該事件,即onTouchEvent返回true,那么后續(xù)事件不會(huì)向上冒泡,而是直接被目標(biāo)控件消費(fèi)
- 如果一個(gè)控件在down事件中,返回了false,那么后續(xù)的事件也不會(huì)被傳遞到該控件中
- 如果一個(gè)控件接受到了down事件,并返回了true,那么后續(xù)的事件會(huì)直接被傳遞到該控件中,其他控件不會(huì)收到事件
HarmonyOS中的事件傳遞更像是冒泡,而非分發(fā),down事件一旦被某一個(gè)控件消費(fèi)了,那么其他控件將都收不到后續(xù)事件了。這樣的機(jī)制比較難去實(shí)現(xiàn)一些復(fù)雜的嵌套效果。
比如子控件響應(yīng)橫向滑動(dòng),父控件響應(yīng)垂直滑動(dòng)這種情況。子控件如果要想收到后續(xù)的move事件,只能在down的時(shí)候返回true,這樣就導(dǎo)致父控件完全收不到觸摸事件。子控件如果像要在move時(shí)判斷滑動(dòng)方向而down事件返回了false,那么子控件將再也接收不到后續(xù)的事件了。
HarmonyOS的事件冒泡比較簡(jiǎn)單,一旦約定好就再也沒有反悔的機(jī)會(huì)了。那么如何類似其他主流系統(tǒng)一樣,從頂層控件分發(fā)并且可以攔截事件呢?
這里只提供思路,具體代碼可以參考:事件分發(fā)
實(shí)現(xiàn)事件分發(fā)
我們構(gòu)想中的事件分發(fā)應(yīng)該是這樣:事件是首先到頂層的父控件,然后經(jīng)過dispatchTouchEvent一層層向下分發(fā)。ComponentContainer可以通過onInterceptTouchEvent攔截事件,并交給自己的onTouchEvent來處理。如果ComponentContainer不處理事件則繼續(xù)向下分發(fā),直到最終的Component控件。這樣的機(jī)制意味著每一層都有機(jī)會(huì)能拿到事件,那么如何在HarmonyOS中實(shí)現(xiàn)呢?
我們可以將事件分發(fā)相關(guān)的函數(shù)與代碼,抽取出來,移植到HarmonyOS中,并通過一些手段應(yīng)用到HarmonyOS的onTouchEvent中。
抽象
HarmonyOS中沒有dispatchTouchEvent、onInterceptTouchEvent等函數(shù),如何應(yīng)用到組件中呢?抽象接口,將事件分發(fā)相關(guān)的函數(shù)抽象成兩個(gè)接口:
View
- /**
- * 事件分發(fā)基礎(chǔ)接口,需要分發(fā)并處理事件的Component需實(shí)現(xiàn)此接口
- */
- public interface View {
- /**
- * 傳遞屏幕的觸摸事件到目標(biāo)控件或自己消費(fèi)
- *
- * @param event 被傳遞的觸摸事件
- * @return 如果事件被自己消費(fèi),返回true,否則返回false
- */
- boolean dispatchTouchEvent(TouchEvent event);
- /**
- * 處理觸摸事件的方法
- * @param event 待消費(fèi)的事件
- * @return 是否消費(fèi)了事件
- */
- boolean onTouchEvent(TouchEvent event);
- /**
- * 事件是否被自己消費(fèi)了,該結(jié)果只能獲取一次,獲取后將重置為false
- * @return 是否消費(fèi)了事件
- */
- boolean isConsumed();
- }
ViewGroup
- /**
- * 包含子控件的事件分發(fā)接口,需要攔截或分發(fā)事件的ComponentContainer需實(shí)現(xiàn)此接口
- */
- public interface ViewGroup extends View {
- /**
- * 當(dāng)子控件不想父控件通過{@link #onInterceptTouchEvent(TouchEvent)}攔截事件時(shí),調(diào)用此方法
- * @param disallowIntercept 如果子控件不想父控件攔截事件,傳遞true
- */
- void requestDisallowInterceptTouchEvent(boolean disallowIntercept);
- /**
- * 當(dāng)需要攔截所有觸摸事件時(shí),實(shí)現(xiàn)此方法。
- * 注意:如果需要在后續(xù)再攔截事件,則down事件不要返回true,不然子控件會(huì)由于事件冒泡機(jī)制而收不到down之后的事件。
- *
- * 當(dāng)此方法返回true,事件會(huì)傳遞到onTouchEvent()方法中,如果在onTouchEvent中返回true后,
- * 后續(xù)的事件將會(huì)持續(xù)到控件的onTouchEvent()方法中,并且不會(huì)再傳遞到onInterceptTouchEvent()中。
- *
- * 當(dāng)此方法返回false,事件會(huì)首先被傳遞onInterceptTouchEvent()中,然后再到子控件的onTouchEvent()中。
- * 一旦此方法返回了true,子控件將會(huì)收到最后一次CANCEL事件,并且事件也不會(huì)再傳遞到onInterceptTouchEvent這里,
- * 而是直接傳遞到自己的onTouchEvent中。
- *
- * @param ev 被傳遞下來的觸摸事件
- * @return 當(dāng)需要攔截子控件的觸摸事件時(shí),返回true,這時(shí)事件會(huì)傳遞到{@link View#onTouchEvent(TouchEvent)}中。
- * 子控件將收到CANCEL事件,并且后續(xù)事件不會(huì)再傳遞到該控件中。
- */
- boolean onInterceptTouchEvent(TouchEvent ev);
- }
實(shí)現(xiàn)
然后借助兩個(gè)幫助類,來實(shí)現(xiàn)兩個(gè)接口中的相關(guān)函數(shù)。將View中事件分發(fā)的具體代碼封裝到ViewHelper中,將ViewGroup中事件分發(fā)的具體代碼封裝到ViewGroupHelper中。
代碼參考ViewHelper、ViewGroupHelper
分發(fā)
最后借助一個(gè)分發(fā)幫助類DispatchHelper,來將HarmonyOS中的事件,從頂層開始按照ViewGroupHelper中的dispatchTouchEvent來分發(fā)。
DispatchHelper主要做了下面幾件事:
- 緩存當(dāng)次事件中,視圖樹內(nèi)所有實(shí)現(xiàn)了View、ViewGroup接口的控件
- 從最頂層的控件開始,調(diào)用其dispatchTouchEvent函數(shù)
- 過濾掉由于事件冒泡,而傳遞過來的可能的重復(fù)事件
代碼:
- /**
- * 事件分發(fā)幫助類,輔助{@link View}與{@link ViewGroup}分發(fā)事件。
- *
- * 在{@link Component.TouchEventListener#onTouchEvent(Component, TouchEvent)}
- * 調(diào)用{@link #dispatch(Component, TouchEvent)}來分發(fā)事件。
- */
- public class DispatchHelper {
- /** 暫存所有的實(shí)現(xiàn)了View接口的控件 **/
- private static final List<Component> nodes = new ArrayList<>();
- /** 暫存每次事件的處理結(jié)果 **/
- private static final HashMap<Integer, Boolean> records = new HashMap<>();
- /** 暫存上次事件,用于過濾由于自下而上的事件冒泡與自上而下的事件分發(fā)機(jī)制而產(chǎn)生的多次分發(fā) **/
- private static String lastEvent = "";
- private final static TouchEventCompact compact = new TouchEventCompact(true);
- /**
- * 在{@link Component.TouchEventListener#onTouchEvent(Component, TouchEvent)}中調(diào)用此函數(shù)來分發(fā)事件。
- * @param component 需分發(fā)事件的控件
- * @param touchEvent 需分發(fā)的事件
- * @return 事件處理的結(jié)果
- */
- public static boolean dispatch(Component component, TouchEvent touchEvent) {
- // 過濾由于自下而上的事件冒泡 與 自上而下的事件分發(fā)機(jī)制而產(chǎn)生的重復(fù)分發(fā)
- if (isSameEvent(touchEvent)) {
- return true;
- }
- // 糾正通過getPointerPosition獲取的y坐標(biāo)的偏移
- compact.correct(touchEvent);
- lastEvent = convertEvent(touchEvent);
- int action = touchEvent.getAction();
- if (action == TouchEvent.PRIMARY_POINT_DOWN) {
- clearNodes();
- }
- if (nodes.size() <= 0) createNodes(component);
- dispatch(nodes.size(), 1, touchEvent);
- // collectRecords();
- // boolean result = findRecord(component);
- if (action == TouchEvent.PRIMARY_POINT_UP) {
- clearNodes();
- }
- return true;
- }
- /**
- * 當(dāng)子控件不想父控件攔截事件時(shí),調(diào)用此方法
- *
- * @param component 不想事件被攔截的控件
- * @param disallowIntercept true為不攔截
- */
- public static void requestDisallowInterceptTouchEvent(Component component, boolean disallowIntercept) {
- if (component.getComponentParent() instanceof ViewGroup) {
- ((ViewGroup) component.getComponentParent()).requestDisallowInterceptTouchEvent(disallowIntercept);
- }
- }
- /**
- * 當(dāng)子控件不想父控件攔截事件時(shí),在{@link EventHandler#postTask(Runnable)}中調(diào)用此方法
- *
- * @param component 不想事件被攔截的控件
- * @param disallowIntercept true為不攔截
- */
- public static void postRequestDisallowInterceptTouchEvent(Component component, boolean disallowIntercept) {
- EventHandler handler = new EventHandler(EventRunner.getMainEventRunner());
- handler.postTask(() -> requestDisallowInterceptTouchEvent(component, disallowIntercept));
- }
- public static TouchEventCompact getTouchEventCompact() {
- return compact;
- }
- /**
- * 自頂?shù)较碌氖录职l(fā),如果最上層的父控件沒有實(shí)現(xiàn){@link ViewGroup},則找尋下一個(gè)實(shí)現(xiàn)了{(lán)@link ViewGroup}的控件來分發(fā)事件。
- *
- * @param size {@link #nodes}的size
- * @param i 尋找實(shí)現(xiàn)了{(lán)@link ViewGroup}的控件的次數(shù),初始為1,自增
- * @param touchEvent 傳遞的事件
- * @return 事件分發(fā)的結(jié)果
- */
- private static boolean dispatch(int size, int i, TouchEvent touchEvent) {
- boolean result = false;
- if (size > 0) {
- Component node = nodes.get(size - i);
- if (node instanceof ViewGroup) {
- ViewGroup group = (ViewGroup) node;
- result = group.dispatchTouchEvent(touchEvent);
- } else if (node instanceof View) {
- View view = (View) node;
- result = view.dispatchTouchEvent(touchEvent);
- } else {
- if (i < size) {
- i++;
- result = dispatch(size, i, touchEvent);
- }
- }
- }
- return result;
- }
- private static void collectRecords() {
- records.clear();
- for (int i = 0; i < nodes.size(); i++) {
- records.put(i, ((View) nodes.get(i)).isConsumed());
- }
- }
- private static boolean findRecord(Component component) {
- int i = nodes.indexOf(component);
- if (i < 0) return false;
- return records.get(i);
- }
- private static void clearNodes() {
- nodes.clear();
- }
- private static void createNodes(Component component) {
- if (component instanceof View) nodes.add(component);
- if (component.getComponentParent() != null) {
- createNodes((Component) component.getComponentParent());
- }
- }
- private static String convertEvent(TouchEvent event) {
- String split = ",";
- MmiPoint point = event.getPointerScreenPosition(event.getIndex());
- return event.getAction() + split + point.getX() + split +
- point.getY() + split + event.hashCode();
- }
- private static boolean isSameEvent(TouchEvent event) {
- return lastEvent.equals(convertEvent(event));
- }
- }
使用方式
參考文檔
注意事項(xiàng)
- 雖然能使用事件分發(fā)了,但是由于底層機(jī)制的不同,在使用上還是會(huì)有一些差別:
- 如果根布局或者中間的ComponentContainer實(shí)現(xiàn)的是View而非ViewGroup,那么事件將不會(huì)繼續(xù)往下傳遞。
- 視圖樹中間可以出現(xiàn)斷層,即出現(xiàn)未實(shí)現(xiàn)View或ViewGroup的控件,事件會(huì)跳過并往下傳遞。
- 未實(shí)現(xiàn)View或ViewGroup的控件,如果設(shè)置了setTouchEventListener,那么事件將在回調(diào)返回true后直接被消費(fèi),而導(dǎo)致不會(huì)被分發(fā)。
- 如果遇到super.onTouchEvent或者super.onInterceptTouchEvent,需要去父類查看邏輯并移植進(jìn)來,如果是普通的布局或者控件一般是可以忽略,或者返回false的。
- 如果遇到super.dispatchTouchEvent則可以直接使用ViewGroupHelper/ViewHelper的dispatchTouchEvent來替代。
- 暫時(shí)只支持單點(diǎn)觸摸的分發(fā)
51CTO和華為官方合作共建的鴻蒙技術(shù)社區(qū)