View的事件体系

View虽然不属于四大组件,但它的作用堪比四大组件,甚至比Receiver和Provider的重要性都大,在Android开发中,Activity承担这可视化的功能,同时Android系统提供了很多基础控件,常见的有Button、Textview、CheckBox等。

View基础知识

什么是View

View是一种界面层的控件的一种抽象,它代表了一个控件。除了View,还有ViewGroup,ViewGroup内部包含了许多个控件,即一组View,在ViewGroup也继承了View,这就意味着View本身就可以是单个控件也可以是由多个控件组成的一组控件,通过这种关系形成了View树的结构。

View的位置参数

View的位置主要由它的四个顶点来决定,分别对应于View的四个属性:top、left、right、bottom,其中top是左上角纵坐标,left是左上角横坐标,right是右下角横坐标,bottom是右下角的纵坐标。需要注意的是,这些坐标都是相对于View的父容器来说的,因为它是一种相对坐标。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XAeT1rxd-1646358386608)(img/331079-20170122173635769-1540262867.jpg)]

在Android中,X轴和Y轴的正方向分别为右和下。那么就可以得出以下关系:

width = right - left
height = bottom - top

MotionEvent和

在手指接触屏幕后会产生一系列的事件,典型的事件类型有如下几种:

  • ACTION_DOWN——手指刚接触屏幕
  • ACTION_MOVE——手指在屏幕上移动
  • ACTION_UP——手指从屏幕上松开的一瞬间

正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件,如下:

  • 点击屏幕后离开松开,事件序列为DOWN->UP
  • 点击屏幕滑动一会再松开,事件序列为DOWN->MOVE->…->MOVE->UP

同时我们可以通过MotionEvent对象我们可以得到点击事件发生的X坐标和Y坐标。为此,系统提供了两组方法:getX/getY和getRawX/getRawY,区别很简单,前者返回的是相对于当前View左上角的X和Y坐标,而后者返回的相对于手机屏幕左上角的X和Y坐标。

TouchSlop

TouchSlop是系统所能识别出的被认为是滑动的最小距离,换句话说,当手指在屏幕上滑动时,如果两次滑动之间的距离小于这个常量,那么系统就不认为你是在进行滑动操作,因为滑动的距离太短了,系统就不认为它是滑动,这是一个常量,跟设备有关。可以通过以下方式获取:

ViewConfiguration.get(getContext()).getScaledTouchSlop()

可以通过这个常量做一些过滤

在frameworks/base/core/res/res/values/config.xml中有对这个常量的定义

<dimen name="config_viewConfigurationTouchSlop">8dp</dimen>

View的滑动

在Android设备中,滑动几乎是应用的标配,不管是下拉刷新还是SlidingMenu,它们的基础都是滑动。因此,掌握滑动的方法是实现绚丽的自定义控件的基础,有三种方式实现View的滑动:

  • scrollTo/scrollBy方法来实现
  • 使用动画
  • 改变布局参数

scrollTo/scrollBy方法来实现

源码

public void scrollTo(int x, int y) {
    if (mScrollX != x || mScrollY != y) {
        int oldX = mScrollX;
        int oldY = mScrollY;
        mScrollX = x;
        mScrollY = y;
        invalidateParentCaches();
        onScrollChanged(mScrollX, mScrollY, oldX, oldY);
        if (!awakenScrollBars()) {
            postInvalidateOnAnimation();
        }
    }
}
public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }

首先,我们需要获取View里的两个属性mScrollX和mScrollY,在滑动过程中,mScrollX的值总是等于View的左边缘和View内容左边缘在水平方向的距离,而mScrollY的值总等于View上边缘和View内容上边缘在竖直方向的距离。View边缘是指View的位置,由四个顶点组成,而View内容边缘是指View中的内容的边缘,scrollTo/scrollBy只能改变View内容的位置而不能改变View在布局中的位置。

如果从左往右滑动,那么mScrollX为负值,反之为正值,如果从上往下滑动,那么mScrollY为负值,反之为正值。

使用动画

使用动画我们能够让一个View进行平移,而平移就是一个滑动。比如:

ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();

但是动画移动会有个问题,那就是View动画并不能真正改变View的位置,这会带来一个很严重的问题,就是移动到新位置了,却发现无法触发事件,而单击原来的位置却能触发。那么从Android3.0开始,使用属性动画可以解决上面问题。

改变布局参数

改变布局参数,即改变LayoutParams,这个比较好理解,比如我们想把一个Button向右平移100px,我们只需要将这个Button的LayoutParams里的marginLeft参数的值增加100px即可。

MarginLayoutParams params = (MarginLayoutParams)mButton1.getLayoutParams;
params.width += 100;
parms.leftMargin += 100;
mButton1.requestLayout();

