Android自定义View

一个Android玩家自我救赎之路。

本篇记录自定义View相关基础知识。

1.自定义View

首先我们要了解为什么要自定义View?顾名思义就是Android原生系统提供的view无法满足我们当前的业务需求,所以就需要我们开发者自己去定制特殊的view来支撑我们的业务。自定义view有两种:第一种是通过继承原生的系统控件将一系列子控件组成一个新的控件,此方法虽然看起来有的low但是的确是我们日常开发中最常用的。第二种是通过继承View来实现自定义,此方法将在本篇中着重讨论。

1.1组合View

为什么要使用组合view?当某些业务需要频繁调用一些共性比较大的UI时,为保证代码简洁性和复用性,可以考虑使用组合view来实现,比如界面的标题栏等。

实现方式:通过继承基础的ViewGroup(LinearLayout等)渲染一个存放各个基础组件的布局,并提供对应的数据填充方法。使用时和普通形式一样直接使用。

1.2继承View

制定复杂效果时,普通的view无法实现这时候就需要我们继承view或者viewgroup来实现。主要关注的方法有onMeasure 和onDraw。其中onMeasure负责测量view的大小,onDraw负责绘制

1.2.1onMeasure

自定义view的时候我们需要测量view的尺寸,为什么要测量尺寸?刚刚接触的开发者可能会有这个疑问,明明已经在xml中设置了宽高,这边直接拿不就好了吗,为啥要多次一举?

其实这个也不难理解,在学习Android的过程中我们发现大多数时候我们并不是使用具体的数值来设置view的尺寸,而是设置wrap_contentmatch_parent,这两个值顾名思义就是将view设置为包裹布局和填充父布局给的最大尺寸。这两个值没有固定的尺寸,但是绘制view的时候又必须要具体的尺寸,所以就需要我们处理下给定具体的值。当然系统默认也会给一个值,但是往往这个值不能满足要求,比如设置wrap_content 但是布局效果却是铺满父布局。

看下onMeasure方法:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) 

参数中的widthMeasureSpecheightMeasureSpec时什么呢?从参数名称上来看似乎是width与height,没错这两个参数包含了widthheight信息,并且还包含了测量模式信息。那么他是如何保存信息的呢?我们知道尺寸有wrap_contentmatch_parent具体尺寸,而测量方式分UNSPECIFIEDEXACTLYAT_MOST,当然他们并不是对应关系,但无非这三种情况,所以我们只需要2个bit的就可以存放这3个数据,而int占用32位,所以Google采用前两位区分测量方式,后面30位存放具体尺寸的方式。
那么我们该如何获取这些值呢? 当然肯定不会让你每次都通过移位<<和取且&操作,系统MeasureSpec类已经帮我们写好了,下面让我们一起来看看吧。

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

上面的代码可以看到我们可以通过MeasureSpec拿到尺寸数据,这时候有的开发者会想既然我们已经拿到了尺寸为什么还要做重复的操作?请注意:这个时候获取到的尺寸并非最终的尺寸,而是父布局提供的参考的尺寸。下面我们来看一下测量模式:

测量模式 代表含义
EXACTLY 表示当前的尺寸就是view实际的尺寸
AT_MOST 当前尺寸是view能够取的最大的尺寸
UNSPECIFIED 表示父容器没有对view做限制,view可以去任意的尺寸

那么测量模式和我们布局的wrap_contentmatch_parent具体尺寸有什么关系呢,下面请看下viewGroupgetChildMeasureSpec方法

