如何解决滑动冲突?

引言

在android中为我们提供了NestedScrollingChild接口和NestedScrollingParent接口,我们只需要实现这两个接口,即可完成解决嵌套滑动冲突。此外,安卓还提供了NestedScrollView,它默认提供了许多解决嵌套滑动冲突的实现,本文将从零描述NestedScrollView的实现方式,以及美团、淘宝,京东APP首页是如何使用该机制解决嵌套滑动冲突的。

实现原理

为什么会冲突?

因为我们两个可滑动的View相互嵌套的,那么应该先滑动哪个?这里应该根据不同的业务有不同的解决方案,比如有的可能需要先让childView滑动,当childView滑到底了,之后让parentView滑动。又或者像美团、京东等首页一样,先让parentView滑动,当parentView滑完之后,再让childView滑动。

如何解决冲突?

这里有两个例子:
1、子View先滑,当子View滑不了,将滑动事件/距离传递给parentView。如,一个可以手势下滑的dialog中嵌套了一个recyclerView,其中recyclerView是可以自由滑动的,当recyclerView先滑动完后,我们才将剩余的距离传递给parentView,这样才较为合理。如,bottomSheetDialog里面放了一个recyclerView。

2、父View先滑,当父View滑不了,将滑动剩余的距离/速度传递给子View,如京东首页、美团首页。

例1中要求子View先滑,子View滑完后将剩余的速度/距离传递给parentView。从该需求可知,我们使用child滑完后再驱动parent滑动较好。此外,众所周知,View的事件通常是在onTouchEvent中被处理,因此,我们在该方法中,当收到的事件类型为Down的时候记录初始位置lastY,当收到Move的时候记录当前滑动的位置,与上次记录的位置差值即为滑动的距离,简单起见,暂不讨论x轴方向上的滑动距离,当向下滑动时,如下图所示:

然后,我们只需要将该距离传递给对应的View进行消费即可,比如需要childView滑动,那么只需要调用child.scrollBy(dX, dy)即可,如果需要parentView滑动,那么需要向上找到支持滑动的parent,然后调用parentView.scrollBy(dx, dy)即可。然而,事实上,通过这一个简单的方法往往达不到我们的需求,例1的需求是,childView滑完后,将剩余的距离交给parentView滑,因此,我们还需要计算childView当前最多可以滑动的剩余距离、滑完该距离后当前事件剩余距离,然后将该距离传递给parentView。
简单起见,我们暂不考虑多点触控的情况,伪代码:

// 在childView中重写该方法
int mLastY;
public boolean onTouchEvent(MotionEvent event) {
  if(event.getAction() == MotionEvent.ACTON_DOWN) {
    mLastY = event.getRawY();
    mParentView = startNestedScroll()    // 开始滑动,并找到可以滑动的parentView
  } else if(event.getAction() == MotionEvent.ACTION_MOVE) {
    int dy = event.getRawY() - mLastY;
    int space = getCanScrollSpace()
    int unConsumedY = scrollBy(0, min(dy, space));    // 先让自己滑动,并返回未消费的距离
    dispatchNestedScroll(mParentView, unConsumedY);  // 将未消费的距离分发parentView
  } else if(event.getAction() == MotionEvent.ACTION_UP) {
    // 获取当前y轴上的速度velocityY
    int unConsumedVelocityY = scrollByVelocity(0, velocityY);  // 手指抬起时,自己消耗剩余的速度
    dispatchNestedFling(mParentView, unConsumedVelocityY);    // 将剩余的速度分发给parentView
    stopNestedScroll();
  }
  return true;
}

以上这段伪代码就解决了例1中的问题,我们梳理一下,将其抽象成接口:在childView中需要startNestedScroll()、dispatchNestedScroll()、dispatchNestedFling()、stopNestedScroll(),
在parentView中需要onStartNestedScroll()、onDispatchNestedScroll()、onNestedScroll()、onStopNestedScroll()。

例2中要求parentView先滑动,当parentView滑动完之后,再将剩余的距离传递给childView。那么用childView做驱动还是用parentView做驱动呢?这其实都是可以的,但为了延续例1中的机制,我们仍然使用childView做驱动。我们只需要在childView滑动前先将滑动的距离分发给parentView,之后再将剩余的距离给childView滑动,那就只需要在dispatchNestedScroll()之前增加dispatchNestedPreScroll(),在这个方法里将距离分发给parentView,之后让ParentView滑动,之后再将剩余的距离回传给childView,childView再继续例1的处理过程。
我们将例1中的伪代码改造如下:

// 在childView中重写该方法
int mLastY;
public boolean onTouchEvent(MotionEvent event) {
  if(event.getAction() == MotionEvent.ACTON_DOWN) {
    mLastY = event.getRawY();
    mParentView = startNestedScroll()    // 开始滑动,并找到可以滑动的parentView
  } else if(event.getAction() == MotionEvent.ACTION_MOVE) {
    int dy = event.getRawY() - mLastY;
    int space = getCanScrollSpace()
    int parentUnConsumedY = dispatchNestedPreScroll(mParentView, min(dy, space));   // 先让parentView滑动
    int unConsumedY = scrollBy(parentUnConsumedY);    // 先让自己滑动,并返回未消费的距离
    dispatchNestedScroll(mParentView, unConsumedY);  // 将未消费的距离分发parentView
  } else if(event.getAction() == MotionEvent.ACTION_UP) {
    // 获取当前y轴上的速度velocityY
    int parentUnConsumedY = dispatchNestedPreFling(velocityY);  // 将速度交给parentView
    int unConsumedVelocityY = scrollByVelocity(parentUnConsumedY );  // 手指抬起时,自己消耗剩余的速度
    dispatchNestedFling(mParentView, unConsumedVelocityY);    // 将剩余的速度分发给parentView
    stopNestedScroll();
  }
  return true;
}

