View体系详解

1,091 阅读14分钟

一、学习脑图

二、View基础

2.1 什么是View

Q1:怎么理解View

  • View是界面层的控件的一种抽象,代表了一个控件。
  • android在视觉上的呈现。
  • 是所有控件是基类,可以是单个控件View可以是一组控件ViewGroup

Q2:View的重要性?

ViewAndroid中是一个十分重要的概念,虽然说View不属于四大组件,但是它的作用堪比四大组件,在开发中,Activity承担了可视化的功能,Android提供了很多基础的控件,当我们不满足于这些基础控件的功能时,可以用自定义控件,而控件的自定义就需要对View体系有深入的了解。

2.2 View的位置参数

Android系统中,有两种坐标系,分别是Android坐标系和View坐标系。

2.2.1 Android坐标系
  • 将屏幕左上角作为坐标原点
  • 原点向右是X轴正方向
  • 原点向下是Y轴正方向

注意:使用getRawX()getRawY()方法获得的坐标是Android坐标系的坐标

2.2.2 View坐标系

Q1:View的位置由什么来决定?

四个顶点:top(左上角纵坐标)、left(左上角横坐标)、right(右下角横坐标)、bottom(右下角纵坐标)

注意:这些坐标都是相对于父容器来说的,是一种相对坐标

Top = getTop()Left = getLeft(),Right = getRight(),Bottom=getBottom()

自Anroid3.0后,增加了xytranslationXtranslationY这几个参数。

  • xyView左上角的坐标
  • translationXtranslationY:左上角相对于父容器的偏移量

注意:View在平移过程中,topleft表示原始左上角的位置信息,发生改变的值是xytranslationXtranslationY这四个参数。

Q2:getX()getY()getRawX()getRawY()有什么区别?

getXgetY是视图坐标,是相对于控件的距离

getRawXgetRawY是绝对坐标,是与整个屏幕的距离

Q3:View怎么获取自身的宽和高?

width = getRight()-getLeft() = getWidth()

height = getBottom()-getTop() = getHeight()

2.2.3 View的触控
2.2.3.1 MotionEvent

手指接触屏幕后所产生的一系列事件。

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

正常情况下,触摸屏幕会出现以下两种情况

  • 点击屏幕后松开,DOWN -> UP
  • 点击屏幕滑动后再松开,DOWN->MOVE->...->MOVE->UP
2.2.3.2 TouchSlop

TouchSlop是系统所能识别出的被认为是滑动的最小距离,是一个常量。

Q1:怎么获取这个常量?

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

Q2:这个常量有什么意义?

在处理滑动时,可以利用这个常量来进行过滤,当两次滑动事件的滑动距离小于这个常量时,可以认为它们不是滑动。

2.2.3.3 VelocityTracker

速度追踪,用于追踪手指在滑动过程中的速度,包括水平和竖直速度。

Q:怎么使用VelocityTracker

1.在ViewonTouchEvent方法中追踪当前点击事件的速度

 VelocityTracker velocityTracker = VelocityTracker.obtain(); 
 velocityTracker.addMovement(event);

2.获取当前速度

  velocityTracker.computeCurrentVelocity(1000);
        int xVelocity = (int)velocityTracker.getXVelocity();
        int yVelocity = (int)velocityTracker.getYVelocity();

注意:

  • 获取速度之前需要先计算速度,即getXVelocity()getYVelocity()方法前必须先调用velocityTracker.computeCurrentVelocity(1000);

  • 这里的速度指的事一段时间内手滑动的像素数。速度可以为正也可以为负,因为在Android坐标系中,手指逆着坐标正方向滑动,速度结果就是负的,这里的正负指的是方向。

    速度 = (终点位置 - 起点位置)/时间段

3.使用clear重置并回收内存

当不需要使用velocityTracker时,需要使用clear去回收它。

   velocityTracker.clear();
        velocityTracker.recycle();
