PS:本篇文章大多数翻译自github上一篇英文文章!
总所周知,安卓UI是基于View(屏幕上的单一节点)和ViewGroup(屏幕上节点的集合),在android中有很多widgets和layouts可以用于创建UI界面,比如最常见的View有Button,TextView等等,而最常见的布局也有RelativeLayout,LinearLayout等。
在一些应用中我们不得不自定义View去满足我们的需求,自定义View可以继承一个View或者已存在的子类去创建我们自己的自定义View,甚至可以用SurfaceView去做更复杂的绘图。
创建一个自定义View的一般步骤是继承View或者其子类,重写一些方法比如onDraw,onMeasure,onLayout,onTouchEvent,然后再activity中使用我们的自定义View。
我们主要是通过以下五个方面创建一个自定义View
1,绘图,通过重写onDraw方法控制View在屏幕上的渲染效果
2,交互,通过重写onTouchEvent方法或者使用手势来控制用户的交互
3,测量,通过重写onMeasure方法来对控件进行测量
4,属性,可以通过xml自定义控件的属性,然后通过TypedArray来进行使用
5,状态的保存,为了避免配置改变时丢失View状态,通过重写onSaveInstanceState,onRestoreInstanceState方法来保存和恢复状态
可能这样说比较笼统,我们通过一个例子来进一步了解,假设我们需要一个View允许用户选择不同的形状,而这个控件只会显示一些简单的形状,比如正方形,圆形,三角形,通过点击图形能够在不同形状之间切换。先看下效果图,不断点击进行切换。
一、定义自定义View的类。
为了创建点击可切换的形状的自定义View,我们继承View,编写构造方法。实现三个构造方法,最终调用三个参数的构造方法。
public class CustomView extends View { public CustomView(Context context) { this(context, null); } public CustomView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CustomView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); }}
二、把自定义View加入到Layout中。
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" > <cn.edu.zafu.view.CustomView android:id="@+id/customview" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" /></RelativeLayout>
三、定义自定义属性。
一个良好的自定义控件应该是能通过xml进行控制的,所以我们需要考虑一下我们的自定义View的哪些属性需要被提取到xml中,比如,我们应该可以让用户选择图形的颜色,是否显示图形的名称等。我们可以通过下面的代码在xml中进行配置
<cn.edu.zafu.view.CustomView xmlns:app="http://schemas.android.com/apk/res/cn.edu.zafu.view" app:displayShapeName="true" app:shapeColor="#7f0000" />
为了能够使用图形的颜色和图形显示的名字的属性,我们应该新建res/values/attrs.xml文件,在里面定义这些属性
<?xml version="1.0" encoding="utf-8"?><resources> <declare-styleable name="CustomView"> <attr name="shapeColor" format="color" /> <attr name="displayShapeName" format="boolean" /> </declare-styleable></resources>
注意上述代码,我们为每一个attr节点都写了name属性和format属性,format是属性的数据结构,合法的值包括string, color, dimension, boolean, integer, float, enum等
一旦我们定义了自定义属性,我们就可以在xml文件里进行使用,唯一的区别就是我们自定义属性的命名空间是不同的,我们需要在布局的根节点上或者自定义View上定义命名空间,然后才能使用自定义属性。这里我直接在View上定义命名空间,完全可以把命名空间提取到根布局上。
四、应用自定义属性。
现在我们已经通过xml设定了自定义属性shapeColor和displayShapeName,我们需要在构造方法中提取到这些属性。为了提取属性,我们使用TypedArray类和obtainStyledAttributes方法。
public class CustomView extends View { private int shapeColor; private boolean displayShapeName; public CustomView(Context context) { this(context, null); } public CustomView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CustomView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); setupAttributes(attrs); } private void setupAttributes(AttributeSet attrs) { // 提取自定义属性到TypedArray对象中 TypedArray a = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.CustomView, 0, 0); // 将属性赋值给成员变量 try { shapeColor = a.getColor(R.styleable.CustomView_shapeColor, Color.BLACK); displayShapeName = a.getBoolean( R.styleable.CustomView_displayShapeName, false); } finally { // TypedArray对象是共享的必须被重复利用。 a.recycle(); } }}
五、增加属性的getter和setter方法
public boolean isDisplayingShapeName() { return displayShapeName; } public void setDisplayingShapeName(boolean state) { this.displayShapeName = state; invalidate();//重绘 requestLayout(); } public int getShapeColor() { return shapeColor; } public void setShapeColor(int color) { this.shapeColor = color; invalidate(); requestLayout(); }
注意以上代码,当View的属性发生改变时我们需要进行重绘和重新布局,为了保证正常进行,请确保调用了invalidate和requestLayout方法。
六、绘制图形
接下来,让我们开始真正使用自定义属性(颜色,是否显示图形名)进行图形的绘制。所有的View的绘制发生在onDraw方法里,我们使用其参数Canvas将图形绘制到View上,现在我们绘制一个正方形。
public class CustomView extends View { private int shapeWidth = 100; private int shapeHeight = 100; private int textXOffset = 0; private int textYOffset = 30; private Paint paintShape; private int currentShapeIndex = 0; public CustomView(Context context) { this(context, null); } public CustomView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public CustomView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); setupAttributes(attrs); setupPaint(); } private void setupPaint() { paintShape = new Paint(); paintShape.setStyle(Style.FILL); paintShape.setColor(shapeColor); paintShape.setTextSize(30); }}
以上代码会绘制我们定义的颜色的图形,如果显示图形名,其图形名也会被显示,效果图就跟上面的gif图片里的正方形一样。
七、计算尺寸
为了按照用户定义的宽度高度进行绘制,我们需要重写onMeasure方法进行View的测量,该方法决定了View的宽度和高度。我们定义的View的宽度和高度由我们的形状和形状名字共同决定。我们先看下onMeasure的代码。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 简单定义文本边距 int textPadding = 10; int contentWidth = shapeWidth; // 使用测量模式获得宽度 int minw = contentWidth + getPaddingLeft() + getPaddingRight(); int w = resolveSizeAndState(minw, widthMeasureSpec, 0); // 同宽度 int minh = shapeHeight + getPaddingBottom() + getPaddingTop(); //如果现实图形名,则加上文字高度 if (displayShapeName) { minh += textYOffset + textPadding; } int h = resolveSizeAndState(minh, heightMeasureSpec, 0); // 测量完成后必须调用setMeasuredDimension方法 // 之后可以通过getMeasuredWidth 和 getMeasuredHeight 方法取出高度和宽度 setMeasuredDimension(w, h); }
注意以上计算要将View的内边距计算进去然后再计算整个宽度高度,并且最后必须调用setMeasuredDimension方法设置宽度和高度,resolveSizeAndState() 方法将返回一个合适的尺寸,只要将测量模式和我们计算的宽度高度传进去即可。该方法在API11开始出现,低于该版本将无法使用该方法,这里我抽取android的源码供参考。
/** * Utility to reconcile a desired size and state, with constraints imposed * by a MeasureSpec. Will take the desired size, unless a different size * is imposed by the constraints. The returned value is a compound integer, * with the resolved size in the [email protected] #MEASURED_SIZE_MASK} bits and * optionally the bit [email protected] #MEASURED_STATE_TOO_SMALL} set if the resulting * size is smaller than the size the view wants to be. * * @param size How big the view wants to be * @param measureSpec Constraints imposed by the parent * @return Size information bit mask as defined by * [email protected] #MEASURED_SIZE_MASK} and [email protected] #MEASURED_STATE_TOO_SMALL}. */ public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) { 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: if (specSize < size) { result = specSize | MEASURED_STATE_TOO_SMALL; } else { result = size; } break; case MeasureSpec.EXACTLY: result = specSize; break; } return result | (childMeasuredState&MEASURED_STATE_MASK); }
该方法里设计到了两处位运算,暂时还没搞懂这两处位运算有什么作用,如果有清除的还请帮忙解释下作用。
八、在不同图形之间进行切换
现在我们已经绘制了正方形,但是我们想让view在我们点击它的时候切换图形,现在我们给它加入事件,我们重写onTouchEvent方法即可
private String[] shapeValues = { "square", "circle", "triangle" }; private int currentShapeIndex = 0; @Override public boolean onTouchEvent(MotionEvent event) { boolean result = super.onTouchEvent(event); if (event.getAction() == MotionEvent.ACTION_DOWN) { currentShapeIndex ++; if (currentShapeIndex > (shapeValues.length - 1)) { currentShapeIndex = 0; } postInvalidate(); return true; } return result; }
现在无论什么时候点击view,都会选中对应的形状,当postInvalidate 方法被调用后就会进行重绘,现在我们更新onDraw代码,绘制不同的图形。
protected void onDraw(Canvas canvas) { super.onDraw(canvas); String shapeSelected = shapeValues[currentShapeIndex]; if (shapeSelected.equals("square")) { canvas.drawRect(0, 0, shapeWidth, shapeHeight, paintShape); textXOffset = 0; } else if (shapeSelected.equals("circle")) { canvas.drawCircle(shapeWidth / 2, shapeHeight / 2, shapeWidth / 2, paintShape); textXOffset = 12; } else if (shapeSelected.equals("triangle")) { canvas.drawPath(getTrianglePath(), paintShape); textXOffset = 0; } if (displayShapeName) { canvas.drawText(shapeSelected, 0 + textXOffset, shapeHeight + textYOffset, paintShape); } } protected Path getTrianglePath() { Point p1 = new Point(0, shapeHeight), p2 = null, p3 = null; p2 = new Point(p1.x + shapeWidth, p1.y); p3 = new Point(p1.x + (shapeWidth / 2), p1.y - shapeHeight); Path path = new Path(); path.moveTo(p1.x, p1.y); path.lineTo(p2.x, p2.y); path.lineTo(p3.x, p3.y); return path; }
现在我们点击view,每点击一次图形就会进行切换,其效果图就跟最初贴的gif图片一样。
九、完善控件
增加getter方法获得图形名
public String getSelectedShape() { return shapeValues[currentShapeIndex]; }
现在在activity中,我们就可以通过getSelectedShape可以获取到图形名了。
十、状态的保存
当配置改变时,比如手机屏幕发生旋转,我们必须保存一些数据供从容保证view的状态不会发生改变。我们通过重写onSaveInstanceState和onRestoreInstanceState方法来保存和恢复数据。比如,在我们的view中,我们需啊哟保存的数据是当前是什么图形,可以通过保存数组的下标currentShapeIndex来实现。
@Override public Parcelable onSaveInstanceState() { // 新建一个Bundle Bundle bundle = new Bundle(); // 保存view基本的状态,调用父类方法即可 bundle.putParcelable("instanceState", super.onSaveInstanceState()); // 保存我们自己的数据 bundle.putInt("currentShapeIndex", this.currentShapeIndex); // 当然还可以继续保存其他数据 // 返回bundle对象 return bundle; } @Override public void onRestoreInstanceState(Parcelable state) { // 判断该对象是否是我们保存的 if (state instanceof Bundle) { Bundle bundle = (Bundle) state; // 把我们自己的数据恢复 this.currentShapeIndex = bundle.getInt("currentShapeIndex"); // 可以继续恢复之前的其他数据 // 恢复view的基本状态 state = bundle.getParcelable("instanceState"); } // 如果不是我们保存的对象,则直接调用父类的方法进行恢复 super.onRestoreInstanceState(state); }
一旦我们定义这些保存和恢复的方法,我们就能够在配置发生改变时保存我们必要的数据。
好了,整个流程就大致这样,可能很多语句都会读上去不通,但是还是可以凑合看的,整个文章翻译后自己做过部分整理。希望可以给android刚入门的新手带来一些帮助,同时呢,大神勿喷。
源码下载
自定义View过程解析源代码下载