03 View及自定义View

[toc]

View

View 是 Android 中所有 Widget 的基类,在屏幕上占据一个矩形区域。View 主要分两类控件容器。控件有自己的功能,例如 TextView 用来显示文本。容器用来控制内部控件的排列方式和位置,例如 LinearLayout 用来将内部控件进行线性布局。

容器是 ViewGroup 的子类,ViewGroup 是 View 的子类。容器也是一个 View,又可以包含多个 View,所以 Android 中的布局就成了一个 View 树。

view树结构.png

自定义 View

自定义 View 有三种形式:

  1. 继承已有控件,拓展功能
  2. 组合多个控件,完成复杂的功能
  3. 继承 View,完全自定义。

这里我们只讨论第三种情况,在这种情况下我们一般要去实现自定义 xml 属性、测量、绘制。实现这些功能需要重写 View 中不同的方法:

  1. 实现自定义 xml 属性 -> 构造方法
  2. 测量 -> onMeasure()
  3. 绘制 -> onDraw()

自定义 xml 属性

xml 属性应用在布局文件的 xml 中,用来设置 View 的属性。常见的如 android:id 有两部分组成,命名空间:属性名

xmlns(Xml NameSpace)

命名空间在布局 xml 文件的根节点定义:



我们设置控件属性的时候一直在打android:xxx 就是因为第一行设置了 xmlns:androidxmlns 是 xml namespace 缩写,android 是命名空间的名字。命名空间的具体作用取决于定义时等号后面的值, android 代表 Android 系统属性。

如果改成 xmlns:abc ,布局文件中再使用原来的属性就是abc:xxx。例如: android:id => abc:id。(不建议修改)

添加自定义属性

自定义属性名在 res/values/attrs.xml中定义。


    
        
        
        
            
            
        
    

format 中的 reference 表示可以接受 res 中定义资源的引用,例如 @color/colorPrimary

在布局文件 xml 中使用自定义属性需要在根标签中添加 xmlns。使用 AS 创建布局文件时会在根标签中默认加入 app xmlns,代表应用自定义属性和 support 包中定义的属性。


xmlns:xxx="http://schemas.android.com/apk/res/[your package name]" 在AS中会报错。 Gradle Project 中建议使用 res-auto , 不推荐硬编码包名

在自定义 View 的标签中加入定义好的属性:

    

在构造函数中使用自定义属性

public class CustomView extends View {

    private static final String TAG = "CustomView";

    private final String DEFAULT_TITLE = "CustomView";
    private final int DEFAULT_TITLE_COLOR = Color.rgb(0,133,119);

    private String title;
    private int titleColor;
    private int titleStyle;
    
    private Paint titlePaint;

    /**
     * 使用代码创建对象的构造,不解析 xml ,在构造中给自定义属性默认值
     * @param context
     */
    public CustomView(Context context) {
        super(context);
        //设置属性默认值
        title = DEFAULT_TITLE;
        titleColor = DEFAULT_TITLE_COLOR;
        
        //初始化画笔 
        initTitlePaint(titleColor,titleStyle);
    }

    /**
     *  xml 解析生成对象时使用的构造
     * @param context
     * @param attrs 布局中的属性 解析后 存入 AttributeSet 集合中
     */
    public CustomView(Context context,AttributeSet attrs) {
        super(context, attrs);

        // 从 AttributeSet 中取出 R.styleable.MyAttr 定义的属性
        TypedArray myAttrs = context.obtainStyledAttributes(attrs,R.styleable.MyAttr);

        try {
            title = myAttrs.getString(R.styleable.MyAttr_title);
            titleColor = myAttrs.getColor(R.styleable.MyAttr_titleColor, DEFAULT_TITLE_COLOR);
            titleStyle = myAttrs.getInt(R.styleable.MyAttr_titleStyle,0);
        }finally {
            // TypedArray 类型的对象在使用完成以一定要调用 recycle() 进行回收
            myAttrs.recycle();
        }

        Log.d(TAG, "CustomView: title = " + title +
                "; titleColor = " + titleColor +
                "; titleStyle = " + (titleStyle == 0 ? "Normal" : "Bold"));
        
        //初始化画笔 
        initTitlePaint(titleColor,titleStyle);
    }
}

日志输出:D/CustomView: CustomView: title = CustomViewDemo; titleColor = -2614432; titleStyle = Bold , 自定义属性大功告成。

测量 onMeasure

