自定义控件三部曲之视图篇

该文为汇总网上优秀的资源所得,喜欢请关注原作者,我记录下来主要是为了以后我自己查找用的
ref 参考

启舰

Android自定义控件三部曲文章索引

HenCoder Android UI 部分 2-1 布局基础

一、测量与布局

1. ViewGroup绘制流程

注意,View及ViewGroup基本相同,只是在ViewGroup中不仅要绘制自己还是绘制其中的子控件,而View则只需要绘制自己就可以了,所以我们这里就以ViewGroup为例来讲述整个绘制流程。

绘制流程分为三步:测量、布局、绘制
分别对应:onMeasure() -> onLayout() -> onDraw()

image.png

其中,他们三个的作用分别如下:

onMeasure():测量自己的大小,为正式布局提供建议。(注意,只是建议,至于用不用,要看onLayout);
onLayout():使用layout()函数对所有子控件布局;
onDraw():根据布局的位置绘图;

2. onMeasure与MeasureSpec

布局绘画涉及两个过程:测量过程布局过程
测量过程通过measure方法实现,是View树自顶向下的遍历,每个View在循环过程中将尺寸细节往下传递,当测量过程完成之后,所有的View都存储了自己的尺寸。第二个过程则是通过方法layout来实现的,也是自顶向下的。在这个过程中,每个父View负责通过计算好的尺寸放置它的子View。
前面讲过,onMeasure()是用来测量当前控件大小的,给onLayout() 提供数值参考,需要特别注意的是:测量完成以后通过setMeasuredDimension(int,int)设置给系统。

2.1 onMeasure

首先,看一下onMeasure()的声明:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

这里我们主要关注传进来的两个参数:int widthMeasureSpec, int heightMeasureSpec

他们的意义:

他们是父类传递过来给当前view的一个建议值,即想把当前view的尺寸设置为宽widthMeasureSpec,高heightMeasureSpec

有关他们的组成,我们就直接转到MeasureSpec部分。

2.2 MeasureSpec

虽然表面上看起来他们是int类型的数字,其实他们是由mode+size两部分组成的。

widthMeasureSpec和heightMeasureSpec转化成二进制数字表示,他们都是32位的。

前两位代表mode(测量模式),后面30位才是他们的实际数值(size)。

(1)模式分类

它有三种模式:

①、UNSPECIFIED(未指定),父元素不对子元素施加任何束缚,子元素可以得到任意想要的大小;
②、EXACTLY(完全),父元素决定自元素的确切大小,子元素将被限定在给定的边界里而忽略它本身大小
③、AT_MOST(至多),子元素至多达到指定大小的值。

他们对应的二进制值分别是:

UNSPECIFIED=00000000000000000000000000000000

EXACTLY =01000000000000000000000000000000

AT_MOST =10000000000000000000000000000000

由于最前面两位代表模式,所以他们分别对应十进制的0,1,2;

(2)模式提取

现在我们知道了widthMeasureSpec和heightMeasureSpec是由模式和数值组成的,而且二进制的前两位代表模式,后28位代表数字。

andorid系统提供提取模式和数值的类,MeasureSpec

下面两个函数就可以实现这个功能:

//MODE的取值为
MeasureSpec.AT_MOST

MeasureSpec.EXACTLY

MeasureSpec.UNSPECIFIED
//获取MODE
MeasureSpec.getMode(int spec) 
//获取数值
MeasureSpec.getSize(int spec) 

通过下面的代码就可以分别获取widthMeasureSpec和heightMeasureSpec的MODE和数值

int measureWidth = MeasureSpec.getSize(widthMeasureSpec);

int measureHeight = MeasureSpec.getSize(heightMeasureSpec);

int measureWidthMode = MeasureSpec.getMode(widthMeasureSpec);

int measureHeightMode = MeasureSpec.getMode(heightMeasureSpec);

(3)模式有什么用呢

我们知道有三个模式:EXACTLY、AT_MOST、UNSPECIFIED

需要注意的是widthMeasureSpec和heightMeasureSpec各自都有它对应的模式,模式的由来分别来自于XML定义:

简单来说,XML布局和模式有如下对应关系:

  • wrap_content-> MeasureSpec.AT_MOST
  • match_parent -> MeasureSpec.EXACTLY
  • 具体值 -> MeasureSpec.EXACTLY

例如,下面这个XML

<com.example.harvic.myapplication.FlowLayout
     android:layout_width="match_parent"
     android:layout_height="wrap_content">
</com.example.harvic.myapplication.FlowLayout>

那FlowLayout在onMeasure()中传值时widthMeasureSpec的模式就是 MeasureSpec.EXACTLY,即父窗口宽度值。heightMeasureSpec的模式就是 MeasureSpec.AT_MOST,即不确定的。