三种滑动方式对比:

  • scrollTo/scrollBy:操作简单,适合对View内容的滑动
  • 动画:操作简单,主要使用于没有交互的View和实现复杂的动画效果
  • 改变布局参数:操作稍微复杂,适用于有交互的View

弹性滑动

如何实现弹性滑动呢,有个共同思想:将一次大的滑动分成若干次小的滑动,并在一个时间段内完成,常见的弹性滑动具体实现方式有很多,比如通过Scroller、Handler#postDelayed,以及Thread#Sleep等等。

VelocityTracker

主要用跟踪手指在滑动过程中的速度。

private VelocityTracker mVelocityTracker;
   @Override
    public boolean onTouchEvent(MotionEvent event) {
    if (mVelocityTracker == null) { 
            mVelocityTracker = VelocityTracker.obtain();//获得VelocityTracker类实例 
    } 
          //将事件加入到VelocityTracker类实例中 
    mVelocityTracker.addMovement(ev);
    }
//设置参数值为1000,意思为一秒时间内运动了多少个像素 
    velocityTracker.computeCurrentVelocity(1000); 
//获取水平方向的速度
    float xVelocity = mVelocityTracker.getXVelocity();
  1. VelocityTracker.obtain()方法来获得VelocityTracker类的一个实例对象
  2. 在onTouchEvent回调函数中,使用addMovement(MotionEvent)函数将当前的移动事件传递给VelocityTracker对象
  3. 使用computeCurrentVelocity (int units)函数来计算当前的速度
  4. 使用 getXVelocity ()、 getYVelocity ()函数来获得当前的速度

GestureDetector

用于辅助检测用户的单击,滑动,长按,双击等行为。

源码

public interface OnGestureListener {

                // Touch down时触发, e为down时的MotionEvent

                boolean onDown(MotionEvent e);

                // 在Touch down之后一定时间(115ms)触发,e为down时的MotionEvent

                void onShowPress(MotionEvent e);

                // Touch up时触发,e为up时的MotionEvent

                boolean onSingleTapUp(MotionEvent e);

                // 滑动时触发,e1为down时的MotionEvent,e2为move时的MotionEvent

                boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);

                // 在Touch down之后一定时间(500ms)触发,e为down时的MotionEvent

                void onLongPress(MotionEvent e);

                // 滑动一段距离,up时触发,e1为down时的MotionEvent,e2为up时的MotionEvent

                boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);

}

 

public interface OnDoubleTapListener {

                // 完成一次单击,并确定没有二击事件后触发(300ms),e为down时的MotionEvent

                boolean onSingleTapConfirmed(MotionEvent e);

                // 第二次单击down时触发,e为第一次down时的MotionEvent

                boolean onDoubleTap(MotionEvent e);

                // 第二次单击down,move和up时都触发,e为不同时机下的MotionEvent,表示发生双击行为,在双击的期间

                boolean onDoubleTapEvent(MotionEvent e);

}
  • 创建一个GestureDetector的对象,传入listener对象,在自己接收到的onTouchEvent中将event传给GestureDetector进行分析,listener会回调给我们相应的动作。
private MyGestureListener mgListener;
    private VelocityTracker mVelocityTracker;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mgListener = new MyGestureListener();
        mDetector = new GestureDetector(this, mgListener);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return mDetector.onTouchEvent(event);
    }
  • 创建MyGestureListener类继承GestureDetector.SimpleOnGestureListener,该类实现了上面的OnGestureListener和OnDoubleTapListener两个接口的类,我们只需要继承它并重写其中我们需要的回调即可。
private class MyGestureListener extends GestureDetector.SimpleOnGestureListener {

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float v, float v1) {
            if (e1.getY() - e2.getY() > MIN_MOVE) 
        		startActivity(new Intent(MainActivity.this, MainActivity.class));
                Toast.makeText(MainActivity.this, "通过手势启动Activity", Toast.LENGTH_SHORT).show();
            } else if (e1.getY() - e2.getY() < MIN_MOVE) {
                finish();
                Toast.makeText(MainActivity.this, "通过手势关闭Activity", Toast.LENGTH_SHORT).show();
            }
            return true;
        }

Scroller

Scroller类源码

public class Scroller  {  
  
    private int mStartX;    //起始坐标点 ,  X轴方向  
    private int mStartY;    //起始坐标点 ,  Y轴方向  
    private int mCurrX;     //当前坐标点  X轴, 即调用startScroll函数后,经过一定时间所达到的值  
    private int mCurrY;     //当前坐标点  Y轴, 即调用startScroll函数后,经过一定时间所达到的值  
     
    private float mDeltaX;  //应该继续滑动的距离, X轴方向  
    private float mDeltaY;  //应该继续滑动的距离, Y轴方向  
    private boolean mFinished;  //是否已经完成本次滑动操作, 如果完成则为 true  
  
