×

Android 贴纸

96
周君宜
2016.04.07 21:04* 字数 2193

刚到老东家FitTime的时候正好赶上了team刚组建,大家每天都在对着IOS版本看需求,到开发启动那天就是谁看到哪就做哪,于是我就被分到了做图像处理的模块。其中最让人抓狂的是贴纸部分,每个贴纸要独立具备拖动、放大缩小、旋转翻转、删除等功能,当然这也是所有贴纸模块需要具备的基本要素,但是当时真是一穷二白,大家都不懂,网上也找不到好的解决方案,就只能硬着头皮去试验各种方法,最后发现只有自定义View可以拯救我。准备把我的思路分享给各位,希望能有所帮助。因为我的解决方案是使用自定义View,所有对于View的生命周期、Canvas、Matrix等相关知识都需要有一定的了解,如果有对这些并不清楚的童鞋最好先去了解一下,这样能方便你更好的阅读接下来的内容,关于这些背景知识我之前的博客中也有介绍,感兴趣的各位可以看看。

概述

了解过自定义View的童鞋 对Canvas.drawBitmap(Bitmap, Matrix, Paint)这个函数应该不会陌生,Bitmap的位置、大小、旋转角度、扭曲程度等都由Matrix来管理,而实现贴纸效果的就需要借助这个神奇的函数。我们可以通过很多种方法获取到贴纸的Bitmap,也可以很容易的定义绘制Bitmap所使用的Paint,那么剩下我们只需要关心怎样可以借助Matrix来让贴纸随着我们的指尖翩翩起舞。


device-2016-04-07-204711~1.gif

为了更好的管理每个贴纸的Bitmap和Matrix信息,我简单的将二者进行了封装,大家不要在意名字,知道这个类是干嘛的就好了,毕竟如何起一个优雅准确的名字是一个世界性的难题。

public static class ImageGroup {
    public Bitmap bitmap;
    public Matrix matrix = new Matrix();

    //删除贴纸时释放资源时使用
    public void release() {
        if (bitmap != null) {
            bitmap.recycle();
            bitmap = null;
        }

        if (matrix != null) {
            matrix.reset();
            matrix = null;
        }
    }
}

说到随着指尖,我们就会想到Android丰富的手势操作,因为是自定义View,所有对Bitmap的操作都需要用到手势触点坐标,因此我使用了View的onTouchEvent(MotionEvent event)方法直接对手势触点就行操作,onTouchEvent也是整个贴纸模块的核心。

public boolean onTouchEvent(MotionEvent event) {
    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            anchorX = event.getX();
            anchorY = event.getY();
            moveTag = decalCheck(anchorX, anchorY);
            deleteTag = deleteCheck(anchorX, anchorY);
            if (moveTag != -1 && deleteTag == -1) {
                downMatrix.set(mDecalImageGroupList.get(moveTag).matrix);
                mode = DRAG;
            }
            break;

        case MotionEvent.ACTION_POINTER_DOWN:
            moveTag = decalCheck(event.getX(0), event.getY(0));
            transformTag = decalCheck(event.getX(1), event.getY(1));
            if (moveTag != -1 && transformTag == moveTag && deleteTag == -1) {
                downMatrix.set(mDecalImageGroupList.get(moveTag).matrix);
                mode = ZOOM;
            }
            oldDistance = getDistance(event);
            oldRotation = getRotation(event);
            midPoint = midPoint(event);
            break;

        case MotionEvent.ACTION_MOVE:
            if (mode == ZOOM) {
                moveMatrix.set(downMatrix);
                float newRotation = getRotation(event) - oldRotation;
                float newDistance = getDistance(event);
                float scale = newDistance / oldDistance;
                moveMatrix.postScale(scale, scale, midPoint.x, midPoint.y);// 缩放
                moveMatrix.postRotate(newRotation, midPoint.x, midPoint.y);// 旋转
                if (moveTag != -1) {
                    mDecalImageGroupList.get(moveTag).matrix.set(moveMatrix);
                }
                invalidate();
            } else if (mode == DRAG) {
                moveMatrix.set(downMatrix);
                moveMatrix.postTranslate(event.getX() - anchorX, event.getY() - anchorY);// 平移
                if (moveTag != -1) {
                    mDecalImageGroupList.get(moveTag).matrix.set(moveMatrix);
                }
                invalidate();
            }
            break;

        case MotionEvent.ACTION_UP:
            if (deleteTag != -1) {
                mDecalImageGroupList.remove(deleteTag).release();
                invalidate();
            }
            mode = NONE;
            break;

        case MotionEvent.ACTION_POINTER_UP:
            mode = NONE;
            break;
    }
    return true;
}

