Android自定义ViewGroup的事件,手指的各种操作实战

804 阅读10分钟

Viewgroup中手指事件,单指滑动,双指缩放,三指滑动截屏,多指连贯滑动如何实现

前言

在前文中我们讲到 ViewGroup 的滚动与惯性逻辑【传送门】,本文我们继续查漏补缺讲一些手指的操作。

在前文中我们讲到了 ViewGroup 滚动逻辑,可以让容器动,也可以让子布局动。本文两种方式都做了示例。

本文讲到了手指的滑动,双指缩放与旋转,多指共同滑动,多指连贯交替滑动,是我们常见的一些功能,如何实现这些效果呢?

接下来就一起看看都怎么实现的吧!

话不多说,Let's go

300.png

一、单指滑动,双指缩放

很简单的功能,需要注意的是我们这里使用的是ViewGroup容器的移动,而不是对内部的子 View 做操作。

为什么?是因为缩放和移动的话就比较方便支持多个子布局的场景。

同时需要注意的是我们常用的都是子布局的宽高大于等于ViewGroup容器,而这里我特殊处理并没有重写测量与布局而是直接使用的 FrameLayout 所以,容器的宽高是固定的。

那么在父容器在滚动的时候就需要注意不要超出子View的边界,所以我在 onLayout 回调中先确定了子 View 的边界,然后在父布局做移动的过程中判断是否超出这个边界。

对于父布局的缩放操作基本上是模板代码,只是需要注意的是缩放也需要处理子 View 边界问题哦!

直接放出完整代码:

public class ViewGroup8 extends FrameLayout {

    private static float MAX_SCALE = 1.5f;  //最大能缩放值
    private static float MIN_SCALE = 0.8f;  //最小能缩放值
    private float scaleFactor = 1.0f;      // 当前缩放倍数

    private float scaleBaseR;              // 两指按下时二者的初始距离
    private float preScaleFactor = 1.0f;   // 缩放操作之前的缩放倍数

    private PointF preTranslate = new PointF();       // 用于缩放之前平移量的记录
    private PointF preFocusCenter = new PointF();     // 缩放操作前手指中心点的位置
    private PointF postFocusCenter = new PointF();    // 缩放操作后手指中心点的位置

    private Rect layoutInParentRect = new Rect();     // 定义子视图应该移动的范围


    private static final int TOUCH_MODE_UNSET = -1;   //当前的触摸事件类型
    private static final int TOUCH_MODE_RELEASE = 0;
    private static final int TOUCH_MODE_SINGLE = 1;
    private static final int TOUCH_MODE_DOUBLE = 2;
    private int mode = 0;                             // 当前触摸事件模式的状态

    private float actionDownX;                        // 记录单指按下时的X,Y坐标
    private float actionDownY;

    private boolean isKeepInViewport = true;          //控制移动限制的开关


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

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

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

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        //确保所有子视图的位置已被设置好后调用
        calculateChildBounds();
    }

    // 计算并更新所有子视图构成的矩形边界
    private void calculateChildBounds() {
        int childCount = getChildCount();
        int leftMost = Integer.MAX_VALUE;
        int topMost = Integer.MAX_VALUE;
        int rightMost = Integer.MIN_VALUE;
        int bottomMost = Integer.MIN_VALUE;

        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) child.getLayoutParams();

            int cl = child.getLeft() - lp.leftMargin;
            int ct = child.getTop() - lp.topMargin;
            int cr = child.getRight() + lp.rightMargin;
            int cb = child.getBottom() + lp.bottomMargin;

            leftMost = Math.min(leftMost, cl);
            rightMost = Math.max(rightMost, cr);
            topMost = Math.min(topMost, ct);
            bottomMost = Math.max(bottomMost, cb);
        }

        // 更新子视图边界
        layoutInParentRect.set(leftMost, topMost, rightMost, bottomMost);
    }

    /**
     * 当前事件的真正处理逻辑
     */
    public boolean onTouchEvent(MotionEvent event) {

        // 分解事件代码
        int action = event.getAction() & MotionEvent.ACTION_MASK;
        // 根据事件类型分发处理逻辑
        switch (action) {
            case MotionEvent.ACTION_DOWN: // 手指初次接触屏幕
                mode = TOUCH_MODE_SINGLE; // 设置模式为单指操作
                actionDownX = event.getRawX(); // 记录按下时的X坐标
                actionDownY = event.getRawY(); // 记录按下时的Y坐标
                break;
            case MotionEvent.ACTION_UP: // 手指离开屏幕
                mode = TOUCH_MODE_RELEASE; // 重置模式为释放状态
                break;
            case MotionEvent.ACTION_POINTER_UP:
            case MotionEvent.ACTION_CANCEL: // 多指操作结束或者触摸事件被取消
                mode = TOUCH_MODE_UNSET; // 重置模式为未设置状态
                break;
            case MotionEvent.ACTION_POINTER_DOWN: // 屏幕上已经有一个手指,又有一个新的手指按下
                mode++; //增加模式,用于标记多指状态
                if (mode >= TOUCH_MODE_DOUBLE) { // 如果是双指缩放
                    scaleFactor = preScaleFactor = this.getScaleX(); // 记录并更新当前缩放比例
                    preTranslate.set(this.getTranslationX(), this.getTranslationY()); // 记录当前视图的平移量
                    scaleBaseR = (float) distanceBetweenFingers(event); // 记录两个手指间的起始距离
                    centerPointBetweenFingers(event, preFocusCenter); // 记录两个手指间的起始中心点
                    centerPointBetweenFingers(event, postFocusCenter); // 同样记录为新的中心点,以备后续使用
                }
                break;

            // 当手指在屏幕上滑动时
            case MotionEvent.ACTION_MOVE:
                // 如果处于双指模式,处理缩放逻辑
                if (mode >= TOUCH_MODE_DOUBLE) {
                    float scaleNewR = (float) distanceBetweenFingers(event); // 获取新的两指间距离
                    centerPointBetweenFingers(event, postFocusCenter); // 获取新的两指中心点
                    if (scaleBaseR <= 0) { // 如果起始距离为0(非法),则不处理
                        break;
                    }
                    // 根据起始距离和当前距离计算缩放比例
                    scaleFactor = (scaleNewR / scaleBaseR) * preScaleFactor * 0.15f + scaleFactor * 0.85f;
                    // 确保缩放比例在合法范围内
                    if (scaleFactor >= MAX_SCALE) {
                        scaleFactor = MAX_SCALE;
                    } else if (scaleFactor <= MIN_SCALE) {
                        scaleFactor = MIN_SCALE;
                    }

                    // 设置缩放原点为视图的左上角
                    this.setPivotX(0);
                    this.setPivotY(0);
                    // 应用缩放比例到视图的X和Y轴
                    this.setScaleX(scaleFactor);
                    this.setScaleY(scaleFactor);

                    // 计算并设置新的平移量,以保持缩放中心
                    float tx = postFocusCenter.x - (preFocusCenter.x - preTranslate.x) * scaleFactor / preScaleFactor;
                    float ty = postFocusCenter.y - (preFocusCenter.y - preTranslate.y) * scaleFactor / preScaleFactor;
                    this.setTranslationX(tx);
                    this.setTranslationY(ty);

                } else if (mode == TOUCH_MODE_SINGLE) { // 如果处于单指模式,处理平移逻辑
                    // 计算手指移动的差异值
                    float deltaX = event.getRawX() - actionDownX;
                    float deltaY = event.getRawY() - actionDownY;
                    // 调用平移处理函数
                    onSinglePointMoving(deltaX, deltaY);
                }

                break;
            // 当手指操作被认为是操作之外的事件时
            case MotionEvent.ACTION_OUTSIDE:
                // 这里可以处理事件,但通常不需处理
                break;
        }
        // 更新按下位置,为下一次滑动事件计算做准备
        actionDownX = event.getRawX();
        actionDownY = event.getRawY();

        return true; // 返回true标记事件已被处理
    }

    /**
     * 单指移动
     */
    private void onSinglePointMoving(float deltaX, float deltaY) {

        if (isKeepInViewport) { // 如果启用边界保留功能
            // 计算新的平移量
            float newTranslationX = this.getTranslationX() + deltaX;
            float newTranslationY = this.getTranslationY() + deltaY;

            // 检查计算后的平移量是否超越边界
            newTranslationX = checkTranslationXBoundary(newTranslationX);
            newTranslationY = checkTranslationYBoundary(newTranslationY);

            // 应用修正后的平移量
            this.setTranslationX(newTranslationX);
            this.setTranslationY(newTranslationY);

        } else {
            // 如果未启用边界保留功能,直接根据手指移动量更新平移量
            float translationX = this.getTranslationX() + deltaX;
            this.setTranslationX(translationX);
            float translationY = this.getTranslationY() + deltaY;
            this.setTranslationY(translationY);
        }
    }

    // 限制X轴方向的平移量
    private float checkTranslationXBoundary(float newTranslationX) {
        // 计算缩放后的宽度
        float scaledWidth = this.getWidth() * this.scaleFactor;
        // 计算由缩放引起的X轴方向偏移量
        float deltaX = (scaledWidth - getWidth()) / 2;

        // 限制平移量不超过左边界
        if (newTranslationX - deltaX > layoutInParentRect.left) {
            newTranslationX = layoutInParentRect.left + deltaX;
            // 限制平移量不超过右边界
        } else if (newTranslationX + getWidth() + deltaX < layoutInParentRect.right) {
            newTranslationX = layoutInParentRect.right - getWidth() - deltaX;
        }
        return newTranslationX; // 返回修正后的平移量
    }

    // 限制Y轴方向的平移量
    private float checkTranslationYBoundary(float newTranslationY) {
        // 计算缩放后的高度
        float scaledHeight = this.getHeight() * this.scaleFactor;
        // 计算由缩放引起的Y轴方向偏移量
        float deltaY = (scaledHeight - getHeight()) / 2;

        // 限制平移量不超过顶部边界
        if (newTranslationY - deltaY > layoutInParentRect.top) {
            newTranslationY = layoutInParentRect.top + deltaY;
            // 限制平移量不超过底部边界
        } else if (newTranslationY + getHeight() + deltaY < layoutInParentRect.bottom) {
            newTranslationY = layoutInParentRect.bottom - getHeight() - deltaY;
        }
        return newTranslationY; // 返回修正后的平移量
    }

    // 计算两个手指之间的距离,用于计算缩放比例
    private double distanceBetweenFingers(MotionEvent event) {
        if (event.getPointerCount() > 1) { // 确保有两个手指触摸屏幕
            // 计算两指在X轴和Y轴方向上的距离差
            float disX = Math.abs(event.getX(0) - event.getX(1));
            float disY = Math.abs(event.getY(0) - event.getY(1));
            // 返回两指间的直线距离
            return Math.sqrt(disX * disX + disY * disY);
        }
        return 1;
    }

    // 计算两个手指之间的中心点,用于确定缩放的中心
    private void centerPointBetweenFingers(MotionEvent event, PointF point) {

        float xPoint0 = event.getX(0); // 第一个手指的X坐标
        float yPoint0 = event.getY(0); // 第一个手指的Y坐标

        float xPoint1 = event.getX(1); // 第二个手指的X坐标
        float yPoint1 = event.getY(1); // 第二个手指的Y坐标

        // 设置PointF对象,该对象表示两指中心点的坐标
        point.set((xPoint0 + xPoint1) / 2f, (yPoint0 + yPoint1) / 2f);
    }

    // 允许开发者通过调用此方法来设置是否启用边界保留功能
    public void setKeepInViewport(boolean keepInViewport) {
        isKeepInViewport = keepInViewport;
    }

}

基本上每一行代码,每一个逻辑我都给出了详细的注释,完整的代码加上注释应该是很好理解,运行之后的效果如图所示:

viewgroup_scan01.gif

二、三指滑动操作

同样的道理,我们可以对三根手指的操作做记录并且移动到一定的阈值就可以满足条件回调出去:

private static final int TOUCH_MODE_TRIPLE = 3;
private boolean isTripleFingerGesture; // 用于判断是否检测到三指手势
private OnTripleFingerGestureListener tripleFingerGestureListener; //三指手势的回调接口

public interface OnTripleFingerGestureListener {
    void onTripleFingerSwipeDown(); // 三指下滑手势的回调方法
}

在事件的处理中我们额外处理三指的操作:

case MotionEvent.ACTION_POINTER_DOWN:
    if (event.getPointerCount() == 3) { // 如果是三指手势
        isTripleFingerGesture = true; // 标记三指手势为激活状态
    }
    // ...其他代码保持不变
    break;
case MotionEvent.ACTION_MOVE:
     //如果是单根手指事件,同时有三根手指,也可以设置为三指模式
         if (event.getPointerCount() == 3 && mode != TOUCH_MODE_TRIPLE) {
            // 初始时设置模式为三指触控模式
            mode = TOUCH_MODE_TRIPLE;
            isTripleFingerGesture = true;
            YYLogUtils.w("ACTION_MOVE中标记三指手势为激活状态");
         }

       //如果是三指模式
         if ((mode == TOUCH_MODE_TRIPLE && isTripleFingerGesture)) {
            float deltaX = event.getRawX() - actionDownX;
            if (Math.abs(deltaX) > THRESHOLD_FOR_TRIPLE_FINGER_SWIPE) {
               YYLogUtils.w("执行三指下滑的滑动操作回调");
               //这里你可以调用回调方法来实现三指下滑操作
               if (tripleFingerGestureListener != null) {
                     tripleFingerGestureListener.onTripleFingerSwipeDown();
               }
               isTripleFingerGesture = false; // 重置三指手势标记
            }
         }  else if (mode >= TOUCH_MODE_DOUBLE) {
        // ...双指缩放事件处理代码保持不变
    }
    // ...更多代码
    break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
    if (isTripleFingerGesture) {
        isTripleFingerGesture = false; // 重置三指手势标记
    }
    // ...其他代码保持不变
    break;

在移动中再次判断三指是因为有些设备同时放入三指会识别不了,在移动中再次判断是为了提交识别精准性。

效果:

image.png

三、多指连贯滑动

多指连贯滑动有很多的应用场景,一般会带上阻尼实现拖动的效果,例如QQ,微信,微博的一些朋友圈页面,在往下拖动的过程中会根据阻尼实现“原来越难拖动”的效果,可以伴随顶部的一些缩放动画实现特殊的效果。

如何实现多指连贯交替的滑动呢?和上面的一个示例有所不同不需要两个、三个手指同时滑动。这里需要的是一根手指抬起的时候去找到下一个手指,记录当前的手指的偏移量然后继续运动。

完整的代码如下:

public class ViewGroup9 extends FrameLayout {

    private static final int INVALID_POINTER_ID = -1;
    private int mActivePointerId = INVALID_POINTER_ID;
    private float mLastTouchY;
    private float dampingFactor = 0.3f; // 阻尼系数

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

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

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


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getActionMasked();
        final int actionIndex = event.getActionIndex();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mActivePointerId = event.getPointerId(0);
                mLastTouchY = event.getY();
                break;
            case MotionEvent.ACTION_POINTER_DOWN:
                break;  // 新手指按下,此时不必做什么
            case MotionEvent.ACTION_MOVE:
                // 找到当前活动手指的index
                final int activePointerIndex = event.findPointerIndex(mActivePointerId);
                if (activePointerIndex != -1) {
                    final float y = event.getY(activePointerIndex);
                    final float deltaY = y - mLastTouchY;

                    // 在这里处理下拉逻辑,可以通过 deltaY 进行滑动处理或者添加阻尼效果
                    int scrollDistance = (int) (deltaY * dampingFactor);
                    scrollBy(0, -scrollDistance);

                    mLastTouchY = y;
                }
                break;
            case MotionEvent.ACTION_POINTER_UP:
                // 一个手指抬起,但不一定是我们跟踪的那个手指
                int pointerId = event.getPointerId(actionIndex);
                if (pointerId == mActivePointerId) {
                    // 我们跟踪的那个手指抬起了,需要找到一个新的手指来跟踪
                    // 此时默认选择最后一个手指(但排除抬起的手指)
                    int newPointerIndex = (actionIndex == event.getPointerCount() - 1) ?
                            event.getPointerCount() - 2 :
                            event.getPointerCount() - 1;
                    mActivePointerId = event.getPointerId(newPointerIndex);
                    mLastTouchY = event.getY(newPointerIndex);
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mActivePointerId = INVALID_POINTER_ID;

                scrollTo(0, 0);
                break;
        }
        return true;
    }

}

效果如下:

viewgroup_scan02.gif

总结

在双指操作的过程中,由于我们用到了缩放,所以我们选择的是 ViewGroup 容器来进行操作变换,选择的是 Translation 与 Scale 的方案,而后面的多指连贯滚动,我们是对 ViewGroup 做的滚动操作 scrollBy 与 scrollTo 的方案。

两种方案的处理有相同点也有不同点,我们需要注意处理。

本文我们复习了 ViewGroup 单指与多指操作需要注意的点,以及一些常用的效果,相对而言比较简单。本文也只给出了基础的思路,如果需要在实际项目中使用还需要你自己定义对应的逻辑哦。

关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。

惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。

如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!

Ok,这一期就此完结。