    //构造函数  
    public Scroller(Context context) {  
        this(context, null);  
    }  
    public final boolean isFinished() {  
        return mFinished;  
    }  
    //强制结束本次滑屏操作  
    public final void forceFinished(boolean finished) {  
        mFinished = finished;  
    }  
    public final int getCurrX() {  
        return mCurrX;  
    }  

    //根据当前已经消逝的时间计算当前的坐标点,保存在mCurrX和mCurrY值中  
    public boolean computeScrollOffset() {  
        if (mFinished) {  //已经完成了本次动画控制,直接返回为false  
            return false;  
        }  
        int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);  
        if (timePassed < mDuration) {  
            switch (mMode) {  
            case SCROLL_MODE:  
                float x = (float)timePassed * mDurationReciprocal;  
                ...  
                mCurrX = mStartX + Math.round(x * mDeltaX);  
                mCurrY = mStartY + Math.round(x * mDeltaY);  
                break;  
            ...  
        }  
        else {  
            mCurrX = mFinalX;  
            mCurrY = mFinalY;  
            mFinished = true;  
        }  
        return true;  
    }  
    //开始一个动画控制,由(startX , startY)在duration时间内前进(dx,dy)个单位,即到达坐标为(startX+dx , startY+dy)出  
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {  
        mFinished = false;  
        mDuration = duration;  
        mStartTime = AnimationUtils.currentAnimationTimeMillis();  
        mStartX = startX;       mStartY = startY;  
        mFinalX = startX + dx;  mFinalY = startY + dy;  
        mDeltaX = dx;            mDeltaY = dy;  
        ...  
    }
  • computeScrollOffset()方法
    函数功能说明:根据当前已经消逝的时间计算当前的坐标点,保存在mCurrX和mCurrY值中
    当想要知道新位置时调用computeScrollOffset()方法。 如果返回真,动画尚未完成。
  • startScroll(int startX, int startY, int dx, int dy, int duration)方法

函数功能说明:开始一个动画控制,由(startX , startY)在duration时间内前进(dx,dy)个单位,到达坐标(startX+dx , startY+dy)处。

computeScroll()方法

为了易于控制滑屏控制,Android框架提供了 **computeScroll()**方法去控制这个流程。在绘制View时,会在draw()过程调用该方法。因此, 再配合使用Scroller实例,我们就可以获得当前应该的偏移坐标,手动使View/ViewGroup偏移至该处。computeScroll()方法原型如下,该方法位于View.java类中

private void smoothScrollBy(int dx, int dy) {
    mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
    invalidate();
}

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

在smoothScrollBy方法中调用 invalidate()会导致View重绘,在View的draw方法中会调用computeScroll,通过实现computeScroll方法,让该方法通过scrollTo实现滑动,然后调用postInvalidate()进行第二次重绘,这一过程和第一过程一样,如此反复,直到整个滑动过程结束。

为了易于控制滑屏控制,Android框架提供了 **computeScroll()**方法去控制这个流程。在绘制View时,会在draw()过程调用该方法。因此, 再配合使用Scroller实例,我们就可以获得当前应该的偏移坐标,手动使View/ViewGroup偏移至该处。computeScroll()方法原型如下,该方法位于ViewGroup.java类中

最后,再提一下双击和三击的识别过程:在第一次单击down时,给Hanlder发送了一个延时300ms的消息,如果300ms里,发生了第二次单击的down事件,那么,就认为是双击事件了,并移除之前发送的延时消息。如果300ms后仍没有第二次的down消息,那么就判定为SingleTapConfirmed事件(当然,此时用户的手指应已完成第一次点击的up过程)。三击的判定和双击的判定类似,只是多了一次发送延时消息的过程,有意思吧~嘿嘿~

最近在研究场景切换的动画效果,其中需要用到三连击的动作触发。三连击,即点三下屏幕,但意义上是双击效果。 因此,我需要研究如何识别三连击的动作。

我们知道,一般的View只能响应点击(Click)和长按(LongPress)事件。这是因为View里只暴露了这些listener给我们使用。而实质上,View是在onTouchEvent(MotionEvent event)里对用户的动作做了一定的分析,从而通知我们是发生了点击还是长按等事件。

View里提供的回调在我描述的场景里,并不能满足要求。因此,GestureDetector出场了。我需要对其啃透才能写出自己的ActionDetector。

GestureDetector类可以帮助我们分析用户的动作,和View的onTouchEvent的处理方式差不多,但分析的动作类型更加细致,以下是它的回调接口:

View的事件分发机制

点击事件的传递规则

点击事件的分发过程由三个很重要的方法共同完成:dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent。

