查看图片是每个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());
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; } }
一般的,双击放大都会看到图片有一个放大的动画,不会一下放大到一个比例。
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; }
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); } } }
最后,为了让手势生效,在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比较好了。