一定要注意是,当模式是MeasureSpec.EXACTLY时,我们就不必要设定我们计算的大小了,因为这个大小是用户指定的,我们不应更改。但当模式是MeasureSpec.AT_MOST时,也就是说用户将布局设置成了wrap_content,我们就需要将大小设定为我们计算的数值,因为用户根本没有设置具体值是多少,需要我们自己计算。

即,假如width和height是我们经过计算的控件所占的宽度和高度。那在onMeasure()中使用setMeasuredDimension()最后设置时,代码应该是这样的:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int measureWidth = MeasureSpec.getSize(widthMeasureSpec);
    int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
    
    int measureWidthMode = MeasureSpec.getMode(widthMeasureSpec);
    int measureHeightMode = MeasureSpec.getMode(heightMeasureSpec);

    //经过计算,控件所占的宽和高分别对应width和height
    //计算过程,我们会在下篇细讲
    …………
    setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth: width, (measureHeightMode == MeasureSpec.EXACTLY) ? measureHeight: height);

}

3. onLayout()

3.1 概述

上面说了,onLayout()是实现所有子控件布局的函数。注意,是所有子控件!!!那它自己的布局怎么办?后面我们再讲,先讲讲在onLayout()中我们应该做什么。

我们先看看ViewGroup的onLayout()函数的默认行为是什么

在ViewGroup.java中

@Override
protected abstract void onLayout(boolean changed, int l, int t, int r, int b);

是一个抽象方法,说明凡是派生自ViewGroup的类都必须自己去实现这个方法。像LinearLayout、RelativeLayout等布局,都是重写了这个方法,然后在内部按照各自的规则对子视图进行布局的。

3.2 实例

下面我们就举个例子来看一下有关onMeasure()和onLayout()的具体使用:

//com.richy.kotlindemo.customview.MyLinLayout
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val measureWidth = View.MeasureSpec.getSize(widthMeasureSpec)
        val measureHeight = View.MeasureSpec.getSize(heightMeasureSpec)
        val measureWidthMode = View.MeasureSpec.getMode(widthMeasureSpec)
        val measureHeightMode = View.MeasureSpec.getMode(heightMeasureSpec)

        var height = 0
        var width = 0

        for (i in 0 until childCount) {
            //测量子控件
            val child = getChildAt(i)
            //measureChild 测量子控件
            measureChild(child, widthMeasureSpec, heightMeasureSpec)
            //获得子控件的高度和宽度
            val childHeight = child.measuredHeight
            val childWidth = child.measuredWidth
            //得到最大宽度,并且累加高度
            height += childHeight
            width = Math.max(childWidth, width)
        }

        //设置测量结果
        setMeasuredDimension(if (measureWidthMode === View.MeasureSpec.EXACTLY) measureWidth else width, if (measureHeightMode === View.MeasureSpec.EXACTLY) measureHeight else height)

        /*
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
        示例中的这里的measureWidthMode应该是MeasureSpec.EXACTLY,measureHeightMode应该是MeasureSpec.AT_MOST;

        * */
    }

总体来讲,onMeasure()中计算出的width和height,就是当XML布局设置为layout_width="wrap_content"、layout_height="wrap_content"时所占的宽和高;即整个container所占的最小矩形 (因为 match_parent -> MeasureSpec.EXACTLY,具体值 -> MeasureSpec.EXACTLY)

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    var top = 0;
    for (i in 0 until childCount) {
        val child = getChildAt(i)

        val childWidth = child.measuredWidth
        val childHeight = child.measuredHeight

        child.layout(0, top, childWidth, top + childHeight)
        top += childHeight
    }
}

(1)、getMeasuredWidth()与getWidth()

趁热打铁,就这个例子,我们讲一个很容易出错的问题:getMeasuredWidth()与getWidth()的区别。他们的值大部分时间都是相同的,但意义确是根本不一样的,我们就来简单分析一下。

区别主要体现在下面几点:
- 首先getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到。
- getMeasureWidth()方法中的值是通过setMeasuredDimension()方法来进行设置的,而getWidth()方法中的值则是通过layout(left,top,right,bottom)方法设置的。

还记得吗,我们前面讲过,setMeasuredDimension()提供的测量结果只是为布局提供建议,最终的取用与否要看layout()函数。大家再看看我们上面重写的MyLinLayout,是不是我们自己使用child.layout(left,top,right,bottom)来定义了各个子控件所应在的位置:

val childWidth = child.measuredWidth
val childHeight = child.measuredHeight

child.layout(0, top, childWidth, top + childHeight)
top += childHeight

从代码中可以看到,我们使用child.layout(0, top, childWidth, top + childHeight);来布局控件的位置,其中getWidth()的取值就是这里的右坐标减去左坐标的宽度;因为我们这里的宽度是直接使用的child.getMeasuredWidth()的值,当然会导致getMeasuredWidth()与getWidth()的值是一样的。如果我们在调用layout()的时候传进去的宽度值不与getMeasuredWidth()相同,那必然getMeasuredWidth()与getWidth()的值就不再一样了。