  • dispatchTouchEvent(MotionEvent ev),用来进行事件的分发,如果事件能够传递给当前view,那么此方法一定会被调用,返回结果受当前View的onTouchEvent和下级view的dispatchTouchEvent方法的影响
  • onInterceptTouchEvent(MotionEvent event),用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,此方法不会被再次调用,返回结果表示是否拦截当前事件。
  • onTouchEvent(MotionEvent event),在dispatchTouchEvent方法调用,用来处理当前点击事件,返回结果表示是否消耗当前事件。如果不消耗,那么在同一个事件序列中,当前View无法再次接收到事件。
public boolean dispatchTouchEvent(MotionEvent ev){
  boolean consume = false;
  if(onInterceptTouchEvent(ev)){
    consume = onTouchEvent(ev);
  }
  else{
    consume = child.dispatchTouchEvent(ev);
  }
  return consume;
}

简单来说,对于一个根ViewGroup来说,点击事件产生后,首先会传递给它,这时它的dispatchTouchEvent会被调用,如果这个ViewGroup的onInterceptTouchEvent方法返回true就表示它要拦截当前事件,接着事件就会交给这个ViewGroup处理,即它的onTouchEvent方法就会被调用,如果这个ViewGroup的onInterceptTouchEvent方法返回false,就表示它不拦截当前事件,这时当前事件就会继续传递给它的子元素,接着子元素的dispatchTouchEvent方法就会被调用,如此反复直到事件被最终处理。

还有一个当View需要处理事件时,如果它设置了OnTouchListener,那么OnTouchListener中的onTouch方法会被回调,OnTouchListener优先于onTouchEvent。在onTouchEvent中,如果设置了OnClickListener,那么它的OnClick方法会被调用,可以看出我们平时常用的OnClickListener优先级最低,onTouch>onClick.

View不处理流程:

View处理流程

总结:

  • 当一个点击事件产生后,它的传递过程遵循如下顺序:Activity->Window->View,如果一个view的onTouchEvent返回false,那么它的父容器的onTouchEvent将会被调用,以此类推,如果所有的元素都不处理这个事情,最终就将会交给Activity来处理
  • 同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束。一般是以down事件开始,中间含有数量不定的move事件,最终以up事件结束。
  • 正常情况下,一个事件序列只能被一个View拦截且消耗,事件一旦交给View处理,就必须消耗掉(onTouchEvent=true),否则同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交给它的父元素去处理,即父元素的onTouchEvent会被调用。
  • 如果View不消耗除ACTION_DOWN以外的其他事件,那么这个点击事件就会消失,此时父元素的onTouchEvent并不会被调用,最终会交给Activity处理。
  • ViewGroup默认不拦截任何事件。
  • View中没有onInterceptTouchEvent方法。
  • View的onTouchEvent默认都会被消耗,除非它是不可点击的(clickable和longClickable同时为false),View的longClickable默认属性都是false,clickable需要分情况例如Button默认为true,TextView默认为false。
  • 事件传递过程是由外向内的,即事件先是传递给父元素,然后再由父元素分发给子View。

ViewGroup事件分发过程

源码

final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                if (!disallowIntercept) {
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                    intercepted = false;
                }
            } else {
                // There are no touch targets and this action is not an initial down
                // so this view group continues to intercept touches.
                intercepted = true;
            }

当actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null会判断是否要拦截当事件由ViewGroup的子元素成功处理后,mFirstTouchTarget会被赋值并指向子元素,如果mFirstTouchTargetnull actionMasked MotionEvent.ACTION_MOVE|| MotionEvent.ACTION_UP onInterceptTouchEvent不会调用,将会拦截,可以通过requestDisallowInterceptTouchEvent()方法重置FLAG_DISALLOW_INTERCEPT标记位,一旦设置后,Viewgroup将无法拦截除ACTION_DOWN之外的其他事件

可以通过requestDisallowInterceptTouchEvent()方法重置FLAG_DISALLOW_INTERCEPT标记位,一般用于子view中

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
        // We're already in this state, assume our ancestors are too
        return;
    }

    if (disallowIntercept) {
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Pass it up to our parent
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
}

一旦设置后,Viewgroup将无法拦截除ACTION_DOWN之外的其他事件,因为ACTION_DOWN会重置FLAG_DISALLOW_INTERCEPT

// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
    // Throw away all previous state when starting a new touch gesture.
    // The framework may have dropped the up or cancel event for the previous gesture
    // due to an app switch, ANR, or some other state change.
    cancelAndClearTouchTargets(ev);
    resetTouchState();
}
private void resetTouchState() {
    clearTouchTargets();
    resetCancelNextUpFlag(this);
    mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    mNestedScrollAxes = SCROLL_AXIS_NONE;
}

View对点击事件的处理过程

