图片操作系列 —(1)手势缩放图片功能

5,644 阅读11分钟

概述

项目开发中,大家APP开发一般都会用到上传图片,比如是上传了自己的生活照,然后在某个界面处查看上传的图片,这时候一般在这个查看详情的界面,会有手势放大缩小功能,手势进行旋转功能,双击放大图片等等。

不巧,我以前也有需要这个需求的时候,而且特别指出了要用手势进行图片的选择功能。

于是我查看了BiliBili的开源库:

Boxing

使用了这个Demo后发现里面有手势控制图片大小,手势控制图片旋转等功能,看了代码后我发现BiliBili这个demo中也是用了第三方的库:

RotatePhotoView


我们可以看到介绍:在PhotoView的基础上添加了通过二个手指来旋转图片的功能,所以这个库又是用了其他的第三方库:

PhotoView

我们可以看到这个PhotoView的库有一万多个star了。说明还是很不错的。

所以通过这次。我就来看PhotoView如何进行实现那么多功能。


正题

大家在看正文之前如果对于Matrix不是很了解的,可以先看看:
android matrix 最全方法详解与进阶(完整篇)
Android Matrix
Float中的那些常量 Infinity、NaN

本来是想直接拿着PhotoView 的源码,贴上源码分析一个个具体的功能,但是因为源码是考虑到很多功能,所以有很多代码量,而且太多看着很乱,所以我的方案是直接自己写个demo,然后根据我们要讲解的功能,仿照PhotoView的源码,在自己一个个具体的功能demo分别实现。所以本文我先来实现实现根据手势来实现图片的缩放功能:

1.添加图片布局

PhotoView是继承了ImageView,然后直接在layout中使用PhotoView,为了更方便的讲解,我就直接还是使用ImageView,然后让大家看到是如何对ImageView做处理实现相应的功能。

先添加我们要的demo布局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.dialog.photoviewdemo.MainActivity">

    <ImageView
        android:id="@+id/photo_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/black"
        />

</LinearLayout>

2. 对图片设置手势监听

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    //对我们的ImageView设置相应的一张图片
    ivPhoto = (ImageView) findViewById(R.id.photo_view);
    drawable = ContextCompat.getDrawable(this, R.mipmap.ic_launcher);
    ivPhoto.setImageDrawable(drawable);

    //对我们的ImageView设置触摸事件监听,并且把监听交给了GestureDetector.
    ivPhoto.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
           return scaleGestureDetector.onTouchEvent(event);
        }
    }); 

    //GestureDetector的实例生成
    scaleGestureDetector = new ScaleGestureDetector(this, new ScaleGestureDetector.OnScaleGestureListener() {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            float scaleFactor = detector.getScaleFactor();
            float focusX = detector.getFocusX();
            float focusY = detector.getFocusY();
            if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) {
                return false;
            }

            mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY);
            if(checkMatrixBounds()) {
                ivPhoto.setImageMatrix(getDrawMatrix());
            }

            return true;
        }

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {
        }
    });
}

根据上面的代码我们一样样来分析:

1.GestureDetector和ScaleGestureDetector

当用户触摸屏幕的时候,会产生许多手势,例如down,up,scroll,filing等等。
一般情况下,我们知道View类有个View.OnTouchListener内部接口,通过重写他的onTouch(View v, MotionEvent event)方法,我们可以处理一些touch事件,但是这个方法太过简单,如果需要处理一些复杂的手势,用这个接口就会很麻烦(因为我们要自己根据用户触摸的轨迹去判断是什么手势)。
Android sdk给我们提供了GestureDetector(Gesture:手势Detector:识别)类,通过这个类我们可以识别很多的手势,主要是通过他的onTouchEvent(event)方法完成了不同手势的识别。虽然他能识别手势,但是不同的手势要怎么处理,应该是提供给程序员实现的。
具体具体可以看这篇文章,写的很详细:用户手势检测-GestureDetector使用详解

而此处我们因为做的功能是通过手势来缩放图片,所以我们就要监听二个手指头缩放动作,所以我们使用的是ScaleGestureDetector

ScaleGestureDetector介绍:
用于处理缩放的工具类,用法与GestureDetector类似,都是通过onTouchEvent()关联相应的MotionEvent的。使用该类时,用户需要传入一个完整的连续不断地motion事件(包含ACTION_DOWN,ACTION_MOVE和ACTION_UP事件)。

我们看上面的代码就会发现ScaleGestureDetector有三个方法:

@Override
public boolean onScale(ScaleGestureDetector detector) {
    return true;
}

@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
    return true;
}

@Override
public void onScaleEnd(ScaleGestureDetector detector) {}

