Android 手势图片,强大的开源框架PhotoView

查看图片是每个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();


然后,在对图片进行初始化的时候,调用下面的方法:这是我对这个开源控件最喜爱的地方之一,完全考虑到了ImageView本身对ScaleType的设置。


	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比较好了。




你可能感兴趣的:(android,开源框架,photoview,gesturedetector)