Android允许开发人员自定义视图,以实现特殊的效果。
自定义视图的步骤非常简单,基本上可以分为两步:
1、自定以类,继承合适的父类。
对于不包含子视图的类,一般直接继承自View;
包含子视图的类,可以继承FrameLayout等。
2、覆盖父类中的构造函数及回调接口。
自定义视图一般至少覆盖一个父类的构造函数,
并选择性地覆盖其它回调接口,以定制视图行为。
本篇博客就以一个简单的示例,记录一下Android中定制视图的方法。
首先定义出定制视图对应类,如下面代码所示:
..............
public class BoxDrawingView extends View {
...........
//实现该构造函数后,才能在xml中直接使用该定制View
//xml中的属性信息,将通过AttributeSet传入到该接口
public BoxDrawingView(Context context, AttributeSet attrs) {
super(context, attrs);
...............
}
...........
}
有了该类后,就可以在布局文件中直接使用了,类似于:
<stark.a.is.zhang.draganddraw.view.BoxDrawingView
xmlns:android="http://schemas.android.com/apk/res/android"
--定义id后,才能利用View的onSaveInstanceState、onRestoreInstanceState(Parcelable state) -->
android:id="@+id/boxDrawingView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
在使用自定义视图时,必须使用类的全路径类名,这样布局inflater才能找到它。
布局inflater解析布局XML文件,并按视图定义创建View实例。
如果元素名不是全路径类名,布局inflater会转而在android.view和android.widget包中寻找目标。
如果目标视图类放置在其它包中,但未使用全路径,布局inflater将无法找到目标并最终导致应用崩溃。
自定义视图可以覆盖父类的函数,实现自己的功能。
例如,可以覆盖onTouchEvent来捕捉屏幕触摸事件,
以下代码中列举了比较常用的事件:
...........
@Override
public boolean onTouchEvent(MotionEvent event) {
//PointF为容器类,可以用于保存当前触摸点的位置坐标信息
PointF current = new PointF(event.getX(), event.getY());
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
//捕获到单指按下,或者说捕获到第一个指头按下
........................
break;
case MotionEvent.ACTION_MOVE:
//捕获到指头移动,多个指头触摸屏幕时,任意指头移动均会触发
........................
break;
case MotionEvent.ACTION_UP:
//捕获到最后一个指头离开,这个事件触发后,屏幕上应该没有指头了
...............
break;
case MotionEvent.ACTION_POINTER_DOWN:
//在有一个指头按下的前提下,捕获到其它指头按下
............
break;
case MotionEvent.ACTION_POINTER_UP:
//屏幕上有多个指头时,有一个指头离开屏幕时触发
//即多个指头时,最后一个离开触发ACTION_UP,其余离开均是触发ACTION_POINTER_UP
...........
break;
case MotionEvent.ACTION_CANCEL:
//触摸屏幕的事件,被当前视图的父视图拦截
...............
break;
}
return true;
}
............
此外,一般还可以覆盖View的onDraw方法,实现视图的自定义绘制。
@Override
protected void onDraw(Canvas canvas) {
............
}
应用启动时,所有视图都处于无效状态。
为了绘制视图,Android会调用顶级View视图的draw方法,引起自上而下的链式调用。
即顶级视图完成自我绘制,然后完成是其子视图的自我绘制,再然后是子视图的子视图的自我绘制。
如此调用下去直至继承结构的末端。
当继承结构中的所有视图都完成自我绘制后,最顶级View视图也就生效了。
在代码中主动调用View的invalidate接口,可以强制令View调用onDraw重新绘制自己。
设备旋转后,视图会进行重绘,如果需要保存视图绘制相关的状态,则需要覆盖以下方法:
@Override
protected Parcelable onSaveInstanceState() {
..........
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
........
}
上文已经提到过,自定义View有id时,才能利用onSaveInstanceState、onRestoreInstanceState(Parcelable state)
保存和回复视图相关的状态信息。
而且与Fragment、Activity不同的是,View保存和回复使用的是基于Parcelable接口的对象。
一般我们可以按照Parcelable的接口规则,定义实现该接口的对象;
也可以使Bundle封装需要保存的信息,然后保存Bundle。
因为Bundle本身就是个继承了Parcelable接口的对象。
需要注意的是,在自定义视图中保存信息时,还需要保存父视图相关的信息。
整个过程的示例代码类似于:
.................
@Override
protected Parcelable onSaveInstanceState() {
Bundle state = new Bundle();
//保存父类状态
state.putParcelable(PARENT_KEY, super.onSaveInstanceState());
ArrayList boxInfo = new ArrayList<>();
//mBoxes中保存的是视图中的一些信息
for (Box box : mBoxes) {
Bundle bundle = new Bundle();
bundle.putParcelable(ORIGIN_KEY, box.getOrigin());
bundle.putParcelable(CURRENT_KEY, box.getCurrent());
bundle.putFloat(DEGREE_KEY, box.getDegree());
boxInfo.add(bundle);
}
state.putParcelableArrayList(BOX_INFO_KEY, boxInfo);
return state;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
Bundle bundle = (Bundle) state;
//恢复父视图中的信息
super.onRestoreInstanceState((Parcelable) bundle.get(PARENT_KEY));
ArrayList boxInfo = bundle.getParcelableArrayList(BOX_INFO_KEY);
if (boxInfo != null && boxInfo.size() > 0) {
for (Bundle info : boxInfo) {
Box box = new Box((PointF) info.getParcelable(ORIGIN_KEY),
(PointF) info.getParcelable(CURRENT_KEY));
box.setDegree(info.getFloat(DEGREE_KEY, 0));
//恢复Box中的信息
mBoxes.add(box);
}
}
}
.............
View的onRestoreInstanceState接口先于onDraw接口被调用,
即设备旋转后,先恢复保存的信息,然后才会进行绘制。
要检测手势,首先需要识别出不同的手指。
在一次触摸屏幕的过程中,Android为每个手指分配了一个唯一的pointer ID。
根据pointer Id就可以知道每个手指在移动中的位置,从而计算出手势。
举例如下:
public class BoxDrawingView extends View {
...........
//我们定义两种模式,分别为普通绘制模式,一种是旋转屏幕的模式
//一个手指触摸屏幕时,mNormalPointerId记录其pointer ID
private static int NORMAL = 0;
private int mNormalPointerId = -1;
//第二个手指触摸屏幕时,mRotatePointerId记录其pointer ID
private static int ROTATE = 1;
private int mRotatePointerId = -1;
//默认为NORMAL
private int mMode = NORMAL;
...........
@Override
public boolean onTouchEvent(MotionEvent event) {
............
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
.............
//第一个手指触摸屏幕时,触发MotionEvent.ACTION_DOWN信息
//此时利用MotionEvent的getPointerId接口,就可以得到手指对应的pointer ID
//getPointerId的接口需要传入pointerIndex,利用getActionIndex接口可得到
mNormalPointerId = event.getPointerId(event.getActionIndex());
break;
...........
case MotionEvent.ACTION_POINTER_DOWN:
//在有一个手指触屏的情况下,有其它手指触摸屏幕,则触发ACTION_POINTER_DOWN消息
changeMode(event, false);
break;
case MotionEvent.ACTION_POINTER_UP:
//多个手指触屏的情况下,非最后一个手指离开屏幕,触发ACTION_POINTER_UP消息
changeMode(event, true);
break;
}
.................
}
private void changeMode(MotionEvent event, boolean up) {
//利用MotionEvent的getPointerCount接口
//可以得到该MotionEvent触发时,屏幕上手指的数量
int pointerCount = event.getPointerCount();
//因此手指离开后,屏幕上剩余手指的数量应减1
if (up) {
--pointerCount;
}
//手指已经离开屏幕,不使用对应的pointer ID
if (pointerCount == 2 && !up) {
mMode = ROTATE;
mRotatePointerId = event.getPointerId(event.getActionIndex());
} else if (pointerCount == 1){
mMode = NORMAL;
mRotatePointerId = -1;
..............
}
}
}
得到手指的pointer ID后,就可以得到每个手指的坐标了,例如:
@Override
public boolean onTouchEvent(MotionEvent event) {
..............
switch (event.getAction() & MotionEvent.ACTION_MASK) {
...........
case MotionEvent.ACTION_MOVE:
if (mMode == NORMAL) {
........
} else if (mMode == ROTATE) {
if (mRotatePointerId != -1 && mNormalPointerId != -1) {
//利用MotionEvent的getX、getY接口,以及pointer ID
//就可以得到每个手指的横纵坐标
float normalX = event.getX(mNormalPointerId);
float normalY = event.getY(mNormalPointerId);
float rotateX = event.getX(mRotatePointerId);
float rotateY = event.getY(mRotatePointerId);
//利用横纵坐标计算旋转角
float k = Math.abs((normalX-rotateX)/(normalY-rotateY));
float degree = (float) Math.toDegrees(Math.atan(k));
//P.S.:
//canvas.rotate接口,大于0的度数,则顺时针转动
//小于0,则逆时针转动
//canvas的save和restore必须配套使用
if ((normalX < rotateX && normalY < rotateY)
|| (normalX > rotateX && normalY > rotateY)){
degree = -degree;
}
................
}
break;
}
..........
}
自己做了下测试,例如当有A、B、C、D四个手指放到屏幕上时,
将依次得到4个不同的pointer ID,一般是0、1、2和3。
将手指B离开屏幕,重新发上B或E手指,分配的仍是序号1。
按照我上面写的代码,手指A对应的将是normalPointer ID,手指B得到的将是rotatePointer ID。
C、D的pointer ID并未保存。
此时,保持屏幕上有四个手指,然后做出旋转的滑动,旋转角度将以A、B的位置为准。
现在,我让A、B手指离开屏幕,用C、D手指做旋转动作,发现屏幕将以C、D的位置计算旋转角度。
由此,可以得到:
每个手指都会分配一个确定的pointer ID,
但MotionEvent的getX(int pointerIndex)、getY(int pointerIndex)得出的并不一定是pointer ID对应的坐标。
getX和getY的实现类似于从数组中取出信息,虽然A、B手指离开,但getX、getY传入的参数为0和1,
相当于取出数组中的第0、1位数据。
此时,保存坐标信息的数组在0、1位上,存储的是C、D手指的信息。
这也说明了,当检测两个手指的手势时,不能在第三个指头放上屏幕,第一个指头离开时,
利用getX、getY获取pointer ID = 2的数据,底层的数据仍然需要利用0、1为索引获取。
否则将抛出 java.lang.IllegalArgumentException: pointerIndex out of range的异常。