开发艺术探索笔记

开发艺术之View

2020-01-22  本文已影响0人  请叫我林锋

一、View 的基础知识

1.什么是 View

View 是 Android 中所有控件的基类,ViewGroup 继承了 View,代表一组 View

2.关于 View 的位置参数

View 的位置由它的四个顶点决定,分别是:

从 Android 3.0 开始,View 增加了 x,y,translationX,translationY。x 和 y 是 View 左上角的坐标,而 translationX 和 translationY 是 View 左上角相对于容器的偏移量。他们之间的关系如下:

x = left + translationX

需要注意:

3、MotionEvent 和 TouchSlop

MotionEvent:典型的事件类型有以下几种:

通过 MotionEvent 对象我们可以得到点击事件发生的 x 和 y 坐标,系统提供了两组方法来获取坐标:

TouchSlop 是系统所能识别出的被认为是滑动的最小距离,可以通过如下方式获取这个值

ViewConfiguration.get(getApplicationContext()).getScaledTouchSlop()

可以在源码中找到这个常量的定义 Android/sdk/platforms/android-29/data/res/values/config.xml

    <!-- Base "touch slop" value used by ViewConfiguration as a
         movement threshold where scrolling should begin. -->
    <dimen name="config_viewConfigurationTouchSlop">8dp</dimen>
4.VelocityTracker、GestureDetector 和 Scroller

VelocityTracker,速度追踪,用于追踪手指在活动过程中的速度(包括水平、竖直),在 onTouch 中使用如下代码可追踪到当前事件的速度:

                VelocityTracker velocityTracker = VelocityTracker.obtain();
                velocityTracker.addMovement(event);
                velocityTracker.computeCurrentVelocity(1000);
                int xVelocity = (int) velocityTracker.getXVelocity();
                int yVelocity = (int) velocityTracker.getYVelocity();
                velocityTracker.clear();
                velocityTracker.recycle();

速度的计算通过公式:速度 = (终点位置 - 起点位置) / 时间段

需要注意:

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

使用方法:创建一个 GestureDetector 对象并实现 OnGestureListener 接口,根据需要还可以实现 OnDoubleTapListener 来监听双击行为:

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

接着,接管目标 View 的 onTouchEvent 方法,在待监听 View 的 onTouchEvent 方法中添加如下实现:

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

我们可以通过 setOnDoubleTapListener 给 GestureDetector 添加双击事件的监听

如果只是监听滑动相关的,建议自己在 onTouchEvent 中实现,如果要监听双击行为,那么就使用 GestureDetector。


二、View的 滑动

通过三种方式可以实现 View 的滑动:

1.使用 scrollTo/scrollBy

两者区别:scrollBy 实际上也是调用了 scrollTo 方法,它实现了基于当前位置的相对滑动,而 scrollTo 则实现了基于所传递参数的绝对滑动。

注意:scrollTo 和 scrollBy 只能改变 View 的内容位置而不能改变 View 在布局中的位置。

mScrollX = View 左边缘 - View 内容左边缘,mScrollY 同理,他们的关系如图:


mScrollX 和 mScrollY 的变换规律示意.png
2.使用动画

动画可分为 View 动画(在 xml 中定义)和属性动画,需要注意的是 View 动画并不能改变 View 的实际位置,而属性动画可以,Android 3.0 以下无法使用属性动画。

3.改变布局参数

改变布局参数,即改变 LayoutParams,比如要将一个 View 向右移动 100px

ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) View.getLayoutParams();
params.leftMargin += 100;
View.setLayoutParams(params);

这三种方式都能实现 View 的滑动,他们的优缺点总结:


三、弹性滑动

我们还需要知道如何实现 View 的弹性滑动。比如通过 Scroller、动画、Handler#postDelayer 以及 Thread#sleep 等,它们的共同思想:将一次大的滑动分为若干次小的滑动并在一个时间段内完成。

1.使用 Scroller

Scroller 的典型使用方法:

Scroller scroller = new Scroller(mContext); //实例化一个Scroller对象

private void smoothScrollTo(int dstX, int dstY) {
  int scrollX = getScrollX();//View的左边缘到其内容左边缘的距离
  int scrollY = getScrollY();//View的上边缘到其内容上边缘的距离
  int deltaX = dstX - scrollX;//x方向滑动的位移量
  int deltaY = dstY - scrollY;//y方向滑动的位移量
  scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000); //开始滑动
  invalidate(); //刷新界面
}