2.2.3.4 GestureDetector

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

Q:怎么使用GestureDetector

1.创建一个GestureDetector对象并实现OnGestureDetector接口

 GestureDetector mGestureDetector = new GestureDetector(this,this);
        //解决长按屏幕后无法拖动的现象
  mGestureDetector.setIsLongpressEnabled(false);

2.在ViewonTouchEvent方法添加

 boolean consume = mGestureDetector.onTouchEvent(event);
        return consume;

3.有选择的实现OnGestureListenerOnDoubleTapListener中的方法

建议:如果只是监听滑动操作,建议在onTouchEvent中实现;如果要监听双击这种行为,则使用GestureDetector

2.2.3.5 Scroller

弹性滑动对象,用于实现View的弹性滑动

Q:如何使用Scroller?典型代码固定,如下。

Scroller scroller = new Scroller(mContext);

//缓慢滚动到指定位置
    private void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int delta = destX -scrollX;
        //1000ms内滑向destX,效果就是慢慢滑动
        scroller.startScroll(scrollX,0,delta,0,1000);
        invalidate;
    }
    
    @Override
    Public void computeScroll(){
    	if(mScroller.computeScrollOffset()){
    		ScrollTo(mScroll.getCurrX(),mScroller.getCurrY());
    		postInvalidate();
    	}
    }

三、View的滑动

滑动在Android开发中具有重要的作用,掌握滑动的方法是实现自定义控件的基础。

View滑动的基本思想:

当触摸事件传到View时,系统记下触摸点的坐标,手指移动时系统记下移动后的触摸的坐标并算出偏移量,并通过偏移量来修改View的坐标。

3.1 View滑动的7种方法

3.2.1 layout()

思路:view进行绘制的时候会调用onLayout()方法来设置显示的位置,因此可以通过修改Viewlefttoprightbottom这四种属性来控制View的坐标。

  • 使用:
 public boolean onTouchEvent(MotionEvent event) {
        //获取到手指处的横坐标和纵坐标
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算移动的距离
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //调用layout方法来重新放置它的位置
                layout(getLeft()+offsetX, getTop()+offsetY,
                        getRight()+offsetX , getBottom()+offsetY); //layout()方法
                break;
        }
        return true;
    }

3.2.2 offsetLeftAndRight()offsetTopAndBottom()

思路:与layout()方法思路一样,不同的是offsetLeftAndRight()offsetTopAndBottom()方法设置的是左右和上下的偏离值。

使用:

public boolean onTouchEvent(MotionEvent event) {
        //获取到手指处的横坐标和纵坐标
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算移动的距离
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //对left和right进行偏移
                offsetLeftAndRight(offsetX);  
                //对top和bottom进行偏移
                offsetTopAndBottom(offsetY);
                break;
        }
        return true;
    }
3.2.3 LayoutParams(改变布局参数)

思路:LayoutParams主要保存了一个View的布局参数,可以通过LayoutParams来改变View的布局的参数从而达到了改变View的位置的效果。

  • 使用:
public boolean onTouchEvent(MotionEvent event) {
        //获取到手指处的横坐标和纵坐标
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算移动的距离
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                LinearLayout.LayoutParams layoutParams= (LinearLayout.LayoutParams) getLayoutParams();
                layoutParams.leftMargin = getLeft() + offsetX;
                layoutParams.topMargin = getTop() + offsetY;
                setLayoutParams(layoutParams);
                break;
        }
        return true;
    }

​ 父控件若是LinearLayout则按代码上所示,父控件若是RelativeLayout,则要使用RelativeLayout.LayoutParams,除了使用布局的LayoutParams外,也可以用ViewGroup.MarginLayoutParams来实现

ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();

3.2.4 动画

思路:通过动画可以让一个View进行平移,而平移也就是一种滑动,主要操作的是ViewtranslationXtranslationY属性。

