热门搜索 :
考研考公
您的当前位置:首页正文

写给自己的自定义View,不含糊

来源:东饰资讯网

概要

ViewWidget 组件最基本的类,主要负责两个事情 ,绘制和事件处理

View 的事件流程

onFinishInflate() -> onAttachedToWindow()->onMeausre()->onSizeChanged()->onLayout()->onDraw()

这只是一个简单流程,其中onMeasure()onLayout() 可能需要进行多次。

View.measure()

View.onMeasure() 方法默认实现代码

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

由于 Viewmeasure() 方法是 final 类型的,因此 View 子类并不能 override 这个方法,而是 override onMeasure() 这个方法。

View.onMeasure() 决定了 View 的测量宽和高,即 getMeasuredWidth()getMeasuredHeght(),也就是说只有在 onMeasure() 方法后,调用这两个方法才有效。

onMeasure() 中 需要调用 setMeasuredDimension()设置测量的尺寸。 如果 View 的子类 overrideonMeasure(),要么调用 super.onMeasure()使用系统默认的测量,要么就调用setMeasuredDimension()自己来设置测量的大小。这两种方法先其一,不然会有异常。

先看看系统的默认实现是怎么回事呢?再回头看上面贴出的系统的实现代码,把焦点定位到 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;
    }

MeasureSpec.UNSPECIFIED 这个比较特殊,它是 ViewGroup 用来决定 child view 需要的尺寸,例如,一个 LinearLayout 可以调用 child Viewmeasure() 方法,但是传入的参数中,高度设置为 UNSPECIFIED,宽度设置为 240 pixels,这是为了检测在240 pixels 宽度下,child View 想要的高度。从上面的代码中可以看到,如果 parent 给的参数是 UNSPECIFIED ,那么 child view 给出的测量值为 getSuggestedMinimumXXX(),也就是设置的 android:minWidth / minHeight 的值,而一般不用关心MeasureSpec.UNSPECIFIED

重点就是 MeasureSpec.AT_MOST 和 Measure.EXACTLY, 这两种情况返回的 size 是从 prent 传过来的 MeasureSpec 中获得的。

为了搞清楚这个 size 到底是多大,做个实验。

CustomView

自定义一个 View,CustomView.java

/**
 * Created by David on 2017/1/20.
 */

public class CustomView extends View {
    private int mIndex;
    private static final String TAG = "david";

    public CustomView(Context context) {
        this(context, null);
    }

    public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        mIndex++;
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        Log.d(TAG, "父View给的建议: 宽 = " + widthSize + " , 高 = " + heightSize);

        if (widthMode == MeasureSpec.AT_MOST) {
            Log.d(TAG, mIndex + " widthMode AT_MOST");
        } else if (widthMode == MeasureSpec.EXACTLY) {
            Log.d(TAG, mIndex + " widthMode EXACTLY");
        }

        if (heightMode == MeasureSpec.AT_MOST) {
            Log.d(TAG, mIndex + " heightMode AT_MOST");
        } else if (heightMode == MeasureSpec.EXACTLY) {
            Log.d(TAG, mIndex + " heightMode EXACTLY");
        }
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

}

现在把 CustomView 应用到布局中,并给它一个精确的 dp 值。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    
    
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.ckt.customviewtest.MainActivity">

    <com.ckt.customviewtest.CustomView
        android:background="@color/colorAccent"
        android:layout_width="200dp"
        android:layout_height="200dp"/>
</RelativeLayout>

效果如下


宽高为固定的DP值

打印Log为

01-16 13:47:16.231 9940-9940/com.ckt.customviewtest D/david: 父View给的建议: 宽 = 300 , 高 = 660
01-16 13:47:16.231 9940-9940/com.ckt.customviewtest D/david: 1 widthMode EXACTLY
01-16 13:47:16.231 9940-9940/com.ckt.customviewtest D/david: 1 heightMode AT_MOST
01-16 13:47:16.232 9940-9940/com.ckt.customviewtest D/david: 父View给的建议: 宽 = 300 , 高 = 300
01-16 13:47:16.232 9940-9940/com.ckt.customviewtest D/david: 2 widthMode EXACTLY
01-16 13:47:16.232 9940-9940/com.ckt.customviewtest D/david: 2 heightMode EXACTLY

从Log中看,如果给 CustomView 200dp 的宽和高,它最终得到了实际宽高为 300px(测试手机为 hdpi,200dp = 200*1.5 px),这与预期结果一致。

而如果把 CustomView 的宽高改为 match_parent

    <com.ckt.customviewtest.CustomView
        android:background="@color/colorAccent"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

效果为

match_parent

这与预期的效果也是一样的。

但是如果 CustomView 的宽高改为 wrap_content

    <com.ckt.customviewtest.CustomView
        android:background="@color/colorAccent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

效果为

wrap_content

wrap_content 还是填充整个布局,这就有点搞事情了。

所以总结下,如果调用系统的默认实现super.onMeasure(widthMeasureSpec, heightMeasureSpec); 那么需要注意属性为 wrap_content 的情况