onScaleBegin:缩放开始会执行的方法,但是我们发现这个方法需要返回一个Boolean值,这个值决定是否处理后继的缩放事件,返回false时,不会执行onScale()

onScaleEnd:缩放结束执行

onScale:缩放时候执行的方法,用来做具体的逻辑处理。

我们具体来看看onScale方法:

@Override
public boolean onScale(ScaleGestureDetector detector) {
    return true;
}

我们可以看到这里是返回Boolean值,那这里返回true和false有什么区别呢。

float scaleFactor = detector.getScaleFactor();

我们可以通过这个方法获取到缩放因子,缩放因子会根据你的手势的变大会越来越大,如果你返回了true,那就说明这次的缩放行为就已经结束了,如果你返回了false,那就说明没有结束,然后缩放因子越来越大。

public boolean onScale(ScaleGestureDetector detector) {  
    if(detector.getScaleFactor()< 2){  
        return false;  
    }  
    return true;  
}


我们可以看到,我们设置了大于2才返回true,(前提二个手指是做放大手势)那么缩放因子就会一直变大到2,才会认为这次缩放行为结束了,就再次从1开始了。

(PS:如果二个手指做缩小的手势,那么这个缩放因子就会小于1,如果返回false,那么就会从1开始越来越小。)

2.图片初始化呈现状态

假设我们现在的ImageView设置的是全屏,我们有个小图片,ImageView设置了图片后是这样的:

我们发现默认是在左上角,而且因为我们的ImageView设置的是全屏,而图片又特别小,这样的初步呈现方式很不友好。
所以我们要做如下操作:
<1>把图片居中显示。
<2>图片和ImageView相适应(我们这里是把图片适当的放大,来适应这么大的ImageView.)

所以也就是我们上面提到过的代码:

drawableWidth = drawable.getIntrinsicWidth();
drawableHeight = drawable.getIntrinsicHeight();

viewWidth = ivPhoto.getWidth() - ivPhoto.getPaddingLeft() - ivPhoto.getPaddingRight();
viewHeight = ivPhoto.getHeight() - ivPhoto.getPaddingLeft() - ivPhoto.getPaddingRight();
RectF mTempScr = new RectF(0, 0, drawableWidth, drawableHeight);
RectF mTempDst = new RectF(0, 0, viewWidth, viewHeight);
mBaseMatrix.setRectToRect(mTempScr, mTempDst, Matrix.ScaleToFit.CENTER);
mDrawableMatrix.set(mBaseMatrix);
ivPhoto.setImageMatrix(mDrawableMatrix);

获取图片的真实宽高和ImageView用来显示图片的宽高我就不多说了。重点是setRectToRect方法:

public boolean setRectToRect(RectF src, RectF dst, ScaleToFit stf)

将rect变换成rect,通过stf参数来控制。

ScaleToFit 有如下四个值:
FILL: 可能会变换矩形的长宽比,保证变换和目标矩阵长宽一致。
START:保持坐标变换前矩形的长宽比,并最大限度的填充变换后的矩形。至少有一边和目标矩形重叠。左上对齐。
CENTER: 保持坐标变换前矩形的长宽比,并最大限度的填充变换后的矩形。至少有一边和目标矩形重叠。
END:保持坐标变换前矩形的长宽比,并最大限度的填充变换后的矩形。至少有一边和目标矩形重叠。右下对齐。

这里使用谷歌的api demo的图片作为例子:

我们很明显发现,那个蓝色的小球的变化不就是我们想要的变化么,并且我们是要居中,所以用的是Matrix.ScaleToFit.CENTER

我们看下我们最终的效果:

3.图片实时手势缩放

我们前面已经知道了。手势变化的时候会触发onScale方法,所以我们只要把图片的具体的放大缩小的逻辑放在onScale里面即可。

@Override
public boolean onScale(ScaleGestureDetector detector) {
    //缩放因子
    float scaleFactor = detector.getScaleFactor();
    //返回组成该手势的两个触点的中点在组件上的x和y轴坐标,单位为像素。
    float focusX = detector.getFocusX();
    float focusY = detector.getFocusY();
    //如果为nan或者无强大,则无效
    if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) {
        return false;
    }
    //进行缩放,传入x轴缩放比例,y轴缩放比例,缩放中心点的x和y值
    mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY);
    if(checkMatrixBounds()) {
        ivPhoto.setImageMatrix(getDrawMatrix());
    }

    return true;
}

大家应该看到了我这边有个checkMatrixBounds方法,本来其实单纯的缩放就是先postScale然后在直接setImageMatrix就可以了。

mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY);
ivPhoto.setImageMatrix(getDrawMatrix());

但是这样有什么不好的地方呢。我来具体跟大家说下:

  • 缩放跟手势的二个触点的中心有关,而且图片会随着那个方向移动

