Android开发实战——自定义view之PhotoView图片查看器

 

为了方便代码的阅读,我将类都写成了内部类,下面的代码拿了直接可以使用,换一下bitmap就行了。注释也是比较详细的,认真看再结合使用,应该很容易理解。

PhotoView.java

public class PhotoView extends View {
    private static final float IMAGE_WIDTH = Utils.dpToPixel(300);
    private Bitmap bitmap;
    private Paint paint;

    private float originalOffsetX; // X轴偏移 这里主要用于图片初始化时设置居中
    private float originalOffsetY; // Y轴偏移 这里主要用于图片初始化时设置居中

    private float smallScale;
    private float bigScale;
    private float currentScale;

    private GestureDetector gestureDetector;

    /*
     * 标志位,用于判断当前图片是smallScale还是bigScale
     * boolean值默认为false PhotoView默认也是smallScale
     * 所以当图片为smallScale时isFlag=false  当图片为bigScale时isFlag=true 值在onDoubleTap方法里面进行修改
     * */
    private boolean isFlag;

    private ObjectAnimator animator;// 双击图片时的动画

    private float offsetX; // 拖动图片时X轴偏移量
    private float offsetY; // 拖动图片时Y轴偏移量

    private OverScroller scroller; // 惯性滑动

    private ScaleGestureDetector scaleGestureDetector;


    private void init(Context context) {
        // todo 步骤1 初始化
        bitmap = Utils.getPhoto(getResources(), (int) IMAGE_WIDTH);// 获取bitmap对象
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);// 使位图抗锯齿

        // todo 步骤3 手势监听
        gestureDetector = new GestureDetector(context, new PhotoGestureDetector());
        // 关闭长按响应
        // gestureDetector.setIsLongpressEnabled(false);
        scroller = new OverScroller(context);


