自定义 PhotoView

11 篇文章 0 订阅

实现的是一些基础效果:显示一张图片,可以对其进行双击放大缩小、双指手势放大缩小,并且在放大状态下可以滑动图片。效果图:

就是个非常简单的 Demo,实现功能的方式都很基础,肯定有逻辑上考虑的不严谨导致的 bug,主要为了了解功能如何实现。

以下是实现步骤。

1.绘制图片到屏幕中间

	private float mOriginalOffsetX;
    private float mOriginalOffsetY;
	
	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 居中绘制图片
        canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
    }

    /**
     * 初始会在 onDraw() 之前调用一次,之后当尺寸发生变化时回调
     */
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        // 求出初始偏移量,让图片居中
        mOriginalOffsetX = (getWidth() - mBitmap.getWidth()) / 2f;
        mOriginalOffsetY = (getHeight() - mBitmap.getHeight()) / 2f;
    }

2.计算缩放比例

默认状态下图片的显示可能在左右两边有空白,需要对图片进行缩放,让图片的宽填充满图片的宽:


图 1 是图片当前的显示状态,我们需要缩放图片,让它默认显示的状态变成图 2 那样,宽度占满屏幕的宽。除此之外还有一中更大的缩放比例,使得图片的高度占满屏幕的高度,我们将图 2 的缩放比例称为小比例,图 3 的缩放比例称为大比例。

	private float mSmallScale;
    private float mBigScale;
    private float mCurrentScale;
    
	@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        ......

        // 计算缩放比例,若图片的宽高比大于屏幕的宽高比,说明宽比高更容易占满屏幕,宽就用小比例
        if (mBitmap.getWidth() / mBitmap.getHeight() > getWidth() / getHeight()) {
            // 放大后一边全屏,一边留白叫小缩放
            mSmallScale = (float) getWidth() / mBitmap.getWidth();
            // 放大后一边全屏,一边超出界面叫大缩放
            mBigScale = (float) getHeight() / mBitmap.getHeight();
        } else {
            mSmallScale = (float) getHeight() / mBitmap.getHeight();
            mBigScale = (float) getWidth() / mBitmap.getWidth();
        }

        mCurrentScale = mSmallScale;
    }

	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 以 canvas 中心为缩放中心进行缩放
        canvas.scale(mCurrentScale, mCurrentScale, getWidth() / 2f, getHeight() / 2f);
        // 居中绘制图片
        canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
    }

3.双击缩放

3-1.GestureDetector

双击事件可以通过 GestureDetector 的 OnDoubleTapListener 接口进行监听:

	public interface OnDoubleTapListener {
        /**
         * 单击按下时触发,双击时不会触发。
         * 单击事件为什么不直接用 onClick 方法?
         * 1.onClickListener 中的 onClick 方法与该方法冲突
         * 2.用 onClick 方法,不管单击还是双击都会触发(双击就触发两次)
         */
        boolean onSingleTapConfirmed(MotionEvent e);
 
        /**
         * 双击第二次点击按下时发生回调。
         * 300ms 内点击两次才算双击。
         */
        boolean onDoubleTap(MotionEvent e);

        /**
         * 双击的第二次点击时,按下、移动和抬起事件都会回调。
         */
        boolean onDoubleTapEvent(MotionEvent e);
    }

onDoubleTap() 判断双击事件是在 40ms ~300ms 之内生效,40ms 以内认为是抖动。在 ACTION_DOWN 内进行处理的时候,双击的第一次点击时,hadTapMessage 为 false,会进入 else 条件发送一个延时消息,第二次点击后 hadTapMessage 才会为 true,进入到 if 条件回调 onDoubleTap() 和 onDoubleTapEvent():

			case MotionEvent.ACTION_DOWN:
                if (mDoubleTapListener != null) {
                    boolean hadTapMessage = mHandler.hasMessages(TAP);
                    if (hadTapMessage) mHandler.removeMessages(TAP);
                    if ((mCurrentDownEvent != null) && (mPreviousUpEvent != null)
                            && hadTapMessage
                            && isConsideredDoubleTap(mCurrentDownEvent, mPreviousUpEvent, ev)) {
                        // This is a second tap
                        mIsDoubleTapping = true;
                        recordGestureClassification(
                                TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__DOUBLE_TAP);
                        // Give a callback with the first tap of the double-tap
                        handled |= mDoubleTapListener.onDoubleTap(mCurrentDownEvent);
                        // Give a callback with down event of the double-tap
                        handled |= mDoubleTapListener.onDoubleTapEvent(ev);
                    } else {
                        // This is a first tap
                        mHandler.sendEmptyMessageDelayed(TAP, DOUBLE_TAP_TIMEOUT); // DOUBLE_TAP_TIMEOUT 为 300
                    }
                }
				......