@Override//计算一段时间间隔内偏移的距离,并返回是否滚动结束的标记
public void computeScroll() {
  if (scroller.computeScrollOffset()) { 
    scrollTo(scroller.getCurrX(), scroller.getCurY());
    postInvalidate();//通过不断的重绘不断的调用computeScroll方法
  }
}

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

    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;
    }

原理:仅仅传递几个参数是无法让 View 滑动的,真正实现滑动效果的是 invalidate(),invalidate() 方法会导致 View 重绘,在 View 的 draw 方法中调用 computeScroll() 方法,这个方法在 View 是空实现,因此我们自己实现了 computeScroll() 方法。

View 重绘后会在 draw 中调用 computeScroll() 方法,而 computeScroll() 又会去向 Scroller 获取当前的 scrollX 和 scrollY,然后通过 scrollTo 方法实现滑动;接着调用 postInvalidate() 方法来进行第二次绘制,绘制过程和第一次一样。

其中 computeScrollOffset() 方法的作用:根据时间的流逝来计算出当前 scrollX 和 scrollY 的值。

Scroller 实现弹性滑动流程图.png
概括:Scroller 本身并不能实现 View 的滑动,它需要配合 View 的 computeScroll 方法才能完成弹性滑动的效果。

推荐阅读:Android Scroller实现View弹性滑动完全解析

2.通过动画

动画本身就是一种渐进的过程,因此通过它实现的滑动天然酒具有弹性效果,方法:

// 在100ms内使得View从原始位置向右平移100像素
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
3.使用延时策略

使用 Handler 或者 View 的 postDelayed 方法,也可以用使用线程 sleep 方法,来不断的滑动 View。不过这种方式无法精确定时,因为系统消息的调度也是需要时间的。


四、View 的事件分发机制

1、点击事件的传递规则

我们要分析的对象就是 MotionEvent,即点击事件,当一个 MotionEvent 产生后,系统需要把这个事件传递给一个具体的 View,这个传递的过程就是分发过程。

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

它们之间的关系可以用如下伪代码表示:

public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean consume = false;
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    } else {
        consume = child.dispatchTouchEvent(ev);
    }
    return consume;
}
2、事件分发源码解析

推荐阅读:Android事件分发机制完全解析,带你从源码的角度彻底理解


五、滑动冲突

1、常见场景
2.、滑动冲突处理规则
3、滑动冲突的解决方式

方法一:外部拦截法

//重写父容器的拦截方法
public boolean onInterceptTouchEvent (MotionEvent event){
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
      // 对于ACTION_DOWN事件必须返回false,否则后续事件将不能传递给子View  
      case MotionEvent.ACTION_DOWN:
         intercepted = false;
         break;
      // 对于ACTION_MOVE事件根据需要决定是否拦截
      case MotionEvent.ACTION_MOVE:
         if (父容器需要当前事件) {
             intercepted = true;
         } else {
             intercepted = flase;
         }
         break;
   }
     // 对于ACTION_UP事件必须返回false,否则子View的onClick事件将不会触发
      case MotionEvent.ACTION_UP:
         intercepted = false;
         break;
      default : break;
   }
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
}

如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理。内部拦截法需要重写子元素的 dispatchTouchEvent 方法并配合 requestDisallowInterceptTouchEvent 才可以使用,下面是伪代码:

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);
         }
         break;
      case MotionEvent.ACTION_UP:
         break;
      default :
         break;        
 }

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

除了子元素需要做处理外,父元素要拦截除了 ACTION_DOWN 以外的其他事件,这样当子元素调用 parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。父元素所做的修改如下:

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

内部拦截法要求父容器不能拦截 ACTION_DOWN 的原因:由于该事件并不受 FLAG_DISALLOW_INTERCEPT 标记位控制,所以一旦父容器拦截了 ACTION_DOWN 事件,那么所有的事件都不会传递给子 View,内部拦截法也就失效了。


六、View 工作原理

1、关于 ViewRoot 和 DecorView

View 的绘制流程是从 ViewRootImpl 的 performTraversals() 方法开始的具体过程为:

performTraversals() 工作流程图.png

推荐阅读:Android View源码解读:浅谈DecorView与ViewRootImpl

2、理解 MeasureSpec
普通 View 的 MeasureSpec 的创建规则.png
3、View 的工作流程

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

a.measure

measure 过程分两种情况,如果是原始 View,通过 measure 方法就完成了测量过程,如果是一个 ViewGroup,除了完成自己的测量过程外,还要调用所有子元素的 measure 方法,各个子元素再去递归这个流程。