一定要注意的一点是:getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到。再重申一遍!!!!!

3.3 疑问:container自己什么时候被布局

前面我们说了,在派生自ViewGroup的container中,比如我们上面的MyLinLayout,在onLayout()中布局它所有的子控件。那它自己什么时候被布局呢?

它当然也有父控件,它的布局也是在父控件中由它的父控件完成的,就这样一层一层地向上由各自的父控件完成对自己的布局。直到所有控件的最顶层结点,在所有的控件的最顶部有一个ViewRoot,ViewRootImpl是View中的最高层级,属于所有View的根(但ViewRootImpl不是View,只是实现了ViewParent接口),实现了View和WindowManager之间的通信协议。那让我们来看看它是怎么来做的吧。

每个Activity中都包含一个Window对象,通常,Android中的Window是由PhoneWindow实现的。而PhoneWindow又将一个DecorView设置为整个窗口的根View(DecorView是一个ViewGroup)。

在它布局里,会调用一个layout()函数(不能被重载,代码位于View.java):

/* final 标识符 , 不能被重载 , 参数为每个视图位于父视图的坐标轴 
 * @param l Left position, relative to parent 

 * @param t Top position, relative to parent 

 * @param r Right position, relative to parent 

 * @param b Bottom position, relative to parent

 */  
public final void layout(int l, int t, int r, int b) {  
    boolean changed = setFrame(l, t, r, b); //设置每个视图位于父视图的坐标轴  
    if (changed || (mPrivateFlags & LAYOUT_REQUIRED) == LAYOUT_REQUIRED) {  
        onLayout(changed, l, t, r, b);//回调onLayout函数 ,设置每个子视图的布局  
        mPrivateFlags &= ~LAYOUT_REQUIRED;  
    }  

    mPrivateFlags &= ~FORCE_LAYOUT;  

在SetFrame(l,t,r,b)就是设置自己的位置,设置结束以后才会调用onLayout(changed, l, t, r, b)来设置内部所有子控件的位置。

OK啦,到这里有关onMeasure()和onLayout()的内容就讲完啦,想必大家应该也对整个布局流程有了一个清楚的认识了,下面我们再看一个紧要的问题:如何得到自定义控件的左右间距margin值。

4. 获取子控件Margin的方法

4.1 获取方法及示例

如果要自定义ViewGroup支持子控件的layout_margin参数,则自定义的ViewGroup类必须重载generateLayoutParams()函数,并且在该函数中返回一个ViewGroup.MarginLayoutParams派生类对象,这样才能使用margin参数。

需要特别注意的是,如果我们在onLayout()中根据margin来布局的话,那么我们在onMeasure()中计算container的大小时,也要加上margin,不然会导致container太小,而控件显示不全的问题。


    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)//注意这里返回的是MarginLayoutParams,而不是LayoutParams
    }

    override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
        return MarginLayoutParams(p)
    }

/*
*如果要使用默认的构造方法,就生成layout_width="match_parent"、layout_height="match_parent"对应的参数
*/
    override fun generateDefaultLayoutParams(): LayoutParams {
        return MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
    }


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        ...
        for (i in 0 until childCount) {
            //测量子控件
            val child = getChildAt(i)
            //measureChild 测量子控件
            measureChild(child, widthMeasureSpec, heightMeasureSpec)
            //获得子控件的高度和宽度
//            val childHeight = child.measuredHeight
//            val childWidth = child.measuredWidth

            val lp =  child.layoutParams as ViewGroup.MarginLayoutParams
            val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin
            val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin

            //得到最大宽度,并且累加高度
            height += childHeight
            width = Math.max(childWidth, width)
        }
    
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var top = 0
        for (i in 0 until childCount) {
            val child = getChildAt(i)

//            val childWidth = child.measuredWidth
//            val childHeight = child.measuredHeight
//            child.layout(0, top, childWidth, top + childHeight)
            val lp = child.layoutParams as ViewGroup.MarginLayoutParams//generateLayoutParams()返回的是MarginLayoutParams
            val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin
            val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
            child.layout(lp.leftMargin, top + lp.topMargin, child.measuredWidth + lp.leftMargin, top + child.measuredHeight + lp.topMargin);
            top += childHeight
        }
        
    }

4.2 原理

上面我们看了要重写generateDefaultLayoutParams()函数才能获取控件的margin间距。那为什么要重写呢?下面这句就为什么非要强转呢?

MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

下面我们来看看这么做的原因。

/**
*从指定的XML中获取对应的layout_width和layout_height值
*/
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);//默认返回LayoutParams
}
/*
*如果要使用默认的构造方法,就生成layout_width="wrap_content"、layout_height="wrap_content"对应的参数
*/
protected LayoutParams generateDefaultLayoutParams() {
     return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}

