Android定制视图及手势检测的基本示例

Android允许开发人员自定义视图,以实现特殊的效果。
自定义视图的步骤非常简单,基本上可以分为两步:
1、自定以类,继承合适的父类。
对于不包含子视图的类,一般直接继承自View;
包含子视图的类,可以继承FrameLayout等。

2、覆盖父类中的构造函数及回调接口。
自定义视图一般至少覆盖一个父类的构造函数,
并选择性地覆盖其它回调接口,以定制视图行为。

本篇博客就以一个简单的示例,记录一下Android中定制视图的方法。


1、定义类

首先定义出定制视图对应类,如下面代码所示:

..............
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后,才能利用ViewonSaveInstanceStateonRestoreInstanceState(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将无法找到目标并最终导致应用崩溃。


2、覆盖父类函数

自定义视图可以覆盖父类的函数,实现自己的功能。
例如,可以覆盖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接口被调用,
即设备旋转后,先恢复保存的信息,然后才会进行绘制。


4、手势检测初探

要检测手势,首先需要识别出不同的手指。

在一次触摸屏幕的过程中,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的异常。

你可能感兴趣的:(Android开发)