目前为止即便我们给 CustomView 的宽高设置成 wrap_content 依旧会填满整个父容器,这一点可以给 CustomView 设置背景颜色来验证。 或者我们希望 CustomView 是个正方形,它仍然是个矩形,除非我们明确指定 CustomView 的宽高。

先来看一下 onMeasure() 方法,方法接受两个参数 widthMeasureSpec、heightMeasureSpec,但这并不是 View 的宽高。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

MeasureSpec

MeasureSpec 是 int 类型所以有 32 位,其中高 2 位代表测量模式(SpecMode),后 30 位代表测量模式下的大小(SpecSize)。

    // widthMeasureSpec 分解成 SpecMode 和 SpecSize
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);

Mode 有三种:

SpceMode 含义 布局参数 SpceSize
UNSPECIFIED 父容器不对当前 View 做任何限制,想多大就多大。 一般用于系统 0
EXACTLY SpecSize 的值就是当前 View 的确切大小。 match_parent 或 指定的数值 parentSize 或 指定的数值
AT_MOST 大小不确定,但不能大于 SpecSize 的值。 wrap_content parentSize

指定的数值 是指在布局文件中显示的设置大小。如:100dp)

现在我们知道为什么即使 CustomView 设置了 wrap_content 它还是会填满父容器了,因为它的 SpceSize 是父容器的大小。

onMeasure()

onMeasure() 方法的作用是 利用参数中的 MeasureSpec 计算出真正的宽高并调用 setMeasuredDimension() 方法设置真实宽高。

在 onMeasure 时我们会有两个宽高:

  1. 参数传递的 specSize(parentSize 、布局中指定的宽高、0)
  2. 根据内容计算出的宽高。例如: CustomView 中可以根据 title 显示的文本计算出内容的宽高。

再根据 SpceMode 得出合理的宽高。

    private int getRealSize(int contentSize, int measureSpec){
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);
        
        switch (specMode){
            case MeasureSpec.EXACTLY:// EXACTLY 明确知道了大小,返回 specSize
                return specSize;
            case MeasureSpec.AT_MOST:// AT_MOST 不能超过 specSize ,返回最小的
                return Math.min(contentSize,specSize);
            case MeasureSpec.UNSPECIFIED: // UNSPECIFIED 不限制大小 想多大就多大 ,返回 contentSize
                default:
                    return contentSize;
        }
    }


最后不要忘了处理 padding。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //根据 font 和 title 的内容, 计算出 title 的宽高
        Paint.FontMetrics fontMetrics = titlePaint.getFontMetrics();
        // 处理 padding
        int contentHeight = (int) Math.ceil(fontMetrics.bottom - fontMetrics.top)  + getPaddingTop() + getPaddingBottom();
        int contentWidth = (int) titlePaint.measureText(title)  + getPaddingLeft() + getPaddingRight();

        // 根据 MeasureSpec 和 ContentSize 计算出真正的宽高
        int realWidth = resolveSizeAndState1(contentWidth,widthMeasureSpec,MEASURED_STATE_MASK);
        int realHeight = getRealSize(contentHeight,heightMeasureSpec);
        
        //设置测量后的宽高
        setMeasuredDimension(realWidth,realHeight);
    }

编写 getRealSize() 方法可以帮助我们理解怎样计算真正需要的尺寸, View 类中 resolveSizeAndState() 方法同样可以帮我们计算出真正需要的尺寸,所以我们在自定义 View 时不用每次都编写 getRealSize() 方法。

resolveSizeAndState()

这个方法跟我们写的 getRealSize() 方法类似,返回一个带掩码的尺寸,这个尺寸可以直接传递给 setMeasuredDimension() 方法。

  • 第一个参数传内容大小
  • 第二个参数传 onMeasure() 方法中的参数
  • 第三个参数传什么都行(0,1,MEASURED_STATE_MASK,对尺寸的计算没有影响,具体作用没弄清楚)

如果了解位运算可以看下面的解析,不了解就跳过解析直接把它当成 getRealSize() 方法用。

    public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
        final int specMode = MeasureSpec.getMode(measureSpec);
        final int specSize = MeasureSpec.getSize(measureSpec);
        final int result;
        switch (specMode) {
            case MeasureSpec.AT_MOST:
                if (specSize < size) { // 1
                    result = specSize | MEASURED_STATE_TOO_SMALL;
                } else {
                    result = size;
                }
                break;
            case MeasureSpec.EXACTLY:
                result = specSize;
                break;
            case MeasureSpec.UNSPECIFIED:
            default:
                result = size;
        }
        return result | (childMeasuredState & MEASURED_STATE_MASK);//2
    }

跟 getRealSize() 区别在于