首先,在container在初始化子控件时,会调用LayoutParams#generateLayoutParams(LayoutParams p)来为子控件生成对应的布局属性,但默认只是生成layout_width和layout_height所以对应的布局参数,即在正常情况下的generateLayoutParams()函数生成的LayoutParams实例是不能够取到margin值的。即:

(1)generateLayoutParams()的默认实现

//位于ViewGrop.java中
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}
public LayoutParams(Context c, AttributeSet attrs) {
    TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_Layout);
    setBaseAttributes(a,
            R.styleable.ViewGroup_Layout_layout_width,
            R.styleable.ViewGroup_Layout_layout_height);
    a.recycle();
}
protected void setBaseAttributes(TypedArray a, int widthAttr, int heightAttr) {
    width = a.getLayoutDimension(widthAttr, "layout_width");
    height = a.getLayoutDimension(heightAttr, "layout_height");
}

从上面的代码中明显可以看出,generateLayoutParams()调用LayoutParams()产生布局信息,而LayoutParams()最终调用setBaseAttributes()来获得对应的宽,高属性。
这里是通过TypedArray对自定义的XML进行值提取的过程。从这里也可以看到,generateLayoutParams生成的LayoutParams属性只有layout_width和layout_height的属性值。

(2)MarginLayoutParams实现

下面再来看看MarginLayoutParams的具体实现,其实通过上面的过程,大家也应该想到,它也是通过TypeArray来解析自定义属性来获得用户的定义值的。

public MarginLayoutParams(Context c, AttributeSet attrs) {
    super();
 
    TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_MarginLayout);
    int margin = a.getDimensionPixelSize(
            com.android.internal.R.styleable.ViewGroup_MarginLayout_layout_margin, -1);
//第一部分:提取layout_margin的值并设置
    if (margin >= 0) {
        leftMargin = margin;
        topMargin = margin;
        rightMargin= margin;
        bottomMargin = margin;
    } else {
//第二部分:如果用户没有设置layout_margin,而是单个设置的,那么就一个个提取
       leftMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginLeft,
               UNDEFINED_MARGIN);
       rightMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginRight,
               UNDEFINED_MARGIN);
 
       topMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginTop,
               DEFAULT_MARGIN_RESOLVED);
 
       startMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginStart,
               DEFAULT_MARGIN_RELATIVE);
       endMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginEnd,
               DEFAULT_MARGIN_RELATIVE);
    }
    a.recycle();
}

这里大家也可以看到为什么非要重写generateLayoutParams()函数了,就是因为默认的generateLayoutParams()函数只会提取layout_width、layout_height的值,只有MarginLayoutParams()才具有提取margin间距的功能!

二、 绘制顺序

Android 里面的绘制都是按顺序的,先绘制的内容会被后绘制的盖住。比如你在重叠的位置先画圆再画方,和先画方再画圆所呈现出来的结果肯定是不同的:

img

1 super.onDraw() 前 or 后?

前几期我写的自定义绘制,全都是直接继承 View 类,然后重写它的 onDraw() 方法,把绘制代码写在里面,就像这样:

public class AppView extends View {  
    ...
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        ... // 自定义绘制代码
    }
    ...
}

这是自定义绘制最基本的形态:继承 View 类,在 onDraw() 中完全自定义它的绘制。

在之前的样例中,我把绘制代码全都写在了 super.onDraw() 的下面。不过其实,绘制代码写在 super.onDraw() 的上面还是下面都无所谓,甚至,你把 super.onDraw() 这行代码删掉都没关系,效果都是一样的——因为在 View 这个类里,onDraw() 本来就是空实现:

// 在 View.java 的源码中,onDraw() 是空的
// 所以直接继承 View 的类,它们的 super.onDraw() 什么也不会做
public class View implements Drawable.Callback,  
        KeyEvent.Callback, AccessibilityEventSource {
    ...
    /**
     * Implement this to do your drawing.
     *
     * @param canvas the canvas on which the background will be drawn
     */
    protected void onDraw(Canvas canvas) {
    }
    ...
}

然而,除了继承 View 类,自定义绘制更为常见的情况是,继承一个具有某种功能的控件,去重写它的 onDraw() ,在里面添加一些绘制代码,做出一个「进化版」的控件:

img

基于 EditText,在它的基础上增加了顶部的 Hint Text 和底部的字符计数。

而这种基于已有控件的自定义绘制,就不能不考虑 super.onDraw() 了:你需要根据自己的需求,判断出你绘制的内容需要盖住控件原有的内容还是需要被控件原有的内容盖住,从而确定你的绘制代码是应该写在 super.onDraw() 的上面还是下面。

1.1 写在 super.onDraw() 的下面

