如何应对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 画了一条鱼;

欢迎三连

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

相关推荐
笃励13 分钟前
Java面试题二
java·开发语言·python
易雪寒31 分钟前
IDEA在git提交时添加忽略文件
java·git·intellij-idea
打码人的日常分享1 小时前
企业人力资源管理,人事档案管理,绩效考核,五险一金,招聘培训,薪酬管理一体化管理系统(源码)
java·数据库·python·需求分析·规格说明书
27669582921 小时前
京东e卡滑块 分析
java·javascript·python·node.js·go·滑块·京东
爱写代码的刚子1 小时前
C++知识总结
java·开发语言·c++
冷琴19961 小时前
基于java+springboot的酒店预定网站、酒店客房管理系统
java·开发语言·spring boot
沐言人生1 小时前
Android10 Framework—Init进程-9.服务端属性值初始化
android·android studio·android jetpack
daiyang123...2 小时前
IT 行业的就业情况
java
追光天使2 小时前
【Mac】和【安卓手机】 通过有线方式实现投屏
android·macos·智能手机·投屏·有线
爬山算法2 小时前
Maven(6)如何使用Maven进行项目构建?
java·maven