如何应对Android面试官-> 自定义 PhotoView 事件分发

前言

先上效果:

实现的是双指放大和缩小、双击放大和缩小,以及放大之后支持拖动的一个效果;

基础架子


我们先来搭建一个简单的架子

java 复制代码
public class PhotoView extends View {

    private static final float IMAGE_WIDTH = Utils.dpToPixel(300);
    private Bitmap bitmap;
    private Paint paint;


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

    }

    private void init() {
        bitmap = Utils.getPhoto(getResources(), (int) IMAGE_WIDTH);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bitmap, 0, 0, paint);
    }
}

我们运行看下效果:

接下来我们开始一步一步实现最终的效果

绘制图片到屏幕中间

想要将图片绘制到屏幕中间,也就是 canvas.drawBitmap 的时候,坐标起始点不是 0,0 了,而是 屏幕宽度的一半 减去 图片宽度的一半,屏幕高度的一半 减去 图片高度的一半;

scss 复制代码
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint);
}

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

    originalOffsetX = (float) getWidth() / 2f - (float) bitmap.getWidth() / 2f;
    originalOffsetY = (float) getHeight() / 2f - (float) bitmap.getHeight() / 2f;
}

我们运行看下效果:

可以看到,图片绘制到了屏幕中心位置,接下来我们实现缩放能力

缩放和比例计算

我们再展示图片的时候,其实是需要一个缩放的,也就是说需要让我们的图片的宽或者高可以适配屏幕,所以是分为两种情况

一种是 宽度充满

一种是 高度充满

我们来给这两种充满分别定义一个概念,宽度充满我们称为『小放大 smallScale』,高度充满我们称为『大放大 bigScale』,我们需要计算使用哪种缩放方式

缩放计算方式(smallScale)


scss 复制代码
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()){
        smallScale = (float) getWidth() / bitmap.getWidth();
    }
    currentScale = smallScale;
}

也就是假设下面的 这种计算方式,图片的宽/高 = 2,屏幕的宽/高 = 0.5,所以是宽度充满的缩放比例

缩放计算方式(bigScale)


反之,就是大缩放

scss 复制代码
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()){
        smallScale = (float) getWidth() / bitmap.getWidth();
        bigScale = (float) getHeight() / bitmap.getHeight();
    }
    currentScale = smallScale;
}

然后我们需要在绘制之前,设置一下 Canvas 的缩放,通过 canvas.scale(); 来设置,我们可以看下这个 scale 方法

sx 和 sy 这里就不做过多解释了,这个 px 和 py 指的是 scale 的中心点坐标,是从 0,0 点缩放,还是指定的位置缩放;

如果我们想让图片以中心点缩放,而我们的图片现在又是绘制在屏幕中间,所以缩放的中心点 其实就是屏幕的中心点;

scss 复制代码
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.scale(currentScale, currentScale, (float) getWidth() / 2f, (float) getHeight() / 2f);
}

另外,图片放大之后,会进行一个平移来查看完整的图片,所以我们需要给图片进行一个边界的设置,例如:当我们的图片高度充满屏幕的时候,我们这个时候可以认为垂直方向是不可以移动的,只能水平方向左右移动,为了避免这种情况,bigScale 需要配置一个系数,让垂直方向也可以移动;

scss 复制代码
private float OVER_SCALE_FACTOR = 1.5f;
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    // 
    if ((float) bitmap.getWidth() / bitmap.getHeight() > (float) getWidth() / getHeight()) {
        smallScale = (float) getWidth() / bitmap.getWidth();
        bigScale = (float) getHeight() / bitmap.getHeight() * OVER_SCALE_FACTOR;
    } else {
        smallScale = (float) getHeight() / bitmap.getHeight();
        bigScale = (float) getWidth() / bitmap.getWidth() * OVER_SCALE_FACTOR;
    }

    currentScale = smallScale;
}

手势处理

我们接下来进行手势的处理,也就是双击放大、缩小,放大之后可以拖动,以及原始状态双指缩放;

系统为我们提供了 GestureDetector(手势探测器)我们可以直接使用这个 API,它就会为我们来处理单击、双击等手势,我们来使用它完成手势的处理;

typescript 复制代码
private ScaleGestureDetector scaleGestureDetector;

private void init(Context context) {
    //
    ...
    // 
    gestureDetector = new GestureDetector(context, new PhotoGestureDetector());
}

// 为了便于理解,重写了 SimpleOnGestureListener 的所有方法

private static class PhotoGestureDetector extends GestureDetector.SimpleOnGestureListener {

    public PhotoGestureDetector() {
        super();
    }

    /**
     * up的时候触发,非长按单击或者双击的第一次点击触发
     * @param e The up motion event that completed the first tap 
     * @return true
     */
    @Override
    public boolean onSingleTapUp(MotionEvent e) {
        return super.onSingleTapUp(e);
    }

    /**
     * 长按事件处理
     * @param e The initial on down motion event that started the longpress. 
     */
    @Override
    public void onLongPress(MotionEvent e) {
        super.onLongPress(e);
    }

    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        return super.onScroll(e1, e2, distanceX, distanceY);
    }

    /**
     * up 手指抬起之后的惯性活动 -- 大于 50dp/s
     *
     * @param e1        The first down motion event that started the fling.
     * @param e2        The move motion event that triggered the current onFling.
     * @param velocityX The velocity of this fling measured in pixels per second
     *                  along the x axis.
     * @param velocityY The velocity of this fling measured in pixels per second
     *                  along the y axis.
     * @return true
     */
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        return super.onFling(e1, e2, velocityX, velocityY);
    }

    /**
     * 延迟 100ms 触发,用来处理点击效果
     * @param e The down motion event 
     */
    @Override
    public void onShowPress(MotionEvent e) {
        super.onShowPress(e);
    }

 
    /**
     * 只要想 响应事件,这里就需要返回 true
     * @param e The down motion event. 
     * @return true
     */
    @Override
    public boolean onDown(MotionEvent e) {
        return true;
    }

    /**
     * 双击的第二次down触发,双击触发时间 需要 40ms- 300ms,小于40认为是抖动,超过300认为是单击
     *
     * @param e The down motion event of the first tap of the double-tap.
     * @return true
     */
    @Override
    public boolean onDoubleTap(MotionEvent e) {
        return super.onDoubleTap(e);
    }

    /**
     * 双击的第二次down、move、up都触发
     * @param e The motion event that occurred during the double-tap gesture.
     * @return true
     */
    @Override
    public boolean onDoubleTapEvent(MotionEvent e) {
        return super.onDoubleTapEvent(e);
    }
    
    /**
     * 单击按下时触发,双击不触发,down、up时都可能触发,但是不会同时触发
     * @param e The down motion event of the single-tap.
     * @return true
     */
    @Override
    public boolean onSingleTapConfirmed(MotionEvent e) {
        return super.onSingleTapConfirmed(e);
    }

    @Override
    public boolean onContextClick(MotionEvent e) {
        return super.onContextClick(e);
    }
}

双击放大处理


前面我们已经分析了各个方法的作用,双击的处理,是在 onDoubleTap 中处理,我们需要一个标志位来判断,当次需要小缩放还是大缩放(双击放大 -> 双击缩小)

ini 复制代码
priviate boolean isEnLarge = false;

public boolean onDoubleTap(MotionEvent e) {
    isEnLarge = !isEnLarge;
    if (isEnLarge) {
        currentScale = bigScale;
    } else {
        currentScale = smallScale;
    }
    // 这个方法调用之后,会重新走 onDraw 逻辑,也就会还行 canvas.scale 逻辑
    invalidate();
    return super.onDoubleTap(e);
}

我们运行看下效果:

可以看到,双击并没有效果,这是为什么呢?

原因就是,我们的 GestureDetector 需要接管我们 View 的 onTouchEvent 事件才能响应,它本身就是对 Touch 事件的处理;

typescript 复制代码
@Override
public boolean onTouchEvent(MotionEvent event) {
    return gestureDetector.onTouchEvent(event);
}

我们再来看下效果:

可以看到,实现了我们期望的双击放大,双击缩小的效果;

但是整体效果是瞬间变大,瞬间变小,缩放的效果不是很好,我们可以加一个缩放动画,来让它有一个变化的过程,我们使用缩放属性动画来搞一下;

csharp 复制代码
private ObjectAnimator scaleAnimator;

private ObjectAnimator getScaleAnimator() {
    if (scaleAnimator == null) {
        scaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", 0);
    }
    scaleAnimator.setFloatValues(smallScale, bigScale);
    return scaleAnimator;
}

public float getCurrentScale() {
    return currentScale;
}

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

然后我们的双击回调中,启动这个动画即可

scss 复制代码
@Override
public boolean onDoubleTap(MotionEvent e) {
    isEnLarge = !isEnLarge;
    if (isEnLarge) {
        // 放大的时候,启动缩放动画
        getScaleAnimator().start();
    } else {
        // 缩小的时候,反转这个动画
        getScaleAnimator().reverse();
    }
    return super.onDoubleTap(e);
}

我们运行看下效果:

可以看到有一个平滑的缩放效果了;

手指的滑动


我们接下来处理下放大之后的手指滑动,图片跟着滑动,其实就是 canvas 的 translate 手指滑动的距离;

java 复制代码
private float offsetX;
private float offsetY;

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(offsetX, offsetY);
}

然后我们只需要赋值这个 offsetX 和 offsetY 即可,我们需要在 onScroll 中进行滑动事件的处理,也就是 offsetX 和 offsetY 的处理;

java 复制代码
/**
 * @param e1        手指按下位置.
 * @param e2        当前位置.
 * @param distanceX 旧位置 - 新位置.
 * @param distanceY 旧位置 - 新位置.
 * @return true
 */
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
    // 放大才能滑动
    if (isEnLarge) {
        offsetX = offsetX - distanceX;
        offsetY = offsetY - distanceY;
        invalidate();
    }
    return super.onScroll(e1, e2, distanceX, distanceY);
}

我们运行看下效果:

我们实现了滑动效果

设置滚动边界


可以看到上面的效果,当滑动到边界的时候,还是可以继续滑动,我们来处理下这个问题,当滑动到边界的时候,不再支持滑动

黑色是屏幕,红色是放大之后的默认位置,绿色表示我们在拖动,而 offsetY 的值,就是图片和屏幕之间的那个距离,同理 offsetX 就是左边距和右边距的值,这里要考虑坐标轴上的:『下正上负,右正左负』

即:(图片的高度 - 屏幕的高度)/ 2 就是 offsetY 可以移动的距离; 所以,边界设定如下:

ini 复制代码
private void fixOffset() {
        offsetX = Math.min(offsetX, ((bitmap.getWidth() * bigScale - getWidth()) / 2));
        offsetX = Math.max(offsetX, -((bitmap.getWidth() * bigScale - getWidth()) / 2));
        offsetY = Math.min(offsetY, ((bitmap.getHeight() * bigScale - getHeight()) / 2));
        offsetY = Math.max(offsetY, -((bitmap.getHeight() * bigScale - getHeight()) / 2));
}

然后在 scroll 方法中调用这个方法,然后我们运行看下效果:

java 复制代码
/**
 * @param e1        手指按下位置.
 * @param e2        当前位置.
 * @param distanceX 旧位置 - 新位置.
 * @param distanceY 旧位置 - 新位置.
 * @return true
 */
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
    // 放大才能滑动
    if (isEnLarge) {
        offsetX = offsetX - distanceX;
        offsetY = offsetY - distanceY;
        fixOffset();
        invalidate();
    }
    return super.onScroll(e1, e2, distanceX, distanceY);
}

可以看到,滑动到边界之后就不能再滑动了,达到了我们期望的效果;

设置惯性滑动


上图可以看到,我们还没有处理惯性滑动的效果,我们来处理下,前面有介绍 onFling 是处理惯性滑动的,我们在这个方法中借用 OverScroll 这个 API 来实现我们的惯性滑动;

惯性滑动,也是依赖要在放大状态下处理:

scss 复制代码
private OverScroller overScroller;

private void init(Context context) {
    overScroller = new OverScroller(context);
}

/**
 * up 手指抬起之后的惯性活动
 *
 * @param e1        The first down motion event that started the fling.
 * @param e2        The move motion event that triggered the current onFling.
 * @param velocityX x 轴方向运动速度,px/s.
 * @param velocityY y 轴方向运动速度,px/s.
 * @return true
 */
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    if (isEnLarge) {
        // 使用 fling 实现惯性滑动
        overScroller.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),
                400, 400);
        // 下一帧动画的时候执行
        postOnAnimation(new Runnable() {
            @Override
            public void run() {
                if (overScroller.computeScrollOffset()) {
                    offsetX = overScroller.getCurrX();
                    offsetY = overScroller.getCurrY();
                    invalidate();
                    postOnAnimation(this);
                }
            }
        });
        
    }
    return super.onFling(e1, e2, velocityX, velocityY);
}

我们运行看下效果:

可以看到,我们的惯性滑动,以及回弹效果都实现了;

双指缩放


可以看到,我们现在还没有实现双指缩放的能力,我们来搞一下,这里我们借助 ScaleGestureDetector 这个系统的提供的 API 来实现;

typescript 复制代码
private ScaleGestureDetector scaleGestureDetector;

private void init(Context context) {
    
    scaleGestureDetector = new ScaleGestureDetector(context, new PhotoScaleGestureListener);
}

private class PhotoScaleGestureListener implements ScaleGestureDetector.OnScaleGestureListener {

    @Override
    public boolean onScale(ScaleGestureDetector detector) {
        return false;
    }

    @Override
    public boolean onScaleBegin(ScaleGestureDetector detector) {
        // 这里一定要返回 true,否则不生效
        return true;
    }

    @Override
    public void onScaleEnd(ScaleGestureDetector detector) {

    }
}

我们需要在这个 onScale 和 onScaleBegin 方法中处理我们的缩放系数,然后进行重绘;

typescript 复制代码
float initScale;

@Override
public boolean onScale(ScaleGestureDetector detector) {
    // 缩放因子
    currentScale = initScale * detector.getScaleFactor();
    invalidate();
    return false;
}

@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
    initScale = currentScale;
    return true;
}

同样的,只要是手势探测,我们都需要接管 onTouchEvent 事件,双指缩放的优先级高于手指滑动的优先级;

ini 复制代码
@Override
public boolean onTouchEvent(MotionEvent event) {
    // 优先双指缩放的手势处理
    boolean result = scaleGestureDetector.onTouchEvent(event);
    // 如果双指缩放不处理了,在交给滑动手势处理
    if (!scaleGestureDetector.isInProgress()) {
        result = gestureDetector.onTouchEvent(event);
    }
    return result;
}

我们运行看下效果:

可以看到,我们实现了双指缩放的效果;但是有一些不足之处,例如:平移后缩小,直接留白了很多,我们来处理下;

实际上我们的平移(canvas.translate)和缩放(canvas.scale)是可以进行绑定的,我们在缩放的时候是有一个缩放系数的,那么我们在平移的时候也可以和这个系数关联起来;

scss 复制代码
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // 
    float scaleFaction = (currentScale - smallScale) / (bigScale - smallScale);
    canvas.translate(offsetX * scaleFaction, offsetY * scaleFaction);
    canvas.scale(currentScale, currentScale, (float) getWidth() / 2, (float) getHeight() / 2);
}

我们运行看下效果:

可以看到,缩放的时候,并没有了留白的badcase;并且缩小到宽度充满屏幕之后,就不再响应缩放的手势了;

双击偏移


可以看到,我们再次双击放大的时候,并没有从点击位置开始缩放,我们需要在双击的时候修改下偏移量,也就是我们缩小之后,再次双击放大的时候,应该是从哪里点击,就以哪里为中心进行放大处理;

scss 复制代码
/**
 * 双击触发时间 需要 40ms- 300ms 之间来处理
 *
 * @param e The down motion event of the first tap of the double-tap.
 * @return true
 */
@Override
public boolean onDoubleTap(MotionEvent e) {
    isEnLarge = !isEnLarge;
    if (isEnLarge) {
        offsetX = (e.getX() - getWidth() / 2f) -
                (e.getX() - getWidth() / 2f) * bigScale / smallScale;
        offsetY = (e.getY() - getHeight() / 2f) -
                (e.getY() - getHeight() / 2f) * bigScale / smallScale;
        // 归正偏移量的值就可以        
        fixOffset();        
        getScaleAnimator().start();
    } else {
        getScaleAnimator().reverse();
    }
    return super.onDoubleTap(e);
}

我们运行看下效果:

可以看到,我们实现了哪里点击,哪里放大的效果;

初始状态双指放大


这是因为我们在执行 scale 方法的时候,没有进行是否是放大状态的判断以及改变;

ini 复制代码
@Override
public boolean onScale(ScaleGestureDetector detector) {
    if ((currentScale > smallScale && !isEnLarge) || (currentScale == smallScale && isEnLarge)) {
        isEnLarge = !isEnLarge;
    }
    // 缩放因子
    currentScale = initScale * detector.getScaleFactor();
    // 执行重新绘制
    invalidate();
    return false;
}

我们运行看下效果:

好了,到这里 PhotoView 的能力基本上就全都实现了,在双指缩放的时候,大家可以自行加一个缩放动画的过度,让它看起来不那么生硬;

简历润色

熟练使用Gesture手势探测器实现复杂图片的处理;

下一章预告

我用自定义 View 画了一条鱼;

欢迎三连

来都来了,点个关注,点个赞吧,你的支持是我最大的动力~~

相关推荐
憨子周33 分钟前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
SRC_BLUE_1733 分钟前
SQLI LABS | Less-39 GET-Stacked Query Injection-Intiger Based
android·网络安全·adb·less
霖雨2 小时前
使用Visual Studio Code 快速新建Net项目
java·ide·windows·vscode·编辑器
SRY122404192 小时前
javaSE面试题
java·开发语言·面试
Fiercezm3 小时前
JUC学习
java
无尽的大道3 小时前
Java 泛型详解:参数化类型的强大之处
java·开发语言
ZIM学编程3 小时前
Java基础Day-Sixteen
java·开发语言·windows
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
P.H. Infinity3 小时前
【RabbitMQ】07-业务幂等处理
java·rabbitmq·java-rabbitmq
爱吃土豆的程序员3 小时前
java XMLStreamConstants.CDATA 无法识别 <![CDATA[]]>
xml·java·cdata