把绘制代码写在 super.onDraw() 的下面,由于绘制代码会在原有内容绘制结束之后才执行,所以绘制内容就会盖住控件原来的内容。

这是最为常见的情况:为控件增加点缀性内容。比如,在 Debug 模式下绘制出 ImageView 的图像尺寸信息:

public class AppImageView extends ImageView {  
    ...

    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (DEBUG) {
            // 在 debug 模式下绘制出 drawable 的尺寸信息
            ...
        }
    }
}
img

这招很好用的,试过吗?

当然,除此之外还有其他的很多用法,具体怎么用就取决于你的需求、经验和想象力了。

1.2 写在 super.onDraw() 的上面

如果把绘制代码写在 super.onDraw() 的上面,由于绘制代码会执行在原有内容的绘制之前,所以绘制的内容会被控件的原内容盖住。

相对来说,这种用法的场景就会少一些。不过只是少一些而不是没有,比如你可以通过在文字的下层绘制纯色矩形来作为「强调色」:

public class AppTextView extends TextView {  
    ...

    protected void onDraw(Canvas canvas) {
        ... // 在 super.onDraw() 绘制文字之前,先绘制出被强调的文字的背景

        super.onDraw(canvas);
    }
}
img

2 dispatchDraw():绘制子 View 的方法

讲了这几期,到目前为止我只提到了 onDraw() 这一个绘制方法。但其实绘制方法不是只有一个的,而是有好几个,其中 onDraw() 只是负责自身主体内容绘制的。而有的时候,你想要的遮盖关系无法通过 onDraw() 来实现,而是需要通过别的绘制方法。

例如,你继承了一个 LinearLayout,重写了它的 onDraw() 方法,在 super.onDraw() 中插入了你自己的绘制代码,使它能够在内部绘制一些斑点作为点缀:

public class SpottedLinearLayout extends LinearLayout {  
    ...

    protected void onDraw(Canvas canvas) {
       super.onDraw(canvas);

       ... // 绘制斑点
    }
}
img

看起来没问题对吧?

但是你会发现,当你添加了子 View 之后,你的斑点不见了:

<SpottedLinearLayout  
    android:orientation="vertical"
    ... >

    <ImageView ... />

    <TextView ... />

</SpottedLinearLayout>  
img

造成这种情况的原因是 Android 的绘制顺序:在绘制过程中,每一个 ViewGroup 会先调用自己的 onDraw() 来绘制完自己的主体之后再去绘制它的子 View。对于上面这个例子来说,就是你的 LinearLayout 会在绘制完斑点后再去绘制它的子 View。那么在子 View 绘制完成之后,先前绘制的斑点就被子 View 盖住了。

具体来讲,这里说的「绘制子 View」是通过另一个绘制方法的调用来发生的,这个绘制方法叫做:dispatchDraw()。也就是说,在绘制过程中,每个 View 和 ViewGroup 都会先调用 onDraw() 方法来绘制主体,再调用 dispatchDraw() 方法来绘制子 View。

注:虽然 ViewViewGroup 都有 dispatchDraw() 方法,不过由于 View 是没有子 View 的,所以一般来说 dispatchDraw() 这个方法只对 ViewGroup(以及它的子类)有意义。

img

回到刚才的问题:怎样才能让 LinearLayout 的绘制内容盖住子 View 呢?只要让它的绘制代码在子 View 的绘制之后再执行就好了。

2.1 写在 super.dispatchDraw() 的下面

只要重写 dispatchDraw(),并在 super.dispatchDraw() 的下面写上你的绘制代码,这段绘制代码就会发生在子 View 的绘制之后,从而让绘制内容盖住子 View 了。

public class SpottedLinearLayout extends LinearLayout {  
    ...

    // 把 onDraw() 换成了 dispatchDraw()
    protected void dispatchDraw(Canvas canvas) {
       super.dispatchDraw(canvas);

       ... // 绘制斑点
    }
}
img

好萌的蝙蝠侠啊

2.2 写在 super.dispatchDraw() 的上面

同理,把绘制代码写在 super.dispatchDraw() 的上面,这段绘制就会在 onDraw() 之后、 super.dispatchDraw() 之前发生,也就是绘制内容会出现在主体内容和子 View 之间。而这个……

其实和前面 1.1 讲的,重写 onDraw() 并把绘制代码写在 super.onDraw() 之后的做法,效果是一样的。

能想明白为什么吧?图就不上了。

3 绘制过程简述