从上述伪代码可以看出,NestedScrollingChild和NestedScrollingParent协调图:

接口分析

进一步地,我们分析NestedScrollingParent和NestedScrollingChild这两个接口。

package com.hc.my_views.nestedScrollVIew;

/**
 * 滑动事件的主要发起者,滑动距离的分发者
 */
public interface MyNestedScrollingChild {

    /**
     * 开始滑动,该过程希望找到可滑动的parentView
     */
    boolean startNestedScroll(int orientation);

    void stopNestedScroll();

    /**
     * 找到parent应该直接记录在当前View,便于后续直接让parent处理
     */
    boolean hasNestedScrollingParent();

    /**
     * 子View一旦找到可以滑动的parent,先通过该方法将滑动距离分发给parent,consumed用于记录parent消费的距离
     */
    boolean dispatchNestedPreScroll(int dx, int dy, int [] consumed);

    /**
     * 当dispatchNestedPreScroll没消费完,子View继续消费,子View还没消费完时,将距离继续分发给parent
     * 四个参数分别记录了当前子View消费和未消费的距离
     */
    boolean dispatchNestedScroll(int consumedX, int consumedY, int unConsumedX, int unConsumedY, int [] consumed);

    /**
     * 手抬起时,速度剩余,实现惯性滑动,将剩余速度发送给parent
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);

    /**
     * 手抬起时,当dispatchNestedPreFling没消费完,子View消费完成之后,将剩余速度发送给parent,consumed标记是否已经被消费
     */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
}
import android.view.View;
import androidx.annotation.NonNull;

/**
 * 滑动事件的消费者,消费完成,还需要还给子View
 */
public interface MyNestedScrollingParent {
    /**
     * 开始滑动,如果返回false,表示该parent不参与滑动距离的消费,axes是方向
     * target是某个子或者孙View,滑动事件的发起者
     * child当前parent的直接子View,有可能就是target
     */
    boolean onStartNestedScroll(View child, View target, int axes);

    /**
     * 通过onStartNestedScroll,target View已经知道parentView是否接收滑动,如果接收,这里进行后续的初始化工作
     */
    void onNestedScrollAccepted(View child, View target, int axes);

    /**
     * target通过onStopNestedScroll发送停止事件
     */
    void onStopNestedScroll(View target);

    /**
     * targetView传递给父View已消费和未消费的距离
     */
    void onNestedPreScroll(View target, int dx, int dy, int [] consumed);

    /**
     * targetView传递给父View已消费和未消费的距离
     */
    void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnConsumed, int dyUnConsumed);

    /**
     * targetView传递给parent的速度
     */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    /**
     * target fling后的剩余速度
     */
    boolean onNestedFling(View target, float velocityX, float velocityY);

    /**
     * 获取滑动的方向
     */
    int getNestedScrollAxes();
}

任何嵌套滑动都可以使用上述两个接口完成需求,如SwipeRefreshLayout(下拉刷新)、CoordinateLayout、RecyclerView等都实现了上述一个/两个接口。
例如,平时我们在RecyclerView外面套一个SwipeRefreshLayout就能实现当recyclerView滑动完之后,继续下拉就能触发刷新动作,这是因为SwipeRefreshLayout实现了NestedScrollingParent接口,并重写了onNestedScroll()方法,在该方法中生成的下拉刷新动画等,recyclerView实现了NestedScrollingChild接口,充当子View。
再如,例1的例子,bottomSheetDialog中其实使用了一个CoordinateLayout,它实现了NestedScrollingParent接口,并将onNestedPreScroll和onNestedScroll转发给了对应的BottomSheetBehavior。

总结

本文描述了NestedScrollView的滑动冲突解决方案,通过引例,一步一步描述NestedScrollView接口的设计由来,并使用伪代码简要描述了该接口的协调方式,最终讲解了接口中每个方法的作用和调用时机,并通过例子来描述其用处。对于NestedScrollView的这两个接口,
1、如果是需要child先滑,parent后滑,那么只需要重写parent的onNestedScroll()方法。
2、如果parent先滑,child后滑,则重写parent的onNestedPreScroll()方法。
3、如果parent的滑动实现是在onNestedScroll()中,而我们又需要让parent先滑,如果在不重写parent的方法的前提下,我们可以在child中parentView.requestDisallowInterceptTouchEvent(false),直接让parent拦截调请求,让parent自己消费滑动事件。
4、Fling的滑动,需要用到速度和距离的转换工具,我们才知道要滑动多远。

posted @ 2021-09-12 05:44  、、、路遥  阅读(529)  评论(0编辑  收藏  举报