自定义一个图片展示PhotoView,先看最终效果:


Android PreviewView 和拍照图片大小不一致 android photoview_android studio

实现了双击放大缩小、双指缩放、拖动和惯性滑动功能。
这里有几个关键点:

  • 重写onDraw方法,使用translate和scale来实现拖动和缩放功能
  • 使用GestureDetector来处理双击事件和惯性滑动
  • 使用ScaleGestureDetector来处理双指缩放

下面来一步步实现,首先自定义PhotoView,继承View并进行一些初始化工作:

public class PhotoView extends View {

private float fixScale; //基础缩放,让图片刚好充满view
private float bigScale;  //大缩放,为小缩放的2倍
private float currentScale;  //用于保存当前的缩放值
private boolean isInLarge; //是否在放大模式
private Paint paint; //画笔
private Bitmap bitmap; //图片bitmap
private float offsetX;  //图片绘制开始x坐标
private float offsetY; //图片绘制开始y坐标

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);

    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        init();
    }
  
}

这里定义了fixScale、bigScale和currentScale;分别是让图片刚好充满屏幕的缩放比例、双击放大的比例和当前缩放的比例。

isInLarge用于标记是否处于放大模式,offsetX和offsetY则是图片的x,y轴偏移量。

说明一下fixScale和bigScale,以横向图片为例:

Android PreviewView 和拍照图片大小不一致 android photoview_ci_02


黑色框为手机屏幕,绿色为原图,fixScale的作用就是进行一个初始的缩放,让原图的宽刚好充满屏幕。横向图的话bigScale则是让高度与屏幕高度相等。

接下来是init()方法:

private void init() {
        paint = new Paint(); 
        // 加载一个本地图片创建bitmap
        bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.test_img2);
        if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) {//图片是横向图片
            //smallScale*图片大小 刚好是view的大小
            fixScale = (float) getWidth() / bitmap.getWidth();
            //大放大的系数是让图片的高放大到整个view的总高度 ; * 1.2f是让图片更大点,使得上下也可以拖动
            bigScale = (float) getHeight() / bitmap.getHeight() * 1.2f;
        } else {
            fixScale = (float) getHeight() / bitmap.getHeight();
            bigScale = (float) getWidth() / bitmap.getWidth() * 1.2f;

        }
        currentScale = fixScale;
        isInLarge = false;
    }

根据图片的宽高比和屏幕的宽高比判断是横向还是纵向图片,再计算出fixScale和bigScale;然后是onDraw:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //平移需要放在缩放前面,因为如果先缩放(相当于屏幕方法了,像素没变)坐标系也会缩放,平移一点会移动很远的距离
        canvas.translate(offsetX, offsetY);
        //使用四参的这个方法,从view中心开始缩放
        canvas.scale(currentScale, currentScale, getWidth() / 2f, getHeight() / 2f);
        canvas.drawBitmap(bitmap, (getWidth() - bitmap.getWidth()) / 2f, (getHeight() - bitmap.getHeight()) / 2f, paint);
    }

简单的绘制了图片,接下使用GestureDetector来处理双击放大和拖动:

class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
        //和move事件类似
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (isInLarge) {
                offsetX -= distanceX;
                offsetY -= distanceY;
                fixOffset();
                invalidate();
            }
            return super.onScroll(e1, e2, distanceX, distanceY);
        }

        //抛掷动作,惯性;只会调用一次
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            return super.onFling(e1, e2, velocityX, velocityY);
        }

         
        //按下时触发
        @Override
        public boolean onDown(MotionEvent e) {
            return true; //onDown需要return true 消费此事件
        }

        //双击第二次按下的时候触发
        //两次时间间隔为40-300ms,限制大于40ms是为了防抖动
        @Override
        public boolean onDoubleTap(MotionEvent e) {
            if (!isInLarge) {
                //让图片中的点在放大后位于点击的地方
                offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / fixScale;
                offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / fixScale;
                fixOffset();//计算完偏移再限制一下不能移除屏幕
            } 
             //使用属性动画变化缩放值,让动画更平滑
            getScaleAnimator().start();
            return super.onDoubleTap(e);
        }
    }
private ObjectAnimator getScaleAnimator() {
        if (scaleAnimator == null) {
            //属性动画中的属性需要设置set方法,因为源码中会反射调用它的set方法
            scaleAnimator = (ObjectAnimator) ObjectAnimator.ofFloat(this, "currentScale", 0);
            //属性变化范围

            //scaleAnimator.setDuration(10000);
        }
        if (currentScale >= bigScale) {//当当前缩放比例大于或等于bigScale时还原
            scaleAnimator.setFloatValues(currentScale, fixScale);
            isInLarge=false;
        } else {//当当前缩放小于bigScale时,放大到bigScale
            scaleAnimator.setFloatValues(currentScale, bigScale);
            isInLarge=true;
        }

        return scaleAnimator;
    }