注释 1 处: 如果 specSize(parentSize)小于内容的大小,用按位或运算加上 MEASURED_STATE_TOO_SMALL 掩码。

// public static final int MEASURED_STATE_TOO_SMALL = 0x01000000; 系统定义的值

// 假如 specSize 1080 ,计算后内容的宽度是 1081 
// 进入注释 1 逻辑

0000 0000 0000 0000 0000 0100 0011 1000  // 1080

|

0000 0001 0000 0000 0000 0000 0000 0000  //MEASURED_STATE_TOO_SMALL

=

0000 0001 0000 0000 0000 0100 0011 1001

注释1:处最后的解释是 如果内容的大小(期望大小)大于 specSize(最大的限制),计算后的大小是将 specSize 高8位标记为1的值,表示这个 specSize 比实际期望的小。

注释2:同理在 result 的高1~8位上做标记。

最后系统在使用的时候 会将高 1~8位擦除,所以 resolveSizeAndState() 方法的返回值可以直接传递给 setMeasuredDimension() 方法。

    public final int getMeasuredWidth() {
        //MEASURED_SIZE_MASK = 0x00ffffff
        return mMeasuredWidth & MEASURED_SIZE_MASK;
    }

也就说这个 int 值只有24位用来表示实际大小,最大为 2^24 = 16777215对于尺寸来说足够用了。

绘制 onDraw()

绘制的主要任务是将内容用 Paint 画在 Canvas 上,Paint 和 Canvas 具体使用不在这里讲解。
需要注意的是 onDraw() 方法调用的频率非常高,所以尽量不要在 onDraw() 方法中创建对象。

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 计算基线 y
        float baseLineY = (getHeight() - getPaddingTop() - getPaddingBottom()) / 2 + Math.abs(titlePaint.ascent() + titlePaint.descent()) / 2;

        canvas.drawText(title,getPaddingLeft(),getPaddingTop() + baseLineY,titlePaint);
    }

绘制文本推荐博客:https://www.jianshu.com/p/c3c9aea4cb01

自定义 ViewGroup

有了自定义 View 的基础,自定义 ViewGroup 就 so easy。我们来继承 ViewGroup 简单实现一个垂直线性布局 CustomViewGroup。

onMeasure()

根据子 View 的数量、子 View 的 margin 及 CustomViewGroup 的padding 计算出 CustomViewGroup 实际的宽高。

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int maxWidth = 0;
        int totalHeight = 0;

        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == View.GONE){ // GONE 不显示 ,直接下一个
                continue;
            }

            //测量子 View , 重点 WithMargins
            measureChildWithMargins(child,widthMeasureSpec,0,heightMeasureSpec,0);

            //子 View 的宽高
            int childWidth = child.getMeasuredWidth() ;
            int childHeight = child.getMeasuredHeight();

            maxWidth = Math.max(maxWidth,childWidth);
            totalHeight += childHeight;
        }

        //不要忘了处理 ViewGroup 的 padding
        int realWidth = resolveSizeAndState(maxWidth + getPaddingLeft() + getPaddingRight(),widthMeasureSpec,0) ;
        int realHight = resolveSizeAndState(totalHeight + getPaddingTop() + getPaddingBottom(),heightMeasureSpec,0);

        setMeasuredDimension(realWidth,realHight);
    }

onLayout()

设置子 View 的位置,要考虑 CustomViewGroup 的 padding 及子 View 的 margin。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int left = getPaddingLeft();
        int top = getPaddingTop();
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == View.GONE){ // GONE 不显示 ,直接下一个
                continue;
            }

            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            //计算子 View 的 left, top, right, bottom
            int childLeft = left + lp.leftMargin;
            int childTop = top + lp.topMargin;
            int childRight = childLeft + child.getMeasuredWidth();
            int childBottom = childTop + child.getMeasuredHeight();

            child.layout(childLeft,childTop,childRight,childBottom);

            top = childBottom + lp.bottomMargin;
        }
    }

ViewGroup generateLayoutParams() 方法默认返回 ViewGroup.LayoutParams 运行时会报错,重写此方法返回 LinearLayout.LayoutParams 对象。

    @Override
    public LinearLayout.LayoutParams generateLayoutParams(AttributeSet attrs) {
        // LinearLayout 的 LayoutParams 继承 ViewGroup.MarginLayoutParams 拿来直接用了
        return new LinearLayout.LayoutParams(getContext(), attrs);
    }

LayoutParams 也是一个比较重要的知识点,留个小尾巴。

你可能感兴趣的:(03 View及自定义View)