Android-事件分发-嵌套滑动

前言

在前期做一些Android酷炫效果并遇到嵌套滑动问题的算是一大难点,没有标准的规范,开发人员根据自己的需求进行随意控制,导致做的一些组件缺少复用性,同时也不利于后期的维护。后期官方出了自己的嵌套滑动标准,主要由NestedScrollingChild、NestedScrollingChildHelper、NestedScrollingParent、NestedScrollingParentHelper进行控制,从而规范了嵌套滑动事件的处理标准。

下图展示的一个嵌套滑动的效果图,这里我们定义:

  1. A:表示蓝色部分视图,一个ViewGroup
  2. B:表示淡绿色部分视图,自定义的一个View

后面的描述我们都将用A和B表示。

image

们需要达到的效果是当触摸到B的时候,B滑动一段距离,然后再滑动A;接下来我们已两种不同的方式来实现该效果。

如何实现

方案一:使用传统的事件分发机制进行控制

要实现该效果,那么我们就需要在B中消费事件,同时满足一定条件后控制父视图A的滑动;这里我们定义一个view,重写onToucheEvent()进行事件消费处理:

public class DemoView extends View {
    //最大滑动距离-需要根据自己需要进行控制
    private int maxMoveDistance = 200;
    public DemoView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return super.onTouchEvent(event);
    }
}

这里的onTouchEvent是我们处理的关键,因此详细介绍具体实现过程:

第一步: 处理Down事件

//做横向滑动,记录坐标即可
private int lastTouchX;
@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()){
        case MotionEvent.ACTION_DOWN:
            lastTouchX = (int) event.getRawX();
            ViewParent viewParent = getParent();
            if(viewParent != null){
                //请求父视图不拦截事件
                viewParent.requestDisallowInterceptTouchEvent(true);
            }
            break;
    }
    return super.onTouchEvent(event);
}

上面的代码已有注释,我们取出点击时的坐标,同时要求父View不拦截事件.

第二步: 处理Move事件

View的移动我们通过setTranslationX的方式来控制,移动的距离为当前移动X坐标与上一次按下或者移动的X坐标的差,即event.getRawX()-lastTouchX;在滑动的过程中,我们需要判断当前View达到相应临界点消费的滑动距离,没有消费完成则由父View消费,整个onTouch的代码如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            lastTouchX = (int) event.getRawX();
            ViewParent viewParent = getParent();
            if (viewParent != null) {
                //请求父视图不拦截事件
                viewParent.requestDisallowInterceptTouchEvent(true);
            }
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            ViewParent viewParent = getParent();
            if (viewParent != null) {
                //请求父视图不拦截事件
                viewParent.requestDisallowInterceptTouchEvent(true);
            }

            int dx = (int) (event.getRawX() - lastTouchX);
            //判断当前View能够移动的最大距离
            int oldViewX = (int) getTranslationX();
            int viewTranslationX = oldViewX + dx;
            if (viewTranslationX < 0) {
                setTranslationX(0);
            } else if (viewTranslationX > maxMoveDistance) {
                setTranslationX(maxMoveDistance);
            } else {
                setTranslationX(viewTranslationX);
            }
            //判断当前View是否已经消费完滑动的距离,没有消费完则交由父View处理
            int dxConsumed = (int) (getTranslationX() - oldViewX);
            int dxUnConsumed = dx - dxConsumed;
            if (dxUnConsumed != 0) {
                if (viewParent != null) {
                    ViewGroup viewGroup = (ViewGroup) viewParent;
                    viewGroup.setTranslationX(viewGroup.getTranslationX() + dxUnConsumed);
                }
            }
            lastTouchX = (int) event.getRawX();
            break;
        }
    }
    return true;
}

第三步: 验证结果

<FrameLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="100dp">

    <View
        android:layout_width="150dp"
        android:layout_height="50dp"
        android:background="@color/colorPrimary"></View>

    <com.water.view.demo.DemoView
        android:layout_width="100dp"
        android:layout_height="30dp"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="50dp"
        android:background="@color/colorAccent"></com.water.view.demo.DemoView>

</FrameLayout>

运行整个代码,可得到前言中运行的效果。

整个触摸事件的控制流程:当触摸到当前B View的时候–>请求父View不拦截事件,同时消费事件–>计算两个相邻滑动点击的距离来计算View滑动的距离,在当前View的范围内,则由当前View通过setTranslationX进行移动,剩下未消费的距离由父View消费,通过setTranslationX的方式进行移动。

方案二:使用官方定义的滑动嵌套机制进行控制