两个方法的区别是,onDoubleTap() 只有双击才会触发,而 onDoubleTapEvent() 的第二个事件是 DOWN、MOVE、UP 都可以触发:

				case MotionEvent.ACTION_MOVE:
                    if (mInLongPress) {
                        break;
                    }
                    final float scrollX = mLastFocusX - focusX;
                    final float scrollY = mLastFocusY - focusY;
                    if (mIsDoubleTapping) {
                        // Give the move events of the double-tap
                        handled |= mDoubleTapListener.onDoubleTapEvent(ev);
                    }
                    ......
                case MotionEvent.ACTION_UP:
                    mStillDown = false;
                    MotionEvent currentUpEvent = MotionEvent.obtain(ev);
                    if (mIsDoubleTapping) {
                        // Finally, give the up event of the double-tap
                        handled |= mDoubleTapListener.onDoubleTapEvent(ev);
                    }
                    ......

另外在 GestureDetector 的构造方法中,如果传入的 OnGestureListener listener 同时也是 OnDoubleTapListener 或 OnContextClickListener 的接口实例,就会设置对应的监听器:

	public GestureDetector(Context context, OnGestureListener listener, Handler handler) {
        if (handler != null) {
            mHandler = new GestureHandler(handler);
        } else {
            mHandler = new GestureHandler();
        }
        mListener = listener;
        if (listener instanceof OnDoubleTapListener) {
            setOnDoubleTapListener((OnDoubleTapListener) listener);
        }
        if (listener instanceof OnContextClickListener) {
            setContextClickListener((OnContextClickListener) listener);
        }
        init(context);
    }

既然说到 OnGestureListener 我们也顺便看一下,之后的用手指滑动图片和惯性滑动图片时用得到:

	public interface OnGestureListener {

        /**
         * 按下,返回 true 表示消费事件
         */
        boolean onDown(MotionEvent e);

        /**
         * 触摸反馈
         * 在 View 被点击(按下)时调用,其作用是给用户一个视觉反馈,让用户知道我这个控件被点击了,
         * 这样的效果我们也可以用 Material design 的 ripple 实现,或者直接 drawable 写个背景也行。
         * 它是一种延时回调,延迟时间是 100 ms。也就是说用户手指按下后,如果立即抬起或者事件立即被拦截,
         * 时间没有超过 100ms 的话,这条消息会被 remove 掉,也就不会触发这个回调。
         */
        void onShowPress(MotionEvent e);

        /**
         * 单击抬起
         * 单击抬起时触发,或在双击的第一次抬起时触发。(连续点击三次,则会触发两次)
         */
        boolean onSingleTapUp(MotionEvent e);

        /**
         * 手指按下后移动,类似 move事件
         * e1:手指按下时的 MotionEvent
         * e2:手指当前的 MotionEvent
         * distanceX:在X轴上划过的距离 --- 旧位置 减去 新位置
         * distanceY:在Y轴上划过的距离
         * @return true 表示事件被消费
         */
        boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);

        /**
         * 长按事件,可以通过 setIsLongpressEnabled(false) 取消长按事件的响应
         * 在 dispatchLongPress() 分发长按事件时被回调
         * 按住超过 300ms 才认为是长按
         */
        void onLongPress(MotionEvent e);

        /**
         * 惯性滑动,最小滑动速度50dip/s(dp=dip),最大8000dp/s
         * 在 onTouchEvent() 处理 ACTION_UP 时判断,大于 mMinimumFlingVelocity 才被回调
         * e1、e2 含义与 onScroll() 相同
         * velocityX:在X轴上的运动速度(像素/秒)
         * velocityY:在Y轴上的运动速度(像素/秒)
         * @param e1 The first down motion event that started the fling.
         * @param e2 The move motion event that triggered the current onFling.
         * @return true 表示被消费
         */
        boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
    }