childDimension是childView的宽高
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec);
        int specSize = MeasureSpec.getSize(spec);

        int size = Math.max(0, specSize - padding);

        int resultSize = 0;
        int resultMode = 0;

        switch (specMode) {
        // Parent has imposed an exact size on us
        case MeasureSpec.EXACTLY:
        //如果父布局Mode为MeasureSpec.EXACTLY
        //子view宽高大于0 mode为MeasureSpec.EXACTLY; 
        //子view宽高为LayoutParams.MATCH_PARENT,mode为MeasureSpec.EXACTLY;
        //子view宽高为LayoutParams.WRAP_CONTENT,mode为MeasureSpec.AT_MOST;
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size. So be it.
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent has imposed a maximum size on us
        case MeasureSpec.AT_MOST:
        //如果父布局Mode为MeasureSpec.AT_MOST
        //子view宽高大于0 mode为MeasureSpec.EXACTLY; 
        //子view宽高为LayoutParams.MATCH_PARENT,mode为MeasureSpec.AT_MOST;
        //子view宽高为LayoutParams.WRAP_CONTENT,mode为MeasureSpec.AT_MOST;
            if (childDimension >= 0) {
                // Child wants a specific size... so be it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size, but our size is not fixed.
                // Constrain child to not be bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent asked to see how big we want to be
        case MeasureSpec.UNSPECIFIED:
        //如果父布局Mode为MeasureSpec.UNSPECIFIED
        //子view宽高大于0 mode为MeasureSpec.EXACTLY; 
        //子view宽高为LayoutParams.MATCH_PARENT,mode为MeasureSpec.UNSPECIFIED;
        //子view宽高为LayoutParams.WRAP_CONTENT,mode为MeasureSpec.UNSPECIFIED;
            if (childDimension >= 0) {
                // Child wants a specific size... let him have it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size... find out how big it should
                // be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size.... find out how
                // big it should be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
        //noinspection ResourceType
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

从上面的代码块中我们可以看到测量模式与布局宽高的关系

parent测量模式为MeasureSpec.EXACTLY时
match_parent 的测量模式是 MeasureSpec.EXACTLY
wrap_content 的测量模式是 MeasureSpec.AT_MOST
具体尺寸 的测量模式是 MeasureSpec.EXACTLY

parent测量模式为MeasureSpec.AT_MOST时
match_parent 的测量模式是 MeasureSpec.AT_MOST
wrap_content 的测量模式是 MeasureSpec.AT_MOST
具体尺寸 的测量模式是 MeasureSpec.EXACTLY

parent测量模式为MeasureSpec.UNSPECIFIED时
match_parent 的测量模式是 MeasureSpec.UNSPECIFIED
wrap_content 的测量模式是 MeasureSpec.UNSPECIFIED
具体尺寸 的测量模式是 MeasureSpec.EXACTLY

1.2.2重写onMeasure
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getSize(widthMeasureSpec,mDefaultWidth),getSize(heightMeasureSpec,mDefaultHeight));
    }
    private int getSize(int measureSpec,int defaultSize){
        int widthMode = MeasureSpec.getMode(measureSpec);
        int widthSize = MeasureSpec.getSize(measureSpec);
        if (widthMode == MeasureSpec.EXACTLY){
           //如果是固定的大小,不要去改变它
            widthSize = widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST){
         //如果测量模式是取最大值 这时候我们需要处理一下 这边是为了兼容wrap_content所以取小值,
        //可以使用其他的逻辑处理
            widthSize = Math.min(widthSize,defaultSize);
        } else if (widthMode == MeasureSpec.UNSPECIFIED){
            widthSize = defaultSize;
        }
        return widthSize;
    }
1.2.3重写onDraw

之前我们学会了自定义尺寸了,那么现在我们来通过onDraw画出我们想要的效果吧。绘制很简单,直接通过canvas画布来操作,下面我们在布局中心的位置画一个以宽高较小值的1/2为半径的圆。

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        float centerX = getMeasuredWidth()/2f;
        float centerY = getMeasuredHeight()/2f;
        float radius = Math.min(centerX,centerY);
        canvas.drawCircle(centerX,centerY,radius,mPaint);
    }

当然,ondraw方法中不仅仅可以做一下简单的绘制效果,也可以实现一些复杂的动效,这就需要自己动手去摸索了。


ondraw.png

1.3自定义属性

无论是组合布局还是自定义view,当我们希望开发者可以动态配置view属性的时候,我们该如何操作呢?
答案呼之欲出:自定义属性。那么我们如何自定义属性呢?