手势操作

onTouchEvent中我们使用了ACTION_DOWN、ACTION_POINTER_DOWN、ACTION_MOVE、ACTION_UP、ACTION_POINTER_UP五种事件,其中ACTION_DOWN和ACTION_UP、ACTION_POINTER_DOWN和ACTION_POINTER_UP分别对应。

  • ACTION_DOWN和ACTION_UP:当View从无到有手指触摸,ACTION_DOWN会被触发,对应的ACTION_UP则为从有到无,也就是说只有当没有任何一根手指在触摸View时ACTION_UP才会被触发,ACTION_DOWN和ACTION_UP是整个手势操作生命周期的起点和终点。
  • ACTION_POINTER_DOWN和ACTION_POINTER_UP:当View有多点触摸时ACTION_POINTER_DOWN会被触发,而当其中某个触点消失后ACTION_POINTER_UP会被触发。这里我们只考虑有两根手指触摸的情况。
  • ACTION_MOVE:当View被触摸且该触摸点在移动时ACTION_MOVE会被触发,多点触摸时无论哪个点移动都会触发。

Matrix的Translate(平移)外,Scale(缩放)、Rotate(旋转)、Skew(扭曲)四大操作除了Skew外我们都需要使用,对应到手势上我们通过单点触摸来控制Bitmap平移,通过多点触摸来控制Bitmap缩放和旋转,因此,在ACTION_MOVE阶段我们需要根据两种不同情况做区分。

int NONE = 0;//无
int DRAG = 1;//平移模式
int ZOOM = 2;//缩放、旋转模式

我们定义三种mode,mode的初始值为NONE,当ACTION_DOWN被触发时mode置为DRAG,当ACTION_POINTER_DOWN被触发时mode置为ZOOM。当ACTION_MOVE被触发时,我们对mode进行判断,针对DRAG和ZOOM两种情况分别进行处理。

  1. mode == DRAG
anchorX = event.getX();
anchorY = event.getY();
moveTag = decalCheck(anchorX, anchorY);
deleteTag = deleteCheck(anchorX, anchorY);
if (moveTag != -1 && deleteTag == -1) {
    downMatrix.set(mDecalImageGroupList.get(moveTag).matrix);
    mode = DRAG;
}

ACTION_DOWN被触发时我们首先将当前触摸点的坐标保存下来以备使用。之后要判断当前触摸点是否在某一个贴纸的Bitmap范围内以及当前触摸点是否在某一个贴纸的删除按钮范围内,我们分别用moveTag和deleteTag来保存结果,当结果为-1时表示没有在任何相关范围内,结果为0~贴纸数量-1时表示在某个贴纸的相关范围内。只有当moveTag != -1 && deleteTag == -1(触摸点某一个贴纸范围内且不在任何删除按钮范围内)时,我们将当前贴纸的Matrix保存到downMatrix中并将mode置成DRAG,若deleteTag不等于-1时,我们在ACTION_UP就将对应的贴纸从贴纸列表中移除。

moveMatrix.set(downMatrix);
moveMatrix.postTranslate(event.getX() - anchorX, event.getY() - anchorY);// 平移
if (moveTag != -1) {
    mDecalImageGroupList.get(moveTag).matrix.set(moveMatrix);
}
invalidate();

进入到ACTION_MOVE阶段,我们首先将downMatrix赋值给moveMatrix,downMatrix是这次手势操作的起始Matrix,之后的变换都是基于downMatrix进行的,所以我们不能直接对downMatrix进行操作,moveMatrix承担了这个责任。DRAG模式下表示当前要进行的是平移操作,而平移的横纵距离是在ACTION_DOWN阶段保存下来的触摸点横纵坐标值与当前移动到的触摸点横纵坐标值的差值。最后将处理好的moveMatrix赋值回该贴纸的Matrix,并调用重绘函数,完成此次贴纸平移操作。因为ACTION_MOVE会在ACTION_UP触发之前一直保持,所以整个平移操作会一直持续,贴纸随着手指移动而移动的效果就出现了。

  1. mode == ZOOM
moveTag = decalCheck(event.getX(0), event.getY(0));
transformTag = decalCheck(event.getX(1), event.getY(1));
if (moveTag != -1 && transformTag == moveTag && deleteTag == -1) {
    downMatrix.set(mDecalImageGroupList.get(moveTag).matrix);
    mode = ZOOM;
}
oldDistance = getDistance(event);
oldRotation = getRotation(event);
midPoint = midPoint(event);