3-2.利用 GestureDetector 实现双击缩放

实现双击缩放时要用到 OnDoubleTapListener,而实现手指拖动缩放时要用到 OnGestureListener,如果直接实现这俩接口,要实现的方法过多。所以可以利用 GestureDetector.SimpleOnGestureListener,它实现了 GestureDetector 内的 OnDoubleTapListener、OnGestureListener 和 OnContextClickListener 接口,这样我们继承 SimpleOnGestureListener,然后重写需要的接口方法即可:

	private class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {

        @Override
        public boolean onDown(MotionEvent e) {
            return true;
        }

        @Override
        public boolean onDoubleTap(MotionEvent e) {
            mIsInBigScale = !mIsInBigScale;
            if (mIsInBigScale) {
                mCurrentScale = mBigScale;
            } else {
                mCurrentScale = mSmallScale;
            }
            invalidate();
            return super.onDoubleTap(e);
        }
    }

然后创建一个 GestureDetector 对象,把 PhotoGestureListener 的实例传递给构造方法,再把事件处理交给 GestureDetector:

	private GestureDetector mGestureDetector;

	private void init(Context context) {
        ......
        mGestureDetector = new GestureDetector(context, new PhotoGestureListener());
    }

	@Override
    public boolean onTouchEvent(MotionEvent event) {
        return mGestureDetector.onTouchEvent(event);
    }

3-3.添加动画让缩放更平滑

双击前后缩放倍数 mCurrentScale 直接在 mSmallScale 与 mBigScale 之间跳转显得不够平滑,加个动画:

	private ObjectAnimator getScaleAnimator() {
        if (mScaleAnimator == null) {
            // 属性名称不是看声明的变量名,而是看 getter 和 setter 方法名中的 getXXX 和 setXXX 的 XXX
            mScaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", 0);
        }
        mScaleAnimator.setFloatValues(mSmallScale, mBigScale);
        return mScaleAnimator;
    }

    public float getCurrentScale() {
        return mCurrentScale;
    }

    public void setCurrentScale(float currentScale) {
        mCurrentScale = currentScale;
        invalidate();
    }

然后把 onDoubleTap() 这个回调方法修改一下:

		@Override
        public boolean onDoubleTap(MotionEvent e) {
            mIsInBigScale = !mIsInBigScale;
            if (mIsInBigScale) {
                getScaleAnimator().start();
            } else {
                getScaleAnimator().reverse();
            }
            invalidate();
            return super.onDoubleTap(e);
        }

4.滑动图片

只要当前的缩放比例大于最小的缩放比例 mSmallScale 就可以用手指滑动图片,滑动类型分为两种,触摸滑动与惯性滑动,其实都是在通过改变 Canvas 的偏移量实现滑动效果。

4-1.触摸滑动

在 onScroll() 中监听滑动事件:

	private float mCanvasOffsetX;
    private float mCanvasOffsetY;
    
	private class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
		......
		@Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (mCurrentScale > mSmallScale) {
                // 由于 distance 是旧点坐标 - 新点坐标,所以偏移量应该减掉 distance 而不是加
                mCanvasOffsetX -= distanceX;
                mCanvasOffsetY -= distanceY;
                // 修复边界
                fixOffsets();
                invalidate();
            }
            return super.onScroll(e1, e2, distanceX, distanceY);
        }
	}

目的是为了得到 Canvas 应该平移的偏移量 mCanvasOffsetX 和 mCanvasOffsetY,然后在 onDraw() 时绘制出来:

	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // canvas 平移
        canvas.translate(mCanvasOffsetX, mCanvasOffsetY);
        // 以 canvas 中心为缩放中心进行缩放
        canvas.scale(mCurrentScale, mCurrentScale, getWidth() / 2f, getHeight() / 2f);
        // 居中绘制图片
        canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
    }