一个设计良好的 View 不应该出现与开发都不一致的现象,那么现在如何用 setMeasuredDimension() 来设置测量的宽高?

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        Log.d(TAG, "onMeasure");
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);

        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        // 测量的宽和高
        int measuredWidth, measuredHeight;

        // 设置期望的宽和高
        int desiredWidth = 300; // px
        int desiredHeight = 300; // px


        if (widthMode == MeasureSpec.EXACTLY) {
            // 如果父View传入的是EXACTLY就用父View给的大小
            measuredWidth = widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST) {
            // 如果父View给的是AT_MOST就与期望的值比较取最小值
            measuredWidth = Math.min(widthSize, desiredWidth);
        } else {
            // 如果父View给的是UNSPECIFIED就只给给出期望的值
            measuredWidth = 300;
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            measuredHeight = heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            measuredHeight = Math.min(heightSize, desiredHeight);
        } else {
            measuredHeight = desiredHeight;
        }

        // 设置测量的宽和高
        setMeasuredDimension(measuredWidth, measuredHeight);
    }

代码中直接给出了期望的宽和高desiredWidthdesiredHeight300px ,实际中我们可能需要根据绘制内容的大小来决定需要的宽和高。例如,想要在 View 中显示一些文字,当设置为 wrap_content 的时候,我们期望 View 正好是文字显示的大小,那么,我们需要在 onMeasure() 中测量文字大小,来给出相应的需要的宽和高。

View.onLayout()

View.onLayout() 是在 View.layout() 中调用,然而在 View.layout() 中已经把 View 给定位了。那么 View.onLayout() 有何叼用?下面是 View.onLayout() 的默认实现。

    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    }

空方法!好像并没有任何叼用(个人愚见)。 自定义 View 的话,通常不会 override 这个方法。
那么 View.layout() 过后,可以得到什么呢? 从onLayout() 中参数left, top, right, bootom 可以猜到,layout() 后可以获得 View 的实际宽和高,也就是 getWidth()getHeight(),当然还包含 getLeft(), getTop()getRight(), getBottom() 方法。

View.onDraw()

View.onDraw() 是在 View.draw()中调用的,先看下 View.onDraw() 方法

    protected void onDraw(Canvas canvas) {
    }

空方法!也就是说你想绘制什么就自己来。那么 View.draw() 又做了什么呢 ,它其实给我们做了一些基本的工作,例如,绘制 background,scrollbars。所以在 onDraw() 中只需要绘制自己想要的内容即可,其它的交给系统。

例子

从上面的分析,可以知道,如果自定义一个 View,需要 override 的最基本的方法有 onMeasure()onDraw()

有了这个分析,举个简单的例子,画个圆。

public class CustomView extends View {
    private static final String TAG = "david";
    private Paint mPaint;

    public CustomView(Context context) {
        this(context, null);
    }

    public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    /**
     * 初始化操作
     */
    private void init() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(Color.BLACK);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(5);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        Log.d(TAG, "onFinishInflate");
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        Log.d(TAG, "onMeasure");
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);

        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        // 测量的宽和高
        int measuredWidth, measuredHeight;

        // 设置期望的宽和高
        int desiredWidth = 300; // px
        int desiredHeight = 300; // px


        if (widthMode == MeasureSpec.EXACTLY) {
            // 如果父View传入的是EXACTLY就用父View给的大小
            measuredWidth = widthSize;
        } else if (widthMode == MeasureSpec.AT_MOST) {
            // 如果父View给的是AT_MOST就与期望的值比较取最小值
            measuredWidth = Math.min(widthSize, desiredWidth);
        } else {
            // 如果父View给的是UNSPECIFIED就只给给出期望的值
            measuredWidth = 300;
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            measuredHeight = heightSize;
        } else if (heightMode == MeasureSpec.AT_MOST) {
            measuredHeight = Math.min(heightSize, desiredHeight);
        } else {
            measuredHeight = desiredHeight;
        }

        // 设置测量的宽和高
        setMeasuredDimension(measuredWidth, measuredHeight);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        Log.d(TAG, "onSizeChanged");
    }

    @Override
    protected void onDraw(Canvas canvas) {
        Log.d(TAG, "onDraw");
        // 绘制一个圆
        canvas.drawCircle(getWidth() / 2, getHeight() / 2, Math.min(getWidth() / 2, getHeight() / 2), mPaint);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        Log.d(TAG, "onLayout");
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        Log.d(TAG, "onAttachedToWindow");
    }
}

效果如下

画一个圆

Padding

上面画了一个简单的圆,再思考一个问题,日常中我们喜欢搞个 padding。 如果在 XML 文件中设置 padding ,会发现并没有效果。why? 因为自定义 View 的时候,我们其实并没有考虑 padding 这个因素啊,这是 Bug 中的大Bug,改~

首先在 onDraw() 中,期望的宽和高要把 padding 考虑进去

        // 设置期望的宽和高
        int desiredWidth = 300 + getPaddingLeft() + getPaddingRight(); // px
        int desiredHeight = 300 + getPaddingTop() + getPaddingBottom(); // px

