自定义 View 总结
自定义 View 是一个综合的技术体系,涉及到 View 的层次结构,事件分发机制和 View 的工作原理等技术细节。
一、自定义 View 的分类
大致可以分为如下四种:
1、 继承 View 重写 onDraw 方法
这种情况主要是实现一些复杂的效果,这些效果不方便通过布局的组合来实现,往往需要静态或者动态的显示一些不规则的图形。要实现这种效果就要自己继承 View 并重写onDraw 方法,这种方式往往要自己支持 wrap_content,并且 padding 等需要自己处理。
2、继承 ViewGroup 派生特殊的 Layout
这种方法主要是用于实现自定义布局,也就是除了 LinearLayout、RealativeLayout 和 FrameLayout 等这些系统布局之外,我们需要自己定义一种新的布局,这个布局看起来像几种 View 的组合。这种方法稍微复杂一点,需要合适的处理 ViewGroup 的测量、布局这两个过程,并要同时处理子元素的测量和布局过程。
3、继承特定的 View(比如 TextView)
这种方式比较常见,主要是扩展某个已知 View 的功能,比如 TextView。这种方法比较容易实现,主要是要自己支持 wrap_content 和 padding。
4、继承特定的 ViewGroup (比如 LineayLayout)
这种方法也比较常见,看起来就像是几种 View 组合在一起,这种方法不需要自己去处理 ViewGroup 的测量和布局这两个过程。
二、自定义 View 的注意事项
1、自定义 View 需要支持 wrap_content
自定义 View 如果继承 View 或者 ViewGroup ,如果不在 onMeansure 中对 wrap_content 做特殊处理,那么在布局中使用 wrap_content 时,无法达到预期的效果。
2、如果有必要,自定义 View 需要支持 padding
如果直接继承 View 控件,如果不在 draw 方法中处理 padding,那么padding 属性将无法起作用。另外如果直接继承 ViewGroup,那么要考虑 onMeasure 和 onLayout 中的 padding 和子元素的 margin 对这个 View 的影响,不然将导致 padding 和子元素的 margin 失效。
3、尽量不要在自定义 View 中使用 Handler
因为 View 内部本身提供了 post 方法可以代替 Handler,除非是要明确必须用 Handler 发送消息。
4、View 中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow
当有线程或者动画需要停止时,那么 onDetachedFromWindow 是一个好时机,包含此 View 的 Activity 退出或者当前 View 被 remove 时,View 的 onDetachedFromWindow 会被调用,和此方法对用的是 onAttachedToWindow,当包含此 View 的 Activiyt 启动时,这个方法会被调用。需要注意的是,我们要及时处理线程和动画,否则会造成内存泄漏。
5、View 需要嵌套滑动时,需要处理好滑动冲突。
滑动冲突主要是发生在嵌套 View 中,而不止一个 View 需要对滑动做处理,此时要考虑滑动冲突的解决。
三、 自定义 View 示例
1、继承 View 重写 onDraw 方法
本实例实现一个自定义绘制圆形,比较简单,但是需要考虑 wrap_content 和 padding,同时对外提供自定义属性。
画一个简单的圆:
public class CircleView extends View {
private int mColor = Color.RED;
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public CircleView(Context context) {
super(context);
init();
}
public CircleView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int mColor) {
super(context, attrs, defStyleAttr);
init();
}
private void init(){
mPaint.setColor(mColor);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width,height)/2;
canvas.drawCircle(width/2,height/2,radius,mPaint);
}
}
效果如下:
circle.png
上面实现的效果只是初级的实现,并不是一个规范的自定义 View 。下面考虑设置布局参数。
布局如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#ffffff"
tools:context="com.hcworld.customview.MainActivity">
<com.hcworld.customview.CircleView
android:id="@+id/circleView1"
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="#000000"
/>
</LinearLayout>
再次运行,效果如下:
circle2.png
再次调整布局参数,设置 20dp 的 margin,布局如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#ffffff"
tools:context="com.hcworld.customview.MainActivity">
<com.hcworld.customview.CircleView
android:id="@+id/circleView1"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_margin="20dp"
android:background="#000000"
/>
</LinearLayout>
效果如下:
circle3.png
如上效果,说明 margin 属性是生效的,这是因为 margin 属性是由父容器控制的,因此不需要在自定义 View 中做特殊处理,下面考虑设置 20dp 的padding。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#ffffff"
tools:context="com.hcworld.customview.MainActivity">
<com.hcworld.customview.CircleView
android:id="@+id/circleView1"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_margin="20dp"
android:padding="20dp"
android:background="#000000"
/>
</LinearLayout>
再次运行,效果如下:
circle4.png
可以看到,padding 属性没有生效,如上面说的注意事项,如果自定义 View 继承自 View 或者 ViewGroup,padding 属性是无法生效的,需要自己处理。再次调整布局参数,修改宽度为 wrap_content,布局如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#ffffff"
tools:context="com.hcworld.customview.MainActivity">
<com.hcworld.customview.CircleView
android:id="@+id/circleView1"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_margin="20dp"
android:padding="20dp"
android:background="#000000"
/>
</LinearLayout>
效果如下:
circle4.png
也就是,wrap_content 也是没有生效的,其实这里的 wrap_content 和 match_parent 的效果是一样的。也就是,对于自定义 View,如果不对 wrap_content 做特殊处理,那么说使用 wrap_content 的效果和 match_parnet 是一样的。
为了解决上面出现的问题,使 padding 和 wrap_content 生效,我们需要做如下处理:
对于 wrap_content,我们需要重写onMeasure,为 wrap_content 指定一个默认值,这里选择 200px。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 获取宽-测量规则的模式和大小
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
// 获取高-测量规则的模式和大小
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 设置wrap_content的默认宽 / 高值
// 默认宽/高的设定并无固定依据,根据需要灵活设置
int mWidth = 200;
int mHeight = 200;
// 当布局参数设置为wrap_content时,设置默认值
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT && getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(mWidth, mHeight);
// 宽 / 高任意一个布局参数为= wrap_content时,都设置默认值
} else if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(mWidth, heightSize);
} else if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(widthSize, mHeight);
}
}
对于 padding,我们需要在 onDraw 中做处理:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//使 padding 生效
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddintBottom = getPaddingBottom();
int width = getWidth() - paddingLeft - paddingRight;
int height = getHeight() - paddingTop - paddintBottom;
int radius = Math.min(width,height)/2;
canvas.drawCircle(paddingLeft+width/2,paddingTop+ height/2,radius,mPaint);
}
布局如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#ffffff"
tools:context="com.hcworld.customview.MainActivity">
<com.hcworld.customview.CircleView
android:id="@+id/circleView1"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_margin="20dp"
android:padding="20dp"
android:background="#000000"
/>
</LinearLayout>
效果如下:
circle5.png
也就是 wrap_content 和 padding 都生效了。
最后,我们还要为我们的自定义 View 添加自定义属性。像 android:layout_width 和 android:padding 等,这些 android 开头的是系统自带属性,而要实现自定义属性,一般步骤如下:
1、创建自定义属性 XML 文件,如 attrs.xml
内容如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircleView">
<attr name="circle_color" format="color"/>
</declare-styleable>
</resources>
上面只是声明了一个自定义属性集合 "CircleView",在这个集合里,只有一个属性 circle_color,其类型为 color 代表 颜色。基本类型还有:
refercece:资源 id
dimension: 尺寸大小
还有就是一下基本类型如 string,boolea,float 等。可以根据需要自己实现,如:
<atrr name = "circle_radius" format = "float"/>
2、在自定义 View 的构造方法中,解析自定义属性的值,这里示例只有一个值,其他情况类似
public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 获取用到的这个属性组
TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CircleView);
// 获取布局中设置的值,默认值为 Red
mColor = a.getColor(R.styleable.CircleView_circle_color,Color.RED);
// 用完 recycle
a.recycle();
init();
}
3、在布局文件中使用属性
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#ffffff"
tools:context="com.hcworld.customview.MainActivity">
<com.hcworld.customview.CircleView
android:id="@+id/circleView1"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_margin="20dp"
android:padding="20dp"
android:background="#000000"
app:circle_color="@color/colorPrimary"
/>
</LinearLayout>
在使用自定义属性的时候,需要注意,这里必须在布局文件中添加 schemas 声明:
xmlns:app="http://schemas.android.com/apk/res-auto"
也就是,只有声明了这个 schemas,才能找到我们自定义的属性字段。
至此,自定义属性步骤就完成了,运行程序,效果如下:
circle6.png
完整的自定义 View 代码:
public class CircleView extends View {
private int mColor = Color.RED;
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public CircleView(Context context) {
super(context);
init();
}
public CircleView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs,0);
init();
}
public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 获取用到的这个属性组
TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CircleView);
// 获取布局中设置的值,默认值为 Red
mColor = a.getColor(R.styleable.CircleView_circle_color,Color.RED);
// 用完 recycle
a.recycle();
init();
}
private void init(){
mPaint.setColor(mColor);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 获取宽-测量规则的模式和大小
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
// 获取高-测量规则的模式和大小
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 设置wrap_content的默认宽 / 高值
// 默认宽/高的设定并无固定依据,根据需要灵活设置
int mWidth = 200;
int mHeight = 200;
// 当布局参数设置为wrap_content时,设置默认值
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT && getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(mWidth, mHeight);
// 宽 / 高任意一个布局参数为= wrap_content时,都设置默认值
} else if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(mWidth, heightSize);
} else if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(widthSize, mHeight);
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//使 padding 生效
final int paddingLeft = getPaddingLeft();
final int paddingRight = getPaddingRight();
final int paddingTop = getPaddingTop();
final int paddintBottom = getPaddingBottom();
int width = getWidth() - paddingLeft - paddingRight;
int height = getHeight() - paddingTop - paddintBottom;
int radius = Math.min(width,height)/2;
canvas.drawCircle(paddingLeft+width/2,paddingTop+ height/2,radius,mPaint);
}
}
2、继承 ViewGroup 派生特殊的 Layout
这种方法主要实现自定义的布局,采用这种方式稍微复杂一点,需要合适的处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的测量和布局过程。在 Android View 事件体系 的滑动冲突的实例介绍中,自定义了一个 ViewGroup 控件 HorizontalScrollViewEx ,其代码如下:
public class HorizontalScrollViewEx extends ViewGroup {
public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measureWidth = 0;
int measuredHeight = 0;
final int childCount = getChildCount();
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
if (childCount == 0) {
setMeasuredDimension(getLayoutParams().width, getLayoutParams().height);
} else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
View childView = getChildAt(0);
measureWidth = childView.getMeasuredWidth() * childCount;
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(measureWidth, measuredHeight);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
View childView = getChildAt(0);
measureWidth = widthSpaceSize;
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(measureWidth, measuredHeight);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
View childView = getChildAt(0);
measureWidth = childView.getMeasuredWidth() * childCount;
measuredHeight = heightSpaceSize;
setMeasuredDimension(measureWidth, measuredHeight);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childLeft = 0;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {
int childWidth = childView.getMeasuredWidth();
childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
childLeft += childWidth;
}
}
}
}
现在主要分析一下其 measure 和 layout 的过程。首先我们需要知道的是,对于继承 ViewGroup 来实现自定义 View 是很复杂的,这里只是未来演示而只有 onMeasure 和 onLayout 两个方法。在这个示例中,HorizontalScrollViewEx 要实现的是一个类似 ViewPager 的控件,其内部元素可以水平滑动,并且子元素可以垂直滑动,这里不介绍滑动冲突如何解决,现在考虑自定义控件的实现。
首先假设所有子元素的宽高都是一样的,代码如上所示。现在分析一下上面代码的逻辑。在 onMeasure 方法中,首先会判断是否有子元素,如果没有子元素就直接把自己的宽高设置为0,然后就是判断宽高是不是采用了 wrap_content,如果宽采用了 wrap_content,那么 HorizontalScrollViewEx 的宽度是所有子元素宽度之和,如果高度采用了 wrap_content,那么 HorizontalScrollViewEx 的高度是第一个子元素的高度。
上面代码实现的自定义 View 有两个不规范的地方,首先没有子元素的时候不应该直接把宽高设置为 0,而是应该根据 LayoutParams 中的宽高来做相应的处理。第二个就是在测量 HorizontalScrollViewEx 的宽高时没有考虑它的 padding 和子元素的 margin,但是它的 padding 和 子元素的 margin 还有影响到它的宽高。
接着分析一下 onLayout 方法,这个方法主要是完成子元素的定位。首先,会遍历所有子元素,如果子元素不是处于 GONE 这个状态,那么通过 layotu 这个方法将其放置到合适的位置,由代码可知,放置的顺序,由左向右。这个方法同样不规范的地方在于没有考虑 margin 和 padding ,这也是不对的,在自定义 View 中需要根据实际情况考虑这两个因素。
3、继承 特定的 View 和 继承特定的 ViewGroup
继承特定的 View 和继承特定的 ViewGroup (如 LinearLayout)这两个方式比较简单,就不在举例说明,在 Android View 事件体系 的滑动冲突的实例介绍中,有一个 StickyLayout 布局就是这个类型的,这里不在介绍。
4、总结
自定义 View 是一个综合的技术体系,这里可能设计到 View 的弹性滑动,滑动冲突以及绘制原理等,但是这些都是最基础的知识,只有把这些基础掌握了,才能根据需求设计出高水平的自定义 View。