注意 fixOffsets() 是为了给偏移量设置边界,让图片在向外滑动时刚好能显示到图片的四个边界就停止,不再继续向外滑动:

	private void fixOffsets() {
        mCanvasOffsetX = Math.min(mCanvasOffsetX, (mBitmap.getWidth() * mBigScale- getWidth()) / 2f);
        mCanvasOffsetX = Math.max(mCanvasOffsetX, -(mBitmap.getWidth() * mBigScale- getWidth()) / 2f);
        mCanvasOffsetY = Math.min(mCanvasOffsetY, (mBitmap.getHeight() * mBigScale- getHeight()) / 2f);
        mCanvasOffsetY = Math.max(mCanvasOffsetY, -(mBitmap.getHeight() * mBigScale- getHeight()) / 2f);
    }

以右边界为例,当手指向左滑动时,图片向右滑动,到达右边界时的状态如下:


上面是初始状态,下面是左滑让图片右边正好显示在 PhotoView 的右边界上的情况,这是极限状态。缩放中心的 x 轴坐标变化就是平移时用到的偏移量,看下图就容易看出,这个极限偏移量 maxOffset = 图片宽度/2 - PhotoView 宽度/2。由于是对称的,所以另外一侧的极限偏移量是 maxOffset 直接取反。因此 x 轴的偏移量要在 [-maxOffset,maxOffset] 这个区间内,于是就有了 fixOffsets() 的计算方式。

4-2.惯性滑动

惯性滑动要借助 OverScroller 重写 GestureDetector.SimpleOnGestureListener 的 onFling():

	private OverScroller mOverScroller;
	
	private void init(Context context) {
        ......
        mOverScroller = new OverScroller(context);
    }
    
	private class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
		......
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (mCurrentScale > mSmallScale) {
                mOverScroller.fling((int) mCanvasOffsetX, (int) mCanvasOffsetY, (int) velocityX, (int) velocityY,
                        -(int) ((mBitmap.getWidth() * mCurrentScale - getWidth()) / 2f),
                        (int) ((mBitmap.getWidth() * mCurrentScale - getWidth()) / 2f),
                        -(int) ((mBitmap.getHeight() * mCurrentScale - getHeight()) / 2f),
                        (int) ((mBitmap.getHeight() * mCurrentScale - getHeight()) / 2f),
                        100, 100);
                postOnAnimation(new FlingRunner());
            }
            return super.onFling(e1, e2, velocityX, velocityY);
        }
    }

OverScroller 的 fling() 参数依次为滑动起点坐标、滑动速度、四个方向的边界,最后两个参数 100,100 表示的是 x,y 轴方向允许惯性滑动超出边界的距离,即下面这个效果:

设置了overX和overY参数后会滑出边界后再回弹

postOnAnimation() 中传递的任务,是在 Fling 动画还未结束时,获取当前的 x,y 值作为 Canvas 的偏移量,刷新界面:

	class FlingRunner implements Runnable {

        @Override
        public void run() {
            // 如果 Fling 动画还没有结束
            if (mOverScroller.computeScrollOffset()) {
                mCanvasOffsetX = mOverScroller.getCurrX();
                mCanvasOffsetY = mOverScroller.getCurrY();
                invalidate();
                // 不断调用自己,形成死循环,在 computeScrollOffset() 返回 false
                // 之前会一直取出偏移量并刷新界面达到 Fling 的效果
                postOnAnimation(this);
            }
        }
    }

5.双指缩放

实现 ScaleGestureDetector.OnScaleGestureListener 接口可以完成双指缩放:

	private ScaleGestureDetector mScaleGestureDetector;
	
	private void init(Context context) {
        ......
        mScaleGestureDetector = new ScaleGestureDetector(context, new PhotoScaleGestureListener());
    }
    
	private class PhotoScaleGestureListener implements ScaleGestureDetector.OnScaleGestureListener {

        private float initScale;

        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            // getScaleFactor:比例因子,是个动态值,由上一个缩放事件到当前缩放事件
            // 缩放比例不能小于 mSmallScale,否则图片的宽就不能占满 PhotoView 的宽了
            if (initScale * detector.getScaleFactor() >= mSmallScale) {
                mCurrentScale = initScale * detector.getScaleFactor();
            }
            invalidate();
            return false;
        }

        // 缩放前回调,返回 true 表示消费这个缩放事件
        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            initScale = mCurrentScale;
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {

        }
    }