拖动在onScroll中处理,如果是放大模式那么根据移动距离修改offset然后invalidate即可。放大时使用了属性动画来改变currentScale的值,让缩放更流畅;当当前比例大于或等于bigScale时将其还原为初始缩放fixScale,反之放大到bigScale。

另外,这里比较麻烦的是双击放大的时候对位移的处理,先分析下问题

Android PreviewView 和拍照图片大小不一致 android photoview_android studio_03


因为目前是根据图片中心进行缩放的,缩放后图片的位置如黄色框。但是实际上我们希望是当我们点击某个点放大的时候,放大后的图片该点应该还在我们点下的地方。如上图点击B点时那么放大后效果的应该是绿色框所示,也就是说无论放大与否图片B点所示的事物都应该处于同一位置。然后我们希望放大后屏幕不会留白,还要将绿色框往下移,最终点击B点时图片的效果应该是红色框所示。

那么应该对图片进行平移,X轴的平移量应该是下图线段A-B(蓝色圆圈为点击位置):

Android PreviewView 和拍照图片大小不一致 android photoview_android_04


也就是:

//除fixScale是因为最开始为了让图片充满屏幕乘了fixScale
 offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / fixScale;
 offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / fixScale;

对平移的限制:

Android PreviewView 和拍照图片大小不一致 android photoview_java_05

黄框为原图,红框为放大后的图片,显然横向平移不能超过线段A,纵向移动不能超过线段B,因此:

private void fixOffset() {
        //处理手指向左滑动,offset为负数递减,所以取max值
        offsetX = Math.max(offsetX, -(bigScale * bitmap.getWidth() - getWidth()) / 2f);
        //处理手指向右滑动,offsetX为正数递增,最大不能超过(bigScale * bitmap.getWidth() - getWidth()) / 2f
        offsetX = Math.min(offsetX, (bigScale * bitmap.getWidth() - getWidth()) / 2f);
        offsetY = Math.min(offsetY, (bigScale * bitmap.getHeight() - getHeight()) / 2f);
        offsetY = Math.max(offsetY, -(bigScale * bitmap.getHeight() - getHeight()) / 2f);
    }

目前为止,实现了双击放大缩小和拖动效果,但是出现了一个问题:双击还原的时候图片没法回到中心。这是因为我们做了平移但是还原的时候没有平移回去,在onDraw中进行处理

//处理放大后平移,再恢复到smallScale,图片回不到中心的问题
        float fraction = (currentScale - fixScale) / (bigScale - fixScale);
        canvas.translate(offsetX*fraction  , offsetY*fraction );

根据缩放的比例对平移进行修正。

接着在GestureDetector的onFling方法中使用OverScroller处理惯性滑动