        // todo 步骤7 双指缩放监听
        scaleGestureDetector = new ScaleGestureDetector(context, new PhotoOnScaleGestureListener());
    }

    public PhotoView(Context context) {
        this(context, null);
    }

    public PhotoView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public PhotoView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // todo 步骤9 处理图片放大平移后 再缩小时 图片不回到正中间的问题
        /*
         * 这个系数是如何来的可能有点绕
         * 我们需要解决步骤9的问题,首先需要偏移值offsetX、offsetY与我们的缩放因子进行绑定,缩放因子越大,偏移的值也越大
         * 我们知道,整个图片最大的缩放因子为bigScale(终点),最小缩放因子为smallScale(起点), bigScale-smallScale得到的就是总共缩放因子的区间值(距离)
         * 那么当前缩放因子是currentScale(当前所在位置), 减去smallScale(起点),得到的是当前缩放因子距离最小缩放因子的值(当前位置-起点的位置)
         * 那么  (当前位置-起点的位置)/ 总距离   得到的就是距离比  也就是当前我完成了总路程的百分之几
         *
         * 结合下面的公式我们可以知道当currentScale=smallScale时 scaleAction为0  此时图片不偏移
         * 当currentScale=bigScale时  scaleAction为1此时的图片为最大图 那么这个时候如果移动图片的话offsetX、offsetY该偏移多少就偏移多少
         *
         * 所以当我们这样计算这个比例之后,当图片处于最大和最小之间时,我们手指平移100px,那么图片可能只会平移50px
         * 当图片处于最大的时候,我们手指平移100px,那么图片会平移100px
         * 当图片处于最小的时候,我们手指平移100px,那么图片会平移0px 也就是不平移
         * */
        float scaleAction = (currentScale - smallScale) / (bigScale - smallScale);
        /* 图片拖动的效果 */
        canvas.translate(offsetX * scaleAction, offsetY * scaleAction);

        /* 四个参数的意思分别是:图片X轴缩放系数、图片Y轴缩放系数、缩放时以哪个点进行缩放(我们取的是屏幕的中心点,默认是屏幕左上角,即0,0) */
        canvas.scale(currentScale, currentScale, getWidth() / 2f, getHeight() / 2f);

        /* 居中显示 */
        canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint);
    }


    /* 在控件大小发生改变时调用 初始化时会被调用一次 后续控件大小变化时也会调用*/
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // todo 步骤2 初始图片位置

        /* 居中显示时 X、Y的值 */
        originalOffsetX = (getWidth() - bitmap.getWidth()) / 2f;
        originalOffsetY = (getHeight() - bitmap.getHeight()) / 2f;

        /*
         * 进入界面初始化图片时,需要满足左右两边填充整个屏幕或者上下两边填充整个屏幕
         * 所以要判断图片是竖形状的图片 还是横形状的图片
         * 这里用图片的宽高比与屏幕的宽高比进行对比来判断
         *
         * smallScale表示图片缩放的比例  图片最小是多小
         * 命中if: 图片按照宽度比进行缩放,当图片的宽度与屏幕的宽度相等时停止缩放,图片上下边界与屏幕上下边界会有间距
         * 命中else: 图片按照高度比进行缩放,当图片的高度与屏幕的高度相等时停止缩放,图片左右边界与屏幕左右边界会有间距
         *
         * bigScale表示图片缩放的比例  图片最大是多大
         * 不*1.5f的话,那么图片最大就是窄边与屏边对齐   *1.5f表示图片放大后窄边也可以可以超出屏幕
         *
         * currentScale的值到底是什么,取决于我们的需求
         * 在这里smallScale表示缩小  bigScale表示放大
         * 当currentScale = smallScale时 双击图片之后currentScale = bigScale  否则相反  这个判断在下面的双击事件里面处理
         * */
        if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) {
            smallScale = (float) getWidth() / bitmap.getWidth();
            bigScale = (float) getHeight() / bitmap.getHeight() * 1.5f;
        } else {
            smallScale = (float) getHeight() / bitmap.getHeight();
            bigScale = (float) getWidth() / bitmap.getWidth() * 1.5f;
        }
        currentScale = smallScale;

    }

    /*
     * 因为当view被点击的时候,进入的是view的onTouchEvent方法进行事件分发
     * 而我们这里用到的是GestureDetector 所以view的onTouchEvent要托管给GestureDetector的onTouchEvent去执行
     * 但是同时 双指缩放的监听也需要用到ScaleGestureDetector的onTouchEvent 所以还需要进行判断
     *
     * */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 响应事件以双指缩放优先
        boolean result = scaleGestureDetector.onTouchEvent(event);

        // 判断 如果不是双指缩放 则把事件交给手势监听处理
        if (!scaleGestureDetector.isInProgress()) {
            result = gestureDetector.onTouchEvent(event);
        }
        return result;
    }

    /* 手势相关监听 */
    class PhotoGestureDetector extends GestureDetector.SimpleOnGestureListener {

        /*
         * 单击或者双击的第一次up时触发
         * 即如果不是长按、不是双击的第二次点击  则在up时触发
         * */
        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return super.onSingleTapUp(e);
        }

        /* 长按触发 默认超过300ms时触发 */
        @Override
        public void onLongPress(MotionEvent e) {
            super.onLongPress(e);
        }

        /**
         * 滚动时(拖动图片)触发 -- move
         *
         * @param e1        手指按下的事件
         * @param e2        当前的事件
         * @param distanceX 旧位置 - 新位置  所以小于0表示往右  大于0表示往左  所以计算偏移值时要取反
         * @param distanceY 同上
         * @return
         */
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            // todo 步骤5 处理拖拽
            // 当图片为放大模式时才允许拖动图片
            // distanceX的值并不是起始位置与终点位置的差值,而是期间若干个小点汇聚而成的
            // 比如当从0px滑动到100px时,distanceX在1px时会出现,此时distanceX就是-1,然后可能又在2px时出现,
            // 在整个滑动过程中distanceX的值一直在变动,所以offsetX要一直计算   offsetY同理
            if (isFlag) {
                offsetX = offsetX - distanceX;
                offsetY = offsetY - distanceY;

                // 计算图片可拖动的范围
                fixOffset();
                // 刷新
                invalidate();
            }
            return super.onScroll(e1, e2, distanceX, distanceY);
        }

        /**
         * up时触发 手指拖动图片的时候 惯性滑动 -- 大于50dp/s
         *
         * @param velocityX x轴方向运动速度(像素/s)
         * @param velocityY
         * @return
         */
        @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            // todo 步骤6 处理拖拽时的惯性滑动
            // 当图片为放大模式时才允许惯性滑动
            if (isFlag) {
                // 最后两个参数表示 当惯性滑动超出图片范围多少之后回弹,这也是为什么用OverScroller而不用Scroller的原因
                scroller.fling((int) offsetX, (int) offsetY, (int) velocityX, (int) velocityY,
                        (int) -(bitmap.getWidth() * bigScale - getWidth()) / 2,
                        (int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
                        (int) -(bitmap.getHeight() * bigScale - getHeight()) / 2,
                        (int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
                        300, 300);
                postOnAnimation(new FlingRun());
            }

            return super.onFling(e1, e2, velocityX, velocityY);
        }

        /* 点击后延时100ms触发 -- 主要用于处理自定义的点击效果,例如水波纹等 */
        @Override
        public void onShowPress(MotionEvent e) {
            super.onShowPress(e);
        }

        /*
         *  down时触发 在这个需求里面,我们需要返回true 这与事件分发有关 可以看下我相关的文章
         * 这里只要知道, 当返回true的时候下面的双击等函数才会执行 否则直接在这里就拦截了
         * */
        @Override
        public boolean onDown(MotionEvent e) {
            return true;
        }

        /* 双击的第二次点击down时触发。双击的触发时间 -- 40ms -- 300ms */
        @Override
        public boolean onDoubleTap(MotionEvent e) {
            // todo 步骤4 处理双击
            if (!isFlag) {
                // isFlag为false表示 取反前处于smallScale(缩小)状态,则双击后要变成bigScale(放大)
                // currentScale = bigScale;
                // 点击图片的哪个位置 哪个位置就进行放大 不设置的话 图片只会以中心进行放大
                // 其原理是以中心店先进行放大 再进行偏移
                offsetX = (e.getX() - getWidth() / 2f) -
                        (e.getX() - getWidth() / 2f) * bigScale / smallScale;
                offsetY = (e.getY() - getHeight() / 2f) -
                        (e.getY() - getHeight() / 2f) * bigScale / smallScale;
                fixOffset();

                // 这里直接添加动画, 图片从小到大  在getAnimator()方法里面我们设置了setFloatValues的值
                getAnimator(smallScale, bigScale).start();
            } else {
                // isFlag为true表示当前处于bigScale(放大)状态,则双击后要变成smallScale(缩小)
                // currentScale = smallScale;
                // 这里直接添加动画, 图片从大到小
                // 如果没有双指缩放功能,直接用下面这行代码就行了,但是在有双指缩放的情况下,如果图片被双指缩放到一半的时候再进行双击
                // 图片会有一个先缩小再放大的过程,这就是一个小BUG了
                // getAnimator(bigScale, smallScale).start();
                // 所以这里我们要用currentScale
                getAnimator(currentScale, smallScale).start();
            }
            // 每次双击后取反
            isFlag = !isFlag;
            return super.onDoubleTap(e);
        }

        /* 双击的第二次down、move、up 都触发 */
        @Override
        public boolean onDoubleTapEvent(MotionEvent e) {
            return super.onDoubleTapEvent(e);
        }

        /*
         * 单击按下时触发,双击时不触发,down,up时都可能触发
         * 延时300ms触发TAP事件
         * 300ms以内抬手 -- 会触发TAP -- onSingleTapConfirmed
         * 300ms以后抬手 -- 不是双击不是长按,则触发  但是300ms以后默认是长按事件
         * 因此我们可以关闭长按事件的相应  上面代码有注释
         * */
        @Override
        public boolean onSingleTapConfirmed(MotionEvent e) {
            return super.onSingleTapConfirmed(e);
        }
    }


    class FlingRun implements Runnable {

        @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
        @Override
        public void run() {
            // 判断惯性动画是否结束 没结束返回true   结束了返回的是false
            if (scroller.computeScrollOffset()) {
                offsetX = scroller.getCurrX();
                offsetY = scroller.getCurrY();
                invalidate();
                // 下一帧动画的时候执行
                postOnAnimation(this);
            }
        }
    }

    /*
     * 允许拖动图片的边界
     *
     * 设置图片最大拖动的距离 如果不设置,那么拖动的距离超出图片之后,看到的就是白色的背景
     * 设置之后,当拖动图片到图片边界时,则不能继续往该方向拖了。
     *
     * */
    private void fixOffset() {
        // 注意:offsetX为拖动的距离,offsetX = -(旧位置-新位置)
        // (bitmap.getWidth()*bigScale - getWidth())/2表示 图片宽度的一半-屏幕宽度的一半 得到的就是可以拖动的最大距离
        // 当图片往右时,我们的手也是往右 offsetX为正数,图片可拖动的最大距离也为正数  此时取最小值为offsetX
        // 例如我们手指往右滑动了100px  而图片最大只能动50px 再往右滑的话 图片的左边就超出图片范围了  因此取50px
        offsetX = Math.min(offsetX, (bitmap.getWidth() * bigScale - getWidth()) / 2);
        // 当图片往左时,我们的手也是往左 offsetX为负数,而我们计算的图片可拖动的距离是正数 因此这里要取反 并且取两者最大值
        // 例如我们手指往左滑动了100px 那么offsetX = -100   而最大拖动距离为50px 取反为-50px   -100 与-50 取最大值
        offsetX = Math.max(offsetX, -(bitmap.getWidth() * bigScale - getWidth()) / 2);
        // Y轴一样
        offsetY = Math.min(offsetY, (bitmap.getHeight() * bigScale - getHeight()) / 2);
        offsetY = Math.max(offsetY, -(bitmap.getHeight() * bigScale - getHeight()) / 2);
    }


    /* 图片放大缩小动画 */
    private ObjectAnimator getAnimator(float scale1, float scale2) {
        if (animator == null) {
            // 这个方法内部是通过反射 设置currentScale的值  所以currentScale必须要有get\set方法
            animator = ObjectAnimator.ofFloat(this, "currentScale", 0);
        }
        animator.setFloatValues(scale1, scale2);
        return animator;
    }

    public float getCurrentScale() {
        return currentScale;
    }

    public void setCurrentScale(float currentScale) {
        this.currentScale = currentScale;
        // 由于属性动画animator会不断的调用set方法, 所以刷新放在这里
        invalidate();
    }

    /* 双指缩放监听类 */
    class PhotoOnScaleGestureListener implements ScaleGestureDetector.OnScaleGestureListener {

        float scale;

        /* 双指缩放时 */
        @Override
        public boolean onScale(ScaleGestureDetector detector) {

            // todo 步骤8 处理双指缩放以及缩放边距
            /*
             * detector.getScaleFactor()表示两个手指之间缩放的大小值
             * 例如两个手指的距离缩短一半时 值为0.5  两个手指距离增加一倍时  值为2
             *
             * scale表示初始化时的缩放因子,currentScale为最终的缩放因子
             * 这里不用currentScale直接乘的原因是 双指缩放的动作是持续性的,
             * 因此如果用currentScale直接乘的话 缩放因子基数会一直变动,这样取值不正确,
             * 正确的做法是要一直用缩放之前的因子 乘 detector.getScaleFactor()
             * */
            if ((currentScale >= bigScale && detector.getScaleFactor() < 1)
                    || (currentScale <= smallScale && detector.getScaleFactor() > 1)
                    || (currentScale > smallScale && currentScale < bigScale)) {

                if(scale * detector.getScaleFactor() <= smallScale){
                    // 解决双指缩放时超过图片最小边界
                    currentScale = smallScale;
                    isFlag = false;
                } else if(scale * detector.getScaleFactor() >= bigScale){
                    // 解决双指缩放时超过图片最大边界
                    currentScale = bigScale;
                    isFlag = true;
                } else {
                    currentScale = scale * detector.getScaleFactor();
                    isFlag = true;
                }
                invalidate();
            }

            // 解决图片为smallScanle的时候 进行双指放大后无法拖拽图片的问题 因为上的else修改了isFlag的值,所以这里不用再次判断
            /*if (currentScale >= smallScale && !isFlag) {
                isFlag = !isFlag;
            }*/

            return false;
        }

        /* 双指缩放之前 这里要return true  与我们事件分发一样的道理 */
        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            scale = currentScale;
            return true;
        }

        /* 双指缩放之后 这里一般不做处理 */
        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {

        }
    }

}

 

Utils

public class Utils {

    public static float dpToPixel(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
                Resources.getSystem().getDisplayMetrics());
    }

    /* 获取bitmap */
    public static Bitmap getPhoto(Resources res, int width) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, R.drawable.photo, options);
        options.inJustDecodeBounds = false;
        options.inDensity = options.outWidth;
        options.inTargetDensity = width;
        return BitmapFactory.decodeResource(res, R.drawable.photo, options);
    }
}

 

posted @ 2021-01-08 14:53  金大人的梦  阅读(1053)  评论(0编辑  收藏  举报