Android:在SurfaceView上做放大镜效果

一开始遇到这个需求的时候,觉得应该是一个再普通不过的功能,于是上网查了下怎么实现放大镜效果。果不其然,很快就google出一堆如何在ImageView或者其他View上实现放大镜的方法,但当我把同样的思路用在SurfaceView上时,却遇到一个极坑的问题。于是特意写这篇文章记录实现的思路。

简单起见,我们要实现的是下图展示的功能,当手指触及SurfaceView时,放大手指所指的位置,放大镜出现在手指左上方。

ezgif.com-video-to-gif
ezgif.com-video-to-gif

预备知识

  1. Android canvas基本用法,推荐这篇文章:

    http://blog.csdn.net/harvic880925/article/details/39080931

  2. SurfaceView的初级使用

<br>

普通View的实现思路

SurfaceView放大镜的实现思路和普通View基本一样,所以有必要了解普通View的放大镜如何实现。

其实归根到底是Canvas的作用。

思路是这样的:当用户手指触碰到View时,捕获指尖位置(onTouch()方法),然后用Canvas在该位置左上角裁减出一个圆形区域作为放大镜的位置,在该位置画出放大后的图片。当用户移动手指时,就不断刷新View(通过invalidate()方法调用onDraw()),这样就实现了放大镜效果。

接下来细化每一个细节问题。

  1. 如何裁出那个圆?

    Canvas表示一个图层,在这个图层上可以进行任意的平移旋转等操作,同时可以通过clipXXX()等方面裁减这个图层。因此,我们可以事先定义好一个圆形的Path,并通过clipPath()方法在指尖左上角的位置裁出一个圆形区域。

    当然,在这之前,你要把Canvas移动到裁减的位置。Canvas的操作默认都是以(0,0)坐标为起点执行的,对应到手机UI的坐标系,也就是屏幕左上角。而移动的操作可以通过translate()函数来完成。

    为了方便理解,我简陋地做了几张图:

    屏幕快照 2016-10-24 上午10.29.35
    屏幕快照 2016-10-24 上午10.29.35

    假设上图中,绿色部分代表View,图中那个红点是用户指尖的位置。

    接下来,我们要平移Canvas到合适的位置,并裁剪出放大镜的区域。

    屏幕快照 2016-10-24 上午10.33.27
    屏幕快照 2016-10-24 上午10.33.27

    上面这张图,假设带虚线的绿色框是移动后的Canvas,至于为什么要移动到这个位置,跟我的Path的设置有关:

    1
    2
    mPath = new Path();
    mPath.addCircle(RADIUS, RADIUS, RADIUS, Path.Direction.CW);

    如果以(0,0)点作为标准,这个Path会以(RADIUS,RADIUS)这个点为圆心,以RADIUS为半径形成一个圆。因此,如果Canvas移动到上图的位置,Path对应的就是那个蓝色圆的位置。

    接下来,用Canvas裁剪出这个Path

    1
    canvas.clipPath(mPath);

    这个时候,Canvas会在图层上裁出图中那个蓝色圆。也就是说,下次做画的时候,只有那个蓝色圆的位置会被绘制。

  2. 如何制作放大效果?

    这一步使用了Matrix的作用。简单来讲,只要我们将Bitmap绘制成n倍大小,同时保证蓝色圆的圆心与红点对应原Bitmap同一位置即可。后一步保证放大的区域确实是手指触碰的区域。

    简单起见,这里以放大两倍为例。

    假设Canvas仍然在(0,0)位置,那么放大两倍后的Canvas就如下面的蓝色区域所示,注意原来的红点坐标也被放大了:

    屏幕快照 2016-10-24 上午10.52.59
    屏幕快照 2016-10-24 上午10.52.59

    需要注意的是,裁减出来的那个蓝色圆是不会动的(我觉得这个API的设计有点奇怪)。

    接下来要做的就是让蓝色区域的红点和蓝色圆的圆心重合,这样当Canvas绘制放大两倍的Bitmap的时候,就相当于把指尖位置的区域放大后,再画到蓝色圆的区域,也就是放大镜的效果。

    移动的操作其实很简单,按照上图的标示,横坐标要左移x+RADIUS,纵坐标上移y+RADIUS,换成向量表示就是translate(-x-RADIUS, -y-RADIUS)

    但要注意,我们之前已经平移过Canvas了,所以要把之前平移的距离算上(如下图所示)

    屏幕快照 2016-10-24 上午11.05.18
    屏幕快照 2016-10-24 上午11.05.18

    所以最后总的平移向量为:translate(-2*x+RADIUS, -2*y+RADIUS)

    onDraw()函数如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    protected void onDraw(Canvas canvas) {
    canvas.drawBitmap(bitmap, matrix, null);

    if (isTouching) {
    // 剪切出放大区域
    canvas.translate(mX - 2 * RADIUS, mY - 2 * RADIUS);
    canvas.clipPath(mPath);
    // 画放大后的图
    canvas.translate(RADIUS - mX * 2, RADIUS - mY * 2);
    canvas.drawBitmap(bitmap, scaleMatrix, null);
    }
    }

    <br>

    SurfaceView的放大镜实现

    完成了普通View的放大镜效果后,SurfaceView照理来说应该也就不是问题,毕竟SurfaceView也是View的子类。但真正实现的时候,却遇到一个很大的问题。

    仔细观察上面onDraw()的代码,可以发现,放大效果最终是通过MatrixBitmap放大后再重绘一遍。但应用到SurfaceView时,我发现根本无法拿到SurfaceViewBitmap。后来查了各种资料,发现大家普遍遇到这个问题,有人甚至通过Linux底层的驱动来获取这个SurfaceView的Frame,不过这要在手机root的前提下实现。后来我尝试通过截屏的思路获取SurfaceView的截图,却发现截图是一片漆黑。导致该问题的根本原因在于SurfaceView的实现机制与普通的View完全是两码事。于是,只能另辟蹊径去获得这个Bitmap。思路其实也很简单,我们自己实例化一个CanvasBitmap,将SurfaceView上绘制的结果重新画一遍,这样就相当于间接获得了SurfaceViewBitmap,绘制函数的代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
     private void draw() {
    try {
    Canvas canvas = surfaceHolder.lockCanvas();
    // 画一层底色,防止SurfaceView闪烁
    canvas.drawColor(Color.BLACK);

    if (bitmap != null) {
    canvas.drawBitmap(bitmap, matrix, null);
    }
    if (isTouching) {
    // 将SurfaceView上的结果在自己的Bitmap上重新画一遍
    drawSurfaceToBitmap();

    // 剪切出放大区域
    canvas.translate(mX - 2 * RADIUS, mY - 2 * RADIUS);
    canvas.clipPath(mPath);
    // 画放大后的图
    canvas.translate(RADIUS - mX * 2, RADIUS - mY * 2);
    // magBitmap是我们自己的Bitmap
    canvas.drawBitmap(magBitmap, magMatrix, null);
    }

    surfaceHolder.unlockCanvasAndPost(canvas);

    } catch (NullPointerException e) {
    e.printStackTrace();
    }
    }

当然,由于这个例子里绘制的图片是一张静态的背景图,所以可以直接使用这张背景图的Bitmap,但如果绘制的是一张动图,就只能将所有绘制的操作重新画在自己的Bitmap上了。

另外,如果SurfaceView的绘制流程过于复杂,可能会导致不流畅(毕竟画了两次),需要使用多线程来执行draw()函数,这应该也是使用SurfaceView的正确姿势。

<br>

参考

Android放大镜的实现

自定义控件之绘图篇(四):canvas变换与操作

setScale,preScale和postScale的区别