一、基本结构
1.1 重写onMeasure 方法1.2 三种测量模式1.3 子View的onMeasure方法参数测量模式的由来1.4 子View的onMeasure重写的基本写法1.5 通过案例了解onMeasure的作用 二、组件的属性
2.1 属性的基本定义2.2 自定义属性读取的优先级 三、综合案例
3.1 圆形ImageView组件3.2 验证码组件
一、基本结构组件主要由两部分构成:组件类和属性定义。我们从第一种定义方式说起创建自定义组件类最基本的做法就是继承自类 View,其中,有三个构造方法和两个重写的方法又是重中之重。下面是自定义组件类的基本结构:
public class MyView extends View { public MyView(Context context) { super(context); } public MyView(Context context, AttributeSet attrs) { super(context, attrs); } public MyView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }
上述代码中,我们定义了一个名为 MyView 的类,该类继承自 View,同时,为该类定义了三个构造方法并重写了另外两个方法:
构造方法
public MyView(Context context) public MyView(Context context, AttributeSet attrs) public MyView(Context context, AttributeSet attrs, int defStyleAttr)
这三个构造方法的调用场景其实并不一样,第一个只有一个参数,在代码中创建组件时会调用该构造方法,比如创建一个按钮:
Button btnOK = new Button(this);
第二构造个方法在 layout 布局文件中使用时调用,参数 attrs 表示当前配置中的属性集合,例如在要 layout.xml 中定义一个按钮:
Android 会调用第二个构造方法 Inflate出Button对象。而第三个构造方法是不会自动调用的,当我们在 Theme 中定义了 Style 属性时通常在第二个构造方法中手动调用。
绘图
protected void onDraw(Canvas canvas)
该方法我们再熟悉不过了,前面Android绘图篇一直重写了该方法,用于显示组件的外观。最终的显示结果需要通过 canvas 绘制出来。在 View 类中,该方法并没有任何的默认实现。
测量尺寸
protected void onMeasure(int widthMeasureSpec,int heightMeasureSpec)
这是一个 protected 方法,意味着该方法主要用于子类的重写和扩展,如果不重写该方法,父类 View 有自己的默认实现。在 Android 中,自定义组件的大小都由自身通过onMeasure()进行测量,不管界面布局有多么复杂,每个组件都负责计算自己的大小。
1.1 重写onMeasure 方法View 类对于 onMeasure()方法有自己的默认实现
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 真正指定宽高的方法是setMeasuredDimension setMeasuredDimension(getDefaultSize( getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec)); } 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: // 可以看到无论子View的宽高布局属性是wrap_content、match_parent、具体的dp, // 得到的大小都是MeasureSpec.getSize(measureSpec)返回的,而measureSpec是由父容器计算而来的 result = specSize; break; } return result; }
大部分情况下onMeasure方法都要重写,用于计算组件的宽度值和高度值。定义组件时,必须指定 android:layout_width和android:layout_height 属性,属性值有三种情况:match_parent、wrap_content 和具体值。
- match_parent: 表示组件的大小跟随父容器,所在的容器有多大,组件就有多大;wrap_content: 表示组件的大小由内容决定,比如 TextView 组件的大小由文字的多少决定,ImageView 组件的大小由图片的大小决定;具体值: 这个相对就简单了,直接指定即可,单位为 dp。
总结来说,不管是宽度还是高度,都包含了两个信息:模式和大小。
模式可能是 match_parent、wrap_content 和具体值的任意一种;大小则要根据不同的模式进行计算。
其实,match_parent 也是一个确定了的具体值,为什么这样说呢?因为 match_parent 的大小跟随父容器,而容器本身也是一个组件,他会算出自己的大小,所以我们根本不需要去重复计算了,父容器多大,组件就有多大,View 的绘制流程会自动将父容器计算好的大小通过参数传过来。
模式使用三个不同的常量来区别:
MeasureSpec.EXACTLY
当组件的尺寸指定为 match_parent 或具体值时用该常量代表这种尺寸模式,很显然,处于该模式的组件尺寸已经是测量过的值,不需要进行计算。
MeasureSpec.AT_MOST
当组件的尺寸指定为 wrap_content 时用该常量表示,因为尺寸大小和内容有关,所以,我们要根据组件内的内容来测量组件的宽度和高度。比如 TextView 中的 text 属性字符串越长,宽度和高度就可能越大。
MeasureSpec.UNSPECIFIED
未指定尺寸,这种情况不多,一般情况下,父控件为 AdapterView 时,通过 measure 方法传入。
通过如下方法可以获取当前View的测量模式和尺寸大小,它是由父容器计算后返回的:
int mode = MeasureSpec.getMode(widthMeasureSpec); int size = MeasureSpec.getSize(widthMeasureSpec);
而widthMeasureSpec和heightMeasureSpec刚好就是来自重写的onMeasure方法的参数,它是由父容器测量后传入的,他代表当前子View的宽度测量模式和高度测量模式。
1.3 子View的onMeasure方法参数测量模式的由来为什么说View的onMeasure方法的参数是由父容器计算返回的呢?我们可以查看ViewGroup的源码,ViewGroup没有重写View的onMeasure方法,但是提供了measureChildren和measureChildWithMargins两个有用的方法,这两个方法最终都会调用子View的measure方法
protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) { final LayoutParams lp = child.getLayoutParams(); // 计算子View的宽度测量模式 final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp.width); // 计算子View的高度测量模式 final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp.height); // 最终会触发View的onMeasure方法 child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); // 计算子View的宽度测量模式 final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin + widthUsed, lp.width); // 计算子View的高度测量模式 final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin + heightUsed, lp.height); // 最终会触发View的onMeasure方法 child.measure(childWidthMeasureSpec, childHeightMeasureSpec); }
在调用 child.measure方法传入的2个测量模式其实是经过getChildMeasureSpec方法计算而来的,我们来看看ViewGroup的getChildMeasureSpec源码如下所示:
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) { // 父容器模式是精确模式 case MeasureSpec.EXACTLY: if (childDimension >= 0) { // 如果子view布局的宽高是固定值,那么直接使用子View的固定值 resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { // 如果子View布局宽高是匹配父容器,那么使用父容器的size resultSize = size; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.WRAP_CONTENT) { // 如果子view布局宽高是包裹类型,那么最多的大小不能超过父容器的size resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // 父容器是包裹类型 case MeasureSpec.AT_MOST: if (childDimension >= 0) { // 如果子View的布局宽高是固定值,那么直接使用子View的固定值 resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { // 如果子View的布局宽高是匹配父容器,那么子View的大小最大不能超过父容器的size resultSize = size; resultMode = MeasureSpec.AT_MOST; } else if (childDimension == LayoutParams.WRAP_CONTENT) { // 如果子view的布局宽高是包裹内容,那么子View的大小最大不能超过父容器的size resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // 父容器未指定模式 case MeasureSpec.UNSPECIFIED: if (childDimension >= 0) { // 如果子View的布局宽高是固定值,那么直接使用子View的固定值 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; } // 计算子View的MeasureSpec return MeasureSpec.makeMeasureSpec(resultSize, resultMode); }
然后View的measure方法最终会触发View的onMeasure方法,由此可见我们重写View的onMeasure方法得到的2个参数widthMeasureSpec和widthMeasureSpec其实是经过父容器计算后返回的,不信可以查看DecorView的onMeasure方法:
上图可知DecorView调用的是父类的onMeasure方法,而DecorView的父类是frameLayout,我们来瞧瞧
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int count = getChildCount(); final boolean measureMatchParentChildren = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY || MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY; mMatchParentChildren.clear(); int maxHeight = 0; int maxWidth = 0; int childState = 0; for (int i = 0; i < count; i++) { final View child = getChildAt(i); if (mMeasureAllChildren || child.getVisibility() != GONE) { // 关键代码:这里调用ViewGroup的方法测量子View,传入父容器的测量模式 measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); maxWidth = Math.max(maxWidth, child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin); maxHeight = Math.max(maxHeight, child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin); childState = combineMeasuredStates(childState, child.getMeasuredState()); if (measureMatchParentChildren) { if (lp.width == LayoutParams.MATCH_PARENT || lp.height == LayoutParams.MATCH_PARENT) { // 这里是记录使用了匹配父窗口的子view mMatchParentChildren.add(child); } } } } // 计算最大宽度和高度 maxWidth += getPaddingLeftWithForeground() + getPaddingRightWithForeground(); maxHeight += getPaddingTopWithForeground() + getPaddingBottomWithForeground(); // Check against our minimum height and width maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight()); maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth()); // Check against our foreground's minimum height and width final Drawable drawable = getForeground(); if (drawable != null) { maxHeight = Math.max(maxHeight, drawable.getMinimumHeight()); maxWidth = Math.max(maxWidth, drawable.getMinimumWidth()); } // 这里设置frameLayout的宽度和高度,如果是根frameLayout,那就是屏幕的宽高 setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState), resolveSizeAndState(maxHeight, heightMeasureSpec, childState << MEASURED_HEIGHT_STATE_SHIFT)); // 下面的逻辑是计算使用了匹配父窗口的子view的特殊处理,可以不关注 count = mMatchParentChildren.size(); if (count > 1) { for (int i = 0; i < count; i++) { final View child = mMatchParentChildren.get(i); final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); final int childWidthMeasureSpec; if (lp.width == LayoutParams.MATCH_PARENT) { final int width = Math.max(0, getMeasuredWidth() - getPaddingLeftWithForeground() - getPaddingRightWithForeground() - lp.leftMargin - lp.rightMargin); childWidthMeasureSpec = MeasureSpec.makeMeasureSpec( width, MeasureSpec.EXACTLY); } else { childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, getPaddingLeftWithForeground() + getPaddingRightWithForeground() + lp.leftMargin + lp.rightMargin, lp.width); } final int childHeightMeasureSpec; if (lp.height == LayoutParams.MATCH_PARENT) { final int height = Math.max(0, getMeasuredHeight() - getPaddingTopWithForeground() - getPaddingBottomWithForeground() - lp.topMargin - lp.bottomMargin); childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( height, MeasureSpec.EXACTLY); } else { childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, getPaddingTopWithForeground() + getPaddingBottomWithForeground() + lp.topMargin + lp.bottomMargin, lp.height); } child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } } }
从上面的关键代码也可以知道,frameLayout调用measureChildWithMargins方法后子View最终会回调onMeasure方法,并且入参是经过父View计算后的宽度和高度测量模式。如果子View不重写onMeasure方法,那么就采用View的默认处理,基本上就是随父容器的大小了,除非父容器设置了padding。
1.4 子View的onMeasure重写的基本写法对于继承View的自定义View,我们可以重写onMeasure方法调用setMeasuredDimension方法传入具体的宽高,这样我们的自定义View就可以通过getMeasureWidth和getMeasureHeight得到宽高了.
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 测量子View的宽高,传入子View的宽度和高度测量模式,此测量模式是父容器计算后传入的 int width = measureWidth(widthMeasureSpec); int height = measureHeight(heightMeasureSpec); // 设置子View的宽高 setMeasuredDimension(width, height); } private int measureWidth(int widthMeasureSpec) { // 获取子View的宽度测量模式 int mode = MeasureSpec.getMode(widthMeasureSpec); // 获取子View的宽度size int size = MeasureSpec.getSize(widthMeasureSpec); int width = 0; if (mode == MeasureSpec.EXACTLY) { //子View宽度为 match_parent或者具体值时,直接将 size 作为组件的宽度 width = size; } else if (mode == MeasureSpec.AT_MOST) { //宽度为 wrap_content,宽度需要计算 } return width; } private int measureHeight(int heightMeasureSpec) { // 获取子View的高度测量模式 int mode = MeasureSpec.getMode(heightMeasureSpec); // 获取子View的高度size int size = MeasureSpec.getSize(heightMeasureSpec); int height = 0; if (mode == MeasureSpec.EXACTLY) { //子View高度为 match_parent或者具体值时,直接将 size 作为组件的宽度 height = size; } else if (mode == MeasureSpec.AT_MOST) { //高度为 wrap_content,高度需要计算 } return height; }
上面的代码依然什么事也干不了,表达的是一种基本思路。measureWidth()方法用于计算组件的宽度,如果组件的 layout_width 属性为 match_parent 或指定了具体值,则直接从参数 widthMeasureSpec 获取,如果为 wrap_content,则要通过计算才能得到(因为没有设定具体的功能,所以我们也不知道该干什么)。另一个方法 measureHeight()则用于计算组件的高度,代码实现和 measureWidth()类似,不再赘述。
1.5 通过案例了解onMeasure的作用为了充分说明 onMeasure()方法的作用,我们将模拟 TextView 的功能,也就是在组件中绘制文字,为了简单起见,我们只考虑一行文字(多行文字会让代码变得十分复杂)。
在本案例中,比较麻烦的是绘制文字时方法中参数y的确定,这要从字体的基本结构说起。
public void drawText(String text,float x,float y,Paint paint)
从技术层面上来说,字符由下面几个部分构成,从文字上理解可能比较晦涩,通过上面示意图也许很容易找到答案。简单来说,常用字符的高度是 ascent 和 descent 的和,但是,一些特殊字符比如拼音的音调等则会延伸到 top 的位置。在 Android 中,字体的信息使用Paint.FontMetrics 类来表示,该类源码如下:
public static class FontMetrics { // 上图绿线表示基准线baseline public float top; // 表示字符可达最高处到 baseline 的值,即 ascent 的最大值 public float ascent; // baseline 之上至字符最高处的距离 public float descent; // baseline 之下至字符最低处的距离 public float bottom; // 字符可达最低处到 baseline 的值,即 descent 的最大值 public float leading; }
FontMetrics 类作为 Paint 的内部类,定义了 5 个属性,除了 leading 在上面没有说明外,其
他都有图示与说明。leading 是指上一行字符的descent 到下一行的 ascent 之间的距离,因为案 例中只显示单行字符,所以我们并不打算关注。
要获取 FontMetrics 对象,需调用Paint类的getFontMetrics(),而在drawText()方法中参数y就是baseline的值,因为FontMetrics类并没有声明baseline属性,所以我们需要通过下
面的公式计算出来:
int baseline = viewHeight / 2 + (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
你也许会很疑惑,为啥是fontMetrics.descent - fontMetrics.ascen而不是fontMetrics.ascent-fontMetrics.descent,这是因为baseline之上的值都是负数,而baseline之下的才是正数,通过log打印可以查看:
通过log可以很清楚的知道top和ascent是负数,并且它们的数值跟paint设置的textSize有关,前2条是默认未指定textSize的值,后2条分别是textSize设置为22和23输出的值.
当然你也可以通过下面方式处理:
int baseline = viewHeight / 2 + (Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent)) / 2 - fontMetrics.descent
下面上案例代码:
public class MyView extends View { public MyView(Context context) { this(context, null); } public MyView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public MyView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private Paint paint; private static final String TEXT = "hello world!!"; private void init() { paint = new Paint(Paint.ANTI_ALIAS_FLAG); paint.setTextSize(100); paint.setColor(Color.RED); } @Override protected void onDraw(Canvas canvas) { // 计算文字的区域 Rect textRect = getTextRect(); int viewWidth = getMeasuredWidth(); int viewHeight = getMeasuredHeight(); //将文字放在正中间 Paint.FontMetrics fontMetrics = paint.getFontMetrics(); int x = (viewWidth - textRect.width()) / 2; int distance = (int) ((fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent); int baseline = viewHeight / 2 + distance; // 绘制文本 canvas.drawText(TEXT, x, baseline, paint); } private Rect getTextRect() { //根据Paint设置的绘制参数计算文字所占的宽度 Rect rect = new Rect(); //文字所占的区域大小保存在 rect 中 paint.getTextBounds(TEXT, 0, TEXT.length(), rect); return rect; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { Rect rect = getTextRect(); int textWidth = rect.width(); int textHeight = rect.height(); // 测量子View的宽高,传入子View的宽度和高度测量模式,此测量模式是父容器计算后传入的 int width = measureWidth(widthMeasureSpec, textWidth); int height = measureHeight(heightMeasureSpec, textHeight); // 设置子View的宽高 setMeasuredDimension(width, height); } private int measureWidth(int widthMeasureSpec, int textWidth) { // 获取子View的宽度测量模式 int mode = MeasureSpec.getMode(widthMeasureSpec); // 获取子View的宽度size int size = MeasureSpec.getSize(widthMeasureSpec); int width = 0; if (mode == MeasureSpec.EXACTLY) { //子View宽度为 match_parent或者具体值时,直接将 size 作为组件的宽度 width = size; } else if (mode == MeasureSpec.AT_MOST) { //宽度为 wrap_content,宽度需要计算 width = textWidth; } return width; } private int measureHeight(int heightMeasureSpec, int textHeight) { // 获取子View的高度测量模式 int mode = MeasureSpec.getMode(heightMeasureSpec); // 获取子View的高度size int size = MeasureSpec.getSize(heightMeasureSpec); int height = 0; if (mode == MeasureSpec.EXACTLY) { //子View高度为 match_parent或者具体值时,直接将 size 作为组件的宽度 height = size; } else if (mode == MeasureSpec.AT_MOST) { //高度为 wrap_content,高度需要计算 height = textHeight; } return height; } }
然后我们来在布局中使用,我们比较一下layout_width 和 layout_height 两个属性的值在不同情况下的运行结果。
上面的宽高都是固定值,运行结果如下:
然后将宽高修改为wrap_content,运行结果如下:
可以看出,当 layout_width="wrap_content"且 layout_height="wrap_content"时,组件大小恰 好是文字所占的区域大小,文字刚刚能显示;
如果将宽度修改为match_parent,高度还是wrap_content,运行结果如下:
当 layout_width="match_parent"且 layout_height="wrap_content"时,水平方向占满整个宽度,而高度恰好是文字的高度。当为确定数值时,那就是具体的宽度和高度
在 MytView 组件类中,要显示的文字定义成了常量
private static final String TEXT = "hello world!!";
显然,这并不可取,我们应该可以随意定义文字,这需要用到组件的属性。从View继承后,View已经具备了若干默认属性,比如 layout_width、layout_height,所以,在MyView类中,指定该类的宽度和高度时,我们并没有特别定义和编程。大家找到sdk/platforms/android-21/data/res/values/attrs.xml文件 ,打开后, 定位到 这一行,接下来的 500 多行都是与 View 的默认属性有关的,常用的属性比如layout_width、layout_height、background、alpha 等属性都是默认的属性。
2.1 属性的基本定义除了 View 类中定义的默认属性外,我们也能自定义属性。自定义属性主要有以下几个步骤:
1) 在 res/values/attrs.xml 文件中为指定组件定义 declare-styleable 标记,并将所有的属性都定义在该标记中;
2) 在 layout 文件中使用自定义属性;
3) 在组件类的构造方法中读取属性值。
res/values 目录下,创建 attrs.xml 文件,内容大概如下:
组件的属性都应该定义在 declare-styleable 标记中,该标记的name属性值一般来说都是组件类的名称(此处为 MyView),虽然也可以取别的名称,但和组件名相同可以提高代码的可读性。组件的属性都定义在 declare-styleable 标记内,成为 declare-styleable 标记的子标记,每个属性由两部分组成—属性名和属性类型。属性通过attr来标识,属性名为name,属性类型为format,可选的属性类型如下图所示:
1)string:字符串
2)boolean:布尔
3)color:颜色
4)dimension:尺寸,可以带单位,比如长度通常为 dp,字体大小通常为 sp
5)enum:枚举,需要在 attr 标记中使用标记定义枚举值,例如 sex 作为性别,有
两个枚举值:MALE 和 FEMALE。注意:枚举命名必须大写,否则报错
6)flags:标识位,常见的 gravity 属性就是属性该类型,flag 类型的属性也有一个子标记,语法形如:
7)float:浮点数
8)fraction:百分数,在动画资源、等标记中,fromX、fromY 等属性就是
9)fraction: 类型的属性
10)integer:整数
11)reference :引用另一个资源, 比如 android:paddingRight="@dimen/activity_horizontal_margin"就是引用了一个尺寸资源。
定义好属性的名称和类型后,属性就可以使用了,在布局文件 layout.xml 中,首先要定义好属性的命名空间(namespace),默认情况下,xml 文件中的根元素按如下定义:
其中android是系统属性的命名空间,对应的schemas是:
xmlns:android="http://schemas.android.com/apk/res/android"
而自定义属性通常会是用app,例如:
xmlns:app="http://schemas.android.com/apk/res-auto"
当然你也可以换成其他别名,我们使用属性的时候需要在属性名前加上命名空间,例如 android:layout_width="300dp"是系统属性,用的命名空间是android,而我们自定义的属性可以这样用:
如上说是 app:text就是自定义属性的使用,接下来我们需要在 MyView 类中读取 app:text 属性,组件运行后,所有属性都将保存在AttributeSet 集合中并通过构造方法传入,我们通过 TypedArray 可以读取出指定的属性值
public MyView(Context context, AttributeSet attrs) { super(context, attrs); ... //读取属性值 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView); String text = a.getString(R.styleable.MyView_text); a.recycle(); }
语句TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView); 中参数R.styleable.MyView是配置中的 name 值,TypedArray 对象的getString()方法用于读取特定属性的值(R.styleable.FirstView_text 是指 text 属性),TypedArray 类中定义了很多 getXXX()方法,“XXX”代表对应属性的类型,有些 get 方法有两个参数,第二个参数通常是指默认值。最后,需要调用 TypedArray 的 recycle()方法释放资源。
当通过text属性读取到用户传入的文案后,我们就可以替换TEXT变量了,这样最终绘制的内容就是我们通过属性传递的文案了,灵活性大大提高了。
2.2 自定义属性读取的优先级组件的属性可以在下面 5个地方定义:
1)组件自身使用的自定义属性
2)组件通过style属性引入的style的属性
3)AppTheme中定义的属性
4)AppTheme中引入的其他style的属性
5)theme的style属性
这个问题说起来可能有点儿绕,所以我们索性通过一个案例来进行学习和讲解。假如我们有一个组件类 AttrView,从View类派生,AttrView类有4 个属性:attr1、attr2、attr3、attr4。另外,定义了一个属性myStyle,该属性定义在 declare-styleable 标记之外,类型为 reference,用于theme 的 style 属性。
这些属性在 res/values/attrs.xml 文件中定义如下:
然后在res/values/style.xml文件中定义style,当然,该文件还定义了整个App工程的主题(theme)如下所示:
在工程的主题(theme) AppTheme 中,应用了属性 attr3,同时应用了style 属性 myStyle, 该 style 属性又引用了@style/myDefaultStyle,@style/ myDefaultStyle 中应用了属性 attr4。总结起来,attr1 是组件的直接属性,attr2 是组件的 style 属性引用的属性,attr3 是工程主题(theme)属性,attr4 是工程主题(theme)的 style 属性。
然后我们在布局中这样使用:
app:attr1=“定义在组件中的attr1"应用了属性attr1,style=”@style/viewStyle" 应用了属性 attr2 ,其中@style/viewStyle 定义在 res/values/style.xml 文件中,现在,我们在 AttrView 构造方法中读取这 4 个属性值。
public class AttrView extends View { private static final String TAG = "AttrView"; public AttrView(Context context) { super(context); } public AttrView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, R.attr.myStyle); } public AttrView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AttrView, defStyleAttr, R.style.myDefaultStyle); String attr1 = a.getString(R.styleable.AttrView_attr1); String attr2 = a.getString(R.styleable.AttrView_attr2); String attr3 = a.getString(R.styleable.AttrView_attr3); String attr4 = a.getString(R.styleable.AttrView_attr4); a.recycle(); Log.e(TAG, attr1 + ""); Log.e(TAG, attr2 + ""); Log.e(TAG, attr3 + ""); Log.e(TAG, attr4 + ""); } }
我们在AttrView(Context context,AttributeSet attrs)构造方法中,调用了AttrView(Context context,AttributeSet attrs,int defStyleAttr)构造方法,与上一个案例相比,我们调用了另一个重载的 obtainStyledAttributes()方法,该方法的原型为:
public TypedArray obtainStyledAttributes(AttributeSet set, int[] attrs, int defStyleAttr, int defStyleRes);
运行结果打印日志如下:
我们通过如下图所示的流程图来了解View 是如何读取属性的。图中我们试图读取attr属性,从流程图中也可以看出各个环节的优先级顺序。
由此可知优先级是: 组件的自定义属性 > 组件中用引入的style > AppThem内引入的style > AppTheme中定义的属性
ImageView 是我们常用的组件之一,但该组件存在一定的局限性,比如只能显示矩形的图片,现在很多 App 在显示头像时都支持圆形或其他形状,所以,我们将向大家介绍如何定制支持圆形图片的 ImageView 组件。因为是显示图片,我们自然想到组件类应该继承自 ImageView,ImageView 已经帮我们做了大部分工作,比如已经重写了 onMeasure()方法,不再需要重新计算尺寸,设置图片也已经实现了。我们还要添加一些功能,比如显示出来的图片是圆的,支持添加圆形框线,为圆形框线指定颜色和大小等等。
首先,我们事先定义两个属性:圆形框线的粗细与颜色,定义粗细时使用 dimension 类型,而颜色则使用 color 类型。
其次,定义CircleImageView组件类,该类继承自ImageView 类。
public class CircleImageView extends AppCompatImageView { private Paint paint; private int border; // 边框大小 private int borderColor = Color.WHITE; // 边框颜色 private Rect srcDest; // 原图大小区域 private Rect destRect; // 原图显示后的显示区域 private Bitmap bmpDst; // dst位图(原图) private Bitmap bmpCircle; // src位图(圆) private PorterDuffXfermode xfermode; private int fillMode; //当图片小于控件时的填充模式(0:铺满View,1:按图片大小显示) private static final int MODE_FILL = 0; private static final int MODE_WRAP = 1; public CircleImageView(Context context) { this(context, null); } public CircleImageView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CircleImageView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); paint = new Paint(Paint.ANTI_ALIAS_FLAG); paint.setStyle(Paint.Style.FILL); xfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_IN); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView); border = a.getDimensionPixelSize(R.styleable.CircleImageView_circle_border, 0); borderColor = a.getColor(R.styleable.CircleImageView_circle_border_color, borderColor); fillMode = a.getInt(R.styleable.CircleImageView_fillMode, MODE_WRAP); setLayerType(View.LAYER_TYPE_SOFTWARE, null); a.recycle(); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { if (getDrawable() == null) return; // 获取dst位图 bmpDst = ((BitmapDrawable) getDrawable()).getBitmap(); // 图片的最小值 int minBmpSize = Math.min(bmpDst.getWidth(), bmpDst.getHeight()); // View的最小值 int minViewSize = Math.min(w, h); // 默认展示区域取最小值 int size = Math.min(minBmpSize, minViewSize); if (fillMode == 0 && minViewSize > minBmpSize) { // 当控件比图片大的时候,如果采用填充模式,那么展示区域就由控件大小决定 size = minViewSize; } // 创建圆(src)的位图 bmpCircle = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); Canvas circleCanvas = new Canvas(bmpCircle); int r = (int) (size / 2f); circleCanvas.drawCircle(r, r, r, paint); // 定义原图范围和目标显示范围 srcDest = new Rect(0, 0, bmpDst.getWidth(), bmpDst.getHeight()); destRect = new Rect(0, 0, size, size); } @Override protected void onDraw(Canvas canvas) { if (getDrawable() == null) return; // 平移画布,让内容在View的中心显示 float offsetX = (getWidth() - destRect.width()) / 2f; float offsetY = (getHeight() - destRect.height()) / 2f; canvas.translate(offsetX, offsetY); // 图层Layer的范围和目标显示范围一致,其实只要保证Layer的最小范围>=目标显示的范围即可,超出layer的范围将不显示 int layer = canvas.saveLayer(0, 0, destRect.width(), destRect.height(), null, Canvas.ALL_SAVE_FLAG); // 绘制猫(dst)位图 canvas.drawBitmap(bmpDst, srcDest, destRect, null); // 使用位图运算模式,这里使用DST_IN,也就是取Dst的交集 paint.setXfermode(xfermode); // 绘制圆(src)位图 canvas.drawBitmap(bmpCircle, 0, 0, paint); // 清除位图运算模式 paint.setXfermode(null); // 图层退栈,还原到canvas上 canvas.restoreToCount(layer); // 绘制border if (border != 0) { paint.setStyle(Paint.Style.STROKE); paint.setColor(borderColor); paint.setStrokeWidth(border); // 设置边框的范围,需要减去边框的一半大小,inset传入正数是向内缩小 destRect.inset(border / 2, border / 2); canvas.drawOval(destRect.left, destRect.top, destRect.right, destRect.bottom, paint); } } }
由于图片的大小比控件大小要小,如果在布局中使用填充控件的模式
那么效果图如下:
而使用包裹模式.只需要修改app:fillMode="wrap"即可,那么效果图如下:
上面效果图的黑色背景是在布局中设置的background,主要是用来区分范围看的.
我们将验证码组件命名为 CodeView,默认情况下,随机生成 4 个数字和 50 条干扰线,如果用户测试次数过多,可以动态加大验证码的难度,比如增加验证码的个数、增加干扰线条数、改 变验证码颜色等等。提供的主要功能有:
- 刷新验证码改变验证码个数改变干扰线条数改变验证码字体大小改变验证码字体颜色获取当前验证码
本组件的属性主要包括验证码个数、干扰线条数、字体大小和字体颜色,在 attrs.xml 文件中定义如下属性,其中font_size表示字体大小,类型为dimension,到时将使用sp作为字体单位
代码如下:
public class CodeView extends View { private int count;//验证码的数字个数 private int lineCount; //干扰线的条数 private int fontSize; //字体大小 private int color;//字体颜色 private String codeStr;//验证码 private Random rnd; private Paint paint; private Rect destRect; // 绘制区域 private static final int DEFAULT_COUNT = 4; private static final int DEFAULT_LINE_COUNT = 50; private static final int DEFAULT_FONT_SIZE = 22;//sp private static final int DEFAULT_COLOR = Color.BLACK; private static final int DEFAULT_H_PADDING = 20; private static final int DEFAULT_V_PADDING = 10; public CodeView(Context context) { this(context, null); } public CodeView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public CodeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.CodeView); this.count = array.getInt(R.styleable.CodeView_count, DEFAULT_COUNT); this.lineCount = array.getInt(R.styleable.CodeView_line_count, DEFAULT_LINE_COUNT); this.fontSize = array.getDimensionPixelSize(R.styleable.CodeView_fontSize, (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, DEFAULT_FONT_SIZE, getResources().getDisplayMetrics())); this.color = array.getColor(R.styleable.CodeView_codeColor, DEFAULT_COLOR); array.recycle(); rnd = new Random(); paint = new Paint(Paint.ANTI_ALIAS_FLAG); paint.setColor(color); paint.setTextSize(fontSize); // 设置字间距为半个字的大小 paint.setLetterSpacing(0.5f); this.codeStr = createCode(); } // 随机生成0-9数字的验证码 private String createCode() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < count; i++) { sb.append(rnd.nextInt(10)); } return sb.toString(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); Rect textBound = getTextRect(); if (widthMode != MeasureSpec.EXACTLY) { widthSize = getPaddingLeft() + textBound.width() + getPaddingRight(); } if (heightMode != MeasureSpec.EXACTLY) { heightSize = getPaddingTop() + textBound.height() + getPaddingBottom(); } // 设置验证码的大小 setMeasuredDimension(widthSize, heightSize); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { // 宽高变化后记录新的绘制区域 destRect = new Rect(0, 0, w, h); } private Rect getTextRect() { Rect rect = new Rect(); paint.setTextSize(fontSize); paint.setColor(color); paint.getTextBounds(codeStr, 0, codeStr.length(), rect); return rect; } @Override protected void onDraw(Canvas canvas) { // 绘制矩形外框 canvas.save(); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(2); // 根据绘制区域绘制矩形外框 canvas.drawRect(destRect, paint); canvas.restore(); // 绘制干扰线 canvas.save(); // 缩小干扰线的绘制范围 int width = destRect.width() - 5; int height = destRect.height() - 5; paint.setStyle(Paint.Style.FILL); for (int i = 0; i < lineCount; i++) { int x1 = rnd.nextInt(width); int y1 = rnd.nextInt(height); int x2 = rnd.nextInt(width); int y2 = rnd.nextInt(height); // 随机颜色 int color = Color.rgb(rnd.nextInt(255), rnd.nextInt(255), rnd.nextInt(255)); paint.setColor(color); canvas.drawLine(x1, y1, x2, y2, paint); } canvas.restore(); // 绘制验证码 canvas.save(); paint.setColor(color); paint.setTextSize(fontSize); Paint.FontMetrics fontMetrics = paint.getFontMetrics(); // 获取验证码的文案范围 Rect textRect = getTextRect(); // 计算偏移量,让文案居中显示 int x = (int) ((getWidth() - textRect.width()) / 2f); int y = (int) (getHeight() / 2f + (fontMetrics.descent - fontMetrics.ascent) / 2f - fontMetrics.descent); int length = codeStr.length(); // 每个字符的宽度 int charWidth = textRect.width() / length; for (int i = 0; i < length; i++) { // 每个字符的起始位置 int startX = x + i * charWidth; canvas.drawText(String.valueOf(codeStr.charAt(i)), startX, y, paint); // 随机旋转画布 int degree = rnd.nextInt(6) * (i % 2 == 0 ? -1 : 1); // 随机旋转字体的角度,这里旋转画布即可达到效果 canvas.rotate(degree, textRect.centerX(), textRect.centerY()); } canvas.restore(); } public void setCount(int count) { this.count = count; this.codeStr = createCode(); requestLayout();//重新调整布局大小 } public String getCodeStr() { return codeStr; } public void setLineCount(int lineCount) { this.lineCount = lineCount; invalidate();//重绘 } public void setColor(int color) { this.color = color; invalidate();//重绘 } public void refresh() { this.codeStr = createCode(); invalidate(); } public void setFontSize(int sp) { this.fontSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, getResources().getDisplayMetrics()); requestLayout();//重新调整布局大小 } //=====================重写获取padding的方法,如果布局没有设置,那么返回默认值,避免文案紧贴边框================================== @Override public int getPaddingLeft() { return super.getPaddingLeft() == 0 ? DEFAULT_H_PADDING : super.getPaddingLeft(); } @Override public int getPaddingRight() { return super.getPaddingLeft() == 0 ? DEFAULT_H_PADDING : super.getPaddingLeft(); } @Override public int getPaddingTop() { return super.getPaddingLeft() == 0 ? DEFAULT_V_PADDING : super.getPaddingLeft(); } @Override public int getPaddingBottom() { return super.getPaddingLeft() == 0 ? DEFAULT_V_PADDING : super.getPaddingLeft(); } }
Activity布局如下:
Activity代码如下:
public class MainActivity extends AppCompatActivity { private CodeView mCodeView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mCodeView = findViewById(R.id.codeView); } Random random = new Random(); // 刷新验证码 public void refreshCode(View view) { mCodeView.refresh(); } // 改变验证码颜色 public void changeColor(View view) { int color = Color.rgb(random.nextInt(255), random.nextInt(255), random.nextInt(255)); mCodeView.setColor(color); } // 改变验证字体大小 public void changeFontSize(View view) { mCodeView.setFontSize(20 + random.nextInt(10)); } // 验证个数随机 public void randCount(View view) { mCodeView.setCount(1 + random.nextInt(6)); } // 显示验证码 public void showCode(View view) { Toast.makeText(this, mCodeView.getCodeStr(), Toast.LENGTH_SHORT).show(); } // 干扰线数量随机 public void randLineCount(View view) { mCodeView.setLineCount(50 + random.nextInt(100)); } }
效果如下:
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)