作者:刘丰恺

作者博客:若梦浮生

转载请注明文章来源

我们在开发自定义控件的时候经常会有这样的需求,一个控件既需要能够被拖拽,也需要能够被点击。其实这个需求有个矛盾之处,需要被拖拽就要复写onTouch(...)函数,但是这样点击事件就被覆盖了,正常的 onClick() / onLongClick()事件是不能被响应的了。

现在面对这种情况GestureDetector,ViewDragHelper能为我们的开发提供一些便利,但是有的情况下这些封装的工具类没办法很好的满足我们的需求,这时候我们就需要自己来模拟View的点击事件。

模拟View点击事件说起来也很简单,说白了就是获取当前的点击未知的坐标值,和控件所在的矩形框的相对位置,并且保持了一段时间,这样我们就可以认为用户成功的进行了一次点击,调用View的callOnClick()方法就可以了,这时View就可以正常的回调onClickListener()了。

Bad Implemention

我看过一些项目的不完美的实现方式,大概类似于这样的伪代码。

int x,y; long time;
public void onTouch(view,event){
  // 伪代码
  	switch(event.getAction()){
      case DOWN:
        x = event.getX();
        y = event.getY();
        
        time = getTime();
        
        break;
      case UP:
        // 超过一段较短时间 响应点击事件
        // 超过一段长时间 响应长按时间
        if(getTime() - time > 4
           // 判断x,y 移动的位置不超过一个阀值
          && event.getX() - x...
          event.getY() - y ...){
        	view.callOnClick();
          or view.performOnLongClick()
        }
      case MOVE处理:
        // 处理拖动事件
        break;
  	}
}

这份代码从原理上讲起时没什么问题,完全注意到了时间和位置,但是把对点击的判定完全的放在了onTouch()的触点抬起的UP判定里,这就造成了你的点击必须在你抬手之后才能响应,正常的点按似乎问题,但是长按的话(只加长判定时间)就会造成需要抬起来才能判定长按。

System Source Implemention

下面这段代码是View源代码中onTouchEvent()中的ACTION_UP下的代码,我们发现和之前那种猜想一样,

               case MotionEvent.ACTION_UP:
                    boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
                    if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
                        // take focus if we don't have it already and we should in
                        // touch mode.
                        boolean focusTaken = false;
                        if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
                            focusTaken = requestFocus();
                        }

                        if (prepressed) {
                            // The button is being released before we actually
                            // showed it as pressed.  Make it show the pressed
                            // state now (before scheduling the click) to ensure
                            // the user sees it.
                            setPressed(true, x, y);
                       }

                        if (!mHasPerformedLongPress) {
                            // This is a tap, so remove the longpress check
                            removeLongPressCallback();

                            // Only perform take click actions if we were in the pressed state
                            if (!focusTaken) {
                                // Use a Runnable and post this rather than calling
                                // performClick directly. This lets other visual state
                                // of the view update before click actions start.
                                if (mPerformClick == null) {
                                    mPerformClick = new PerformClick();
                                }
                                if (!post(mPerformClick)) {
                                    performClick();
                                }
                            }
                        }

                        if (mUnsetPressedState == null) {
                            mUnsetPressedState = new UnsetPressedState();
                        }

                        if (prepressed) {
                            postDelayed(mUnsetPressedState,
                                    ViewConfiguration.getPressedStateDuration());
                        } else if (!post(mUnsetPressedState)) {
                            // If the post failed, unpress right now
                            mUnsetPressedState.run();
                        }

                        removeTapCallback();
                    }
                    break;

系统的源码考量更为复杂一点,其中有很多的状态的转换和Flag的修改,我们读源码的时候如果不是必须相关的可以先省略很多Flag的内容,这段源码中考虑了关于View是否有焦点,是否可点击等很多的情况,setPress()这个方法用于设定View的样式改变。这其中主要的一个函数调用是performClick()这个函数,这函数有别于callOnClick()函数不只是回调了click接口,还改变了View的界面状态。