嵌套滑动其实就是触摸到当前View后,由当前View消费触摸事件,同时通知支持嵌套滑动的父View是否消费事件以及消费多少,或者自身消费一定滑动距离后再由父View消费。

在使用官方定义的嵌套滑动机制之前需要了解以下几个类以及相关方法的含义,虽然现在的view和ViewGroup中已经默认加了NestedScrollingChild和NestedScrollingParent的相关方法,其中的实现与NestedScrollingChildHelper、NestedScrollingParentHelper一致,但是前期是在兼容包中提供的。了解了NestedScrollingChild、NestedScrollingChildHelper、NestedScrollingParent、NestedScrollingParentHelper这些类的作用后就了解整套滑动嵌套机制的流程。

2.1 了解官方定义的嵌套滑动方案

2.1.1 NestedScrollingChild

该类提供了一套嵌套滑动子View的控制流程,相关的方法需要调用NestedScrollingChildHelper对应相同签名的方法。现在的View中已经默认实现该接口的方法以及调用流程。我们需要了解对应的方法的作用即可。

public interface NestedScrollingChild {

    //设置当前View是否支持嵌套滑动,如果不支持的话,走自身的事件消费流程
    void setNestedScrollingEnabled(boolean enabled);
    
    //返回当前View是否支持滑动
    boolean isNestedScrollingEnabled();
    
    /**
     * 开始嵌套滑动,一般在当前View的ACTION_DOWN中进行调用
     * 1,axes为当前View支持的嵌套滑动方向,可取SCROLL_AXIS_HORIZONTAL或者SCROLL_AXIS_VERTICAL
     * 2,返回true表示父view支持嵌套滑动
     * 3,方法对应到支持嵌套滑动的父View需要重写 onStartNestedScroll();可用return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_HORIZONTAL) != 0,类似发方法判断是否支持嵌套滑动
     *
     */
    boolean startNestedScroll(@ScrollAxis int axes);
    
    //停止嵌套滑动
    void stopNestedScroll();
    
    //返回父View是否支持嵌套滑动
    boolean hasNestedScrollingParent();
    
     /**
     * 当前View处理完成自身的滑动事件后调用该方法
     *
     * @param dxConsumed 当前View在X方向消费的距离,该值为相邻两个移动点的距离差值的消费情况
     * @param dyConsumed 当前View在Y方向消费的距离,该值为相邻两个移动点的距离差值的消费情况
     * @param dxUnconsumed 在X方向未消费的距离
     * @param dxUnconsumed 在X方向未消费的距离
     * @param offsetInWindow 该值非空的情况下会返回当前View在window的前后两次变化的差值
     *
     * @return true 表示该事件被分发
     *
     * 对应到支持嵌套滑动父View的 onNestedScroll();
     *     
     */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);    
    
    /**
     * 在自身View消费滑动事件之前调用该方法
     *
     * @param target 被触摸的View
     * @param dx 在X方向移动的像素值,相邻两个移动点的差值
     * @param dy 在Y方向移动的像素值,相邻两个移动点的差值
     * @param consumed 嵌套父View针对dx、dy的消费情况
     *     
     * 该方法对应到支持嵌套滑动的父View的 onNestedPreScroll(View target, int dx, int dy, int[] consumed);
     *
     */     
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, int[] offsetInWindow);   
            
    
    /**
     *
     * 惯性滑动
     *
     * @param velocityX X方向速度
     * @param velocityY Y方向速度
     * @param consumed 当前View是否消费fling
     *     
     */  
     boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
     
    
    /**
     *
     * 惯性滑动
     *
     * @param velocityX X方向速度
     * @param velocityY Y方向速度
     *     
     *
     */  
     boolean dispatchNestedFling(float velocityX, float velocityY);     
            
}

2.1.2 NestedScrollingChildHelper

NestedScrollingChild对应相同签名方法的实现帮助类,Android 5.0以前需要借助该类来实现嵌套滑动的处理,Android5.0以后,相关的方法已经默认实现。这里介绍几个方法,了解其实现原理,其他的方法可以查看具体的源码实现。