if (li != null && li.mOnTouchListener != null
            && (mViewFlags & ENABLED_MASK) == ENABLED
            && li.mOnTouchListener.onTouch(this, event)) {
        result = true;
        if (sDebugDispatchInput) {
            Log.d(VIEW_LOG_TAG, "dispatchTouchEvent to the view: " + this
                    + ", which has listener, so we call its onTouch");
        }
    }

    if (!result && onTouchEvent(event)) {
        result = true;
    }
}

从源码可以看出,首先会判断有没有设置OnTouchListener,如果mOnTouchListener.onTouch返回true,onTouchEvent就不会被调用,mOnTouchListener.onTouch优先级高于onTouchEvent

if ((viewFlags & ENABLED_MASK) == DISABLED) {
    if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
        setPressed(false);
    }
    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
    // A disabled view that is clickable still consumes the touch
    // events, it just doesn't respond to them.
    return clickable;
}

可点击的禁用视图仍然消耗触摸事件,它只是不响应它们。

对点击动作的处理

if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
    switch (action) {
        case MotionEvent.ACTION_UP:
            mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
            if ((viewFlags & TOOLTIP) == TOOLTIP) {
                handleTooltipUp();
            }
            if (!clickable) {
                removeTapCallback();
                removeLongPressCallback();
                mInContextButtonPress = false;
                mHasPerformedLongPress = false;
                mIgnoreNextUpEvent = false;
                break;
            }
            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 && !mIgnoreNextUpEvent) {
                    // 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)) {
                            performClickInternal();
                        }
                    }
                }

                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();
            }
            mIgnoreNextUpEvent = false;
            break;

        case MotionEvent.ACTION_DOWN:
            if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN) {
                mPrivateFlags3 |= PFLAG3_FINGER_DOWN;
            }
            mHasPerformedLongPress = false;

            if (!clickable) {
                checkForLongClick(0, x, y);
                break;
            }

            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();
                }
                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, x, y);
            }
            break;

        case MotionEvent.ACTION_CANCEL:
            if (clickable) {
                setPressed(false);
            }
            removeTapCallback();
            removeLongPressCallback();
            mInContextButtonPress = false;
            mHasPerformedLongPress = false;
            mIgnoreNextUpEvent = false;
            mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
            break;

        case MotionEvent.ACTION_MOVE:
            if (clickable) {
                drawableHotspotChanged(x, y);
            }

            // Be lenient about moving outside of buttons
            if (!pointInView(x, y, mTouchSlop)) {
                // Outside button
                // Remove any future long press/tap checks
                removeTapCallback();
                removeLongPressCallback();
                if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                    setPressed(false);
                }
                mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
            }
            break;
    }

View中LONG_CLICKABLE默认为false,CLICKABLE是否为false与具体的View有关,可点击的View为true,不可点击的为false,例如Button为true,TextView为false,通过setOnClickListener和setOnLongClickListener方法 会自动将CLICKABLE和LONG_CLICKABLE设置为true

setOnClickListener

public void setOnClickListener(@Nullable OnClickListener l) {
    if (!isClickable()) {
        setClickable(true);
    }
    getListenerInfo().mOnClickListener = l;
}

setOnLongClickListener

public void setOnLongClickListener(@Nullable OnLongClickListener l) {
    if (!isLongClickable()) {
        setLongClickable(true);
    }
    getListenerInfo().mOnLongClickListener = l;
}

View的滑动冲突

一、常见的滑动冲突

场景1:外部滑动和内部滑动不一致

场景2:外部滑动和内部滑动一致

场景3:上面两种情况的嵌套

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8c9Hzk5i-1646358386610)(img/331079-20170122173803191-145084598.jpg)]

场景1:主要是讲ViewPager和Fragment配合使用组成的页面滑动效果,会产生的问题。

场景2:在开发中,内外两层同时能上下滑动或者内外两层同时能左右滑动。

场景3:是场景1和场景2两种情况的嵌套。

如何处理

根据滑动是水平滑动还是竖直滑动来判断到底是由谁来拦截事件。几种处理方式:

  • 外部拦截法。点击事情都是先经过父容器的拦截处理,如果父容器需要次事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题。
  • 内部拦截法。父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗点,否则就交由父容器进行处理。

场景一

  • 外部拦截法
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            intercepted = false;
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
                intercepted = true;
            }
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            int deltaX = x - mLastXIntercept;
            int deltaY = y - mLastYIntercept;
            if (Math.abs(deltaX) > Math.abs(deltaY)) {
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            intercepted = false;

            break;
        }
        default:
            break;
    }


    mLastX = x;
    mLastY = y;
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
}

MotionEvent.ACTION_MOVE:事件可以根据需要是否拦截,如果父容器需要拦截,就返回true,不需要拦截就返回false,该实例中如果水平方向滑行的距离大于竖直方向上滑行的距离就拦截

