美文网首页Android开发Android开发Android知识
从PhotoView看Android手势监听实践

从PhotoView看Android手势监听实践

作者: sheepm | 来源:发表于2016-11-12 19:45 被阅读2562次

    PhotoView 在做图片缩放的组件或者有类似的需求功能时提供了极大的便利,自身功能也是十分强大。比如

    • 支持手势双击 多指触摸 轻击
    • 完美解决了和一些scroll控件的冲突 比如ViewPager
    • 有drag fling scale 这些操作的回调
    • 兼容性十分好 基本覆盖了全版本 (低版本部分功能不支持)

    PhotoView这个library中最重要核心的部分就是手势的操作,所以这篇文章主要分析整个PhotoView的手势设计思想,学习其中的实现方式。相信看完后对于基本的手指缩放,双击,以及多指操作和事件处理有一个更好的理解。

    手势版本兼容和监听

    先放一张整个library的结构图

    library structure

    看上面的图中能看到有一个类叫做 VersionedGestureDetector ,这就是整个手势的入口,它实际上是一个代理类,里面就一个静态方法 newInstance,通过不同的版本拿到对应的GestureDetector,不过这个并不是系统内部的手势,这个是一个自己创建的抽象接口

    public interface GestureDetector {
    
        public boolean onTouchEvent(MotionEvent ev);
    
        public boolean isScaling();
    
        public boolean isDragging();
    
        public void setOnGestureListener(OnGestureListener listener);
    
    }
    

    整个手势监听的结构是一种高版本继承低版本,必要时进行重写的思想,类之间的继承图是这样的

    Version implement extends
    整个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的实现了

    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维变换,这个部分将会在以后的文章中详细讲解。

    相关文章

      网友评论

        本文标题:从PhotoView看Android手势监听实践

        本文链接:https://www.haomeiwen.com/subject/jqqtuttx.html