重点看其中的这段:

                           if (!focusTaken) {
                                // Use a Runnable and post this rather than calling
                                // performClick directly. This lets other visual state
                                // of the view update before click actions start.
                                if (mPerformClick == null) {
                                    mPerformClick = new PerformClick();
                                }
                                if (!post(mPerformClick)) {
                                    performClick();
                                }
                            }

里面的注释告诉我们,不直接的调用performClick(),而是使用一个Runnable运行,能够让View先更新视觉状态。这里面我们注意到了View源码中用到了一个PerformClick()的Runnable,这个Runnable的run()方法里只有一个函数调用就是performClick()。从这里我们发现系统在点击处理上会使用Runnable或是延时的Runnable去处理点击事件,View中还有几个Runnable。

    private final class CheckForLongPress implements Runnable {
        private int mOriginalWindowAttachCount;

        @Override
        public void run() {
            if (isPressed() && (mParent != null)
                    && mOriginalWindowAttachCount == mWindowAttachCount) {
                if (performLongClick()) {
                    mHasPerformedLongPress = true;
                }
            }
        }

        public void rememberWindowAttachCount() {
            mOriginalWindowAttachCount = mWindowAttachCount;
        }
    }

    private final class CheckForTap implements Runnable {
        public float x;
        public float y;

        @Override
        public void run() {
            mPrivateFlags &= ~PFLAG_PREPRESSED;
            setPressed(true, x, y);
            checkForLongClick(ViewConfiguration.getTapTimeout());
        }
    }

    private final class PerformClick implements Runnable {
        @Override
        public void run() {
            performClick();
        }
    }

其中第一个CheckForLongPress就是对长按的检测,第二个CheckForTap在滑动控件中延迟响应。

CheckForLongPress的实现很简单,检测了几项是否可以点击的状态之后只需要比较保存的mOriginalWindowAttachCount是否相等,如果相等就相应长按事件。

    /**
     * Count of how many windows this view has been attached to.
     */
    int mWindowAttachCount;

在View的onTouchEvent中的ACTION_DOWN中:

                case MotionEvent.ACTION_DOWN:
					...
                    boolean isInScrollingContainer = isInScrollingContainer();
                    // For views inside a scrolling container, delay the pressed feedback for
                    // a short period in case this is a scroll.
                    if (isInScrollingContainer) {
                        mPrivateFlags |= PFLAG_PREPRESSED;
                        if (mPendingCheckForTap == null) {
                            mPendingCheckForTap = new CheckForTap();
                        }
                        mPendingCheckForTap.x = event.getX();
                        mPendingCheckForTap.y = event.getY();
                        postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                    } else {
                        // Not inside a scrolling container, so show the feedback right away
                        setPressed(true, x, y);
                        checkForLongClick(0);
                    }

在按下的事件中首先通过查看View树判断自己在不在一个滚动的空间里,而且如果停留一段时间的话就延迟响应,如果不是直接就相应长按事件。

也就是说其实每次点下去的时候都会发送长按的判断Runnable,那我们要是已经响应了点击事件那该怎么办呢?取消队列中的长按Runnable就好了。

// This is a tap, so remove the longpress check
removeLongPressCallback();
    /**
     * Remove the longpress detection timer.
     */
    private void removeLongPressCallback() {
        if (mPendingCheckForLongPress != null) {
          removeCallbacks(mPendingCheckForLongPress);
        }
    }

刚才在UP的事件里,就已经移除了长按的回调。

所以我们可以根据刚才的分析总结出View实现点击和长按的伪代码:

switch(event.getAction()){
  case UP:
    post performClick Runnable
    remove checkForTap CallBack
  break;
  case DOWN:
    if (in scroll layout){
      post checkForTap Runnable
    } else {
      post checkForLong Runnable
    }
  break;
}

How To Use?

首先模仿View一样定义一个用于长按检测的Runnable:

我们这里通过点击位置来判断而不是attachCount。

 /**
     * 模仿系统的 长按事件任务
     * 每次在 {@link #onTouch(View, MotionEvent)} 中 MotionEvent.ACTION_DOWN 的时候
     * 向 {@link #touchEventHandler} 传递一个 {@link #CheckForLongPress(float, float)} 的延时事件
     * 延时时间为 ViewConfiguration.getLongPressTimeout()
     * 是用于检测是不是长按的时间,默认为500毫秒
     */
    private class CheckForLongPress implements Runnable {
        /**
         * 注意这里的x/y 是触摸值而不是 触摸值+偏移
         * 偏移在长按事件运行的时候再拿去
         */
        float x, y;

        public CheckForLongPress(float x, float y) {
            this.x = x;
            this.y = y;
        }

        /**
         * 为了让任务保持复用 不创建新的事件 而选择复用之前的事件
         *
         * @param x newX
         * @param y newY
         */
        public void setXY(float x, float y) {
            this.x = x;
            this.y = y;
        }

        /**
         * <p>
         * 在 500ms 之后事件运行
         * 这里的 {@link #lastEvent} 在 {@link #onTouch(View, MotionEvent)} 中进行修改
         * 每次处理 MotionEvent 事件之后都会对其进行更新
         * </p>
         * 长按条件判定:
         * <ol>
         * <li>上一次的点击事件还是 MotionEvent.ACTION_DOWN
         * (多半不可能,总是会发生 MotionEvent.ACTION_MOVE)</li>
         * <li>
         * 上一次的点击事件是 MotionEvent.ACTION_MOVE
         * (但是之前的点击位置和当前的位置不超过十个像素值)
         * </li>
         * </ol>
         */
        public void run() {
            if (lastEvent == MotionEvent.ACTION_DOWN
                    || (lastEvent == MotionEvent.ACTION_MOVE
                    && (Math.abs(globalX - x) < 4)
                    && Math.abs(globalY - y) < 4)) {
              	// 处理长按事件
                setCourseOnClick(globalX + scrollX, globalY + scrollY, TouchMode.LongClick);
                /**
                 * 不允许多个时间同时响应 在测试中发现经常会因为误差
                 * 导致一次长按出现两个连续的响应事件
                 * 因此当一个开始响应之后 把当前的任务都从
                 * {@link #touchEventHandler}的任务队列中移除
                 */
                touchEventHandler.removeCallbacks(press);
            }
        }
    }

对它的调用也是同样,在ACTION_DOWN中发送判断:

                /**
                 * ACTION_DOWN中做了三件事
                 * 1.拿到了按下时间
                 * 2.发送了长按事件
                 * 3.记录了按下的点
                 */
                case MotionEvent.ACTION_DOWN:

                    downTime = System.currentTimeMillis();

                    x_temp1 = x;

                    lastEvent = MotionEvent.ACTION_DOWN;

                    if (press == null)
                        press = new CheckForLongPress(x, y);
                    else
                        press.setXY(x, y);

                    touchEventHandler.postDelayed(press, ViewConfiguration.getLongPressTimeout());
                    break;

再判断你的点击区域:


    /**
     * 判断点击事件
     *
     * @param button 拿到按钮
     * @param x      点击的X
     * @param y      点击的Y
     * @return 是否成功点击
     */
    private boolean contains(Button button, int x, int y) {
        int offset = ClassTableDefaultInfo.timeLineWidth;
        int left = button.getLeft() + offset;
        int right = left + button.getWidth();
        int top = button.getTop();
        int bottom = top + button.getHeight();
        return left < right && top < bottom  // check for empty first
                && x >= left && x < right && y >= top && y < bottom;
    }

当你的点击区域在控件矩形区域内即可响应长按或者点击的回调就可以了。

最后再说两句

这篇文章中我们了解了View的点击和长按判断的方式,其实从这个知识点可以发散的去想,这种发送延时Runnable的方式还可以放在很多的条件下使用,比如在使用smoothScroll的时候,当我们手动的去控制多个方向的滚动时,我们就可以发送一个延时的判断条件来判断我们的平移是否停止。当然我们并没有一定要像View源码那样的去分着处理点击和长按事件,我们可以把它和在一个Runnable里面,仅仅通过时间来判断。