绘制过程中最典型的两个部分是上面讲到的主体和子 View,但它们并不是绘制过程的全部。除此之外,绘制过程还包含一些其他内容的绘制。具体来讲,一个完整的绘制过程会依次绘制以下几个内容:

  1. 背景
  2. 主体(onDraw()
  3. 子 View(dispatchDraw()
  4. 滑动边缘渐变和滑动条
  5. 前景

一般来说,一个 View(或 ViewGroup)的绘制不会这几项全都包含,但必然逃不出这几项,并且一定会严格遵守这个顺序。例如通常一个 LinearLayout 只有背景和子 View,那么它会先绘制背景再绘制子 View;一个 ImageView 有主体,有可能会再加上一层半透明的前景作为遮罩,那么它的前景也会在主体之后进行绘制。需要注意,前景的支持是在 Android 6.0(也就是 API 23)才加入的;之前其实也有,不过只支持 FrameLayout,而直到 6.0 才把这个支持放进了 View 类里。

这其中的第 2、3 两步,前面已经讲过了;第 1 步——背景,它的绘制发生在一个叫 drawBackground()的方法里,但这个方法是 private 的,不能重写,你如果要设置背景,只能用自带的 API 去设置(xml 布局文件的 android:background 属性以及 Java 代码的 View.setBackgroundXxx() 方法,这个每个人都用得很 6 了),而不能自定义绘制;而第 4、5 两步——滑动边缘渐变和滑动条以及前景,这两部分被合在一起放在了 onDrawForeground() 方法里,这个方法是可以重写的。

img

滑动边缘渐变和滑动条可以通过 xml 的 android:scrollbarXXX 系列属性或 Java 代码的 View.setXXXScrollbarXXX() 系列方法来设置;前景可以通过 xml 的 android:foreground 属性或 Java 代码的 View.setForeground() 方法来设置。而重写 onDrawForeground() 方法,并在它的 super.onDrawForeground() 方法的上面或下面插入绘制代码,则可以控制绘制内容和滑动边缘渐变、滑动条以及前景的遮盖关系。

4 onDrawForeground()

首先,再说一遍,这个方法是 API 23 才引入的,所以在重写这个方法的时候要确认你的 minSdk 达到了 23,不然低版本的手机装上你的软件会没有效果。

onDrawForeground() 中,会依次绘制滑动边缘渐变、滑动条和前景。所以如果你重写 onDrawForeground()

4.1 写在 super.onDrawForeground() 的下面

如果你把绘制代码写在了 super.onDrawForeground() 的下面,绘制代码会在滑动边缘渐变、滑动条和前景之后被执行,那么绘制内容将会盖住滑动边缘渐变、滑动条和前景。

public class AppImageView extends ImageView {  
    ...
    public void onDrawForeground(Canvas canvas) {
       super.onDrawForeground(canvas);
       ... // 绘制「New」标签
    }
}
<!-- 使用半透明的黑色作为前景,这是一种很常见的处理 -->  
<AppImageView  
    ...
    android:foreground="#88000000" />
img

左上角的标签并没有被黑色遮罩盖住,而是保持了原有的颜色。

4.2 写在 super.onDrawForeground() 的上面

如果你把绘制代码写在了 super.onDrawForeground() 的上面,绘制内容就会在 dispatchDraw()super.onDrawForeground() 之间执行,那么绘制内容会盖住子 View,但被滑动边缘渐变、滑动条以及前景盖住:

public class AppImageView extends ImageView {  
    ...

    public void onDrawForeground(Canvas canvas) {
       ... // 绘制「New」标签

       super.onDrawForeground(canvas);
    }
}
img

由于被半透明黑色遮罩盖住,左上角的标签明显变暗了。

这种写法,和前面 2.1 讲的,重写 dispatchDraw() 并把绘制代码写在 super.dispatchDraw() 的下面的效果是一样的:绘制内容都会盖住子 View,但被滑动边缘渐变、滑动条以及前景盖住。

4.3 想在滑动边缘渐变、滑动条和前景之间插入绘制代码?

很简单:不行。

虽然这三部分是依次绘制的,但它们被一起写进了 onDrawForeground() 方法里,所以你要么把绘制内容插在它们之前,要么把绘制内容插在它们之后。而想往它们之间插入绘制,是做不到的。

5 draw() 总调度方法

除了 onDraw() dispatchDraw()onDrawForeground() 之外,还有一个可以用来实现自定义绘制的方法: draw()

draw() 是绘制过程的总调度方法。一个 View 的整个绘制过程都发生在 draw() 方法里。前面讲到的背景、主体、子 View 、滑动相关以及前景的绘制,它们其实都是在 draw() 方法里的。

// View.java 的 draw() 方法的简化版大致结构(是大致结构,不是源码哦):

public void draw(Canvas canvas) {  
    ...

    drawBackground(Canvas); // 绘制背景(不能重写)
    onDraw(Canvas); // 绘制主体
    dispatchDraw(Canvas); // 绘制子 View
    onDrawForeground(Canvas); // 绘制滑动相关和前景

    ...
}

从上面的代码可以看出,onDraw() dispatchDraw() onDrawForeground() 这三个方法在 draw() 中被依次调用,因此它们的遮盖关系也就像前面所说的——dispatchDraw() 绘制的内容盖住 onDraw() 绘制的内容;onDrawForeground() 绘制的内容盖住 dispatchDraw() 绘制的内容。而在它们的外部,则是由 draw() 这个方法作为总的调度。所以,你也可以重写 draw() 方法来做自定义的绘制。

img

5.1 写在 super.draw() 的下面

由于 draw() 是总调度方法,所以如果把绘制代码写在 super.draw() 的下面,那么这段代码会在其他所有绘制完成之后再执行,也就是说,它的绘制内容会盖住其他的所有绘制内容。

它的效果和重写 onDrawForeground(),并把绘制代码写在 super.onDrawForeground() 下面时的效果是一样的:都会盖住其他的所有内容。

当然了,虽说它们效果一样,但如果你既重写 draw() 又重写 onDrawForeground() ,那么 draw()里的内容还是会盖住 onDrawForeground() 里的内容的。所以严格来讲,它们的效果还是有一点点不一样的。

但这属于抬杠……

5.2 写在 super.draw() 的上面

同理,由于 draw() 是总调度方法,所以如果把绘制代码写在 super.draw() 的上面,那么这段代码会在其他所有绘制之前被执行,所以这部分绘制内容会被其他所有的内容盖住,包括背景。是的,背景也会盖住它。

是不是觉得没用?觉得怎么可能会有谁想要在背景的下面绘制内容?别这么想,有的时候它还真的有用。

例如我有一个 EditText

img

它下面的那条横线,是 EditText 的背景。所以如果我想给这个 EditText 加一个绿色的底,我不能使用给它设置绿色背景色的方式,因为这就相当于是把它的背景替换掉,从而会导致下面的那条横线消失:

<EditText  
    ...
    android:background="#66BB6A" />
img

EditText:我到底是个 EditText 还是个 TextView?傻傻分不清楚。

在这种时候,你就可以重写它的 draw() 方法,然后在 super.draw() 的上方插入代码,以此来在所有内容的底部涂上一片绿色:

public class SpottedLinearLayout extends LinearLayout {  
    ...

    // 把 onDraw() 换成了 dispatchDraw()
    protected void dispatchDraw(Canvas canvas) {
       super.dispatchDraw(canvas);

       ... // 绘制斑点
    }
}
img

当然,这种用法并不常见,事实上我也并没有在项目中写过这样的代码。但我想说的是,我们作为工程师,是无法预知将来会遇到怎样的需求的。我们能做的只能是尽量地去多学习一些、多掌握一些,尽量地了解我们能够做什么、怎么做,然后在需求到来的时候,就可以多一些自如,少一些束手无策。

注意

关于绘制方法,有两点需要注意一下:

  1. 出于效率的考虑,ViewGroup 默认会绕过 draw() 方法,换而直接执行 dispatchDraw(),以此来简化绘制流程。所以如果你自定义了某个 ViewGroup 的子类(比如 LinearLayout)并且需要在它的除 dispatchDraw() 以外的任何一个绘制方法内绘制内容,你可能会需要调用 View.setWillNotDraw(false) 这行代码来切换到完整的绘制流程(是「可能」而不是「必须」的原因是,有些 ViewGroup 是已经调用过 setWillNotDraw(false) 了的,例如 ScrollView)。
  2. 有的时候,一段绘制代码写在不同的绘制方法中效果是一样的,这时你可以选一个自己喜欢或者习惯的绘制方法来重写。但有一个例外:如果绘制代码既可以写在 onDraw() 里,也可以写在其他绘制方法里,那么优先写在 onDraw() ,因为 Android 有相关的优化,可以在不需要重绘的时候自动跳过 onDraw() 的重复执行,以提升开发效率。享受这种优化的只有 onDraw() 一个方法。

总结

今天的内容就是这些:使用不同的绘制方法,以及在重写的时候把绘制代码放在 super.绘制方法() 的上面或下面不同的位置,以此来实现需要的遮盖关系。下面用一张图和一个表格总结一下:

img

嗯,上面这张图在前面已经贴过了,不用比较了完全一样的。

img

另外别忘了上面提到的那两个注意事项:

  1. ViewGroup 的子类中重写除 dispatchDraw() 以外的绘制方法时,可能需要调用 setWillNotDraw(false)
  2. 在重写的方法有多个选择时,优先选择 onDraw()

练习:FlowLayout自适应容器实现

XML布局

Flowlayout

提取margin与onMeasure()重写

提取margin

要提取margin,就一定要重写generateLayoutParams

重写onMeasure()——计算当前FlowLayout所占的宽高

这里就要重写onMeasure()函数,在其中计算所有当前container所占的大小。

要做FlowLayout,首先涉及下面几个问题:

  1. 何时换行

从效果图中可以看到,FlowLayout的布局是一行行的,如果当前行已经放不下 下一个 控件,那就把这个控件移到下一行显示。所以我们要有个变量来计算当前行已经占据的宽度,以判断剩下的空间是否还能容得下 下一个 控件。

  1. 如何得到FlowLayout的宽度

FlowLayout的宽度是所有行宽度的最大值,所以我们要记录下每一行的所占据的宽度值,进而找到所有值中的最大值。

  1. 如何得到FlowLayout的高度

很显然,FlowLayout的高度是每一行高度的总和,而每一行的高度则是取该行中所有控件高度的最大值。

重写onLayout()——布局所有子控件

代码和注释在com.richy.kotlindemo.customview.FlowLayout

class FlowLayout : ViewGroup {

    override fun generateLayoutParams(p: ViewGroup.LayoutParams): ViewGroup.LayoutParams {
        return ViewGroup.MarginLayoutParams(p)
    }

    override fun generateLayoutParams(attrs: AttributeSet): ViewGroup.LayoutParams {
        return ViewGroup.MarginLayoutParams(context, attrs)
    }

    override fun generateDefaultLayoutParams(): ViewGroup.LayoutParams {
        return ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT)
    }

    constructor(context: Context?) : super(context)
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
//        (1)首先,刚进来的时候是利用MeasureSpec获取系统建议的数值的模式
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val measureWidth = View.MeasureSpec.getSize(widthMeasureSpec)
        val measureHeight = View.MeasureSpec.getSize(heightMeasureSpec)
        val measureWidthMode = View.MeasureSpec.getMode(widthMeasureSpec)
        val measureHeightMode = View.MeasureSpec.getMode(heightMeasureSpec)

//        (2)然后,是计算FlowLayout所占用的空间大小
        var lineWidth = 0//记录每一行的宽度
        var lineHeight = 0//记录每一行的高度
        var height = 0//记录整个FlowLayout所占高度
        var width = 0//记录整个FlowLayout所占宽度

        //计算
        val count = childCount
        for (i in 0 until count) {
            val child = getChildAt(i)
            //这里一定要注意的是:在调用child.getMeasuredWidth()、child.getMeasuredHeight()之前,
            // 一定要调用measureChild(child,widthMeasureSpec,heightMeasureSpec);
            // 在onMeasure()之后才能调用getMeasuredWidth()获得值;同样,只有调用onLayout()后,getWidth()才能获取值
            measureChild(child, widthMeasureSpec, heightMeasureSpec)

            val lp = child.layoutParams as ViewGroup.MarginLayoutParams
            val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
            val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin

            if (lineWidth + childWidth > measureWidth) {
                //换行
                //FlowLayout的宽度是所有行宽度的最大值,所以我们要记录下每一行的所占据的宽度值,进而找到所有值中的最大值
                width = Math.max(lineWidth, width)
                height += lineHeight
                //因为由于盛不下当前控件,而将此控件调到下一行,所以将此控件的高度和宽度初始化给lineHeight、lineWidth
                lineHeight = childHeight
                lineWidth = childWidth

            } else {
                //累加
                lineHeight = Math.max(lineHeight, childHeight)
                lineWidth += childWidth
            }

            //最后一行是不会超出width范围的,所以要单独处理
            if (i == count - 1) {
                height += lineHeight
                width = Math.max(width, lineWidth)
            }

        }
        //当属性是MeasureSpec.EXACTLY时,那么它的高度就是确定的,
        // 只有当是wrap_content时,根据内部控件的大小来确定它的大小时,大小是不确定的,属性是AT_MOST,
        // 此时,就需要我们自己计算它的应当的大小,并设置进去
        setMeasuredDimension(if (measureWidthMode == View.MeasureSpec.EXACTLY) measureWidth else width,
                if (measureHeightMode == View.MeasureSpec.EXACTLY) measureHeight else height)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var lineWidth = 0
        var lineHeight = 0
        var top = 0
        var left = 0
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val lp = child.layoutParams as ViewGroup.MarginLayoutParams
            //这样做其实不好,这样把margin当做了padding处理了,包含在了child里面了
            val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
            val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin

            if (childWidth + lineWidth > measuredWidth) {
                //如果换行,当前控件将跑到下一行,从最左边开始,所以left就是0,而top则需要加上上一行的行高,才是这个控件的top点;
                top += lineHeight
                left = 0
                //同样,重新初始化lineHeight和lineWidth
                lineHeight = childHeight
                lineWidth = childWidth
            } else {
                //不换行
                lineHeight = Math.max(lineHeight, childHeight)
                lineWidth += childWidth
            }
            //计算childView的left,top,right,bottom
            val lc = left + lp.leftMargin
            val tc = top + lp.topMargin
            val rc = lc + child.measuredWidth
            val bc = tc + child.measuredHeight
            child.layout(lc, tc, rc, bc)
            //将left置为下一子控件的起始点
            left += childWidth
        }

    }

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

推荐阅读更多精彩内容