开发艺术之View

一、View 的基础知识

1.什么是 View

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

2.关于 View 的位置参数

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

  • top:View 左上顶点相对于父容器的纵坐标,Top = View.getTop()
  • left:View 左上顶点相对于父容器的横坐标,Left = View.getLeft()
  • right:View 右下顶点相对于父容器的横坐标,Right = View.getRight()
  • bottom:View 右下顶点相对于父容器的纵坐标,Bottom = View.getBottom()

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

x = left + translationX

需要注意:

  • 这几个参数都是相对于父容器的坐标
  • View 在平移过程中,top、left 表示原始左上角的坐标,其值并不会发生改变,发生改变的是 x、y、translationX、translationY
  • 在 onCreatr() 方法中无法获取到 View 的坐标参数,因为此时 View 还未开始绘制,可以通过 View.post() 方法来获取 View 的参数
3、MotionEvent 和 TouchSlop

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

  • ACTION_DOWN——手指刚接触屏幕
  • ACTION_MOVE——手指在屏幕上移动
  • ACTION_UP——手指从屏幕上离开

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

  • getX/getY——返回相对于当前 View 左上角的 x 和 y 坐标
  • getRawX/getRawY——返回相对于手机屏幕左上角的 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();

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

需要注意:

  • computeCurrentVelocity 表示时间间隔,单位为毫秒
  • 当不需要使用它时,需要调用 clear() 和 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 的滑动,他们的优缺点总结:

  • scrollTo/scrollBy:操作简单,适合对 View 内容的滑动
  • 动画:属性动画没有明显缺点,View 动画无法改变 View 本身的属性
  • 改变布局参数:操作稍微复杂,适用于有交互的 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

  • 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、常见场景
  • 场景1:外部滑动方向和内部滑动方向不一致(ViewPager + Fragment,Fragment 中有 RecyclerView)

  • 场景2:外部滑动方法和内部滑动方向一致(ScrollView + RecyclerView)

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

2.、滑动冲突处理规则
  • 对于场景1:当用户左右滑动时,外部 View 拦截点击事件;当用户上下滑动时,内部 View 拦截点击事件。

  • 对于场景2和3:根据具体业务来进行处理

3、滑动冲突的解决方式

方法一:外部拦截法

  • 所有点击事件都要先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要就不拦截。外部拦截法需要重新父容器的 onInterceptTouchEvent 方法,下面伪代码:
//重写父容器的拦截方法
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
  • ViewRoot:即 ViewRootImpl,它是连接 WindowManager 和 DecorView 的纽带,ViewRootImpl 通过 WindowManager 将 DecorView 添加到 Window 中
  • DecorView:它包含 titlebar 和 contentView,其中 contentView 为我们在 setContentView 中设置的布局,titlebar 是否存在要看具体主题

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

  • 它依次调用 performMeasure()、performLayout() 和 performDraw() 三个方法,分别完成顶级View的绘制。
  • 其中,performMeasure()会调用 measure() ,measure() 中又调用 onMeasure(),实现对其所有子元素的measure过程,这样就完成了一次 measur e过程
  • 接着子元素会重复父容器的measure过程,如此反复至完成整个View树的遍历。layout 和 draw 同理。
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 的 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 过程

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 过程

  • 作用:计算 View 的四个顶点位置:Left、Top、Right、Bottom。

  • View 的 layout 流程:

    • View 在 layout() 方法中通过 setFrame() 方法确定自己的四个顶点位置
    • 然后在 onLayout() 中遍历所有的子元素并调用其 layout() 方法确定子元素的四个顶点位置
    • 然后子元素的 onLayout() 方法又会被调用,重复如上步骤,就能确定布局下所有元素的位置

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

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

  • getMeasuredWidth() 获得 View 测量时的宽,形成与 measure 过程
  • getWidth() 获得 View 最终的宽,形成于 layout 过程

即两者的赋值时机不同,测量宽的赋值时机稍微早一点。在 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 绘制到屏幕上面
  • View 的 darw 流程:
    • 绘制背景,drawBackground
    • 绘制自己 ,onDraw
    • 绘制 children,dispatchDraw
    • 绘制装饰,onDrawForeground

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原理系列

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,270评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,489评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,630评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,906评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,928评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,718评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,442评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,345评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,802评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,984评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,117评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,810评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,462评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,011评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,139评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,377评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,060评论 2 355

推荐阅读更多精彩内容