比如我是二个红点分别是我的手指,然后不停的缩小图片动作,图片不仅变小,而且会随着那个方向做平移。放大则相反。这不是我们想要的,我们想要的是同样是做缩放,同时,图片还在中间。

既然我们知道了图片在做缩小放大的同时还在平移,那我们就做相应的反方向的平移处理不就好了

我们分为二种情况:

1— 图片在缩放过程中,宽或者高没有超过ImageView的宽或者高:

如果图片再缩放过程中没超过ImageView的大小。我们只需要让图片一直居中现实即可。所以比较简单:

只要算出我们在前面第二个大步里面的初始化后的图片的初始状态后(即和ImageView相适应并且居中),相应的图片的矩阵的宽和高是不是超过ImageView。如果没有超过,我们可以看到我们希望的图片放大和缩小都是希望在正中间的位置,但是现在变成了绿色的地方,我们只需要把绿色的地方移动到咖啡色的地方就行。

以Y轴为例(X轴同样处理):


看到距离是(实际图片的Top值) - (2分之一的ImageView的高度) + (2分之一的实际图片高度),因为是往上移动,所以Y轴实际上是要减少值的,所以最终我们只要让实际的图片减去相应的距离值即可。

  • 实际图片的TOP值(先获取相应的实际图片的矩阵Rect,在获取top属性):

    private RectF getDisplayRect(Matrix matrix) {
      Drawable d = drawable;
      if (d != null) {
          mDisplayRect.set(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight());
          matrix.mapRect(mDisplayRect);
          return mDisplayRect;
      }
      return null;
    }
  • ImageView的高度:

    viewHeight = ivPhoto.getHeight() - ivPhoto.getPaddingLeft() - ivPhoto.getPaddingRight();
  • 实际变化后的图片的高度(rect为上面获取的实际图片的Rect):
    final float height = rect.height(), width = rect.width();

所以我们这里只需要:

private boolean checkMatrixBounds() {
    RectF rect = getDisplayRect(getDrawMatrix());
    if (rect == null) {
        return false;
    }

    final float height = rect.height(), width = rect.width();
    float deltaX = 0, deltaY = 0;

    if (height <= viewHeight) {
        deltaY = (viewHeight - height) / 2 - rect.top;
    }

    if (width <= viewWidth) {
        deltaX = (viewWidth - width) / 2 - rect.left;
    }

     mSuppMatrix.postTranslate(deltaX, deltaY);
    return true;
}

2— 图片在缩放过程中,宽或者高超过ImageView的宽或者高:

这个时候我们就不行简单的在中心位置就可以了。因为这时候不能反而不让他在中心位置,为什么????我们现在的图片是一个安卓机器人,比如我现在要放大它的图片查看它的右眼,我们在右上角用手机不挺放大。变成这样:

这时候就说了。那我什么都不处理,放大这边就是这个效果啊。说的没错的确这样,但是比如现在已经放大成这个样子了。我缩小它,但是我不是从右上角来进行缩小,而是在左边进行缩小,大家知道我们不做处理,这时候缩小的时候是按我们手势的位置进行,所以头像在缩小时候先是往左边方向,然后当小于ImageView的高度时候,又突然居中,效果很不好。

所以我们这个例子里面处理方式是:如果宽度都大于ImageView并且图片的右边界还没出现在ImageView中的时候,先按照自己原来的方式缩小,当图片的右边界出现在了ImageView的范围内了,让它慢慢往右边移动(也就是ImageView的宽度 - Rect.right的距离),这时候就会很和谐。最后宽度小于ImageView的时候居于中间。

PS:还有一种正好反过来。我们放大的图片是左眼!!(这时候移动的距离是 -rect.left)

所以最终变成这样:

private boolean checkMatrixBounds() {
    RectF rect = getDisplayRect(getDrawMatrix());
    if (rect == null) {
        return false;
    }

    final float height = rect.height(), width = rect.width();
    float deltaX = 0, deltaY = 0;

    if (height <= viewHeight) {
        deltaY = (viewHeight - height) / 2 - rect.top;
    } else if (rect.top > 0) {
        deltaY = -rect.top;
    } else if (rect.bottom < viewHeight) {
        deltaY = viewHeight - rect.bottom;
    }


    if (width <= viewWidth) {
        deltaX = (viewWidth - width) / 2 - rect.left;
    } else if (rect.left > 0) {
        deltaX = -rect.left;
    } else if (rect.right < viewWidth) {
        deltaX = viewWidth - rect.right;
    }

    mSuppMatrix.postTranslate(deltaX, deltaY);
    return true;
}

结尾

还是老样子,希望大家不要吐槽。有问题留言哈哈。。O(∩_∩)O哈哈~

附上Demo地址:ScaleImageVewDemo