查看图片是每个APP上非常重要的一个部分,而在开源框架中非常好用的一个查看图片的框架就是PhotoView了。这个框架其实设计的是非常巧妙的,这篇文章主要就是从源码的角度来讲解这个框架的实现。
PhotoView的源码地址:https://github.com/chrisbanes/PhotoView
为了更好的了解这个框架,我们来仿造它来写一个类似的实现。
在通常的APP中,我们对图片的操作包括:双击放大、快速滑动、双指放大,普通拖动。在PhotoView中,这些操作全部通过Matrix来完成,即设置ImageView的ScaleType为Matrix。但是,我们知道ImageView还有很多其他的ScaleType,为了达到更好的效果,PhotoView对这些ScaleType做了特殊的处理,可以认为是转换成了Matrix。
我们写一个自己的类,为了以示区别,取名GestureImageView,首先为了确保我们的ImageView的ScaleType必须为Matrix,定义两个方法:
/**
* 将ImageView的ScaleType类型设置为Matrix
*/
private void setImageViewScaleTypeMatrix() {
if (!ScaleType.MATRIX.equals(this.getScaleType())) {
super.setScaleType(ScaleType.MATRIX);
}
}
/**
* 检查设置的ScaleType是否符合要求
* @param scaleType
* @return
*/
private static boolean isSupportedScaleType(final ScaleType scaleType) {
if (null == scaleType) {
return false;
}
switch (scaleType) {
case MATRIX:
throw new IllegalArgumentException(scaleType.name()
+ " is not supported in GestureImageView");
default:
return true;
}
}
这两个方法会在合适的地方被调用到,先不着急。
对于我们自定义的ImageView,一般都是希望图片初始化的时候就出现在屏幕中间,对于普通的ImageView来说,我们可以通过ImageView的ScaleType来控制,而对于现在我们自己的ImageView,ScaleType只能为Matrix,我们就只能做一点点转化了。
定义一个成员变量:在对图片进行初始化时,我们需要对它的大小、位置等进行限制,以达到我们的需求。可想而知,我们需要对一张图片进行缩放、平移,这些数据放进一个Matrix变量中。
private final Matrix mBaseMatrix = new Matrix();
private void updateBaseMatrix() {
Drawable d = getDrawable();
if (d == null) {
return;
}
final int viewWidth = getImageViewWidth();
final int viewHeight = getImageViewHeight();
final int drawableWidth = d.getIntrinsicWidth();
final int drawableHeight = d.getIntrinsicHeight();
mBaseMatrix.reset();
final float widthScale = 1.0f * viewWidth / drawableWidth;
final float heightScale = 1.0f * viewHeight / drawableHeight;
if (mScaleType == ScaleType.CENTER) {
mBaseMatrix.postTranslate((viewWidth - drawableWidth) / 2f, (viewHeight - drawableHeight) / 2f);
} else if (mScaleType == ScaleType.CENTER_CROP) {
float scale = Math.max(widthScale, heightScale);
mBaseMatrix.postScale(scale, scale);
mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2f, (viewHeight - drawableHeight * scale) / 2f);
} else if (mScaleType == ScaleType.CENTER_INSIDE) {
float scale = Math.min(widthScale, heightScale);
mBaseMatrix.postScale(scale, scale);
mBaseMatrix.postTranslate((viewWidth - drawableWidth * scale) / 2f, (viewHeight - drawableHeight * scale) / 2f);
} else {
RectF tempSrc = new RectF(0, 0, drawableWidth, drawableHeight);
RectF tempDst = new RectF(0, 0, viewWidth, viewHeight);
switch (mScaleType) {
case FIT_CENTER:
mBaseMatrix.setRectToRect(tempSrc, tempDst, ScaleToFit.CENTER);
break;
case FIT_START:
mBaseMatrix.setRectToRect(tempSrc, tempDst, ScaleToFit.START);
break;
case FIT_END:
mBaseMatrix.setRectToRect(tempSrc, tempDst, ScaleToFit.END);
break;
case FIT_XY:
mBaseMatrix.setRectToRect(tempSrc, tempDst, ScaleToFit.FILL);
break;
}
}
resetMatrix();
}
分析一个这个方法。7~10行拿到了ImageView控件的大小和图片的大小,我们需要根据这几个大小来将图片移动到ImageView的中间。12行对mBaseMatrix进行了初始化。
从17行开始,就是根据ImageView自己设置的ScaleType来计算图片需要缩放的比例和移动的距离。17行,如果ScaleType为CENTER,我们仅仅把图片移动到控件中间就好了。19行和23行的CENTER_CROP和CENTER_INSIDE可以一起看,CENTER_CROP是保证图片的一个方向占满控件,比如一张很宽的图片,我们就要让它的高度占满控件高度,宽度就会超出控件范围,看起来就像是被剪裁(Crop)掉了一截,同样,如果是CENTER_INSIDE,对于很宽的图片,我们需要让它在宽度上也在整个控件中(Inside),所以这个时候图片看起来可能会很细。这还只是缩放,对缩放后的图片,我们再将它移动到控件中央。
28行开始,是对以FIT开头的ScaleType进行定义,tempSrc是图片所显示的矩形,tempDst是控件所显示的矩形,为了将图片显示到控件中,使用Matrix的setRectToRect方法,可想而知,这个方法里包括了缩放和平移。
OK,到这里,如果我们将mBaseMatrix设置到ImageView上,图片就会按我们的要求显示到ImageView的合适的位置上。
在上面的方法中,我们用到了ImageView的宽度和高度,而这两个值在控件初始化的时候拿不到,所以上面的方法可以通过getViewTreeObserver().addOnGlobalLayoutListener()来调用。这里注意一下就好了。
基本的显示完成了,是主要是通过mBaseMatrix来完成的。接下来就要对图片进行手势操作了。
可以考虑这样一个问题,所有的手势操作,都会改变图片的位置或大小,而我们的ImageView又是通过Matrix来控制的,那我们怎么来记录这个手势带来的变化呢?显然,我们可以定义另一个Matrix,来记录这个变化,然后在mBaseMatrix的基础上应用这个变化的matrix,图片就改变了。定义为mSuppMatrix。
private final Matrix mSuppMatrix = new Matrix();
先来看双击放大和快速滑动功能。这两个功能可以通过GestureDetector这个类来实现。DefaultOnGestureTabListener是执行这个手势时候的监听器。
mGestureDetector = new GestureDetector(context, new DefaultOnGestureTabListener());
按照我们使用APP的经验,双击放大每次放大的倍数都是固定值,所以,我们来顶一个小、中、大三个缩放比例的固定值。
public static final float DEFAULT_MIN_SCALE = 1.0f;
public static final float DEFAULT_MID_SCALE = 1.75f;
public static final float DEFAULT_MAX_SCALE = 3.0f;
...
private float mMinScale = DEFAULT_MIN_SCALE;
private float mMidScale = DEFAULT_MID_SCALE;
private float mMaxScale = DEFAULT_MAX_SCALE;
...
public float getMinimumScale() {
return mMinScale;
}
public float getMidiumScale() {
return mMidScale;
}
public float getMaximumScale() {
return mMaxScale;
}
我们的DefaultOnGestureTabListener就可以定义了:
private class DefaultOnGestureTabListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDoubleTap(MotionEvent e) {
float x = e.getX();
float y = e.getY();
float scale = getScale();
if (scale >= getMinimumScale() && scale < getMidiumScale()) {
scale = getMidiumScale();
} else if (scale >= getMidiumScale() && scale < getMaximumScale()) {
scale = getMaximumScale();
} else {
scale = getMinimumScale();
}
setScale(scale, x, y, true);
return true;
}
}
可以看出,我们是按小 - 中 - 大的顺序来依次显示的,这里不需要多说,主要看一下setScale()方法。
一般的,双击放大都会看到图片有一个放大的动画,不会一下放大到一个比例。
public void setScale(float scale, float focalX, float focalY, boolean animate) {
if (animate) {
post(new AnimatedZoomRunnable(getScale(), scale, focalX, focalY));
} else {
mSuppMatrix.setScale(scale, scale, focalX, focalY);
checkAndDisplayMatrix();
}
}
animate参数,就是代表需不需要使用这个动画。scale代表缩放的比例,通过方法名setXX可知,最后ImageView应用的缩放比例就是scale,focalX和focalY是缩放的中心,这里传入的值为我们的手指点击的位置。
如果不需要动画,我们直接让mSuppMatrix应用这个变化就行了。如果需要动画,我们就需要使用AnimateZoomRunnable这个类了。
private class AnimatedZoomRunnable implements Runnable {
float fromScale, toScale, focalX, focalY;
long startTime;
public AnimatedZoomRunnable(float fromScale, float toScale, float focalX, float focalY) {
this.fromScale = fromScale;
this.toScale = toScale;
this.focalX = focalX;
this.focalY = focalY;
startTime = System.currentTimeMillis();
}
@Override
public void run() {
float t = interpolate();
float scale = fromScale + (toScale - fromScale) * t;
float deltaScale = scale / getScale();
mSuppMatrix.postScale(deltaScale, deltaScale, focalX, focalY);
checkAndDisplayMatrix();
if (t < 1f) {
post(this);
}
}
private float interpolate() {
float t = 1f * (System.currentTimeMillis() - startTime) / ZOOM_DURATION;
t = Math.min(1f, t);
t = sInterpolator.getInterpolation(t);
return t;
}
}
其实这个类很简单,本质就是通过Handler不断post一个Runnable,来实现这个动画效果。注意run方法中deltaScale的计算,这也是一个比例,在上一次变换后的比例上再应用delta的比例,就变成下一个比例了。getScale()代表当前ImageView上应用的比例:就是拿到mSuppMatrix中代表缩放比例的值。(注意这里,我们认为x和y方向上缩放比例一致,所以只拿x方向上的缩放比例)
private final float[] mMatrixValues = new float[9];
public float getScale() {
return (float) Math.pow(getValue(mSuppMatrix, Matrix.MSCALE_X), 2);
}
private float getValue(Matrix matrix, int whichValue) {
matrix.getValues(mMatrixValues);
return mMatrixValues[whichValue];
}
可以想象有这样一种情况,如果一张图片很大,比控件大,缩放后,它的边缘有可能跑到控件中间来,一般情况下我们不允许这种情况出现。所以,在每次对图片进行改变时,我们需要检查一下,再显示出来,这就是上面多少出现的checkAndDisplayMatrix()方法的作用了。
private void checkAndDisplayMatrix() {
if (checkMatrixBounds()) {
setImageMatrix(getDrawMatrix());
}
}
public void setImageMatrix(Matrix matrix) {
checkImageViewScaleType();
super.setImageMatrix(matrix);
}
private void checkImageViewScaleType() {
if (!ScaleType.MATRIX.equals(getScaleType())) {
throw new IllegalStateException("The ImageView's ScaleType has been changed!");
}
}
private boolean checkMatrixBounds() {
RectF rect = getDisplayRect(getDrawMatrix());
if (rect == null) {
return false;
}
final float width = rect.width(), height = rect.height();
float deltaX = 0, deltaY = 0;
final int viewHeight = getImageViewHeight();
if (height <= viewHeight) {
switch (mScaleType) {
case FIT_START:
deltaY = -rect.top;
break;
case FIT_END:
deltaY = viewHeight - rect.bottom;
break;
default:
deltaY = (viewHeight - height) / 2F - rect.top;
}
} else {
if (rect.top > 0) {
deltaY = -rect.top;
} else if (rect.bottom < viewHeight) {
deltaY = viewHeight - rect.bottom;
}
}
final int viewWidth = getImageViewWidth();
if (width <= viewWidth) {
switch (mScaleType) {
case FIT_START:
deltaX = -rect.left;
break;
case FIT_END:
deltaX = viewWidth - rect.right;
break;
default:
deltaX = (viewWidth - width) / 2F - rect.left;
break;
}
} else {
if (rect.left > 0) {
deltaX = -rect.left;
} else if (rect.right < viewWidth) {
deltaX = viewWidth - rect.right;
}
}
mSuppMatrix.postTranslate(deltaX, deltaY);
return true;
}
显然,最主要的方法就是checkMatrixBounds()。这个方法是在应用手势变化后调用,比如某个手势操作要让图片移动一段距离,在mSuppMatrix应用了这个变化距离后,在将它设置到ImageView之前调用,如果这个移动距离使图片的边缘出现在了控件中间,我们就需要移回去,即让mSuppMatrix调用一次postTranslate方法。
那我们怎样知道在调用该方法之前,图片如果变化后的位置呢?这就是getDisplayRect()方法的作用了。
private RectF getDisplayRect(Matrix matrix) {
if (matrix == null) {
return null;
}
Drawable d = getDrawable();
if (d == null) {
return null;
}
RectF r = new RectF(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight());
matrix.mapRect(r);
return r;
}
private Matrix getDrawMatrix() {
mDrawMatrix.set(mBaseMatrix);
mDrawMatrix.postConcat(mSuppMatrix);
return mDrawMatrix;
}
getDrawMatrix显示就是让mBaseMatrix和mSuppMatrix合并起来,这个matrix就是最终要设置到ImageView上的matrix值。这个matrix值是改变的原始的图片,通过matirx.mapRect()方法,正好可以得到原始图片经过matrix变换后的新的位置。根据这个位置,我们就可以判断我们的图片是不是在合适的位置了。注意这里只是做了这样一个变换,并没有真的将matrix应用到ImageView上去。checkMatrixBounds方法剩下的部分,也就只是一个判断了,注意最后将这个修正后的距离post到了mSuppMatrix上。这时候,这个mSuppMatrix就可以设置到ImageView上而不会出问题了。
到这儿其实整个PhotoView的核心就差不多了,其他的实现都类似。
我们看一下快速滑动的实现,同样是在DefaultOnGestureTabListener中:
private class DefaultOnGestureTabListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
mCurrentFlingRunnable = new FlingRunnable(getContext());
mCurrentFlingRunnable.fling((int) (-velocityX), (int) (-velocityY));
post(mCurrentFlingRunnable);
return true;
}
}
快速滑动显示也不能一下子就滑到目的地,所以也需要一个动画:
private class FlingRunnable implements Runnable {
Scroller scroller;
int currentX, currentY;
public FlingRunnable(Context context) {
scroller = new Scroller(context);
}
public void fling(int velocityX, int velocityY) {
final RectF rect = getDisplayRect();
if (rect == null) {
return;
}
final int viewWidth = getImageViewWidth();
final int viewHeight = getImageViewHeight();
final int minX, maxX, minY, maxY;
final int startX = Math.round(-rect.left);
if (viewWidth < rect.width()) {
minX = 0;
maxX = Math.round(rect.width() - viewWidth);
} else {
minX = maxX = startX;
}
final int startY = Math.round(-rect.top);
if (viewHeight < rect.height()) {
minY = 0;
maxY = Math.round(rect.height() - viewHeight);
} else {
minY = maxY = startY;
}
currentX = startX;
currentY = startY;
scroller.fling(startX, startY, velocityX, velocityY, minX, maxX, minY, maxY);
}
public void cancelFling() {
scroller.forceFinished(true);
}
@Override
public void run() {
if (scroller.isFinished()) {
return;
}
if (scroller.computeScrollOffset()) {
final int newX = scroller.getCurrX();
final int newY = scroller.getCurrY();
mSuppMatrix.postTranslate(currentX - newX, currentY - newY);
checkAndDisplayMatrix();
currentX = newX;
currentY = newY;
postDelayed(this, 10);
}
}
}
这里和双击放大的实现方式类似,只是把距离改成了用Scroller来计算。
最后,为了让手势生效,在onTouchEvent方法中调用。
@Override
public boolean onTouchEvent(MotionEvent event) {
mGestureDetector.onTouchEvent(event);
return super.onTouchEvent(event);
}
这样,我们可以使用双击放大和快速滑动的功能了。
再来说一下缩放和滑动功能。对于缩放,我们可以想到用ScaleGestureDetector,可是,对于滑动,再手势类中没有找到对应的方法,所以我们只能自己捕捉ACTION_MOVE方法来实现了。为了代码更简洁,我们将缩放和拖动的实现进行包装一下。
定义一个类,为了区分ScaleGestureDetector,取名为DragAndScaleGestureDetector:
public class DragAndScaleGestureDetector implements OnScaleGestureListener {
ScaleGestureDetector mScaleGestureDetector;
OnDragAndScaleGestureListener mListener;
int mTouchSlop;
boolean mIsDragging;
float mLastMotionX, mLastMotionY;
public DragAndScaleGestureDetector(Context context, OnDragAndScaleGestureListener listener) {
mScaleGestureDetector = new ScaleGestureDetector(context, this);
mListener = listener;
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (mListener != null) {
mListener.onScale(detector.getScaleFactor(), detector.getFocusX(), detector.getFocusY());
}
return true;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
public boolean onTouchEvent(MotionEvent e) {
mScaleGestureDetector.onTouchEvent(e);
switch (e.getAction()) {
case MotionEvent.ACTION_DOWN:
mIsDragging = false;
mLastMotionX = e.getX();
mLastMotionY = e.getY();
break;
case MotionEvent.ACTION_MOVE:
float x = e.getX();
float y = e.getY();
float dx = x - mLastMotionX, dy = y - mLastMotionY;
if (!mIsDragging) {
mIsDragging = Math.hypot(dx, dy) >= mTouchSlop;
}
if (mIsDragging) {
mLastMotionX = x;
mLastMotionY = y;
if (mListener != null) {
mListener.onDrag(dx, dy);
}
}
break;
}
return true;
}
public interface OnDragAndScaleGestureListener {
public void onDrag(float dx, float dy);
public void onScale(float scaleFactor, float focusX, float focusY);
}
这个类很简单,看看它的使用,在GestureImageView类中:
mDragAndScaleGestureDetector = new DragAndScaleGestureDetector(context, new DefaultOnDragAndScaleGestureListener());
private class DefaultOnDragAndScaleGestureListener implements OnDragAndScaleGestureListener {
@Override
public void onDrag(float dx, float dy) {
mSuppMatrix.postTranslate(dx, dy);
checkAndDisplayMatrix();
}
@Override
public void onScale(float scaleFactor, float focusX, float focusY) {
if (getScale() < mMaxScale) {
mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY);
checkAndDisplayMatrix();
}
}
}
是不是实现和上面双击放大和快速拖动的很像呢。
同样需要在onTouchEvent()方法中调用:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
cancelFling();
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
RectF rect = getDisplayRect();
final float minScale = getMinimumScale();
if (getScale() < minScale) {
setScale(minScale, rect.centerX(), rect.centerY(), true);
}
break;
}
mDragAndScaleGestureDetector.onTouchEvent(event);
mGestureDetector.onTouchEvent(event);
return super.onTouchEvent(event);
}
这里将这个方法优化了一下。如果突破正在快速滑动,当再一次点击屏幕的时候,快速滑动应该停止。而在双指缩放图片时,缩放比例有可能比我们设置的最小缩放比例更小,这时候我们将这个比例恢复到最小的缩放比例。这样,整个效果就都有了。
最后使用的时候只要将普通的ImageView换成我们自己的GestureImageView就好了,特别注意不要设置ScaleType为Matrix。
最后说一句,如果大家要使用查看图片的功能,还是使用原版的PhotoView比较好了。