public class NestedScrollingChildHelper {
    private ViewParent mNestedScrollingParentTouch;
    private ViewParent mNestedScrollingParentNonTouch;
    private final View mView;
    private boolean mIsNestedScrollingEnabled;
    private int[] mTempNestedScrollConsumed;
    
    
    //根据当前View判断父View是否支持嵌套滑动
    public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            //寻找父View是否支持嵌套滑动
            while (p != null) {
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

    //嵌套滑动分发操作
    private boolean dispatchNestedScrollInternal(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type, @Nullable int[] consumed) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    //获取当前view在window的坐标信息,取出改变前的默认值
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
                //初始父View消费数组
                if (consumed == null) {
                    consumed = getTempNestedScrollConsumed();
                    consumed[0] = 0;
                    consumed[1] = 0;
                }
                //父View处理嵌套滑动
                ViewParentCompat.onNestedScroll(parent, mView,
                        dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);

                //计算父View滑动改变后当前View的在window中的坐标变化差值
                if (offsetInWindow != null) {
                    //当前View在window的新坐标
                    mView.getLocationInWindow(offsetInWindow);
                    //计算父View改变位置当前View的改变前的坐标坐标偏移量
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    } 
    
    //分发当前View消费嵌套滑动之前父View的坐标消费情况
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            if (dx != 0 || dy != 0) {
                int startX = 0;
                int startY = 0;
                //获取当前View在父View没有消费滑动距离之前的在window中的坐标
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

                if (consumed == null) {
                    consumed = getTempNestedScrollConsumed();
                }
                consumed[0] = 0;
                consumed[1] = 0;
                //父view处理嵌套滑动距离消费情况,其中consumed需要在父view中计算
                ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

                // //计算父View滑动改变后当前View的在window中的坐标变化差值
                if (offsetInWindow != null) {
                   //计算父View改变位置当前View的改变前的坐标坐标偏移量
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                //判断父View是否有消费值
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }    
    
}

这里主要介绍NestedScrollingChildHelper的几个重要的方法及其实现逻辑,具体的实现过程上述代码做了注释。他的作用就是帮助我们处理NestedScrollingChild中对应的具体逻辑。

2.1.3 NestedScrollingParent

该接口的作用就是定义一套父View处理嵌套滑动的标准规范。下面我们了解其中相关的方法及其作用。

public interface NestedScrollingParent {
    /**
     *
     * 父View重新该方法,根据axes判断是否接受嵌套滑动操作
     *
     * @param child
     * @param target
     * @param axes
     *     
     */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /**
     * 
     * 该方法在onStartNestedScroll返回true后被调用,主要做一些响应操作;重写该方法需要super.onNestedScrollAccepted()
     *
     * @param child
     * @param 
     * @param 
     *     
     */  
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /**
     * 响应嵌套滑动接受操作
     *
     * 通常需要在ACTION_UP或者ACTION_CANCEL被调用
     *
     * 重写该方法需要super.onStopNestedScroll()
     *     
     */      
    void onStopNestedScroll(@NonNull View target);

    /**
     *
     * 相应嵌套滑动操作
     * 
     * 该方法在对应View的dispathcNestedScoll方法中被调用
     *
     * @param target 
     * @param dxConsumed 目标View在X方向消费的距离
     * @param dyConsumed 目标View在Y方向消费的距离
     * @param dxUnconsumed 目标View在X方向未消费的距离
     * @param dyUnconsumed 目标View在Y方向未消费的距离
     *
     */        
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);
    
    /**
     *
     * 在目标触摸目标View消费事件之前对滑动的距离进行消费
     *
     * @param target 
     * @param dx 目标View在X方向的滑动距离
     * @param dy 目标View在Y方向的滑动距离
     * @param consumed Output. 当前View针对dx和dy的消费值
     *
     */               
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed); 
    
    /**
     *
     * 处理惯性滑动
     *
     */     
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    
   /**
     *
     * 处理惯性滑动
     *
     */     
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
   
    /**
     *
     * 返回当前支持嵌套滑动的值
     *
     */    
    int getNestedScrollAxes();
}

2.1.4 NestedScrollingParentHelper

NestedScrollingParentHelper没有对NestedScrollingParent做更多的处理,因为对应的onStartNestedScroll、onNestedScroll、onNestedPreScroll、onNestedFling、onNestedPreFling这些重要的方法都需要子类具体实现。

在了解了NestedScrolling的这套机制后,那么剩下的工作就是具体如何使用了,下面我们针对前言中提到的效果进行具体的实现。

2.2 具体实现

**第一步:**自定义View并支持嵌套滑动

public class DemoViewNestedScroll extends View {
    public DemoViewNestedScroll(Context context) {
        this(context,null);
    }

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

    public DemoViewNestedScroll(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //设置当前View支持嵌套滑动
        setNestedScrollingEnabled(true);
    }
}

**第二步:**处理onTouch的ACTION_DOWN