MotionEvent.ACTION_DOWN:一般返回false,否则ACTION_MOVE和ACTION_UP都会由父容器拦截,该实例中设置为true是为了解决在水平滑动没结束时,如果用户快速竖直滑动,就会导致界面在水平方向无法滑动到终点而处于一种中间状态

MotionEvent.ACTION_UP:这里必须返回false,假设事件交给子元素处理,如果父容器在ACTION_UP时返回true,就会导致子元素无法接收到ACTION_UP事件,这时候子元素中的onclick事件就不会触发,但是父容器比较特殊,一旦开始拦截任何一个事件,那么后续的事件都会交给他处理,即便在onInterceptTouchEvent里设为false

  • 内部拦截法
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN: {
        mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
        break;
    }
    case MotionEvent.ACTION_MOVE: {
        int deltaX = x - mLastX;
        int deltaY = y - mLastY;
        Log.d(TAG, "dx:" + deltaX + " dy:" + deltaY);
        if (Math.abs(deltaX) > Math.abs(deltaY)) {
            mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
        }
        break;
    }
    case MotionEvent.ACTION_UP: {
        break;
    }
    default:
        break;
    }

    mLastX = x;
    mLastY = y;
    return super.dispatchTouchEvent(event);
}

当子元素调用 parent.requestDisallowInterceptTouchEvent(false)时,父元素才能拦截

场景二

外部拦截法

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean intercepted = false;
    switch (ev.getAction()){
        case MotionEvent.ACTION_DOWN:
            intercepted = false;
            break;
        case MotionEvent.ACTION_MOVE:public class CustomScrollView extends ScrollView {

    ListView listView;
    private float mLastY;
    public CustomScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        super.onInterceptTouchEvent(ev);
        boolean intercept = false;
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                listView = (ListView) ((ViewGroup)getChildAt(0)).getChildAt(1);
                   //ListView滑动到顶部,且继续下滑,让scrollView拦截事件
                if (listView.getFirstVisiblePosition() == 0 && (ev.getY() - mLastY) > 0) {
                    //scrollView拦截事件
                    intercept = true;
                }
                //listView滑动到底部,如果继续上滑,就让scrollView拦截事件
                else if (listView.getLastVisiblePosition() ==listView.getCount() - 1 && (ev.getY() - mLastY) < 0) {
                    //scrollView拦截事件
                    intercept = true;
                } else {
                    //不允许scrollView拦截事件
                    intercept = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercept = false;
                break;
            default:
                break;
        }
        mLastY = ev.getY();
        return intercept;
    }
}
            if(父容器需要自己处理改事件){
                intercepted = true;
            }else {
                intercepted = false;
            }
            break;
        case MotionEvent.ACTION_UP:
            intercepted = false;
            break;
            default:
            break;
    }
    return intercepted;
}

内部拦截法

public class CustomListView extends ListView {

    public CustomListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    //为listview/Y,设置初始值,默认为0.0(ListView条目一位置)
    private float mLastY;

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int action = ev.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                //不允许上层的ScrollView拦截事件.
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                //满足listView滑动到顶部,如果继续下滑,那就允许scrollView拦截事件
                if (getFirstVisiblePosition() == 0 && (ev.getY() - mLastY) > 0) {
                    //允许ScrollView拦截事件
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                //满足listView滑动到底部,如果继续上滑,允许scrollView拦截事件
                else if (getLastVisiblePosition() == getCount() - 1 && (ev.getY() - mLastY) < 0) {
                    //允许ScrollView拦截事件
                    getParent().requestDisallowInterceptTouchEvent(false);
                } else {
                    //其它情形时不允ScrollView拦截事件
                    getParent().requestDisallowInterceptTouchEvent(true);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
        }

        mLastY = ev.getY();
        return super.dispatchTouchEvent(ev);
    }
}

View的工作原理

首先,要先了解下View的一些基本概念,这样才能更好理解View的measure、layout和draw过程。

ViewRoot对应于ViewRootImpl类,它是连接WindowManager和DecorView的纽带,View的三大流程是通过VeiwRoot来完成的。在ActivityThread中,当Activity对象被创建完毕后,会将DecorVeiw添加到Window中,同时会创建ViewRootImpl对象,并将ViewRootImpl对象和DecorView建立关联。

View的绘制流程是从ViewRoot的performTraversals方法开始的,它经过measure、layout和draw三个过程才能最终将一个View绘制出来。如图:

android中ui控件 android的view控件_ci

  • performTraversals会依次调用performMeasure、performLayout、performDraw三个方法,这个三个方法分别完成顶级View的measure、layout、draw这三大方法,
  • 在onMeasure方法中则会对所有的子元素进行measure过程,这个时候measure流程就从父容器传递到子元素中了,这样就完成一次measure过程,接着子元素会重复父容器的过程,如此反复就完成了整个View树的遍历。同理,其他两个步骤也是类似的过程。
  • measure过程决定了View的宽和高,Measure完成以后,可以通过getMeasureWidth和getMeasureHeight方法来获取到View的测量后的宽和高。几乎在所有的情况下,这个宽高就是View的最终宽高。layout过程决定了view的四个点的坐标和实际的宽高。可以通过getTop,getRight, getLeft,getBottom来得到四个点的坐标,通过getWidth和getHeight得到实际的宽高。draw的过程就是显示的过程,只有在draw完成之后才能最终显示在屏幕上。
  • DecorView 其实是一个 FrameLayout,View层事件都先经过DecorView ,然后才传给View。

MeasureSpec

MeasureSpec,测量规格,测量说明,从名字上看起来都知道它的作用就是决定View的测量过程或者说它在很大程度上决定了View的尺寸规格。除此之外还有父容器也会影响View的创建过程。在测量过程中,系统会将View的LayoutParams根据父容器的规则转换成对应的MeasureSpex,然后再根据这个MeasureSpec来测量View的宽高。

MeasureSpec代表了一个32位的int的值,高2位代表了SpecMode,低30位代表了SpecSize。SpecMode指测量模式,SpecSize是指在某种测量模式下的规格大小。MeasureSpec 通过将 SpecMode 和 SpecSize 打包成一个 int 值来避免过多的内存分配,为了方便操作,其提供了打包和解包方法源码如下:

//将 SpecMode 和 SpecSize 打包,获取 MeasureSpec  
public static int makeMeasureSpec(int size, int mode) {
    if (sUseBrokenMakeMeasureSpec) {
        return size + mode;
    } else {
        return (size & ~MODE_MASK) | (mode & MODE_MASK);
    }
}
//将 MeasureSpec 解包获取 SpecMode
public static int getMode(int measureSpec) {
            return (measureSpec & MODE_MASK);
        }
//将 MeasureSpec 解包获取 SpecSize
 public static int getSize(int measureSpec) {
            return (measureSpec & ~MODE_MASK);
        }

SpecMode 有三类,每一类都表示特殊的含义:

  1. UNSPECIFIED 父容器不对 View 有任何的限制,要多大给多大,这种情况下一般用于系统内部,表示一种测量的状态。
  2. EXACTLY 父容器已经检测出 View 所需要的精确大小,这个时候 View 的最终大小就是 SpecSize 所指定的值,它对应于LayoutParams 中的 match_parent 和具体的数值这两种模式。
  3. AT_MOST 父容器指定了一个可用大小即 SpecSize,View 的大小不能大于这个值,具体是什么值要看不同 View 的具体实现。它对应于 LayoutParams 中的 wrap_content。

MeasureSpec 和 LayoutParams 的对应关系

  • 对于DecorView,它的 MeasureSpec 由窗口的尺寸和其自身的 LayoutParams 来决定;
  • 对于普通 View,它的MeasureSpec 由父容器的 MeasureSpec 和自身的 LayoutParams 来共同决定,MeasureSpec一旦确定,onMeasure中就可以确定View的宽高。

对普通的 View 来说,View的 measure过程是由其ViewGroup传递而来的,这里先看一下 ViewGroup 的 measureChildWithMargins 方法:

@param child 要被测量的 View
 * @param parentWidthMeasureSpec 父容器的 WidthMeasureSpec
 * @param widthUsed 父容器水平方向已经被占用的空间,比如被父容器的其他子 view 所占用的空间
 * @param parentHeightMeasureSpec 父容器的 HeightMeasureSpec
 * @param heightUsed 父容器竖直已经被占用的空间,比如被父容器的其他子 view 所占用的空间
 */
protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {

   //第一步,获取子 View 的 LayoutParams
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

   //第二步,获取子 view 的 WidthMeasureSpec,其中传入的几个参数说明:
   //parentWidthMeasureSpec 父容器的 WidthMeasureSpec
   //mPaddingLeft + mPaddingRight view 本身的 Padding 值,即内边距值
   //lp.leftMargin + lp.rightMargin view 本身的 Margin 值,即外边距值
   //widthUsed 父容器已经被占用空间值
   // lp.width view 本身期望的宽度 with 值
    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);
     //获取子 view 的 HeightMeasureSpec
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);

// 第三步,根据获取的子 veiw 的 WidthMeasureSpec 和 HeightMeasureSpec 
   //对子 view 进行测量
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

View 的measure过程

View 的工作流程主要是指 measure、layout、draw 这三大流程,即测量、布局和绘制,其中 measure 确定 View 的测量宽和高,layout 确定 View 的最终宽和高及 View 的四个顶点位置,而 draw 是将 View 绘制到屏幕上。

