深入Android开发之--理解View#onTouchEvent

一:前言

View是Android中最基本的UI单元.

当一个View接收到了触碰事件时,会调用其onTouchEvent方法.方法声明如下:

1
2
3
4
5
6
7
/**
 * Implement this method to handle touch screen motion events.
 *
 * @param event The motion event.
 * @return True if the event was handled, false otherwise.
 */
public boolean onTouchEvent(MotionEvent event) {

了解下View怎么处理onTouchEvent方法是很有必要的.

在具体的看View是怎么处理触碰事件之前,从用户交互上,我们先需要对View处理的事件有一些期望:

(1)能够区分将用户的触碰事件是点击还是滑动区别开来.

(2)能够将点击与长按区别开来.

二: 处理流程分析

View#onTouchEvent方法主要做了如下处理:

 (1) 如果此view被禁用了. (如果是触碰完成事件则设置按下状态),然后返回是否可点击.

(中间的注释的意思为:一个可点击的View虽然禁用了,但是还是要把事件消耗掉,只是不响应它们而已.

1
2
3
4
5
6
7
8
9
if ((viewFlags & ENABLED_MASK) == DISABLED) {
    if (event.getAction() == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
        setPressed(false);
    }
    // A disabled view that is clickable still consumes the touch
    // events, it just doesn't respond to them.
    return (((viewFlags & CLICKABLE) == CLICKABLE ||
            (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE));
}

(2) 如果此View有触碰事件处理代理,那么将此事件交给代理处理:

1
2
3
4
5
if (mTouchDelegate != null) {
         if (mTouchDelegate.onTouchEvent(event)) {
             return true;
         }
}

(3)如果不可点击(既不能单击,也不能长按)则直接返回.false

(4)可点击时,处理触控事件.根据,按下,移动,取消,抬起,这些基本触摸事件来分别处理.

 它们其中又有很强的关联性.

 

三:触摸事件分析:

(3.1)当触控开始时:即处理 case MotionEvent.ACTION_DOWN:分支.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mHasPerformedLongPress = false;
 
                   if (performButtonActionOnTouchDown(event)) {
                       break;
                   }
 
                   // Walk up the hierarchy to determine if we're inside a scrolling container.
                   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();
                       }
                       postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                   } else {
                       // Not inside a scrolling container, so show the feedback right away
                       setPressed(true);
                       checkForLongClick(0);
                   }
                   break;

上面分支代码的第一个调用,performButtonActionOnTouchDown(event) 一般的设备都是返回false.

因为目前的实现中,它是处理如鼠标的右键的.(如果此View响应或者其父View响应右键菜单,那么就此事件就被消耗掉了.)

可以看下这个方法的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
     * Performs button-related actions during a touch down event.
     *
     * @param event The event.
     * @return True if the down was consumed.
     *
     * @hide
     */
    protected boolean performButtonActionOnTouchDown(MotionEvent event) {
        if ((event.getButtonState() & MotionEvent.BUTTON_SECONDARY) != 0) {
            if (showContextMenu(event.getX(), event.getY(), event.getMetaState())) {
                return true;
            }
        }
        return false;
    }

对于MotionEvent的BUTTON_SECONDARY常量,对于鼠标中的按键来说,是指右键.

第二个方法调用:isInScrollingContainer(),

   它的注释已经写得很明白了,就是遍历整个View树来判断当前的View是不是在一个滚动的容器中.

因为对于触碰事件的处理,我符合我们讲的,不能把滑动当前点击.所以先判断是不是在一个可滑动的容器中.

下面是此方法的实现代码:

1
2
3
4
5
6
7
8
9
10
public boolean isInScrollingContainer() {
       ViewParent p = getParent();
       while (p != null && p instanceof ViewGroup) {
           if (((ViewGroup) p).shouldDelayChildPressedState()) {
               return true;
           }
           p = p.getParent();
       }
       return false;
   }

检查结果有两种情况: 

 (1)如果不是在一个可滚动的容器中:

调用setPressed(true) 设置按下状态.,setPressed 主要是设置PFLAG_PRESSED标志位.后面会具体分析此方法.

检查长按.

 (2)如果是在一个可滚动的容器中:

  先设置用户准备点击这么一个标志位:PFLAG_PREPRESSED.

  然后则发送一个延迟消息来确定用户到底是要滚动还是点击.

1
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());