//抛掷动作,惯性;只会调用一次
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (isInLarge) {
                overScroller.fling(
                        (int) offsetX, //开始位置为手指松开是的offsetX
                        (int) offsetY, //开始位置为手指松开是的offsetY
                        (int) velocityX,//系统默认速度
                        (int) velocityY,//
                        //x方向最小值
                        -(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
                        (int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
                        -(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
                        (int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
                        //回弹的范围
                        100, 100
                );
                //因为onFling只会处理一次,使用Runnable循环执行
                postOnAnimation(flingRun);

            }

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

因为抛掷动作完成onFling只会调用一次,所以要使用Runnable循环获取惯性滑动的偏移量

private class FlingRun implements Runnable {
        @Override
        public void run() {
            //动画还在执行
            if (overScroller.computeScrollOffset()) {
                //overScroller调用了fling后CurrX会不断变化,拿到当前变化的值,更新到界面
                offsetX = overScroller.getCurrX();
                offsetY = overScroller.getCurrY();
                invalidate();
                //postOnAnimation是每帧执行一次,比post性能更好
                postOnAnimation(this);//重复调用自己,更新offsetX
            }
        }
    }

overScroller.fling()执行后会不断更新滑动的距离,所以在run方法中不断获取overScroller.getCurrX(Y)更新到offsetX(Y),调用invalidate更新画面。
剩下的双指缩放功能,使用了ScaleGestureDetector来实现

private class PhotoViewScaleGesture implements ScaleGestureDetector.OnScaleGestureListener {
        private float initialScale;
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            currentScale = initialScale * detector.getScaleFactor(); //detector.getScaleFactor()获取缩放因子
            fixCurrentScale(); //限制最大最小缩放比例
            if (currentScale > fixScale) isInLarge = true;
            invalidate();
            return false;
        }
        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            initialScale = currentScale;
            return true; //需要返回true,消费事件
        }
    }

在onScaleBegin,开始缩放的时候保存了当前缩放比例。onScale方法中得到缩放因子计算新的缩放比例,invalidate更新。

最后附上完整代码---------------------------------------->>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.widget.OverScroller;

import androidx.annotation.Nullable;

/**
 * 自定义photoView,双击放大缩小,双指缩放,惯性滑动,边界回弹
 */
public class PhotoView extends View {
    private float fixScale; //基础缩放,让图片刚好充满view
    private float bigScale;  //大缩放,为小缩放的2倍
    private float currentScale;  //用于保存当前的缩放值
    private boolean isInLarge; //是否在放大模式
    private Paint paint; //画笔
    private Bitmap bitmap; //图片bitmap
    private float offsetX;  //图片绘制开始x坐标
    private float offsetY; //图片绘制开始y坐标
    private GestureDetector doubleTabGesture;
    private OverScroller overScroller; //用于处理惯性滑动,相比Scroller有回弹效果
    private FlingRun flingRun;
    private ScaleGestureDetector photoViewScaleGesture;

    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);

    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        init();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //将事件交给doubleTabGesture处理
        boolean result = photoViewScaleGesture.onTouchEvent(event);
        //如果没有在双指操作,那么交给双击doubleTabGesture处理
        if (!photoViewScaleGesture.isInProgress()) {
            result = doubleTabGesture.onTouchEvent(event);
        }
        return result;
    }

    private void init() {
        paint = new Paint();
        bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.test_img2);
        if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) {//图片是横向图片
            //smallScale*图片大小 刚好是view的大小
            fixScale = (float) getWidth() / bitmap.getWidth();
            //大放大的系数是让图片的高放大到整个view的总高度 ; * 1.2f是让图片更大点,使得上下也可以拖动
            bigScale = (float) getHeight() / bitmap.getHeight() * 1.2f;
        } else {
            fixScale = (float) getHeight() / bitmap.getHeight();
            bigScale = (float) getWidth() / bitmap.getWidth() * 1.2f;

        }
        currentScale = fixScale;
        isInLarge = false;
        overScroller = new OverScroller(getContext());
        flingRun = new FlingRun();
        doubleTabGesture = new GestureDetector(getContext(), new PhotoGestureListener());
        photoViewScaleGesture = new ScaleGestureDetector(getContext(), new PhotoViewScaleGesture());
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //处理放大后平移,再恢复到smallScale,图片回不到中心的问题
        //float fraction=1;
        float fraction = (currentScale - fixScale) / (bigScale - fixScale);
        // float fraction=1;

        //平移需要放在缩放前面,因为如果先缩放(相当于屏幕方法了,像素没变)坐标系也会缩放,平移一点会移动很远的距离
        canvas.translate(offsetX*fraction  , offsetY*fraction );
        //使用四参的这个方法,从view中心开始缩放
        canvas.scale(currentScale, currentScale, getWidth() / 2f, getHeight() / 2f);
        canvas.drawBitmap(bitmap, (getWidth() - bitmap.getWidth()) / 2f, (getHeight() - bitmap.getHeight()) / 2f, paint);
    }

    private void fixOffset() {
        System.out.println("bef offsetX = " + (int) offsetX);
        //处理手指向左滑动,offset为负数递减,所以取max值
        offsetX = Math.max(offsetX, -(bigScale * bitmap.getWidth() - getWidth()) / 2f);


        //处理手指向右滑动,offsetX为正数递增,最大不能超过(bigScale * bitmap.getWidth() - getWidth()) / 2f
        offsetX = Math.min(offsetX, (bigScale * bitmap.getWidth() - getWidth()) / 2f);
        System.out.println("offsetX = " + (int) offsetX);

        offsetY = Math.min(offsetY, (bigScale * bitmap.getHeight() - getHeight()) / 2f);
        offsetY = Math.max(offsetY, -(bigScale * bitmap.getHeight() - getHeight()) / 2f);
    }

    //使用SimpleOnGestureListener来处理双击事件和惯性滑动
    class PhotoGestureListener extends GestureDetector.SimpleOnGestureListener {
        //手指抬起时触发,双击时只会在第二次抬起时触发
        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return super.onSingleTapUp(e);
        }

        //长按300ms触发
        @Override
        public void onLongPress(MotionEvent e) {
            super.onLongPress(e);
        }

        //和move事件类似
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (isInLarge) {
                offsetX -= distanceX;
                offsetY -= distanceY;
                fixOffset();
                invalidate();
            }

            return super.onScroll(e1, e2, distanceX, distanceY);
        }

        //抛掷动作,惯性;只会调用一次
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (isInLarge) {
                overScroller.fling(
                        (int) offsetX, //开始位置为手指松开是的offsetX
                        (int) offsetY, //开始位置为手指松开是的offsetY
                        (int) velocityX,//系统默认速度
                        (int) velocityY,//
                        //x方向最小值
                        -(int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
                        (int) (bitmap.getWidth() * bigScale - getWidth()) / 2,
                        -(int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
                        (int) (bitmap.getHeight() * bigScale - getHeight()) / 2,
                        //回弹的范围
                        100, 100
                );
                //因为onFling只会处理一次,使用Runnable循环执行
                postOnAnimation(flingRun);

            }

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

        //按下后延时100ms触发,一般用于添加按下效果
        @Override
        public void onShowPress(MotionEvent e) {
            super.onShowPress(e);
        }

        //按下时触发
        @Override
        public boolean onDown(MotionEvent e) {
            return true; //onDown需要return true 消费此事件
        }

        //双击第二次按下的时候触发
        //两次时间间隔为40-300ms,限制大于40ms是为了防抖动
        @Override
        public boolean onDoubleTap(MotionEvent e) {
            //offsetX = 0;
            //offsetY = 0;
            if (!isInLarge) {
                //让图片中的点在放大后位于点击的地方
                offsetX = (e.getX() - getWidth() / 2f) - (e.getX() - getWidth() / 2f) * bigScale / fixScale;
                offsetY = (e.getY() - getHeight() / 2f) - (e.getY() - getHeight() / 2f) * bigScale / fixScale;
                fixOffset();//计算完偏移再限制一下不能移除屏幕
                //使用属性动画变化缩放值,让动画更平滑
                // getScaleAnimator().start();
            } else {
                //offsetX=0;
                //offsetY=0;
                //getScaleAnimator().reverse();
            }
            getScaleAnimator().start();

            return super.onDoubleTap(e);
        }

        //双击事件,第二次按下时 down、move、up都会触发
        @Override
        public boolean onDoubleTapEvent(MotionEvent e) {
            return super.onDoubleTapEvent(e);
        }

        //单击按下时触发,双击时不触发
        @Override
        public boolean onSingleTapConfirmed(MotionEvent e) {
            return super.onSingleTapConfirmed(e);
        }
    }

    private class FlingRun implements Runnable {
        @Override
        public void run() {
            //动画还在执行
            if (overScroller.computeScrollOffset()) {
                //overScroller调用了fling后CurrX会不断变化,拿到当前变化的值,更新到界面
                offsetX = overScroller.getCurrX();
                offsetY = overScroller.getCurrY();
                invalidate();
                //postOnAnimation是每帧执行一次,比post性能更好
                postOnAnimation(this);//重复调用自己,更新offsetX
            }
        }
    }

    private ObjectAnimator scaleAnimator;


    public void setCurrentScale(float currentScale) {
        this.currentScale = currentScale;
        //属性值变化时更新
        invalidate();
    }

    private ObjectAnimator getScaleAnimator() {
        if (scaleAnimator == null) {
            //属性动画中的属性需要设置set方法,因为源码中会反射调用它的set方法
            scaleAnimator = (ObjectAnimator) ObjectAnimator.ofFloat(this, "currentScale", 0);
            //属性变化范围

            //scaleAnimator.setDuration(10000);
        }
        if (currentScale >= bigScale) {//当当前缩放比例大于或等于bigScale时还原
            scaleAnimator.setFloatValues(currentScale, fixScale);
            isInLarge=false;
        } else {//当当前缩放小于bigScale时,放大到bigScale
            scaleAnimator.setFloatValues(currentScale, bigScale);
            isInLarge=true;
        }

        return scaleAnimator;
    }

    //限制双指缩放的大小,不能小于初始的smallScale,不能大于bigScale的1.5倍
    private void fixCurrentScale() {
        currentScale = Math.min(bigScale * 1.5f, currentScale);
        currentScale = Math.max(currentScale, fixScale);
    }

    //OnScaleGestureListener处理双指缩放
    private class PhotoViewScaleGesture implements ScaleGestureDetector.OnScaleGestureListener {
        private float initialScale;

        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            currentScale = initialScale * detector.getScaleFactor(); //detector.getScaleFactor()获取缩放因子
            fixCurrentScale();
            if (currentScale > fixScale) isInLarge = true;
            invalidate();
            return false;
        }

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            initialScale = currentScale;
            return true; //需要返回true,消费事件
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {

        }
    }
}

完。