ACTION_POINTER_DOWN被触发时,我们首先对两个触摸点是否在某个贴纸范围内进行判断,结果分别用moveTag和transformTag进行保存。当moveTag != -1 && transformTag == moveTag && deleteTag == -1(两个触摸点在同一个贴纸范围内且不在任何删除按钮范围内)条件满足时,我们将当前贴纸的Matrix保存到downMatrix中并将mode置成ZOOM。同时我们需要将当前两个触摸点之间的距离、中点、角度用oldDistance、midPoint、oldRotation保存起来以备使用。

moveMatrix.set(downMatrix);
float newRotation = getRotation(event) - oldRotation;
float newDistance = getDistance(event);
float scale = newDistance / oldDistance;
moveMatrix.postScale(scale, scale, midPoint.x, midPoint.y);// 缩放
moveMatrix.postRotate(newRotation, midPoint.x, midPoint.y);// 旋转
if (moveTag != -1) {
    mDecalImageGroupList.get(moveTag).matrix.set(moveMatrix);
}
invalidate();

进入到ACTION_MOVE阶段,我们首先将downMatrix赋值给moveMatrix。用当前的两个触摸点算出新的角度,同之前保存的值算出差值newRotation,算出新的距离newDistance并和oldDistance做商,算出缩放比例。分别对moveMatrix进行缩放和旋转操作,处理好后将moveMatrix赋值回该贴纸的Matrix,并调用重绘函数,完成此次贴纸平移操作。因为ACTION_MOVE会在ACTION_UP触发之前一直保持且在ACTION_POINTER_UP触发之前ZOOM模式会一直保持,所以缩放、旋转操作会一直持续,贴纸随着两根手指之间距离变化而变化,角度变化而变化的效果就出现了。

触摸点判断

protected float[] getBitmapPoints(Bitmap bitmap, Matrix matrix) {
    float[] dst = new float[8];
    float[] src = new float[]{
            0, 0,
            bitmap.getWidth(), 0,
            0, bitmap.getHeight(),
            bitmap.getWidth(), bitmap.getHeight()
    };
    matrix.mapPoints(dst, src);
    return dst;
}
private boolean pointCheck(ImageGroup imageGroup, float x, float y) {
    float[] points = getBitmapPoints(imageGroup);
    float x1 = points[0];
    float y1 = points[1];
    float x2 = points[2];
    float y2 = points[3];
    float x3 = points[4];
    float y3 = points[5];
    float x4 = points[6];
    float y4 = points[7];
    float edge = (float) Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
    if ((2 + Math.sqrt(2)) * edge >= Math.sqrt(Math.pow(x - x1, 2) + Math.pow(y - y1, 2))
            + Math.sqrt(Math.pow(x - x2, 2) + Math.pow(y - y2, 2))
            + Math.sqrt(Math.pow(x - x3, 2) + Math.pow(y - y3, 2))
            + Math.sqrt(Math.pow(x - x4, 2) + Math.pow(y - y4, 2))) {
        return true;
    }
    return false;
}
private boolean circleCheck(ImageGroup imageGroup, float x, float y) {
    float[] points = getBitmapPoints(imageGroup);
    float x2 = points[2];
    float y2 = points[3];
    int checkDis = (int) Math.sqrt(Math.pow(x - x2, 2) + Math.pow(y - y2, 2));
    if (checkDis < 40) {
        return true;
    }
    return false;
}

在整个手势操作流程中我们需要多次使用触摸点判断,不论是判断是否在贴纸范围还是删除按钮范围。Matrix提供了map开头的映射方法,其中的mapPoints(float[] dst, float[] src)可以将src坐标数组根据Matrix映射到dst。使用Matrix来存储Bitmap的绘制信息,Bitmap默认的绘制起点(左上角点)为(0,0),因此默认情况下Bitmap四个点的坐标为(0,0)、(bitmap.getWidth(), 0)、(0, bitmap.getHeight())、(bitmap.getWidth(), bitmap.getHeight()),依此我们可以映射出当前Bitmap四个点的坐标。我们的贴纸在整个流程任何操作下都是正方形,因此我们可以使用已知正方形四个顶点来判断第五点是否在正方形范围内,算法是第五点到四顶点的距离是否小于等于2√2倍的边长。这个算法对长方形适不适用我没有验证,如果要添加非正方形Bitmap的话需要自行优化此处。判断点是否在一个圆的范围内很简单,只要将该点到圆心的距离和半径进行比较即可。

总结

整个Android贴纸的简单实现思路就行这样,完整代码链接如下,有需要的童鞋可以搞下来看看,有什么问题或者好点子欢迎交流。
代码地址:https://github.com/JunyiZhou/AndroidExercises/tree/master/ImageHandleDemo

Android View
Web note ad 1