首先 ViewGroup 是一个抽象类,它没有 onMeasure 方法,因为它无法统一不同布局的测量过程,所以 onMeasure 方法需要它的子类去实现。比如说 LinearLayout、RelativeLayout,它们除了完成自己的 measure 过程外,还会去遍历所以子元素的 measure 方法。

以 LinearLayout 的 onMeasure 方法为例:

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mOrientation == VERTICAL) {
            measureVertical(widthMeasureSpec, heightMeasureSpec);
        } else {
            measureHorizontal(widthMeasureSpec, heightMeasureSpec);
        }
    }

选择竖直布局来查看,即 measureVertical 方法:

...
for (int i = 0; i < count; ++i) {
    ... 
    measureChildBeforeLayout(child, i, widthMeasureSpec, 0,
        heightMeasureSpec, usedHeight);
    ...
}
...

可以看出,系统会遍历子元素执行 measureChildBeforeLayout 方法,在这个方法内部又会调用子元素的 measure 方法,这样各个子元素就开始依次进入 measure 过程了。

View 的 measure 方法是一个 final 类型的方法,意味着子类不能重写此方法,此方法会调用 View 的 onMeasure 方法:

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(
            getDefaultSize(getSuggestedMinimumWidth(),widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

widthMeasureSpec 和 heightMeasureSpec 均是这个 View 的属性,它们是由 ViewGroup 传递进来的。

setMeasuredDimension 方法会设置 View 的宽高测量值,让我们看下 getDefaultSize 方法:

    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:
            result = size;
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;
            break;
        }
        return result;
    }

我们只需要看 AT_MOST 和 EXACTLY 两种情况,可以看到 getDefaultSize 返回的大小其实就是 measureSpec 的 specSize。

至于 UNSPECIFIED 这种情况一般用于系统内部的测量过程,直接给出结论就不分析:如果 View 没有设置背景,那么返回 android:minWidth 这个属性所指定的值;如果 View 设置了背景,则返回 android:minWidth 和背景的最小宽度这两者中的最大值。height 同理。

注意:

1、如果直接继承 View 的自定义控件,且在布局中使用了 wrap_content 这就相当于使用了 match_parent。因为 wrap_content 的 specMode 是 AT_MOST,如上代码所示,它的宽/高等于 specSize,也就等于父容器当前剩余的空间大小。解决办法:重写 onMeasure 方法,在使用 wrap_content 时,给这个 View 指定一个默认的宽/高。

2、View 的measure 过程和 Activity 的生命周期方法不是同步执行的,如果 View 还没测量完毕,那么获得的宽/高就是0。解决办法:使用 Activity/View#WindowFocusChanged 方法、 view.post(runnable) 方法、ViewTreeObserver 回调来获取宽/高。

b.layout 过程

注意:和 onMeasure 方法类似,onLayout 方法的具体实现和具体布局有关,所以 View 和 ViewGroup 均没有实现 onLayout 方法,需要它们的子类去实现。

细节问题:getMeasuredWidth() 和 getWidth() 有什么区别?

即两者的赋值时机不同,测量宽的赋值时机稍微早一点。在 View 的默认实现中,View 的测量宽和最终宽是相等的。除非你重写了 View 的 layout 方法,比如:

@Override
public void layout( int l , int t, int r , int b){
   super.layout(l,t,r+100,b+100);
}

不过这样设置没有实际意义。

c .draw 过程

View 绘制过程的传递是通过 dispatchDraw 来实现的,dispatchDraw 会遍历调用所有子元素的 draw 方法,如此 draw 事件就一层层地传递下去了。

细节问题:View.setWillNotDraw()

/**
 * 源码分析:setWillNotDraw()
 * 定义:View 中的特殊方法
 * 作用:设置 WILL_NOT_DRAW 标记位;
 * 注:
 *   a. 该标记位的作用是:当一个View不需要绘制内容时,系统进行相应优化
 *   b. 默认情况下:View 不启用该标记位(设置为false);ViewGroup 默认启用(设置为true)
 */ 

public void setWillNotDraw(boolean willNotDraw) {

   setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);

}

// 应用场景
// a. setWillNotDraw参数设置为true:当自定义View继承自 ViewGroup 、且本身并不具备任何绘制时,设置为 true 后,系统会进行相应的优化。
// b. setWillNotDraw参数设置为false:当自定义View继承自 ViewGroup 、且需要绘制内容时,那么设置为 false,来关闭 WILL_NOT_DRAW 这个标记位。

推荐阅读:自定义View基础 - 最易懂的自定义View原理系列

上一篇 下一篇

猜你喜欢

热点阅读