4.1 measure过程

measure过程要分情况,如果只是一个原始的View,那么通过measure方法就完成了测量过程,如果是ViewGroup,那么就需要首先测量自己的过程,然后再遍历调用子元素的measure方法,各个子元素在地柜去执行这个流程,下面是对这两种情况的分别讨论。

4.1.1 View的Measure过程

View 的 measure 过程由 measure 方法来完成, measure 方法是一个 final 类型,子类不可以重写,而 View 的 measure() 方法中会调用 onMeasure 方法,因此我们只需要分析 onMeasure 方法即可,源码如下:

/**
  * @param widthMeasureSpec 父容器所施加的水平方向约束条件
  * @param heightMeasureSpec 父容器所施加的竖直方向约束条件
  */
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  //设置 view 高宽的测量值
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

setMeasuredDimension方法就是给 View 设置了测量高宽的测量值,而这个测量值是通过 getDefaultSize 方法获取

/**
   * @param size view 的默认尺寸,一般表示设置了android:minHeight属性
   *  或者该View背景图片的大小值 
   * @param measureSpec 父容器的约束条件 measureSpec
   * @return 返回 view 的测量尺寸
   */
public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
  //获取测量模式
    int specMode = MeasureSpec.getMode(measureSpec);
  //获取尺寸
    int specSize = MeasureSpec.getSize(measureSpec);
    switch (specMode) {
            case MeasureSpec.UNSPECIFIED:
            //如果 测量模式为 UNSPECIFIED ,表示对父容器对子 view 没有限制,那么 view 的测量尺寸为
          //默认尺寸 size
        result = size;
        break;
    case MeasureSpec.AT_MOST:
    case MeasureSpec.EXACTLY:
        //如果测量模式为 AT_MOST 最大测量模式或者 EXACTLY 精准测量模式,
        //那么 View 的测量尺寸为 MeasureSpec 的 specSize
        //即父容器给定尺寸(父容器当前剩余全部空间大小)。
        result = specSize;
        break;
    }
    return result;
}

getDefaultSize方法的逻辑很简单,如果 测量模式为 UNSPECIFIED ,表示对父容器对子 view 没有限制,那么 view 的测量尺寸为默认尺寸 size。如果测量模式为 AT_MOST 最大测量模式或者 EXACTLY 精准测量模式,那么 View 的测量尺寸为 MeasureSpec 的 specSize,即父容器给定尺寸(父容器当前剩余全部空间大小)。

这里来分析一下 UNSPECIFIED 条件下 View 的测量高宽默认值 size 是通过 getSuggestedMinimumWidth() 和 getSuggestedMinimumHeight() 函数获取

protected int getSuggestedMinimumHeight() {
  return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());

protected int getSuggestedMinimumWidth() {
  return (mBackground == null) ? mMinWidth : max(mMinWidth , mBackground.getMinimumWidth());
}

从getSuggestedMinimumHeight代码可以看出,如果 View 没有背景,View 的高度就是 mMinHeight,这个 mMinHeight 是由 android:minHeight 这个属性控制,可以为 默认为0,如果有背景,就返回 mMinHeight 和背景的最小高度两者中的最大值;同理getSuggestedMinimumWidth也是一样。

public  int  getMinimumWidth(){
    final  int  intrinsicWidth =  getInstrinsicWidth();
    return intrinsicWidth >0?intrinsicWidth  : 0;
}

getMinimumWidth方法返回的就是Drawable的原始宽度,前提是这个Drawable的原始宽度,否则就返回0。

ViewGroup 的 measure 过程

后对整个测量过程总结一下就是分为以下几步:

  1. 对 LinearLayout 中的子 View 进行第一次遍历测量,主要是通过 measureChildBeforeLayout 这个方法,这个方法内部会调用 measureChildWithMargins 方法,而在 measureChildWithMargins 方法内部会去调用 child.measure(childWidthMeasureSpec, childHeightMeasureSpec) 方法进行测量。在这次的测量过程中,如果满足了第1.1步测量条件的子 view 不需要进行测量,会在后面的第5.1步中进行测量。
  2. 根据测量各个子 View 的高度会得到一个初步的 LinearLayout 总高度 mTotalLength 值。
  3. 如果 LinearLayout 设置了 android:measureWithLargestChild=”true” 属性并且测量模式为 AT_MOST或者 UNSPECIFIED 重新计算 mTotalLength 总高度。
  4. 根据 LinearLayout 的 heightMeasureSpec 测量模式 和已经测量得到的总高度 mTotalLength ,来确定得到最终 LinearLayout 高度和状态 。
  5. 根据已经测量得到的 LinearLayout 高度来重新测量确定各个子 View 的大小。
  6. 最终执行 setMeasuredDimension 方法设置 LinearLayout 的测量高宽。