onDraw() 中,绘制的时候,也要把 padding 考虑进去

    @Override
    protected void onDraw(Canvas canvas) {
        Log.d(TAG, "onDraw");
        // 绘制一个圆
        float cx = (getWidth() - getPaddingLeft() - getPaddingRight()) / 2 + getPaddingLeft();
        float cy = (getHeight() - getPaddingTop() - getPaddingBottom()) / 2 + getPaddingTop();
        float radius = Math.min((getWidth() - getPaddingLeft() - getPaddingRight()) / 2, (getHeight() - getPaddingTop() - getPaddingBottom()) / 2);
        canvas.drawCircle(cx, cy, radius, mPaint);
    }

ok,现在设置一下 padding 属性

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.ckt.customviewtest.CustomView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/colorAccent"
        android:paddingBottom="10dp"
        android:paddingLeft="1dp"
        android:paddingRight="5dp"
        android:paddingTop="1dp"/>
</RelativeLayout>

看下效果

加入Padding的效果

View的位置

onLayout()是个空方法,View 的位置如何确定呢? 其实 View 只支持 padding,并不支持 margin 。细思下,为何?这不是 ViewGroup 例如 LinearLayout 该干的事吗?所以 View 定位这个事,在 XML 中使用相应的布局的属性即可,例如 RelativeLayout 可以设置android:layout_centerInParent 来让 View 居中,当然也可以设置 margin 自由定位。

抽取属性

为何要抽取属性出来自定义? 可以方便用 XML 属性来控制 View

如何自定义属性

  1. 在res/values/ 目录下新建一个 attrs.xml 文件,名字可以随意取。并在文件中加入 <declare-styleable> 节点,并在其中定义属性
  2. XML 中定义属性的值
  3. 在运行时获取自定义属性
  4. 应用到自定义 View

按照上面的步骤来,首先在 res/values/ 目录下新建一个文件 custom_attrs.xml ,并加入自定义属性

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomView">
        <attr name="circleColor" format="color"/>
    </declare-styleable>
</resources>

然后在运行时获取属性

    public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
        TypedArray a = context.getResources().obtainAttributes(attrs, R.styleable.CustomView);
        int count = a.getIndexCount();
        for (int i = 0; i < count; i++) {
            mCircleColor = a.getColor(R.styleable.CustomView_circleColor, 0xFF352574);
        }
        a.recycle();
        init();
    }

如果细心的人会注意到 context.getResources().obtainAttributes(),我并不是用的 context.getTheme().obtainStyledAttributes() 或者 context.obtainStyledAttributes()。 当然,后面两者是等价的,而第一个,只是获取这个项目的 Resources ,后面两个包括 Theme 中定义的属性,例如,与 RecyclerView有关的一个类 DividerItemDecoration.java 中获取属性代码

        private static final int[] ATTRS = new int[]{ android.R.attr.listDivider };
        final TypedArray a = context.obtainStyledAttributes(ATTRS);
        mDivider = a.getDrawable(0);

如果换成context.getResources().obtainAttributes() 是获取为 null 的。

还要提醒一下,TypeArray 是一个共享资源,所以用完要回收。

最后一步就是使用了自定义属性了

    private void init() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        // 使用自定义属性
        mPaint.setColor(mCircleColor);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(5);
    }

对外暴露接口

自定义的属性只能在 XML 中做初始化的操作,当然我们最好把这个属性设置一个 gettersetter 方法。

    public int getCircleColor() {
        return mCircleColor;
    }

    public void setCircleColor(int circleColor) {
        mCircleColor = circleColor;
        invalidate();
    }

注意,在 setCircleColor() 中调用了 invalidate(),因为需要重绘,而在 onDraw() 中 ,我们需要应用外部设置的属性,所以在 onDraw() 中要设置下 PaintColor

    protected void onDraw(Canvas canvas) {
        Log.d(TAG, "onDraw");
        // 应用自定义属性
        mPaint.setColor(mCircleColor);
        // 绘制一个圆
        float cx = (getWidth() - getPaddingLeft() - getPaddingRight()) / 2 + getPaddingLeft();
        float cy = (getHeight() - getPaddingTop() - getPaddingBottom()) / 2 + getPaddingTop();
        float radius = Math.min((getWidth() - getPaddingLeft() - getPaddingRight()) / 2, (getHeight() - getPaddingTop() - getPaddingBottom()) / 2);
        canvas.drawCircle(cx, cy, radius, mPaint);
    }

事件处理

有时候,我们自定义的 View 要响应用户事件来改变View 的外观 或(和) 行为,如果只是改变外观 ,调用 invalidate() 即可,它会调用 onDraw()。如果涉及到位置的改变就需要调用 requestLayout(),它会在适当的时候调用 onDraw()。 当然 requestLayout() 用的最多的地方还是 自定义ViewGroup 中,在下篇文章中再细说。

参考文章

Top