首先我们需要在attr xml中声明我们定义的属性,没有这个文件可以自己新建一个 ,文件名称不固定。

     //name为属性集合名称 不固定
    <declare-styleable name="CustomView">
         //那么为属性名称 format为类型格式
        <attr name="tColor" format="color"/>
        <attr name="defaultSize" format="dimension"/>
    </declare-styleable>

定义好了属性集合后,我们可以在xml中使用了,使用前我们先在根布局声明命名空间xmlns:app="http://schemas.android.com/apk/res-auto" app是命名空间的名称不固定 ,值一般是固定的。
定义好命名空间后就可以设置控件的属性,比如设置defaultSize就是通过app:defaultSize="12dp"这种方式。

xml设置好了属性后并不是代表生效了,还需要我们在自定义view中接收。这个时候就用到了构造方法中的AttributeSet参数了,我们是通过它来取出属性值。

 //参数2就是我们对应的属性集合名称
 TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.CustomView);
 //获取集合中的属性值时命名的格式为R.styleable.属性集合_属性值名称
 mDefaultWidth = array.getDimensionPixelSize(R.styleable.CustomView_defaultSize,300);
 //最后回收下TypedArray 对象
 array.recycle();

2.自定义viewGroup

自定义ViewGroup是一个容器,作为容器那么它不仅仅需要测量自己的尺寸,还需要测量子view的尺寸,并且需要将子view放到对应的位置上面去。所以在自定义view的时候我们要进行下面几个操作。

需要知道这个容器的布局规则,比如LinearLayout的横向或者竖向摆放等
需要知道所有子控件的尺寸
依据规则和子控件尺寸得出最终布局的尺寸
依据规则将子view摆放到对应的位置上面

下面是一个简单的类似LinearLayout 竖直摆放的ViewGroup

public class CustomViewGroup extends ViewGroup {
    private final static String TAG = CustomViewGroup.class.getSimpleName();
    public CustomViewGroup(Context context) {
        this(context,null);
    }

    public CustomViewGroup(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public CustomViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        //累计当前高度用于摆放子view
        int currentHeight = 0;
        for (int i = 0; i < getChildCount();i++){
            View child = getChildAt(i);
            int left = child.getLeft();
            int top = currentHeight;
            int right = left + child.getMeasuredWidth();
            int bottom = top + child.getMeasuredHeight();
            //摆放布局到对应位置
            child.layout(left,top,right,bottom);
            currentHeight += child.getMeasuredHeight();
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec,heightMeasureSpec);
        //测量所有字view
        measureChildren(widthMeasureSpec,heightMeasureSpec);
        //单纯的将宽高定义为累计高度和最大宽度 这边需要依据逻辑做最终处理
        int totalHeight = getTotalHeight();
        int maxWidth = getMaxWidth();
        Log.d(TAG,"totalHeight == "+ totalHeight +"  maxWidth ==  "+maxWidth);
        setMeasuredDimension(maxWidth,totalHeight);
    }

    private int getMaxWidth() {
        int maxWidth = 0;
        for (int i = 0; i < getChildCount(); i++){
            View child = getChildAt(i);
            maxWidth = Math.max(maxWidth,child.getMeasuredWidth());
        }
        return maxWidth;
    }

    private int getTotalHeight() {
        int totalHeight = 0;
        for (int i = 0; i < getChildCount(); i++){
            View child = getChildAt(i);
            totalHeight += child.getMeasuredHeight();
        }
        return totalHeight;
    }
}

在自定义viewGroup时候,onDraw方法不调用,原因是系统出于性能原因给viewGroup设置了flag WILL_NOT_DROW,这样,ondraw就不会被执行了。
解决方法有两种
1.给viewGroup设置一个背景
2.setWillNotDraw(false);去除WILL_NOT_DROW flag
这方面相关源码分析可参考:源码分析自定义ViewGroup onDraw方法无效

至此,自定义控件基础就学习完毕了。因为本人知识有限,再加上第一次发布博客,肯定有很多不足的地方,希望各位同道能够海涵,顺道帮忙指出来一起进步! 谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容