此外还要将事件优先交给 ScaleGestureDetector 处理:

	@Override
    public boolean onTouchEvent(MotionEvent event) {
    	// 双指缩放操作优先处理事件
        boolean result = mScaleGestureDetector.onTouchEvent(event);
        // 如果不是双指缩放才处理手势事件
        if (!mScaleGestureDetector.isInProgress()) {
            result = mGestureDetector.onTouchEvent(event);
        }
        return result;
    }

6.Bug 解决与优化

问题1.双击放大后平移再双击缩小,图片位置不对


缩小后的图片应该位于 PhotoView 的中央才对,出现这个问题的原因是平移了图片之后,图片的缩放中心不再与 PhotoView 的中心重合,再双击缩小时,仍然是以图片本身的缩放中心(上图中应该是在屏幕右侧)进行缩放,所以缩小后它不在 PhotoView 的中央。

解决办法:设置一个缩放因子 scaleFraction,用来表示当前缩放的程度占整个缩放跨度的百分比,即 scaleFraction = (mCurrentScale - mSmallScale) / (mBigScale - mSmallScale),显然这是一个 [0,1] 的值,当 mCurrentScale 为最大缩放值 mBigScale 时取到 1,当 mCurrentScale 为 mSmallScale 时 scaleFraction 取到 0,把缩放因子与平移的偏移量绑定起来:

	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        float scaleFraction = (mCurrentScale - mSmallScale) / (mBigScale - mSmallScale);

        // canvas 平移
        canvas.translate(mCanvasOffsetX * scaleFraction, mCanvasOffsetY * scaleFraction);
        // 以 canvas 中心为缩放中心进行缩放
        canvas.scale(mCurrentScale, mCurrentScale, getWidth() / 2f, getHeight() / 2f);
        // 居中绘制图片
        canvas.drawBitmap(mBitmap, mOriginalOffsetX, mOriginalOffsetY, mPaint);
    }

这样当缩放比例为 mSmallScale 时 Canvas 就不会进行平移了,双击缩小后就能处于 PhotoView 中央了。

问题2.没有从双击的位置放大

手机系统中的相册,双击一个位置后似乎是以该点为准进行的放大,而 Demo 目前的效果则总是以图片中心为准放大。看对比图:

当前有问题的图
修复问题后的图

根本原因是,双击某一个位置点 A 放大图片后,A 点的位置在 PhotoView 中的坐标应该是不变的,但我们的 Demo 目前的效果则是 A 点会随着缩放进行移动:
问题原因示意图
上图蓝色背景是按照小比例缩放的图片,而绿色背景是按照大比例缩放后的图片。上面的图解释了问题产生的原因,就是被点击的点在按照大比例缩放后,它在 PhotoView 中的位置就发生了变化,如果想做到修复后的效果,需要在执行缩放之前,先平移图片,让上图中的两个红点重合,达到下面图的位置即可:

		@Override
        public boolean onDoubleTap(MotionEvent e) {
            mIsInBigScale = !mIsInBigScale;
            if (mIsInBigScale) {
                mCanvasOffsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * mBigScale / mSmallScale;
                mCanvasOffsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * mBigScale / mSmallScale;
                fixOffsets();
                getScaleAnimator().start();
            } else {
                getScaleAnimator().reverse();
            }
            invalidate();
            return super.onDoubleTap(e);
        }

结合图片说明如何计算 mCanvasOffsetX 和 mCanvasOffsetY:

请添加图片描述
将缩放中心 O 点视为坐标原点 (0,0),被双击的点 A 在放大后到了点 B,过点 A 与 X 轴平行的线与 PhotoView 的左边界交于点 C,与 Y 轴交于点 D:

  • A 点横坐标 x A x_A xA = -(CD - AC) = -(photoViewWidth / 2 - eventA.getX()) = e.getX() - getWidth() / 2
  • B 点与 A 点在 Y 轴同侧,A 点是图片使用小比例时的位置,而 B 点是图片使用大比例的位置,因此可以直接使用比例计算 B 点横坐标 x B x_B xB = x A x_A xA * mBigScale / mSmallScale = (e.getX() - getWidth() / 2f) * mBigScale / mSmallScale

纵坐标计算方式与横坐标类似,不再赘述。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值