在给定的tapTimeout时间之内,用户的触摸没有移动,就当作用户是想点击,而不是滑动.

具体的做法是,将 CheckForTap的实例mPendingCheckForTap添加时消息队例中,延迟执行.

如果在这tagTimeout之间用户触摸移动了,则删除此消息.否则:执行按下状态.然后检查长按.

CheckForTap消息方法如下:

1
2
3
4
5
6
7
private final class CheckForTap implements Runnable {
        public void run() {
            mPrivateFlags &= ~PFLAG_PREPRESSED;
            setPressed(true);
            checkForLongClick(ViewConfiguration.getTapTimeout());
        }
}

检查长按也是大概类似的思路:等等再决定.

checkForLongClick方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
private void checkForLongClick(int delayOffset) {
        if ((mViewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) {
            mHasPerformedLongPress = false;
 
            if (mPendingCheckForLongPress == null) {
                mPendingCheckForLongPress = new CheckForLongPress();
            }
            mPendingCheckForLongPress.rememberWindowAttachCount();
            postDelayed(mPendingCheckForLongPress,
                    ViewConfiguration.getLongPressTimeout() - delayOffset);
        }
}

CheckForLongPress消息类要单独说一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CheckForLongPress implements Runnable {
 
       private int mOriginalWindowAttachCount;
 
       public void run() {
           if (isPressed() && (mParent != null)
                   && mOriginalWindowAttachCount == mWindowAttachCount) {
               if (performLongClick()) {
                   mHasPerformedLongPress = true;
               }
           }
       }
 
       public void rememberWindowAttachCount() {
           mOriginalWindowAttachCount = mWindowAttachCount;
       }
   }

因为等待形成长按的过程中,界面可能发生变化如Activity的pause及restart,这个时候,长按应当失效.

View中提供了mWindowAttachCount来记录View的attach次数.当检查长按时的attach次数与长按到形成时.

的attach一样则处理,否则就不应该再当前长按. 所以在将检查长按的消息添加时队伍的时候,要记录下当前的windowAttachCount.

 (3.2)当手指在屏幕移动时: case MotionEvent.ACTION_MOVE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<strong> case MotionEvent.ACTION_MOVE:
                    final int x = (int) event.getX();
                    final int y = (int) event.getY();
 
                    // Be lenient about moving outside of buttons
                    if (!pointInView(x, y, mTouchSlop)) {
                        // Outside button
                        removeTapCallback();
                        if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                            // Remove any future long press/tap checks
                            removeLongPressCallback();
 
                            setPressed(false);
                        }
                    }
                    break;</strong>

第一句注释讲的是,对于触碰是否超出边界宽容一些.所以在判断触摸中间点是否在此View中时,先将上下左右增大mTouchSlop个像素,再判断.

如果在View的外面,将处理点击消息移除.如果是已经准备长按了,则将长按的消息移除.并将View的按下状态设置为false.

看看上面调用的pointInView的实现,如下:

1
2
3
4
5
6
7
8
9
10
/**
 * Utility method to determine whether the given point, in local coordinates,
 * is inside the view, where the area of the view is expanded by the slop factor.
 * This method is called while processing touch-move events to determine if the event
 * is still within the view.
 */
private boolean pointInView(float localX, float localY, float slop) {
    return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
            localY < ((mBottom - mTop) + slop);
}

(3.3)  精简代码再分析,加深理解:

到这里我们已经分析了一个View中的触控事件的.按下,和移动了.如果初次接触可能有点晕.但是让我以一个最简单的情况把这些上面出现过的代码重新组织一下:

我们的情况就是,一个Activity中只有一个正常的Button.

所以我们View处理触控事件的代码应该如下:

当手指按下时:

1
2
setPressed(true);
checkForLongClick(0);

设置按下的效果.派发一个消息侦查用户是否准备长按.

这个时候有两种情况:

一:用户手指按下过了一段时间.也没有到处移动,所以我们认为用户是想长按.触发执行长按消息:

1
2
3
4
5
6
if (isPressed() && (mParent != null)
                    && mOriginalWindowAttachCount == mWindowAttachCount) {
                if (performLongClick()) {
                    mHasPerformedLongPress = true;
                }
}

这些代码,在上面分析的时候,提及到了一点.

下面再多说几句:

最外面的if判断.

主要是判断,长按是在按下的基础之上出现的.所以要isPressed(),

执行长按时,父View还在(指View层级还没有销毁),WindowAttachCount不变,指此窗口还是当初View按下时的窗口而不是重建的窗口.

最里面的判断,是判断View界面是否执行了长按,然后设置对应标志字段.

二:用户按下之后到处移动:

这个时候就执行了ACTION_MOVE分支的代码了.

if ((mPrivateFlags & PFLAG_PRESSED) != 0) 按照之前的执行流程.

因为,上面调用了setPressed(true).在些方法中,mPrivateFlags字段中的PFLAG_PRESSED标志为被启用了.

setPressed的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * Sets the pressed state for this view.
 *
 * @see #isClickable()
 * @see #setClickable(boolean)
 *
 * @param pressed Pass true to set the View's internal state to "pressed", or false to reverts
 *        the View's internal state from a previously set "pressed" state.
 */
public void setPressed(boolean pressed) {
    final boolean needsRefresh = pressed != ((mPrivateFlags & PFLAG_PRESSED) == PFLAG_PRESSED);
 
    if (pressed) {
        mPrivateFlags |= PFLAG_PRESSED;
    } else {
        mPrivateFlags &= ~PFLAG_PRESSED;
    }
 
    if (needsRefresh) {
        refreshDrawableState();
    }
    dispatchSetPressed(pressed);
}

(3.4)触控完成时(即当手指都抬起来时):

这分支的代码加上注释看起来稍微有点长.我们分开来分析:

首先是检查 PFLAG_PREPRESSED 和PFLAG_PRESSED 这两个标志.如果其中一个为真则处理.

根据上面的分析我们知道这两个标志位首先是在开始触控时(即手指按下ACTION_DOWN)时设置时,

PFLAG_PREPRESSED 表示在一个可滚动的容器中,要稍后才能确定是按下还是滚动.

PFLAG_PRESSED 表示不是在一个可滚动的容器中,已经可以确定按下这一操作.

1
2
3
4
5
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED ) != 0;
boolean pressed = (mPrivateFlags & PFLAG_PRESSED) != 0;
if(pressed || prepressed){
 // 处理些事件
}

然后是看是否需要获得焦点及用变量focusTaken设置是否获得了焦点.

如果我们还没有获得焦点,但是我们在触控屏下又可以获得焦点,那么则请求获得焦点.

1
2
3
4
5
// take focus if we don't have it already and we should in touch mode.
 boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
     focusTaken = requestFocus();
}

然后,如果之前是prepressed那么现在就设置按下状态:

虽然用户在我们还没有显示按下状态的效果时就不按了.我们还是得在进行实际的点击操作时,

让用户看到按下的效果.

1
2
3
4
5
6
7
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);
}

然后是判断是否进行了长按:

如果没有,那好,移除长按的延迟消息.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  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();
        }
    }
}

下面是判断有没有重新请求获得焦点,如果还没有新获得焦点,说明之前已经是按下的状态了.

派发执行点击操作的消息.这是为了在实际的执行点击操作时,让用户有时间再看看按下的效果.

之后就是派发消息来取消点击状态:

1
2
3
4
5
6
7
8
9
10
11
12
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();

ViewConfiguration.getPressedStateDuration() 获得的是按下效果显示的时间,由PRESSED_STATE_DURATION

常量指定,为64毫秒.

小结:其他的事件处理,基本是设置状态,派发消息.到这里就需要对,当前的状态,做出判断及处理.

(3.5) 接下来就是最简单的,但是也很重要的,当触控事件被系统取消:ACTION_CANCEL:

在这个事件中,只需要setPressed(false),并移除按下,及长按的延迟消息就可以了.

具体代码如下:

1
2
3
4
5
case MotionEvent.ACTION_CANCEL:
    setPressed(false);
    removeTapCallback();
    removeLongPressCallback();
    break;

四: 总结

View#onTouchEvent方法虽然只有几十行代码,但是对于我们理解触控事件的处理方法.

MotionEvent各个事件的处理方法都是有很大的帮助.

值得我们这这个方法的代码打印出来,多多学习.

 

上一篇:android开发学习 ------- 自定义View 圆 ,其点击事件 及 确定当前view的层级关系


下一篇:jQuery get() 函数