之前我们从源码的角度对View的工作流程进行了分析,有了这些理论的支撑,我们才能让自定义View更好的服务于我们的工作,接下来我们聊聊自定义View中的那些“套路”。如果还不了解View的工作流程,可以先阅读这篇文章:Android 浅谈自定义View(1)。
根据自定义View的使用场景和自定义View的继承关系,我们可以将自定义View分四类:
- 1、继承系统的View类
- 2、继承特定的View类(例如TextView、ProgressBar等)
- 3、继承系统的ViewGroup类
- 4、继承特定的ViewGroup类(例如LinearLayout、RelativeLayout等)
四种类型的自定义View有什么不同、各自的特点是什么呢,以及如何选择选择一种合适的方式来实现自定义View,这些应该是我们关心的点。接下来,我们结合具体的场景逐一的分析下四种类型的自定义View。
一、继承系统的View类
这种类型的自定义View多用来实现一些不规则的效果,同时不需要包含子View,而且我们无法通过扩展已有的控件来实现,因为是直接继承系统的View类,所以我们应在onMeasure()方法中对View的尺寸进行重新的测量来支持wrap_content属性,否则View使用wrap_content属性将和使用match_parent属性是一个效果,当然这并不是我们愿意看到的,原因在上一篇文章中已经分析过了,同时这种情况下,如果View使用了padding属性,我们依然无法看到效果,所以需要在onDraw()方法中对padding属性进行支持,考虑到了这些因素,我们的自定义View才能更加的健壮。一般情况下,这种类型的自定义View需要在onDraw()方法中通过canvas绘制的方式来实现具体的效果。
来看一个例子,我们在简单的在onDraw()方法中设置View背景为灰色,并绘制了一个圆:
public class CircleView extends View {
private Paint mPaint;
public CircleView(Context context) {
this(context, null);
}
public CircleView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.FILL);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width / 2, height / 2);
canvas.drawColor(Color.GRAY);//设置灰色背景
canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, mPaint);//绘制圆形
}
}
在布局文件中这样使用:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.viewdemo.CircleView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="20dp" />
</RelativeLayout>
看下最终的效果:
和我们分析的一样,由于没有支持wrap_content和padding属性,我们的自定义View和match_parent的效果一样,而且设置的padding属性无效。接下来继续完善:
public class CircleView extends View {
.......省略若干代码........
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(500, 500);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(500, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, 500);
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min((width - getPaddingLeft() - getPaddingRight()) / 2,
(height - getPaddingTop() - getPaddingBottom()) / 2);
canvas.drawColor(Color.GRAY);
canvas.drawCircle(width / 2, height / 2, radius, mPaint);
}
}
在onMesure()方法中,如果宽/高的测量模式为MeasureSpec.AT_MOST,我们通过setMeasuredDimension()重新测量View的尺寸,这样就解决了使用wrap_content属相带来的问题,同时在onDraw()方法中计算半径时考虑padding属性。再看下最终的效果:
此时View的宽/高为500px,同时padding属性也生效了。其它情况大家可以自行测试哦。
二、继承特定的View类
这种类型的自定义View相对第一种要简单一些,因为我们直接继承特定的View类,例如TextView、ImageView等,这些系统已经对这些View类进行了很好的实现,所以一般情况下我们不需要对wrap_content、padding属相进行特别的支持。如果我们要实现的自定义View和系统已有的某个View类似,可以考虑这种方式,我们只需要对其进行扩展即可。和第一种类型类似,这种自定义View一般也需要在onDraw()方法中通过canvas绘制的方式来实现具体的效果。例如我们要实现一个圆角的TextView就可以采用这种方式:
public class RoundTextView extends TextView {
private Paint mPaint;
public RoundTextView(Context context) {
super(context);
init();
}
public RoundTextView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init(){
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.FILL);
}
//重写setBackgroundColor()来设置画笔颜色
@Override
public void setBackgroundColor(int color) {
mPaint.setColor(color);
}
@Override
protected void onDraw(Canvas canvas) {
RectF rect = new RectF(0, 0, getWidth(), getHeight());
canvas.drawRoundRect(rect, 10, 10, mPaint);//绘制圆角矩形作为TextView背景
super.onDraw(canvas);
}
}
在布局文件中的使用方法和系统的TextView一样,有一点需要注意,如果要设置背景色,则要通过java代码:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
RoundTextView roundTextView = (RoundTextView) findViewById(R.id.round_tv);
roundTextView.setBackgroundColor(Color.RED);//设置背景为红色
}
}
最后看一下效果:
简单的扩展就实现了圆角的效果,不需要额外的drawable背景或者图片。
三、继承系统的ViewGroup类
我们知道系统已经提供了LinearLayout、RelativeLayout这样的ViewGroup实现类,但毕竟这些布局控件的都有其特定的使用场景,如果我们需要若干个View按照某种规则组合在一起,而系统的布局控件无法实现类似的场景,我们可以考虑采用这种方式来定义一种新的布局控件。但需要注意的是,在内容区域未超过屏幕尺寸的情况下,我们一般需要在onMeasure()中重新测量ViewGroup尺寸来对wrap_content属性进行支持,如果内容区域的大小超过屏幕尺寸,我们就必须在onMeasure()中重新测量ViewGroup的尺寸,否则ViewGroup的最大尺寸为屏幕尺寸,导致ViewGroup中的内容显示不全。同时根据需要还可以考虑自身的padding属性以及子View的margin属性,这些都会影响我们自定义View最终的测量结果,通常需要在onLayout()方法中确定子View的具体位置。解析来看一个具体的例子:
public class TestViewGroup extends ViewGroup {
//使ViewGroup支持margin属性
@Override
public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int width = 0;
int height = 0;
for (int i = 0; i < getChildCount(); i++) {
View childView = getChildAt(i);
MarginLayoutParams params = (MarginLayoutParams) childView.getLayoutParams();
width += childView.getMeasuredWidth() + params.rightMargin + params.leftMargin;
if (i == 0) {
height += childView.getMeasuredHeight() + params.topMargin + params.bottomMargin;
}
}
if (width > getScreenWidth()) {
setMeasuredDimension(width, height);
} else {
setMeasuredDimension((widthSpecMode == MeasureSpec.AT_MOST) ? width : widthSpecSize,
(heightSpecMode == MeasureSpec.AT_MOST) ? height : heightSpecSize);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int left = 0;
View lastChildView = null;
for (int i = 0; i < getChildCount(); i++) {
View childView = getChildAt(i);
MarginLayoutParams params = (MarginLayoutParams) childView.getLayoutParams();
left += params.leftMargin;
if (lastChildView != null) {
left += lastChildView.getMeasuredWidth() + ((MarginLayoutParams) lastChildView.getLayoutParams()).rightMargin;
}
int right = left + childView.getMeasuredWidth();
int top = params.topMargin;
int bottom = childView.getMeasuredHeight() + top;
childView.layout(left, top, right, bottom);
lastChildView = childView;
}
}
省略了一些非核心代码,首先通过重写generateLayoutParams()方法使ViewGroup支持margin属性,在onMeasure()中,如果计算出子View的总宽度大于屏幕宽度,则根据子View尺寸直接重新测量ViewGroup尺寸,否则使用系统默认的测量值,只在ViewGroup布局参数为wrap_content时使用子View的计算尺寸重新测量ViewGroup尺寸。由于我们实现了一个类似水平滚动的ViewGroup,所以在onLayout()中按照水平从左到右的方式确定View的位置。同时我们考虑了margin属性,所以子View可以使用margin属性。看一下效果:
四、继承特定的ViewGroup类
如果我们的自定View是若干个View组合在一起的效果,同时在系统已有的布局控件中可以找到类似的效果,则可以考虑继承特定的ViewGroup类,例如LinearLayout、RelativeLayout等,比如我们在界面中通常需要顶部title,就可以考虑直接继承LinearLayout来进行封装,来方便复用。当然通过直接继承ViewGroup类也可以实现,但是难度会增加很多,得不偿失。举个例子吧,当LinearLayout为垂直方向,且其中的内容超过屏幕的显示范围,则因为LinearLayout的内容区域无法滚动,我们无法预览整个LinearLayout内容,有一只解决办法是通过和ScrollView嵌套。那能不能扩展LinearLayout来实现呢,继续往下看:
public class ScrollLinearLayout extends LinearLayout {
private int mLastY;
private Context mContext;
//计算ScrollLinearLayout在屏幕的最大显示高度
private int showHeight;
public ScrollLinearLayout(Context context) {
this(context, null);
}
public ScrollLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
setClickable(true);//使onTouchEvent()方法可以消费事件
showHeight = getScreenHeight() - getStatusBarHeight() - getActionBarHeight();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
//计算ScrollLinearLayout子View高度
int height = 0;
for (int i = 0 ; i < getChildCount(); i++){
height += getChildAt(i).getMeasuredHeight();
}
if (height > showHeight){
setMeasuredDimension(widthMeasureSpec, height);
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
if (Math.abs(y - mLastY) > mTouchSlop) {
intercepted = true;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastY = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
if (getHeight() < showHeight){
return true;
}
int scrollY = getScrollY();
int dy = mLastY - y;
if (scrollY + dy <= 0) {
scrollTo(0, 0);
return true;
} else if (scrollY + dy >= getHeight() - showHeight) {
scrollTo(0, getHeight() - showHeight);
return true;
}
scrollBy(0, dy);
break;
case MotionEvent.ACTION_UP:
break;
}
mLastY = y;
return super.onTouchEvent(event);
}
...........省略若干行代码...........
}
核心代码很简单,在onMeasure()方法中计算ScrollLinearLayout 的高度,如果子View高度总和大于其在屏幕的最大显示高度,则重新测量其尺寸。在onTouchEvent()中使ScrollLinearLayout的内容跟随手指移动,同时进行边界检测,防止超出屏幕范围。最后看下效果:
到这里常见的自定义View类型就介绍完毕了,难免有疏忽的地方,还请指正,自定义View大致流程上有一定的规律可循,但更多的方法经验还需要在实践中总结。
有兴趣的话,可以下载源码看看:点我下载哦...