Android 内有两种动画可以使用:View动画和属性动画。

  • View动画使用:

    1.在res目录新建anim文件夹并创建translate.xml:

  LinearLayout.LayoutParams layoutParams= (LinearLayout.LayoutParams) getLayoutParams();
                layoutParams.leftMargin = getLeft() + offsetX;
                layoutParams.topMargin = getTop() + offsetY;
                setLayoutParams(layoutParams);

​ 2.在Java代码中引用:

 mCustomView.setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));

注意:View动画并不能改变View的位置参数。

如果对一个Button进行如上的平移动画操作,当Button平移300像素停留在当前位置时,我们点击这个Button并不会触发点击事件,但在我们点击这个Button的原始位置时却触发了点击事件。这就是补间动画和属性动画的区别

  • 属性动画使用:

    CustomView在1000毫秒内沿着X轴像右平移300像素:

ObjectAnimator.ofFloat(mCustomView,"translationX",0,300).setDuration(1000).start();

3.2.5 scrollTo/scrollBy

思路:scollTo(x,y)表示移动到一个具体的坐标点,而scollBy(dx,dy)则表示移动的增量为dx、dy。其中scollBy最终也是要调用scollTo的。scollToscollBy移动的是View的内容,如果在ViewGroup中使用则是移动他所有的子View

  • 使用:
 public boolean onTouchEvent(MotionEvent event) {
        //获取到手指处的横坐标和纵坐标
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算移动的距离
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                ((View)getParent()).scrollBy(-offsetX,-offsetY);
                break;
        }
        return true;
    }

3.2.6 Scroller

scollTo/scollBy方法来进行滑动时,这个过程是瞬间完成的,使用Scroller来实现有过度效果的滑动,这个过程不是瞬间完成的,而是在一定的时间间隔完成的。Scroller本身是不能实现View的滑动的,它需要配合ViewcomputeScroll()方法才能弹性滑动的效果。

  • 使用:
@Override
    public void computeScroll() {
        super.computeScroll();
        if(mScroller.computeScrollOffset()){
            ((View)getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            //不断的重绘,重复调用computeScroll方法
            PostInvalidate();
        }
    }
    //缓慢滚动到指定位置
    public void smoothScrollTo(int destX,int destY){
        int scrollX=getScrollX();
        int delta=destX-scrollX;
        //1000秒内滑向destX
        mScroller.startScroll(scrollX,0,delta,0,2000);
        invalidate();
    }

View类中调用

    //使用Scroll来进行平滑移动
          mCustomView.smoothScrollTo(-400,0);
  • 源码分析

    当我们构造一个Scroller对象并调用它的startScroll方法时,startScroll保存了传递的几个参数

       /**
         * @param startX 起点的横坐标
         * @param startY 起点的纵坐标
         * @param dx 水平滑动的距离
         * @param dy 竖直滑动的距离
         * @param duration 滑动时间
         */
      public void startScroll(int startX, int startY, int dx, int dy, int duration) {
            mMode = SCROLL_MODE;
            mFinished = false;
            mDuration = duration;
            mStartTime = AnimationUtils.currentAnimationTimeMillis();
            mStartX = startX;
            mStartY = startY;
            mFinalX = startX + dx;
            mFinalY = startY + dy;
            mDeltaX = dx;
            mDeltaY = dy;
            mDurationReciprocal = 1.0f / (float) mDuration;
        }
    

    Q1:在startScroll方法中,内部并没有做滑动相关的事,那么startScroll是如何让View滑动的?

    答:使用invalidate方法。invalidate方法会导致View重绘,重绘过程中Viewdraw方法中又会去调用computeScroll方法,本来computeScroll方法在View中是一个空实现,在上面的代码中我们已经添加代码,通过scrollTo方法实现滑动,接着使用PostInvalidat方法第二次重绘,如此反复,知道整个滑动过程结束。

    computeScroll方法中有使用到computeScrollOffset()方法,下面看看这个方法的源码

    public boolean computeScrollOffset() {
            if (mFinished) {
                return false;
            }
    
            int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
        
            if (timePassed < mDuration) {
                switch (mMode) {
                case SCROLL_MODE:
                    final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
                    mCurrX = mStartX + Math.round(x * mDeltaX);
                    mCurrY = mStartY + Math.round(x * mDeltaY);
                    break;
      .......              
    

    这个方法会根据时间的流逝计算当前的scrollXscrollY的值。返回ture时表示滑动未结束,返回false则表示滑动已结束。

3.2.7 延时策略

核心思想:通过发送一系列延时消息从而达到一种渐进性效果。

使用:Handle/ViewpostDelayed/线程的sleep

注意:无法精确定时,因为系统消息调度也需要时间。

四、事件分发机制

事件分发机制是View体系里学习的核心点,它是解决滑动冲突的理论基础,因此,学习好事件分发机制是非常重要的。

这一部分主要是我对知识的总结概括,看了之后还是对事件分发机制感到模糊的读者,推荐一篇详细的事件分发文章学习 View 事件分发,就像外地人上了黑车!

Q1:什么是点击事件的事件分发?

当一个点击事件MotionEvent产生以后,系统把这个事件传递给具体的View的过程,就是事件分发过程。

4.1 主要方法

  • dispatchTouchEvent:进行事件的分发(传递)。返回值是 boolean 类型,受当前onTouchEvent下级viewdispatchTouchEvent影响

  • onInterceptTouchEvent:对事件进行拦截。该方法只在ViewGroup中有,View(不包含 ViewGroup)是没有的。如果一旦拦截,则执行ViewGrouponTouchEvent,在ViewGroup中处理事件,而不接着分发给View,且只调用一次,所以后面的事件都会交给ViewGroup处理。

  • onTouchEvent:进行事件处理。

三者关系的伪代码:

public boolean dispatchTouchEvent(MotionEvent event) {
        boolean consume  = false; //boolean类型的值表示是否消费事件
        if(onInterceptTouchEvent(event)){ //当前View拦截了这个事件
            consume = onTouchEvent(event); //执行当前View的onTouchEvent()方法,是否消费由该返回值决定
        }else {//当前View没有拦截这个事件
            consume  = child.dispatchTouchEvent(event);  //事件传递给下一层View的dispatchTouchEvent()方法,是否消费由下一层ViewdispatchTouchEvent()方法返回值决定
        }
        return consume;
    }

4.2 事件分发的全流程

Q2: View事件分发的本质是什么?

View事件分发的本质是递归,点击事件自上而下分发的过程是“递”,消耗事件自下而上的过程是“归”。

Q3: “递”和“归”的两个过程分别是什么?

当一个点击事件产生后,它的传递过程会遵循如下顺序:Activity->Window->ViewGroup->...->View。这个自上而下传递的过程就是“递“的过程。

当传递到具体的一个View后,这个View的onTouchEvent返回false,即不消耗这个事件,那么这个事件则会向上传递,假若所有的元素都不处理这事件,那么这个事件最终会传递给Activity处理,这个自下而上传递的过程就是“归”的过程。

注意:

在“递”的过程中,ViewGroup 可以在当前层级,通过设置 onInterceptTouchEvent 方法返回 true,来拦截事件的下发,而直接步入“归”流程。

ViewGroup 可以拦截事件下发的同时,child 也可以通过 getParent.requestDisallowInterceptTouchEvent 方法,来阻止上一级的下发拦截。

图取自学习 View 事件分发,就像外地人上了黑车!

总结:同样参考上面链接

4.3 源码分析

事件分发的三个过程:

  1. Activity对事件的分发
  2. 顶级View对事件的分发
  3. View对事件的处理

这里不列出源码,仅画出流程图,需要查看源码的读者可查看《Android开发艺术探索》相应章节或者在编译器中查看。

五、滑动冲突

在使用滑动的过程中,假设一种情况,一个界面内外两层可以滑动,这个时候你滑动它,这个界面怎么判定你滑动的是内层还是外层?这个时候就会产生滑动冲突,所以在这个部分,我们一起来解决这个滑动冲突。

5.1 场景的滑动冲突场景

  • 场景A:外部滑动与内部滑动不一致的滑动冲突,常见于常见于ScrollViewFragmentLisetView的使用。
  • 场景B:外部滑动与内部滑动一致的滑动冲突,可能出现在自定义ViewListView中,外部可以上下滑动,内部也可以上下滑动。
  • 场景C:场景AB的嵌套。

5.2 处理规则

  • 场景A的处理规则:当用户左右滑动时,让外部的View拦截点击事件,当用户上下滑动时,让内部的View拦截点击事件。

    Q1:如何判断用户是左右滑动还是上下滑动

    利用水平偏移量和竖直方向的偏移量进行相减,用是否大于0来判断哪个偏移量大。若偏移量offsetX-offsetY>0,可判断为水平滑动,这时可以由外部拦截,让它来处理点击事件。

  • 场景B的处理规则:需要根据业务逻辑来处理,,规定何时让外部View拦截事件何时由内部View拦截事件。

  • 场景C的处理规则:同样需要从业务上找突破点

5.3 解决方法

  • 外部拦截法

  • 内部拦截法

5.3.1 外部拦截法

点击事件都先经过父容器的拦截处理,如果父容器需要就拦截,不需要此事件就不拦截。

方法:需要重写父容器的onInterceptTouchEvent方法,在内部做出相应的拦截。

注意:父容器一旦开始拦截任何一个事件,那么后续的事件都会交给它来处理。

//重写父容器的拦截方法
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://对于ACTION_DOWN事件必须返回false,一旦拦截后续事件将不能传递给子View
         intercepted = false;
         break;
      case MotionEvent.ACTION_MOVE://对于ACTION_MOVE事件根据需要决定是否拦截
         if (父容器需要当前事件) {
             intercepted = true;
         } else {
             intercepted = flase;
         }
         break;
   }
      case MotionEvent.ACTION_UP://对于ACTION_UP事件必须返回false,一旦拦截子View的onClick事件将不会触发
         intercepted = false;
         break;
      default : break;
   }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
   }

5.3.2 内部拦截法

父容器不拦截任何事件,所有事件都传递个给子元素,如果子元素需要此事件就直接消耗,否则交由父容器处理。

方法:需要配合requestDisallowInterceptTouchEvent方法。重写子ViewdispatchTouchEvent()

public boolean dispatchTouchEvent ( MotionEvent event ) {
  int x = (int) event.getX();
  int y = (int) event.getY();

  switch (event.getAction) {
      case MotionEvent.ACTION_DOWN:
         parent.requestDisallowInterceptTouchEvent(true);//为true表示禁止父容器拦截
         break;
      case MotionEvent.ACTION_MOVE:
         int deltaX = x - mLastX;
         int deltaY = y - mLastY;
         if (父容器需要此类点击事件) {
             parent.requestDisallowInterceptTouchEvent(false);//为fasle表示允许父容器拦截
         }
         break;
      case MotionEvent.ACTION_UP:
         break;
      default :
         break;        
 }

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

注意:除子容器需要做处理外,父容器也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子容器调用parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。

因此,父View需要重写onInterceptTouchEvent()

public boolean onInterceptTouchEvent (MotionEvent event) {
 int action = event.getAction();
 if(action == MotionEvent.ACTION_DOWN) {
     return false;
 } else {
     return true;
 }
}

Q1:为什么父容器不能拦截ACTION_DOWN事件?

由于该事件并不受FLAG_DISALLOW_INTERCEPT(由requestDisallowInterceptTouchEvent方法设置)标记位控制,所以一旦父容器拦截了该事件,那么所有的事件都不会传递给子View,内部拦截法也就失效了。


参考自: