安卓-触摸事件、事件分发机制及滑动冲突解决方法、CeilingNestedScrollView、常见拖拽容器设计及实现方案

目录

触摸事件

MotionEvent

常见的4个事件

ACTION_CANCEL发生的情况

多点触控

事件分发机制

核心方法

手指触摸屏幕到view响应的一系列过程

具体分发策略

注意点

滑动冲突

常见3种滑动冲突场景

处理规则

解决方法

view滑动冲突外部拦截法

view滑动冲突内部拦截法

代码

实际开发问题

CeilingNestedScrollView

介绍

冲突场景分析

核心代码

注意点

代码

效果视频

拖拽

列表item拖拽+补位动效

view拖拽

页面/区域跟手


触摸事件

MotionEvent
常见的4个事件
  • MotionEvent.ACTION_DOWN; //手指刚接触屏幕

  • MotionEvent.ACTION_MOVE; //手指在屏幕上移动

  • MotionEvent.ACTION_UP; //手指在屏幕上松开瞬间

  • MotionEvent.ACTION_CANCEL; //触摸事件中断取消

常用api

  • ev.getX(); //返回当前view左上角的x、y坐标

  • ev.getY();

  • ev.getRawX(); //返回当前相对于手机屏幕左上角的x、y坐标

  • ev.getRawY();

ACTION_CANCEL发生的情况
  1. 当父视图的 onInterceptTouchEvent() 方法从返回 false 变为返回 true 时,子视图会收到 ACTION_CANCEL。

  2. 触摸边界超出控件范围

  3. 系统事件打断(压后台/锁屏/来电/弹出系统弹窗/)

  4. 窗口失去焦点

  5. 滚动容器开始滚动

多点触控

一般是图片、视频用得多,手试捏合啥的

基础也是onTouchEvent,多指操作可以搭配其point来使用,来区分是哪个手指

复制代码
event.getActionMasked(); //获取当前的多点触控触摸事件
 
event.getActionIndex(); //获取本事件对应的 pointerIndex
 
event.getPointerCount(); ////获取当前触摸点个数
 
event.getPointerId(int pointerIndex) //获取索引值对应的 ID 值
 
event.findPointerIndex(int pointerId)  //获取PointerId对应的PointerIndex

event.getX(0)  //获取索引值=0对应的 ID 值的x坐标

event.getY(0)  //获取索引值=0对应的 ID 值的y坐标

可以看看开源组件PhotoView

//todo::补一个photoview源码解析

多点触控-手指缩放图片demo

java 复制代码
public class MainActivity extends Activity {

    private ImageView iv;
    private FrameLayout.LayoutParams lp;

    private float lastDistance = 0;   // 上一次两指距离
    private float lastX, lastY;       // 单指拖动起始点
    private boolean isDragging = false;

    private static final int MIN_SIZE = 80;
    private static final int MAX_SIZE = 1000;
    private static final float ZOOM_FACTOR = 1.1f;   // 每次缩放 10%
    private static final int DISTANCE_THRESHOLD = 10; // 距离变化超过 10px 触发缩放

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        iv = findViewById(R.id.iv_image);
        lp = (FrameLayout.LayoutParams) iv.getLayoutParams();

        iv.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                handleTouch(event);
                return true;
            }
        });
    }

    private void handleTouch(MotionEvent event) {
        int action = event.getActionMasked();
        int pointerCount = event.getPointerCount();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                isDragging = true;
                lastX = event.getX();
                lastY = event.getY();
                lastDistance = 0;
                break;

            case MotionEvent.ACTION_POINTER_DOWN:
                // 第二根或更多手指按下,准备缩放
                if (pointerCount >= 2) {
                    isDragging = false;     // 有双指,取消拖动模式
                    lastDistance = calculateDistance(event);
                }
                break;

            case MotionEvent.ACTION_MOVE:
                if (pointerCount >= 2 && lastDistance > 0) {
                    // 双指缩放模式
                    float currentDistance = calculateDistance(event);
                    float diff = currentDistance - lastDistance;
                    if (diff > DISTANCE_THRESHOLD) {
                        zoom(ZOOM_FACTOR);          // 放大
                        lastDistance = currentDistance;
                    } else if (diff < -DISTANCE_THRESHOLD) {
                        zoom(1 / ZOOM_FACTOR);      // 缩小
                        lastDistance = currentDistance;
                    }
                } else if (pointerCount == 1 && isDragging) {
                    // 单指拖动模式
                    float dx = event.getX() - lastX;
                    float dy = event.getY() - lastY;
                    move(dx, dy);
                    lastX = event.getX();
                    lastY = event.getY();
                }
                break;

            case MotionEvent.ACTION_POINTER_UP:
                // 有一根手指抬起,重置缩放距离,避免残留
                lastDistance = 0;
                // 如果抬起后还剩一根手指,恢复拖动模式并更新起点
                int remaining = event.getPointerCount(); // 已经减去了抬起的那根
                if (remaining == 1) {
                    isDragging = true;
                    // 找到剩余那根手指的索引
                    int activeIndex = (event.getActionIndex() == 0) ? 1 : 0;
                    lastX = event.getX(activeIndex);
                    lastY = event.getY(activeIndex);
                } else {
                    isDragging = false;
                }
                break;

            case MotionEvent.ACTION_UP:
                isDragging = false;
                lastDistance = 0;
                break;
        }
    }

    private float calculateDistance(MotionEvent event) {
        if (event.getPointerCount() < 2) return 0;
        float x1 = event.getX(0);
        float y1 = event.getY(0);
        float x2 = event.getX(1);
        float y2 = event.getY(1);
        return (float) Math.hypot(x1 - x2, y1 - y2);
    }

    private void zoom(float factor) {
        int newWidth = (int) (lp.width * factor);
        int newHeight = (int) (lp.height * factor);
        newWidth = clamp(newWidth, MIN_SIZE, MAX_SIZE);
        newHeight = clamp(newHeight, MIN_SIZE, MAX_SIZE);
        lp.width = newWidth;
        lp.height = newHeight;
        iv.setLayoutParams(lp);
        System.out.println("zoom: " + newWidth + "x" + newHeight); // 调试输出
    }

    private void move(float dx, float dy) {
        lp.leftMargin = clamp(lp.leftMargin + (int) dx, -500, 800);
        lp.topMargin = clamp(lp.topMargin + (int) dy, -500, 800);
        iv.setLayoutParams(lp);
    }

    private int clamp(int value, int min, int max) {
        return Math.max(min, Math.min(max, value));
    }
}
TouchSlop

系统能识别出被认为是滑动的最小距离。用于事件过滤,如vp中就有用到这个,滑动距离需要大于最小距离才响应滑动事件,该值系统定义一般是8dp。

获取方式:

复制代码
private int mTouchSlop = configuration.getScaledPagingTouchSlop();
VelocityTracker

使用方式

java 复制代码
VelocityTracker velocityTracker = VelocityTracker.obtain();
//1.追踪单击事件的速度
velocityTracker.addMovement(event);
//2.计算速度,一段时间内手指所滑过的像素数。
// 这里是指单位间隔1000ms时,在1s内手指从水平方向从左到右滑过100像素,那么水平速度就是100。速度=(终点位置100px-起点位置0px)/时间段1s
velocityTracker.computeCurrentVelocity(1000);
float xVelocity = velocityTracker.getXVelocity();
float yVelocity = velocityTracker.getYVelocity();
//3.用完记得回收
velocityTracker.clear();;
velocityTracker.recycle();
GestureDetector

手势检测。单击、滑动、长按、双击。

使用方式

java 复制代码
//1.创建 GestureDetector 对象
GestureDetector mGestureDetector = new GestureDetector((Context) this, this.onGestureListener);
// 解决长按屏幕后无法拖动的现象(可选)
mGestureDetector.setOnLongPressEnabled(false);
//双击
mGestureDetector.setOnDoubleTapListener(this.mDoubleTapListener);

//2.接管目标 View 的 onTouchEvent
@Override
public boolean onTouchEvent(MotionEvent event) {
    boolean consume = mGestureDetector.onTouchEvent(event);
    return consume;
}

//3.实现
    private GestureDetector.OnGestureListener onGestureListener = new GestureDetector.OnGestureListener() {
       ...
    };

    private GestureDetector.OnDoubleTapListener mDoubleTapListener = new GestureDetector.OnDoubleTapListener() {
       ...
    };

OnGestureListener 接口方法

方法名 触发条件 描述
onDown(MotionEvent e) 1 个 ACTION_DOWN 手指轻轻触摸屏幕的一瞬间
onShowPress(MotionEvent e) 1 个 ACTION_DOWN 手指触摸屏幕,尚未松开或拖动
onSingleTapUp(MotionEvent e) 1 个 ACTION_UP 手指轻触屏幕后松开,这是单击行为
onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) 1 个 ACTION_DOWN + 多个 ACTION_MOVE 手指按下屏幕并拖动,拖动行为
onLongPress(MotionEvent e) 长按 用户长久地按着屏幕不放
onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) 1 个 ACTION_DOWN + 多个 ACTION_MOVE + 1 个 ACTION_UP 快速滑动后松开,快速滑动行为

OnDoubleTapListener 接口方法

方法名 描述
onDoubleTap(MotionEvent e) 双击行为,由 2 次连续的单击组成(不可能与 onSingleTapConfirmed 共享)
onSingleTapConfirmed(MotionEvent e) 严格的单击行为(确认不是双击的一部分)
onDoubleTapEvent(MotionEvent e) 双击期间,ACTION_DOWNACTION_MOVEACTION_UP 都会触发此回调

注:onDown 必须返回 true:否则后续的 onScroll、onFling 等方法不会触发。

常用的有:

单击-onSingleTapUp 或 onSingleTapConfirmed

快速滑动(扫屏)-onFling

拖动-onScroll

长按-onLongPress

双击-onDoubleTap

Scrooler

用于搭配view弹性滚动。

使用方法

java 复制代码
Scroller mScroller= new Scroller(mContext);

// 缓慢滚动到指定位置
private void smoothScrollTo(int destX, int destY) {
    int scrollX = getScrollX();
    int deltaX = destX - scrollX;
    int scrollY = getScrollY();
    int deltaY = destY - scrollY;
    
    // 1000ms 内滑向 destX,效果就是慢慢滑动
    mScroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000);
    invalidate();
}

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

事件分发机制

核心就3个方法。

核心方法
  • dispatchTouchEvent - 事件分发。返回结果表示是否消费当前事件。

  • onInterceptTouchEvent - 事件拦截。返回结果表示是否拦截当前事件。

  • onTouchEvent - 处理点击事件。返回结果表示是否消费当前事件,默认消费。

手指触摸屏幕到view响应的一系列过程

分为2大步,第一步是物理输入到activity,第2步是activity开始内部的view事件分发。

传递过程:物理触摸 -> IMS -> WMS-> App Process -> ViewRootImpl -> DecorView -> Activity,然后触摸事件开始分发。点击事件分发MotionEvent->具体view。

  1. 物理屏幕触摸

  2. Linux 内核驱动,捕获原始信号

  3. InputManagerService (IMS),读取 /dev/input 设备节点,将原始事件封装为 InputEvent,负责事件读取和初步分发

  4. WindowManagerService (WMS),协助 IMS 确定目标窗口(焦点窗口、布局层级、触摸区域),通过 InputChannel 跨进程传递

  5. App 进程(UI 线程),ViewRootImpl 内的 InputEventReceiver 接收IMS的事件

  6. ViewRootImpl 将事件 dispatch 给 DecorView

  7. DecorView.dispatchTouchEvent()

  8. Activity.dispatchTouchEvent(), 事件分发起点,若未消费再传给DecorView的子view

  9. ViewGroup/View 的事件具体分发策略

具体分发策略

1.由Activity的dispatchTouchEvent向下分发。

  1. 事件到达ViewGroup后,会先调用onInterceptTouchEvent判断是否拦截。

若拦截,事件直接交给该 ViewGroup 自己的onTouchEvent如果不拦截,就继续分发给子View。

3.遍历子view找到能接收事件的那个,调用子View的dispatchTouchEvent,子View的dispatchTouchEvent会调用onTouchEvent来处理事件。如果子view是viewGroup则还有前面第2步的onInterceptTouchEvent判断。

如果某一层的onTouchEvent返回true消费了事件,传递就终止。

4.如果所有子View都不消费,事件会逐级回传给父容器和Activity的onTouchEvent。并且,一旦某个View消费了ACTION_DOWN,后续的MOVE/UP/CANCEL事件会直接分发给它。

注意点
  • 拦截只在viewgroup中存在,普通view没有onInterceptTouchEvent 。

  • onTouchListener 优先级:如果 setOnTouchListener 并且 onTouch 返回 true,则会消耗事件,onTouchEvent 不会被调用。

滑动冲突

即:如果滑动嵌套view的时候,那一层view来响应这个滑动事件,如果没有使用VP则会存在滑动冲突,需要手动解决。

常见3种滑动冲突场景
  1. 外部滑动方向和内部滑动方向不一致

  2. 外部滑动方向和内部滑动方向一致

  3. 2种情况嵌套

处理规则

场景1. 如果外部滑动是竖直滑动,内部滑动是水平滑动,则根据滑动是水平滑动还是竖直滑动来判断由谁拦截,水平滑动时内部拦截,竖直滑动时由外部拦截。

怎么根据坐标得到滑动方向?

  • 滑动路径和水平方向形成的夹角

  • 水平方向和竖直方向上的距离差

  • 水平方向和竖直方向的速度差

场景2. 什么时机外部拦截什么时机内部拦截,根据业务需求得到处理规则。场景3.也是。

解决方法

主要是外部拦截法和内部拦截法

view滑动冲突外部拦截法

修改父容器需要拦截事件的条件。外部拦截法是指点击点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,不需要就不拦截。

外部拦截法需要重写父容器的onInterceptTouchEvent

如下示例,HorizontalScrollViewEx 是一个可以横向滚动的父容器,内部装了可以竖向滚动的view。

手动处理滑动冲突的方法为:比较手势最后点位和手试滑动过程中的返回的点位,当横向滑动距离>竖向滑动距离时,父容器拦截。

java 复制代码
/**
 * view滑动冲突外部拦截法:修改父容器需要拦截事件的条件
 * 自定义view
 */
public class HorizontalScrollViewEx extends ViewGroup {
    private static final String TAG = "HorizontalScrollViewEx";

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;
    //分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
    //分别记录上次滑动的坐标(onInterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;

    private void init(){
        if(mScroller == null){
            mScroller = new Scroller(getContext());
            mVelocityTracker = VelocityTracker.obtain();
        }
    }

    public HorizontalScrollViewEx(Context context) {
        super(context);
        init();

    }

    public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       int measuredWidth = 0;
       int measuredHeight = 0;
       final int childCount = getChildCount();
       measureChildren(widthMeasureSpec,heightMeasureSpec);

        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthSpaceMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpaceMode = MeasureSpec.getMode(heightMeasureSpec);
        if (childCount == 0){   //没有子元素
           setMeasuredDimension(0,0);
        }else if (widthSpaceMode == MeasureSpec.AT_MOST && heightSpaceMode == MeasureSpec.AT_MOST){  //宽高都采用wrap_content
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measuredWidth,measuredHeight);
        }else if (widthSpaceMode == MeasureSpec.AT_MOST){  //宽都采用wrap_content:宽为所有子元素之和
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measuredWidth,heightSpaceSize);
        }else if (heightSpaceMode == MeasureSpec.AT_MOST){  //高都采用wrap_content:高为第一个子元素的高
            final View childView = getChildAt(0);
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpaceSize,measuredHeight);
        }
    }

    /*
    * 完成子元素定位
    * */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;

        for (int i=0; i<childCount; i++){
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE){  //子元素可见
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft,0,childLeft + childWidth,childView.getMeasuredHeight());  //将其放在合适的位置
                childLeft += childWidth;
            }
        }
    }



    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:{
                //不拦截ACTION_DOWN
                intercepted = false;
                //优化滑动体验
                if (!mScroller.isFinished()){
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            }
            case MotionEvent.ACTION_MOVE:{
                int deltaX = x - mLastXIntercept;
                int deltaY = y - mLastYIntercept;
                //水平方向距离大为水平滑动,父容器拦截事件。竖直方向距离大不拦截事件,给listView
                if (Math.abs(deltaX) > Math.abs(deltaY)){   //拦截条件
                    intercepted = true;
                }else {
                    intercepted = false;
                }
                break;
            }
            case MotionEvent.ACTION_UP:{
                intercepted = false;
                break;
            }
            default:
                break;

        }
        Log.d(TAG, "onInterceptTouchEvent: = " + intercepted);
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:{
                if (!mScroller.isFinished()){
                    mScroller.abortAnimation();
                }
                break;
            }
            case MotionEvent.ACTION_MOVE:{
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX,0);   //左右滑动
                break;
            }
            case MotionEvent.ACTION_UP:{
                int scrollX = getScrollX();
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50){
                    mChildIndex = xVelocity>0?mChildIndex - 1: mChildIndex + 1;
                }else {
                    mChildIndex = (scrollX + mChildWidth /2)/ mChildWidth;
                }
                mChildIndex = Math.max(0,Math.min(mChildIndex,mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx,0);
                mVelocityTracker.clear();
                break;
            }
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

    private void smoothScrollBy(int dx, int dy){
        mScroller.startScroll(getScrollX(),0,dx,0,500);
        invalidate();
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            postInvalidate();
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}
复制代码
view滑动冲突内部拦截法

父容器不拦截任何事件,所有的事件都传递给子元素,子元素需要此事件就消耗掉,不需要就给父容器处理。

需要重写子元素的dispatchTouchEvent方法,搭配requestDisallowInterceptTouchEvent处理。

parent.requestDisallowInterceptTouchEvent(false); //允许父容器拦截

parent.requestDisallowInterceptTouchEvent(true); //不允许父容器拦截

父容器不能拦截ACTION_DOWN的事件,因为ACTION_DOWN不受FLAG_DIALLOW_INTERCEPT这个标志位控制,父容器一拦截这个事件,事件就无法传递到子元素去,内部拦截无法生效。所以父布局也要改一下其onInterceptTouchEvent里面的拦截方法,把down放开。

java 复制代码
/**
 * view滑动冲突内部拦截法:父容器拦截除ACTION_DOWN以外的事件
 *
 */
public class HorizontalScrollViewEx2 extends ViewGroup {

    private static final String TAG = "HorizontalScrollViewEx";

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;
    //分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
    //分别记录上次滑动的坐标(onInterceptTouchEvent)

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;

    private void init(){
        if(mScroller == null){
            mScroller = new Scroller(getContext());
            mVelocityTracker = VelocityTracker.obtain();
        }
    }

    public HorizontalScrollViewEx2(Context context) {
        super(context);
        init();
    }

    public HorizontalScrollViewEx2(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public HorizontalScrollViewEx2(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int measuredWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec,heightMeasureSpec);

        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthSpaceMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpaceMode = MeasureSpec.getMode(heightMeasureSpec);
        if (childCount == 0){   //没有子元素
            setMeasuredDimension(0,0);
        }else if (widthSpaceMode == MeasureSpec.AT_MOST && heightSpaceMode == MeasureSpec.AT_MOST){  //宽高都采用wrap_content
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measuredWidth,measuredHeight);
        }else if (widthSpaceMode == MeasureSpec.AT_MOST){  //宽都采用wrap_content:宽为所有子元素之和
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measuredWidth,heightSpaceSize);
        }else if (heightSpaceMode == MeasureSpec.AT_MOST){  //高都采用wrap_content:高为第一个子元素的高
            final View childView = getChildAt(0);
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpaceSize,measuredHeight);
        }
    }

    /*
     * 完成子元素定位
     * */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;

        for (int i=0; i<childCount; i++){
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE){  //子元素可见
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft,0,childLeft + childWidth,childView.getMeasuredHeight());  //将其放在合适的位置
                childLeft += childWidth;
            }
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
            //父容器拦截除ACTION_DOWN以外的事件
            if (event.getAction() == MotionEvent.ACTION_DOWN){
                mLastX = x;
                mLastY = y;
                //优化滑动体验
                if (!mScroller.isFinished()){
                    mScroller.abortAnimation();
                    return true;
                }
                return false;
            }else {
                return true;
            }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:{
                if (!mScroller.isFinished()){
                    mScroller.abortAnimation();
                }
                break;
            }
            case MotionEvent.ACTION_MOVE:{
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX,0);
                break;
            }
            case MotionEvent.ACTION_UP:{
                int scrollX = getScrollX();
                int scrollToChildIndex = scrollX / mChildWidth;
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50){
                    mChildIndex = xVelocity>0?mChildIndex - 1: mChildIndex + 1;
                }else {
                    mChildIndex = (scrollX + mChildWidth /2)/ mChildWidth;
                }
                mChildIndex = Math.max(0,Math.min(mChildIndex,mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx,0);
                mVelocityTracker.clear();
                break;
            }
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

    private void smoothScrollBy(int dx, int dy){
        mScroller.startScroll(getScrollX(),0,dx,0,500);
        invalidate();
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()){
            scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
            postInvalidate();
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}
java 复制代码
/**
 * view滑动冲突内部拦截法:子元素需要此事件就直接消耗,否则交给父容器处理。需重写子元素的dispatchTouchEvent方法
 *
 */
public class ListViewEx extends ListView {
    private static final String TAG = "ListView";

    private HorizontalScrollViewEx2 mHorizontalScrollViewEx2;
    //分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;

    public ListViewEx(Context context) {
        super(context);
    }

    public ListViewEx(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public ListViewEx(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void setHorizontalScrollViewEx(HorizontalScrollViewEx2 horizontalScrollViewEx2){
        mHorizontalScrollViewEx2 = horizontalScrollViewEx2;
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:{
                //父容器不拦截ACTION_DOWN
                mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE:{
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (Math.abs(deltaX) > Math.abs(deltaY)){
                    //父容器拦截ACTION_MOVE
                    mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP:{
                break;
            }
            default:
                break;
        }

        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(event);
    }
}
java 复制代码
/**
 * view滑动冲突内部拦截法:子元素需要此事件就直接消耗,否则交给父容器处理。需重写子元素的dispatchTouchEvent方法
 *
 */
public class ListViewEx extends ListView {
    private static final String TAG = "ListView";

    private HorizontalScrollViewEx2 mHorizontalScrollViewEx2;
    //分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;

    public ListViewEx(Context context) {
        super(context);
    }

    public ListViewEx(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public ListViewEx(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void setHorizontalScrollViewEx(HorizontalScrollViewEx2 horizontalScrollViewEx2){
        mHorizontalScrollViewEx2 = horizontalScrollViewEx2;
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:{
                //父容器不拦截ACTION_DOWN
                mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE:{
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (Math.abs(deltaX) > Math.abs(deltaY)){
                    //父容器拦截ACTION_MOVE
                    mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP:{
                break;
            }
            default:
                break;
        }

        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(event);
    }
}

//todo::stickyLayout

代码

https://gitee.com/flying-guy/ds/blob/master/HelloWorldDemo/ViewConflictTest/src/main/java/com/desay/viewconflicttest/MainActivity.java

实际开发问题

上面的分析提供了问题解决思路,解决方案只适用于简单业务滑动冲突处理。

实际项目中一般需要手动解决的冲突的时候比这复杂很多,比如做外层容器的时候,内部已经有了10~20层的view,里面包含7、8个独立业务方的业务,这7、8个业务方里面各种骚操作,10多年沉淀下来的事件拦截和释放api,已经竖直滑动方向布局嵌套横向滑动布局嵌套竖直滑动方向再嵌套横向滑动布局嵌嵌嵌套套套,**父容器根本收不到onInterceptTouchEvent。。。**因过于复杂且业务方用的技术栈也是五花八门,有的已经脱离native了,不可能推得懂动他们修改了,内外拦截法直接失效,害,学了个寂寞。

那怎么办?

直接使用viewPager或者仿viewPager,这个可行度高

//todo::补一个vp解决滑动冲突源码分析

CeilingNestedScrollView

滑动冲突怎么少得了NestedScrollView,来来来

介绍

常用于主页、个人信息页等页面框架。

一般是长这样,进来可上下滑动,tabbar滑上去的时候吸顶在上面的导航栏底部,tabbar下面的内容可以左右滑动翻页,吸顶后再滑动滑动的是tabbar内部的rv里面的内容。

冲突场景分析

页面存在2层可竖直滑动的view

  • 外层:CustomNestedScrollView

  • 内层:ViewPager2的RecyclerView

需要解决的问题

  • 手指上滑,谁先消费

  • 外层fling到底部,惯性如何传给内层

  • tabbar吸顶时机、怎么吸顶怎么取消吸顶状态

核心代码

1.解决滑动冲突

java 复制代码
public class CustomNestedScrollView extends NestedScrollView implements View.OnScrollChangeListener {
    /**
     * 用于判断recyclerview是否在fling
     */
    boolean isStartFling = false;
    /**
     * 记录当前滑动y轴加速度
     */
    private int velocityY = 0;
    FlingHelper mFlingHelper;
    int totalDy = 0;
    ViewPager2 mViewPager2;

    public CustomNestedScrollView(@NonNull Context context) {
        super(context);
        init();
    }

    public CustomNestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

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

    public void init() {
        setOnScrollChangeListener(this);
        mFlingHelper = new FlingHelper(getContext());
    }

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        int headerViewHeight = getChildAt(0).getMeasuredHeight() - getMeasuredHeight();
        //向上滑动,若当前topview可见,需要将topview滑动至不可见
        boolean hideTop = dy > 0 && getScrollY() < headerViewHeight;
        if (hideTop) {
            scrollBy(0, dy);
            consumed[1] = dy;
        }
    }

    @Override
    public void fling(int velocityY) {
        super.fling(velocityY);
        this.velocityY = velocityY;
        if (velocityY > 0) {
            isStartFling = true;
            totalDy = 0;
        }
    }

    @Override
    public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
        //在recyclerview fling的情况下,记录当前recyclerview在y轴的偏移
        totalDy += scrollY - oldScrollY;
        if (scrollY == (getChildAt(0).getMeasuredHeight() - getMeasuredHeight())) {
            if (velocityY != 0) {
                Double splineFlingDistance = mFlingHelper.getSpineFlingDistance(velocityY);
                if (splineFlingDistance > totalDy) {
                    mViewPager2 = getChildRecyclerView(this, ViewPager2.class);
                    if (mViewPager2 != null) {
                        RecyclerView childRecyclerView = getChildRecyclerView(((ViewGroup) mViewPager2.getChildAt(0)).getChildAt(mViewPager2.getCurrentItem()), RecyclerView.class);
                        if (childRecyclerView != null) {
                            childRecyclerView.fling(0, mFlingHelper.getVelocityByDistance(splineFlingDistance - Double.valueOf(totalDy)));
                        }
                    }
                }
            }
            totalDy = 0;
            velocityY = 0;
        }
    }

    private <T> T getChildRecyclerView(View viewGroup, Class<T> targetClass) {
        if (viewGroup != null && viewGroup.getClass() == targetClass) {
            return (T) viewGroup;
        }
        if (viewGroup instanceof ViewGroup) {
            for (int i = 0; i < ((ViewGroup) viewGroup).getChildCount(); i++) {
                View view = ((ViewGroup) viewGroup).getChildAt(i);
                if (view instanceof ViewGroup) {
                    T result = getChildRecyclerView(view, targetClass);
                    if (result != null) {
                        return result;
                    }
                }
            }
        }
        return null;
    }
}

2.处理外层

java 复制代码
public class MainActivity extends AppCompatActivity {

    final String[] labels = {"苹果", "香蕉", "番茄", "草莓"};

    ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        ViewPagerAdapter viewPagerAdapter = new ViewPagerAdapter(this, getPageFragments());
        binding.viewpagerView.setAdapter(viewPagerAdapter);
        new TabLayoutMediator(binding.tablayout, binding.viewpagerView, new TabLayoutMediator.TabConfigurationStrategy() {
            @Override
            public void onConfigureTab(@NonNull TabLayout.Tab tab, int position) {
                tab.setText(labels[position]);
            }
        }).attach();
        binding.tablayoutViewpager.post(new Runnable() {

            @Override
            public void run() {
                binding.tablayoutViewpager.getLayoutParams().height = binding.scrollview.getMeasuredHeight();
                int height = binding.scrollview.getMeasuredHeight();
                if (Build.VERSION.SDK_INT >= 35) {
                    int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
                    if (resourceId > 0) {
                        height -= getResources().getDimensionPixelSize(resourceId);
                    }
                }
                binding.tablayoutViewpager.getLayoutParams().height = height;
                binding.tablayoutViewpager.requestLayout();
            }
        });
    }

    private List<Fragment> getPageFragments() {
        ArrayList<Fragment> data = new ArrayList<>();
        data.add(new RecyclerViewFragment());
        data.add(new RecyclerViewFragment());
        data.add(new RecyclerViewFragment());
        data.add(new RecyclerViewFragment());
        return data;
    }
}

2.fling转距离

java 复制代码
public class FlingHelper {
    private static float DECELERATION_RATE = ((float) (Math.log(0.78d) / Math.log(0.9d)));
    private static float mFlingFriction = ViewConfiguration.getScrollFriction();
    private static float mPhysicalCoeff;

    public FlingHelper(Context context) {
        mPhysicalCoeff = context.getResources().getDisplayMetrics().density * 160.0f * 386.0878f * 0.84f;
    }

    private double getSplineDeceleration(int i) {
        return Math.log((double) ((0.35f * ((float) Math.abs(i))) / (mFlingFriction * mPhysicalCoeff)));
    }

    private double getSplineDecelerationByDistance(double d) {
        return ((((double) DECELERATION_RATE) - 1.0f) * Math.log(d / ((double) (mFlingFriction * mPhysicalCoeff)))) / ((double) DECELERATION_RATE);
    }

    public double getSpineFlingDistance(int i) {
        return Math.exp(getSplineDeceleration(i) * (((double) DECELERATION_RATE) / (((double) DECELERATION_RATE) - 1.0d))) * ((double) (mFlingFriction * mPhysicalCoeff));
    }

    public int getVelocityByDistance(double d) {
        return Math.abs((int) (((Math.exp(getSplineDecelerationByDistance(d)) * ((double) mFlingFriction)) * ((double) DECELERATION_RATE)) / 0.34999999999d));
    }
}
注意点

1.targetSDK 35及以上版本开启了edge-to-edge,内容会绘制到actionbar和状态栏后面导致

scrollview.getMeasuredHeight()拿到的是全屏高度,导致tabbar被遮挡,因此计算时需要减去这部分高度

java 复制代码
binding.tablayoutViewpager.getLayoutParams().height = binding.scrollview.getMeasuredHeight();
int height = binding.scrollview.getMeasuredHeight();
if (Build.VERSION.SDK_INT >= 35) {
  int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
  if (resourceId > 0) {
     height -= getResources().getDimensionPixelSize(resourceId);
  }
}
binding.tablayoutViewpager.getLayoutParams().height = height;
binding.tablayoutViewpager.requestLayout();

2.使用的databinding,需要在build.gradle里面配置

java 复制代码
 dataBinding {
        enabled true
    }
代码

https://gitee.com/flying-guy/daily-practice/blob/master/AndroidProject/MyApplication/CeilingNestScrollView/src/main/java/com/lwj/ceilingnestscrollview/MainActivity.java

效果视频

CeilingNestedRecyclerview

拖拽

即:view跟手

主要需要的方法:

手指点击/触摸时view的响应,手指移动时view需要移动或者记录移动数据用于业务,手指抬起,view移动到指定位置。

场景拖拽业务场景有:

列表item拖拽+补位动效

(编辑列表排序)

这类一般是用来列表展示,用户可拖拽item删除item或者移动其位置,删除的时候后面的item需要补位动画上前,移动的时候其他item需要自动让开和补位,长按选中item有弹出动画,松手item动画回到目标位置,可使用安卓rv配套的ItemTouchHelper。

1.自定义一个接口ItemTouchHelperAdapter,里面有数据交换和数据删除的方法,用于响应数据交换和删除方法。

2.自定义一个SimpleItemTouchHelperCallback类 继承自ItemTouchHelper.Callback。该类需要处理以下操作

isLongPressDragEnabled(能否拖拽)、isItemViewSwipeEnabled(能否侧滑)

getMovementFlags(同来设置 拖拽移动,或移动删除)

onMove(拖拽时去回调adapter里的onItemMove方法)

onSwiped(左右滑动,回调adapter里的onSwiped方法)

onSelectedChanged(选中项操作,一般样式有改变,或者弹出动画)

clearView(释放选中项操作)

3.关联ItemTouchHelper和rv

java 复制代码
 //关联ItemTouchHelper和RecyclerView
ItemTouchHelper.Callback callback = new SimpleItemTouchHelperCallback(mMyAdapter, true, true);
ItemTouchHelper touchHelper = new ItemTouchHelper(callback);
touchHelper.attachToRecyclerView(mRecyclerView);

4.MyAdapter实现ItemTouchHelperAdapter,在这里响应adapter的数据变化并刷新列表

java 复制代码
    @Override
    public void onItemMove(int fromPosition, int toPosition) {
        //交换位置
        Collections.swap(dataList, fromPosition, toPosition);
        notifyItemMoved(fromPosition, toPosition);
    }
 
    @Override
    public void onSwiped(int position) {
        //移除数据
        dataList.remove(position);
        notifyItemRemoved(position);
    }

demo

view拖拽

(view拖到指定区域、进度条拖拽、滑动视频)

在父容器onTouchEvent,ACTION_DOWN的时候查找这个point的x,y附近是否有view,view是否被选中,如果选中就在ACTION_DOWN的时候通过TranslationX/Y去改变该view的位置。

可搭配长按响应、动画等操作优化体验。

也可直接使用安卓ViewDragHelper组件。

页面/区域跟手

(个人信息页抽屉、浮窗效果、视频图片播放页拖拽)

这个一般是作为业务组件容器

一般使用PhotoView、ViewPager实现。

相关推荐
张风捷特烈3 小时前
状态管理大乱斗#03 | Provider 源码全面评析
android·前端·flutter
鹏晨互联11 小时前
《Android 自定义 WebView 组件:从封装到路由,打造灵活可复用的混合开发利器》
android
程序员陆业聪11 小时前
AI Code Review:让每一行代码都有AI审查员
android
程序员陆业聪11 小时前
AI Bug修复与测试生成:从崩溃日志到修复PR的自动化 | AI提效Android开发(5)
android
诸神黄昏EX11 小时前
Android Google Widevine
android
HealthScience14 小时前
【Bib 2026】基因最新综述(有什么任务、benchmark、代表性模型)
android·开发语言·kotlin
夏沫琅琊15 小时前
Android拨打电话技术文档
android·kotlin
a2591748032-随心所记15 小时前
android studio gradle快速编译配置
android·android studio
一块小土坷垃16 小时前
# 《电影猎手》观影伴侣:一款支持iOS/安卓/电视盒子的全平台影视工具“电影猎手”(附自用评价)
android·ios·电视盒子