我们知道嵌套滑动首先需要父View的支持,后续才执行相关的嵌套滑动事件消费逻辑,因此我们需要在ACTION_DOWN执行startNestedScroll方法。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastTouchX = (int) event.getRawX();
                ViewParent viewParent = getParent();
                //支持水平方向嵌套滑动
                if (viewParent != null && startNestedScroll(SCROLL_AXIS_HORIZONTAL)) {
                    viewParent.requestDisallowInterceptTouchEvent(true);
                }
                break;
        }
        retrun true;
    }

第三步: 处理onTouch的ACTION_MOVE

这个里面是我们的核心处理逻辑,根据自己的功能需求进行判断是父View先消费事件还是自己先消费事件以及消费多少。

    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                ......
                break;
            case MotionEvent.ACTION_MOVE:
                int dx = (int) (event.getRawX() - lastTouchX);
                //根据该view的功能需求,事件首先由自己消费,剩下才交给父View处理,因此不用判断dispatchNestedPreScroll
                int viewOldX = (int) getTranslationX();
                int translationX = viewOldX + dx;
                if (translationX < 0) {
                    setTranslationX(0);
                } else if (translationX > maxMoveDistance) {
                    setTranslationX(maxMoveDistance);
                } else {
                    setTranslationX(translationX);
                }
    
                int dxConsumed = (int) (getTranslationX() - viewOldX);
                int dxUnConsumed = dx - dxConsumed;
                //判断未消费完的滑动距离才由父View进行处理
                if (dxUnConsumed != 0) {
                    dispatchNestedScroll(dxConsumed, 0, dxUnConsumed, 0, null);
                }
                lastTouchX = (int) event.getRawX();
                break;
        }
        return true;
    }

第四步: 定义支持嵌套滑动的父view,并处理相关的事件消费逻辑

ViewGroup需要支持嵌套滑动,那么我们一定需要onStartNestedScroll进行逻辑处理,根据子View支持的类型判断是否支持嵌套滑动,剩下的就是重写onNestedScroll消费滑动距离。

public class DemoViewGroupNestedScroll extends FrameLayout {

    public DemoViewGroupNestedScroll(@NonNull Context context) {
        super(context);
    }

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

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

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        //支持水平方向嵌套
        return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_HORIZONTAL) != 0;
    }
    //消费子View未消费滑动距离
    @Override
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
        setTranslationX(getTranslationX() + dxUnconsumed);
    }
}

上面定义的DemoViewGroupNestedScroll代码比较少和清晰,关键就onStartNestedScroll和onNestedScroll方法。这些方法的实现都是根据具体的功能进行实现。我们这里的比较简单,只支持水平方向滑动,通过setTranslationX进行控制。

第五步: 验证结果

相关支持嵌套滑动的子view以及父View都已经开发完成,剩下就是在代码中引入进行测试。

<com.water.view.demo.DemoViewGroupNestedScroll
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="100dp">

    <View
        android:layout_width="150dp"
        android:layout_height="50dp"
        android:background="@color/colorPrimary"></View>

    <com.water.view.demo.DemoViewNestedScroll
        android:layout_width="100dp"
        android:layout_height="30dp"
        android:layout_gravity="center_vertical"
        android:layout_marginLeft="50dp"
        android:background="@color/colorAccent"></com.water.view.demo.DemoViewNestedScroll>

</com.water.view.demo.DemoViewGroupNestedScroll>

最终运行的效果图和前言中的效果图一致,这里就不在展示。

从上面的demo中我们得出要使用官方的滑动嵌套机制,需要以下流程:

  1. 自定义被触摸的View,并设置支持嵌套滑动,即调用setNestedScrollingEnabled(true);
  2. 需要重新处理触摸事件,使用TouchListener或者重写onTouchEvent(),并在ACTION_DOWN中调用startNestedScroll方法判断是否有支持嵌套滑动的父View,同时调用viewParent.requestDisallowInterceptTouchEvent(true),让父View不拦截事件。
  3. 在ACTION_MOVE中进行滑动逻辑处理,调用dispatchNestedPreScroll()、dispatchNestedScroll()以及自身的滑动逻辑进行嵌套滑动处理。
  4. 定义支持嵌套滑动的父View,重写onStartNestedScroll(),再根据功能需求重写onNestedPreScroll()、onNestedScroll()等方法。

最后

和传统的实现方式对比,我们处理嵌套滑动的思路都是一致,只是官方的嵌套滑动机制给我们定义了一套规范,我们只需要按照该规范实现相关功能即可。这样我们开发的组件复用性更好,后期迭代维护成本也会降低。

事件分发的文章可参考:

Android-ViewGrop事件分发机制

Android-View事件分发机制

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值