HarmonyOS自定義控件之觸摸事件與事件分發
觸摸事件
如何監聽觸摸事件
HarmonyOS中可以通過Listener的方式:
- setTouchEventListener(new TouchEventListener() {
- @Override
- public boolean onTouchEvent(Component component, TouchEvent touchEvent) {
- return false;
- }
- });
注意:setTouchEventListener會被覆蓋
常用的觸摸事件的類型
這里我們對比其他主流系統中MotionEvent與HarmonyOS中TouchEvent來方便理解與記憶。
MotionEvent的常用的事件類型與HarmonyOS中的TouchEvent類型基本可以對應起來:
- 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
獲取手指相對于屏幕的x、y坐標
- touchEvent.getPointerScreenPosition(touchEvent.getIndex()).getX();
- touchEvent.getPointerScreenPosition(touchEvent.getIndex()).getY();
獲取手指相對于父控件的x、y坐標
- touchEvent.getPointerPosition(touchEvent.getIndex()).getX();
- touchEvent.getPointerPosition(touchEvent.getIndex()).getY();
getPointerScreenPosition與getPointerPosition的區別
前者是相對的屏幕的坐標,而后者是相對于父控件的坐標。如果在手指滑動過程中,對該控件做了位移,那么getPointerPosition獲取的坐標將會是手指本身坐標加上控件的位移量,導致位移異常。
這里建議,如果需要根據坐標來計算,都使用getPointerScreenPosition比較保險。
總結
TouchEvent提供了基礎api,但是沒有MotionEvent內一些比較高階的api,比如obtain等。接下來我們來關注更為重要的事件分發。
事件分發
事件分發是一套比較重要同時也比較復雜的機制,如果不熟悉這套機制,那么在遇到稍微復雜的滑動失效問題就會覺得手足無措。在這里通過打印日志的方式來摸索HarmonyOS上的事件的傳遞機制。
HarmonyOS中事件的傳遞機制
首先,我們通過打印日志的方式,來摸索觸摸事件是如何在Component中傳遞的。經過實驗,發現如下幾條規律:
- 事件首先會傳遞到最底層的目標控件,而非頂層的父控件
- 如果目標控件不處理該事件,即onTouchEvent返回false,那么事件冒泡到父控件
- 如果目標控件處理了該事件,即onTouchEvent返回true,那么后續事件不會向上冒泡,而是直接被目標控件消費
- 如果一個控件在down事件中,返回了false,那么后續的事件也不會被傳遞到該控件中
- 如果一個控件接受到了down事件,并返回了true,那么后續的事件會直接被傳遞到該控件中,其他控件不會收到事件
HarmonyOS中的事件傳遞更像是冒泡,而非分發,down事件一旦被某一個控件消費了,那么其他控件將都收不到后續事件了。這樣的機制比較難去實現一些復雜的嵌套效果。
比如子控件響應橫向滑動,父控件響應垂直滑動這種情況。子控件如果要想收到后續的move事件,只能在down的時候返回true,這樣就導致父控件完全收不到觸摸事件。子控件如果像要在move時判斷滑動方向而down事件返回了false,那么子控件將再也接收不到后續的事件了。
HarmonyOS的事件冒泡比較簡單,一旦約定好就再也沒有反悔的機會了。那么如何類似其他主流系統一樣,從頂層控件分發并且可以攔截事件呢?
這里只提供思路,具體代碼可以參考:事件分發
實現事件分發
我們構想中的事件分發應該是這樣:事件是首先到頂層的父控件,然后經過dispatchTouchEvent一層層向下分發。ComponentContainer可以通過onInterceptTouchEvent攔截事件,并交給自己的onTouchEvent來處理。如果ComponentContainer不處理事件則繼續向下分發,直到最終的Component控件。這樣的機制意味著每一層都有機會能拿到事件,那么如何在HarmonyOS中實現呢?
我們可以將事件分發相關的函數與代碼,抽取出來,移植到HarmonyOS中,并通過一些手段應用到HarmonyOS的onTouchEvent中。
抽象
HarmonyOS中沒有dispatchTouchEvent、onInterceptTouchEvent等函數,如何應用到組件中呢?抽象接口,將事件分發相關的函數抽象成兩個接口:
View
- /**
- * 事件分發基礎接口,需要分發并處理事件的Component需實現此接口
- */
- public interface View {
- /**
- * 傳遞屏幕的觸摸事件到目標控件或自己消費
- *
- * @param event 被傳遞的觸摸事件
- * @return 如果事件被自己消費,返回true,否則返回false
- */
- boolean dispatchTouchEvent(TouchEvent event);
- /**
- * 處理觸摸事件的方法
- * @param event 待消費的事件
- * @return 是否消費了事件
- */
- boolean onTouchEvent(TouchEvent event);
- /**
- * 事件是否被自己消費了,該結果只能獲取一次,獲取后將重置為false
- * @return 是否消費了事件
- */
- boolean isConsumed();
- }
ViewGroup
- /**
- * 包含子控件的事件分發接口,需要攔截或分發事件的ComponentContainer需實現此接口
- */
- public interface ViewGroup extends View {
- /**
- * 當子控件不想父控件通過{@link #onInterceptTouchEvent(TouchEvent)}攔截事件時,調用此方法
- * @param disallowIntercept 如果子控件不想父控件攔截事件,傳遞true
- */
- void requestDisallowInterceptTouchEvent(boolean disallowIntercept);
- /**
- * 當需要攔截所有觸摸事件時,實現此方法。
- * 注意:如果需要在后續再攔截事件,則down事件不要返回true,不然子控件會由于事件冒泡機制而收不到down之后的事件。
- *
- * 當此方法返回true,事件會傳遞到onTouchEvent()方法中,如果在onTouchEvent中返回true后,
- * 后續的事件將會持續到控件的onTouchEvent()方法中,并且不會再傳遞到onInterceptTouchEvent()中。
- *
- * 當此方法返回false,事件會首先被傳遞onInterceptTouchEvent()中,然后再到子控件的onTouchEvent()中。
- * 一旦此方法返回了true,子控件將會收到最后一次CANCEL事件,并且事件也不會再傳遞到onInterceptTouchEvent這里,
- * 而是直接傳遞到自己的onTouchEvent中。
- *
- * @param ev 被傳遞下來的觸摸事件
- * @return 當需要攔截子控件的觸摸事件時,返回true,這時事件會傳遞到{@link View#onTouchEvent(TouchEvent)}中。
- * 子控件將收到CANCEL事件,并且后續事件不會再傳遞到該控件中。
- */
- boolean onInterceptTouchEvent(TouchEvent ev);
- }
實現
然后借助兩個幫助類,來實現兩個接口中的相關函數。將View中事件分發的具體代碼封裝到ViewHelper中,將ViewGroup中事件分發的具體代碼封裝到ViewGroupHelper中。
代碼參考ViewHelper、ViewGroupHelper
分發
最后借助一個分發幫助類DispatchHelper,來將HarmonyOS中的事件,從頂層開始按照ViewGroupHelper中的dispatchTouchEvent來分發。
DispatchHelper主要做了下面幾件事:
- 緩存當次事件中,視圖樹內所有實現了View、ViewGroup接口的控件
- 從最頂層的控件開始,調用其dispatchTouchEvent函數
- 過濾掉由于事件冒泡,而傳遞過來的可能的重復事件
代碼:
- /**
- * 事件分發幫助類,輔助{@link View}與{@link ViewGroup}分發事件。
- *
- * 在{@link Component.TouchEventListener#onTouchEvent(Component, TouchEvent)}
- * 調用{@link #dispatch(Component, TouchEvent)}來分發事件。
- */
- public class DispatchHelper {
- /** 暫存所有的實現了View接口的控件 **/
- private static final List<Component> nodes = new ArrayList<>();
- /** 暫存每次事件的處理結果 **/
- private static final HashMap<Integer, Boolean> records = new HashMap<>();
- /** 暫存上次事件,用于過濾由于自下而上的事件冒泡與自上而下的事件分發機制而產生的多次分發 **/
- private static String lastEvent = "";
- private final static TouchEventCompact compact = new TouchEventCompact(true);
- /**
- * 在{@link Component.TouchEventListener#onTouchEvent(Component, TouchEvent)}中調用此函數來分發事件。
- * @param component 需分發事件的控件
- * @param touchEvent 需分發的事件
- * @return 事件處理的結果
- */
- public static boolean dispatch(Component component, TouchEvent touchEvent) {
- // 過濾由于自下而上的事件冒泡 與 自上而下的事件分發機制而產生的重復分發
- if (isSameEvent(touchEvent)) {
- return true;
- }
- // 糾正通過getPointerPosition獲取的y坐標的偏移
- 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;
- }
- /**
- * 當子控件不想父控件攔截事件時,調用此方法
- *
- * @param component 不想事件被攔截的控件
- * @param disallowIntercept true為不攔截
- */
- public static void requestDisallowInterceptTouchEvent(Component component, boolean disallowIntercept) {
- if (component.getComponentParent() instanceof ViewGroup) {
- ((ViewGroup) component.getComponentParent()).requestDisallowInterceptTouchEvent(disallowIntercept);
- }
- }
- /**
- * 當子控件不想父控件攔截事件時,在{@link EventHandler#postTask(Runnable)}中調用此方法
- *
- * @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;
- }
- /**
- * 自頂到下的事件分發,如果最上層的父控件沒有實現{@link ViewGroup},則找尋下一個實現了{@link ViewGroup}的控件來分發事件。
- *
- * @param size {@link #nodes}的size
- * @param i 尋找實現了{@link ViewGroup}的控件的次數,初始為1,自增
- * @param touchEvent 傳遞的事件
- * @return 事件分發的結果
- */
- 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));
- }
- }
使用方式
參考文檔
注意事項
- 雖然能使用事件分發了,但是由于底層機制的不同,在使用上還是會有一些差別:
- 如果根布局或者中間的ComponentContainer實現的是View而非ViewGroup,那么事件將不會繼續往下傳遞。
- 視圖樹中間可以出現斷層,即出現未實現View或ViewGroup的控件,事件會跳過并往下傳遞。
- 未實現View或ViewGroup的控件,如果設置了setTouchEventListener,那么事件將在回調返回true后直接被消費,而導致不會被分發。
- 如果遇到super.onTouchEvent或者super.onInterceptTouchEvent,需要去父類查看邏輯并移植進來,如果是普通的布局或者控件一般是可以忽略,或者返回false的。
- 如果遇到super.dispatchTouchEvent則可以直接使用ViewGroupHelper/ViewHelper的dispatchTouchEvent來替代。
- 暫時只支持單點觸摸的分發