从PhotoView看Android手势监听实践

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,517评论 25 707
  • 我知道你在这个世界的某个角落煎熬,在被子里哭得一塌糊涂,心痛得快要死掉。 我知道你在中国的某个城市,生...
    云归处vv阅读 405评论 0 0
  • 岁月惊艳了流年 凡尘俗世迷了那双清澈的眼 时光长河中 失去了多少 又收获了多少 那个夏天炎炎烈日下 讲述一个没有结...
    远方孤雁阅读 162评论 1 2
  • 继承概念:通过一个类,创建另一个类这样新创建出来的类不仅拥有了原来的属性和方法,而且还可以添加自己独有的属性和方法...
    爱琴宝阅读 328评论 0 0
  • 多年的朋友,即使身处异地,也会为你着想。我多幸运我的人生中有你出现,为我的青春添了一份活力,也让我在无聊的时候有人...
    安瑾顔柒柒阅读 226评论 0 0