【中软国际】HarmonyOS 自定义控件之触摸事件与事件分发 原创 精华

开鸿助手
发布于 2021-8-3 17:52
浏览
9收藏

本篇文章所讨论的触摸事件与事件分发只局限于控件内,不涉及到更上层。

触摸事件

如何监听触摸事件

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中。

代码参考ViewHelperViewGroupHelper

分发

最后借助一个分发帮助类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));
    }
}

使用方式

参考文档

注意事项

虽然能使用事件分发了,但是由于底层机制的不同,在使用上还是会有一些差别:

  1. 如果根布局或者中间的ComponentContainer实现的是View而非ViewGroup,那么事件将不会继续往下传递。
  2. 视图树中间可以出现断层,即出现未实现View或ViewGroup的控件,事件会跳过并往下传递。
  3. 未实现View或ViewGroup的控件,如果设置了setTouchEventListener,那么事件将在回调返回true后直接被消费,而导致不会被分发。
  4. 如果遇到super.onTouchEvent或者super.onInterceptTouchEvent,需要去父类查看逻辑并移植进来,如果是普通的布局或者控件一般是可以忽略,或者返回false的。
  5. 如果遇到super.dispatchTouchEvent则可以直接使用ViewGroupHelper/ViewHelper的dispatchTouchEvent来替代。
  6. 暂时只支持单点触摸的分发

作者:朱帆

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
16
收藏 9
回复
举报
4条回复
按时间正序
/
按时间倒序
Anzia
Anzia

很详细,注释也齐全hhh

回复
2021-8-3 20:14:41
mb610b4d3a6f766
mb610b4d3a6f766

帆写的真好

回复
2021-8-5 10:33:56
mb600fb310dea6c
mb600fb310dea6c

仰望大佬666

回复
2021-8-5 10:50:44
wx6037764b15f01
wx6037764b15f01

非常有参考价值,mark

回复
2021-8-13 09:40:05
回复
    相关推荐