PhotoView 在做图片缩放的组件或者有类似的需求功能时提供了极大的便利,自身功能也是十分强大。比如
- 支持手势双击 多指触摸 轻击
- 完美解决了和一些scroll控件的冲突 比如ViewPager
- 有drag fling scale 这些操作的回调
- 兼容性十分好 基本覆盖了全版本 (低版本部分功能不支持)
PhotoView这个library中最重要核心的部分就是手势的操作,所以这篇文章主要分析整个PhotoView的手势设计思想,学习其中的实现方式。相信看完后对于基本的手指缩放,双击,以及多指操作和事件处理有一个更好的理解。
手势版本兼容和监听
先放一张整个library的结构图
看上面的图中能看到有一个类叫做 VersionedGestureDetector
,这就是整个手势的入口,它实际上是一个代理类,里面就一个静态方法 newInstance
,通过不同的版本拿到对应的GestureDetector,不过这个并不是系统内部的手势,这个是一个自己创建的抽象接口
public interface GestureDetector {
public boolean onTouchEvent(MotionEvent ev);
public boolean isScaling();
public boolean isDragging();
public void setOnGestureListener(OnGestureListener listener);
}
整个手势监听的结构是一种高版本继承低版本,必要时进行重写的思想,类之间的继承图是这样的
整个GestureDetector提供了
OnGestureListener
监听的注册,这个监听从PhotoViewAttacher传递到VersionedGestureDetector,然后到上面继承实现的每个类中。
那么还有一个问题就是在什么地方把onTouchEvent这个方法从实现类中注入到PhotoViewAttacher中,我们直接搜一下这个touchEvent在哪调用的
public boolean onTouch(View v, MotionEvent ev) {
boolean handled = false;
if (mZoomEnabled && hasDrawable((ImageView) v)) {
...
// Try the Scale/Drag detector
if (null != mScaleDragDetector) {
boolean wasScaling = mScaleDragDetector.isScaling();
boolean wasDragging = mScaleDragDetector.isDragging();
//注入onTouchEvent
handled = mScaleDragDetector.onTouchEvent(ev);
boolean didntScale = !wasScaling && !mScaleDragDetector.isScaling();
boolean didntDrag = !wasDragging && !mScaleDragDetector.isDragging();
mBlockParentIntercept = didntScale && didntDrag;
}
// Check to see if the user double tapped
if (null != mGestureDetector && mGestureDetector.onTouchEvent(ev)) {
handled = true;
}
}
return handled;
}
从这段代码可以看出在 onTouch
中如果两个变量满足,就会调用 onTouchEvent
把事件传递进去,而onTouch实际上是ImageView设置的setOnTouchListener的回调实现。
imageView.setOnTouchListener(this);
到这里,其实就很清晰了,imageView触发了Touch事件并且将这个event传递给抽象的自定义GestureDetector处理。在这里,事件的处理会调用设置进来的OnGestureListener的对应方法,这也是PhotoView这个库如何实现手势拖动,滑动,缩放的重点。
public interface OnGestureListener {
public void onDrag(float dx, float dy);
public void onFling(float startX, float startY, float velocityX,
float velocityY);
public void onScale(float scaleFactor, float focusX, float focusY);
}
然后继续往下看event是怎么处理的。先从最低版本的实现开始看,也就是CupcakeGestureDetector,主要就是看onTouchEvent的实现。
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
mVelocityTracker = VelocityTracker.obtain();
if (null != mVelocityTracker) {
mVelocityTracker.addMovement(ev);
} else {
LogManager.getLogger().i(LOG_TAG, "Velocity tracker is null");
}
mLastTouchX = getActiveX(ev);
mLastTouchY = getActiveY(ev);
mIsDragging = false;
break;
}
case MotionEvent.ACTION_MOVE: {
final float x = getActiveX(ev);
final float y = getActiveY(ev);
final float dx = x - mLastTouchX, dy = y - mLastTouchY;
if (!mIsDragging) {
// Use Pythagoras to see if drag length is larger than
// touch slop
mIsDragging = Math.sqrt((dx * dx) + (dy * dy)) >= mTouchSlop;
}
if (mIsDragging) {
mListener.onDrag(dx, dy);
mLastTouchX = x;
mLastTouchY = y;
if (null != mVelocityTracker) {
mVelocityTracker.addMovement(ev);
}
}
break;
}
case MotionEvent.ACTION_CANCEL: {
// Recycle Velocity Tracker
if (null != mVelocityTracker) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
break;
}
case MotionEvent.ACTION_UP: {
if (mIsDragging) {
if (null != mVelocityTracker) {
mLastTouchX = getActiveX(ev);
mLastTouchY = getActiveY(ev);
// Compute velocity within the last 1000ms
mVelocityTracker.addMovement(ev);
mVelocityTracker.computeCurrentVelocity(1000);
final float vX = mVelocityTracker.getXVelocity(), vY = mVelocityTracker
.getYVelocity();
// If the velocity is greater than minVelocity, call
// listener
if (Math.max(Math.abs(vX), Math.abs(vY)) >= mMinimumVelocity) {
mListener.onFling(mLastTouchX, mLastTouchY, -vX,
-vY);
}
}
}
// Recycle Velocity Tracker
if (null != mVelocityTracker) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
break;
}
}
return true;
}
代码比较长,不过还是比较清晰简单的,在ACTION_DOWN的事件中初始化了一个VelocityTracker,这个是系统用于监听速度的一个类,获取对象的方法为obtain,看到这种形式就应该想到是设计模式中的享元模式,Message其实也是一样,在DOWN事件同时初始化了一个mIsDragging的flag。
然后就是MOVE事件,如果mIsDragging为false,也就是当前没有处于Drag状态,就判断滑动的相对位移是否大于系统认定的一个滑动大小。如果是的话就回调设置进来的 onDrag(dx, dy)
方法。
最后就是UP事件,如果当前已经处于Drag状态,在手指释放的瞬间,通过前面所说的VelocityTracker的一个方法 computeCurrentVelocity
来计算速度,这里传进去1000,也就是计算前面1000ms的平均速度,如果大于一个可以判定为Fling状态的最小速度,那么就直接回调onFling(mLastTouchX, mLastTouchY, -vX,-vY)
,最后再将VelocityTracker回收掉。
所以这个类是一个基础的手势处理,主要是回调drag,fling两个方法,而真正的scale方法并不是在这个类实现了,也说明了scale是存在版本限制的。
然后我们继续看EclairGestureDetector,它继承了上面的类,也就是说拥有了父类这些方法,而且看到这个类的API限制为5。同样的我们直接看onTouchEvent方法。
@Override
public boolean onTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
mActivePointerId = ev.getPointerId(0);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mActivePointerId = INVALID_POINTER_ID;
break;
case MotionEvent.ACTION_POINTER_UP:
final int pointerIndex = Compat.getPointerIndex(ev.getAction());
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mActivePointerId = ev.getPointerId(newPointerIndex);
mLastTouchX = ev.getX(newPointerIndex);
mLastTouchY = ev.getY(newPointerIndex);
}
break;
}
mActivePointerIndex = ev
.findPointerIndex(mActivePointerId != INVALID_POINTER_ID ? mActivePointerId
: 0);
return super.onTouchEvent(ev);
}
这个API为5的限制主要是因为加入了多指监听,如果想要详细的了解多指监听,可以看官方文档,这里简短的描述一下,如果想要监听多指,首先在获取action时,需要使用 action & MotionEvent.ACTION_MASK
,普通的action是拿不到ACTION_POINTER_UP的事件的,这个事件只有在手指UP并且屏幕上依然还有手指时才会回调,这里所做的工作就是将x,y的坐标切换到新的手指上,修正坐标计算的偏差,在多指操作上这个步骤十分重要。
最后看FroyoGestureDetector这个类,这个类的api限制是8,因为系统在8之后才加入了ScaleGestureDetector这个类。
@Override
public boolean onTouchEvent(MotionEvent ev) {
mDetector.onTouchEvent(ev);
return super.onTouchEvent(ev);
}
在这个onTouchEvent中只是加入了一个ScaleGestureDetector的对象来进行监听缩放。下面就会分析这个类是做什么的。
手势缩放监听ScaleGestureDetector
先看一下这个类的系统注释
/**
* Detects scaling transformation gestures using the supplied {@link MotionEvent}s.
* The {@link OnScaleGestureListener} callback will notify users when a particular
* gesture event has occurred.
*
* This class should only be used with {@link MotionEvent}s reported via touch.
*/
从注释可以看出这个类主要就是用来检测手势变换,并且有一个callback来通知用户scale发生。
ScaleGestureDetector.OnScaleGestureListener mScaleListener = new ScaleGestureDetector.OnScaleGestureListener() {
@Override
public boolean onScale(ScaleGestureDetector detector) {
float scaleFactor = detector.getScaleFactor();
if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor))
return false;
mListener.onScale(scaleFactor,
detector.getFocusX(), detector.getFocusY());
return true;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
// NO-OP
}
};
mDetector = new ScaleGestureDetector(context, mScaleListener);
在前面所说的FroyoGestureDetector中,就是创建了这样一个对象,并且实现了一个OnScaleGestureListener 的接口,用法也是十分的简单,onScale
这个方法中可以获取缩放倍数,以及控制点,所以在这里将结果通过 mListener.onScale(scaleFactor,detector.getFocusX(), detector.getFocusY())
回调出去,这些参数用于后面通过Matrix来缩放ImageView。
我们继续跟到系统ScaleGestureDetector里面看看是怎么判断缩放的。
首先第一步就是确定缩放的焦点,简单的双击和单击焦点就不说了,主要是多指焦点的判断。
在多指情况下分为几种事件,其中POINT_UP的计算和非POINT_UP的事件焦点计算是不一样的,来一段简短的代码
final int count = event.getPointerCount();
final boolean pointerUp = action == MotionEvent.ACTION_POINTER_UP;
final int skipIndex = pointerUp ? event.getActionIndex() : -1;
final int div = pointerUp ? count - 1 : count;
...
for (int i = 0; i < count; i++) {
if (skipIndex == i) continue;
sumX += event.getX(i);
sumY += event.getY(i);
}
focusX = sumX / div;
focusY = sumY / div;
可以看到是将所有手指的坐标加起来除上手指的个数,并且其中考虑了POINT_UP,并且将抬起的手指坐标移出计算的范围。
第二步就是通过焦点坐标和每个手指的坐标,计算一个偏差,源码里称之为span。
计算的代码比较长,这里就不贴了,先分别计算每个点距离焦点的X轴距离和Y轴距离,求得一个平均数,然后一个 Math.hypot
求得最终的span。这个span主要是后面用来计算缩放比例的一个值。
第三步就是根据前面的span来判断是否满足Scale的标准,如果满足就先触发 onScaleBegin
回调
if (!mInProgress && span >= minSpan &&
(wasInProgress || Math.abs(span - mInitialSpan) > mSpanSlop)) {
mPrevSpanX = mCurrSpanX = spanX;
mPrevSpanY = mCurrSpanY = spanY;
mPrevSpan = mCurrSpan = span;
mPrevTime = mCurrTime;
mInProgress = mListener.onScaleBegin(this);
}
这里有一个mInProgress 的返回值,是用来后面的onScale判定的,如果覆写成false,那么后面的一系列回调都不会发生。
第四步就是在ACTION_MOVE的事件中产生缩放
if (action == MotionEvent.ACTION_MOVE) {
mCurrSpanX = spanX;
mCurrSpanY = spanY;
mCurrSpan = span;
boolean updatePrev = true;
if (mInProgress) {
updatePrev = mListener.onScale(this);
}
第五步也就是结束整个Scale,
final boolean streamComplete = action == MotionEvent.ACTION_UP ||
action == MotionEvent.ACTION_CANCEL || anchoredScaleCancelled;
if (action == MotionEvent.ACTION_DOWN || streamComplete) {
// Reset any scale in progress with the listener.
// If it's an ACTION_DOWN we're beginning a new event stream.
// This means the app probably didn't give us all the events. Shame on it.
if (mInProgress) {
mListener.onScaleEnd(this);
可以看到结束的场景很多,ACTION_DOWN ,ACTION_UP ,ACTION_CANCEL以及取消缩放设置都会导致 onScaleEnd
的回调。
到这里就分析完了整个ScaleGestureDetector是如何产生以及缩放比例的设置,以及焦点和结束所有的操作。
双击放大缩小以及单击监听
相比于前面的内容,这里就更简单的, PhotoViewAttacher在构造函数中给了一个默认的单击和双击的手势实现
mGestureDetector.setOnDoubleTapListener(new DefaultOnDoubleTapListener(this));
可以看到所有的实现其实是在DefaultOnDoubleTapListener这个类中。
当然为了扩展性,PhotoView也同样提供了一个方法,可以让我们在外部设置这个实现。
@Override
public void setOnDoubleTapListener(GestureDetector.OnDoubleTapListener newOnDoubleTapListener) {
if (newOnDoubleTapListener != null) {
this.mGestureDetector.setOnDoubleTapListener(newOnDoubleTapListener);
} else {
this.mGestureDetector.setOnDoubleTapListener(new DefaultOnDoubleTapListener(this));
}
}
这里就直接看DefaultOnDoubleTapListener的实现了
这里比较难理解的一块就是图中的 onSingleTapConfirmed
方法,这个方法主要是判断当前的点击是否在ImageView上面,实际使用的话会发现如果点击在ImageView的缩放之外是无法触发单击的那个事件的
final RectF displayRect = photoViewAttacher.getDisplayRect();
if (null != displayRect) {
final float x = e.getX(), y = e.getY();
// Check to see if the user tapped on the photo
if (displayRect.contains(x, y)) {
float xResult = (x - displayRect.left)
/ displayRect.width();
float yResult = (y - displayRect.top)
/ displayRect.height();
photoViewAttacher.getOnPhotoTapListener().onPhotoTap(imageView, xResult, yResult);
return true;
}
}
这里有一个方法getDisplayRect,主要是根据我们设置的ScaleType转换后的matrix的坐标进行一个变换得到一个新的RectF ,这个就是Imageview实际缩放后的边界,再与Event进行比较看是否单击发生在边界之内,如果在里面,就会触发 onPhotoTap
这个回调。
这个类另一个就是双击的实现
@Override
public boolean onDoubleTap(MotionEvent ev) {
if (photoViewAttacher == null)
return false;
try {
float scale = photoViewAttacher.getScale();
float x = ev.getX();
float y = ev.getY();
if (scale < photoViewAttacher.getMediumScale()) {
photoViewAttacher.setScale(photoViewAttacher.getMediumScale(), x, y, true);
} else if (scale >= photoViewAttacher.getMediumScale() && scale < photoViewAttacher.getMaximumScale()) {
photoViewAttacher.setScale(photoViewAttacher.getMaximumScale(), x, y, true);
} else {
photoViewAttacher.setScale(photoViewAttacher.getMinimumScale(), x, y, true);
}
} catch (ArrayIndexOutOfBoundsException e) {
// Can sometimes happen when getX() and getY() is called
}
return true;
}
可以看到双击放大和缩小都是在这里产生的,根据当前的缩放比来决定下一次缩放到哪一个等级。
后记
经过以上分析就能了解整个PhotoView关于手势的一切实现,如何产生拖拽,如何在手指抬起后依然滑动,如何实现多指的缩放,以及单击和双击的事件响应。当然这篇文章只是讲解手势部分。
至于产生事件后,PhotoView是如何通过Matrix来进行变换在这里并没有讲解,因为Matrix这个类能使用的场景远比这个需求复杂,不仅是平移和缩放,更能做到错切以及3维变换,这个部分将会在以后的文章中详细讲解。