本期内容
需求背景
实践过程
总结
“需求开发中遇到了一个三层ViewPager2的滑动冲突问题,本篇文章主要来探讨一下关于复杂嵌滑动冲突的解决思路。
”
市面上的各大电商平台,比如淘宝都开发并上线了feed流中的轮播组件。这个组件能与之前的横幅banner类似,但是融入到feed流内。用户感知上减少对banner的独立认知,但是又保留了banner的轮播所带来的信息的主动传递,在不减少首页信息透出的基础上让用户感受到首页更加简洁了。
为了方便我们分析技术问题,将玩物得志APP剥离掉具体业务后,页面控件结构图如下:
根据上图可以知道,玩物得志APP存在 三个可横向滑动+二个垂直滑动交互设计:
下面就从原理角度分析,一步步如何解决该多层的滑动交互问题。
在解决问题之前,我们来回顾一下android系统中的事件分发。在Android中事件(MotionEvent)zhuy分为四类,
ACTION_DOWN为事件的开始,且一次事件只会出现一次。之后直到手指离开屏幕之前的事件都为ACTION_MOVE。ACTION_UP和ACTION_CANCEL为事件的结束,和ACTION_DOWN对应,ACTION_UP和ACTION_CANCEL也只会在一次事件中出现一次。
要注意的是,当系统在处理ACTION_DOWN的时候,会初始化所有的触摸状态,保证每一次的事件的正确。具体代码如下:
//处理down事件
if (actionMasked == MotionEvent.ACTION_DOWN) {
//...忽略部分注视
cancelAndClearTouchTargets(ev);
resetTouchState();
}
我们已经知道事件的类型和基本事件流,而Android对这类事件的分发主要由以下三类方法进行处理:
事件的分发会对事件进行一系列判断,决定事件是否需要在分发到本级,本层级决定是否下发到子级,本级是否使用事件。其事件分发的流程图如下,可以看到事件分发的三个方法是通过返回值来决定事件的走向。
RecyclerView是android官方推荐的实现了竖向滑动/横向滑动的控件,所以当用户在RecyclerView中进行对应方向上的拖动,RecyclerView会拦截所有滑动事件,即RecyclerView中所有提供和RecyclerView相同方向滑动能力的控件都不会接收到滑动事件。
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
// 这里只是摘要了部分关键代码...
switch (action) {
case MotionEvent.ACTION_MOVE: {
if (mScrollState != SCROLL_STATE_DRAGGING) {
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
boolean startScroll = false;
// 下面两个判断是为了匹配用户滑动的方向和当前RecyclerView允许的滑动方向
// 如果匹配,则startScroll设置为true,且调用setScrollState方法
// setScrollState方法会将一个全局变量mScrollState设置为SCROLL_STATE_DRAGGING
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
mLastTouchX = x;
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
mLastTouchY = y;
startScroll = true;
}
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
break
}
}
// 所以当RecyclerView正在拖动的时候,则会拦截所有的事件
return mScrollState == SCROLL_STATE_DRAGGING;
}
ViewPager2是官方用来替代之前android开发经常会使用的ViewPager的控件,而ViewPager2本质也是一个RecyclerView,事件的分发和拦截与RecyclerView一致。但是有一个点需要注意,那就是ViewPager2是final的,也就是说我们无法重写ViewPager2,也就没有办法通过重写来改变ViewPager2的事件分发逻辑。通过RecyclerView和ViewPager2的分析,我们已经能够理清,为什么在上面这个页面结果中,只有蓝色ViewPager2能够横向滑动,黑色RecyclerView能够进行竖向滑动,而其他的滑动控件都不会收到滑动事件。
在我们常规处理事件冲突的思路分别有内部拦截法和外部拦截法。
从顶层布局向下拦截,先确定上层View是否需要消费事件,再决定是否分发给下层。主要是通过事件分发三个关键方法进行控制。
是让上层约定不去拦截事件,所有事件都先进入底层,底层优先确定是否需要消费事件,再通过事件分发流程回给上层。
内部拦截法有一个关键方法就是requestDisallowInterceptTouchEvent,该方法的作用是请求禁止拦截触摸事件。内部具体的实现逻辑就是通过一个标志位来让所有的父布局在分发之后跳过拦截,直接下发所有事件。
@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
// We're already in this state, assume our ancestors are too
return;
}
//当禁止拦截时,会对mGroupFlags进行处理
if (disallowIntercept) {
mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
} else {
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}
// Pass it up to our parent
// 注意,requestDisallowInterceptTouchEvent方法会一层一层向上调用。
if (mParent != null) {
mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
//当禁止拦截时,事件分发流程就不会执行拦截事件,而直接向下级分发
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
}
}
在本次案例的交互规则中,粉色ViewPager2横向滑动的优先级高于绿色ViewPager2且高于蓝色ViewPager2,所有的横向滑动都要先让粉色ViewPager2消费。所以,理论上我们需要覆写粉色ViewPager2的滑动事件方法,来进行使用内部拦截法。但是,由于ViewPager2是final,不可覆写的特性。我们用一个单独的FrameLayout来包裹ViewPager2。
下图是三级ViewPager2的事件分发处理逻辑:1、绿色ViewPager2在ACTION_DOWN之后调用禁止拦截方法,直到绿色ViewPager2和粉色ViewPager2不需要滑动的时候再去取消禁止拦截,让蓝色ViewPager2拦截并处理滑动事件
double mLastX = 0;
double mLastY = 0;
int currentItem = 0;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
mLastX = ev.getX();
mLastY = ev.getY();
// 在ACTION_DOWN的时候禁止拦截,在绿色ViewPager中处理事件分发
getParent().requestDisallowInterceptTouchEvent(true);
// 获取用户触摸的粉色ViewPager的当前页数,用来判断粉色ViewPager是否需要滑动
RecyclerView recyclerView = findViewById(R.id.nest_scroll_child_recyclerView);
NestScrollChildItemViewPager viewPager = (NestScrollChildItemViewPager) recyclerView.findChildViewUnder(ev.getX(), ev.getY());
currentItem = viewPager.getViewPager().getCurrentItem();
break;
}
case MotionEvent.ACTION_MOVE: {
double x = ev.getX() - mLastX;
double y = ev.getY() - mLastY;
// 当绿色ViewPager已在最后一页,且粉色ViewPager最后一页
// 取消禁止拦截,让蓝色ViewPager去拦截滑动事件并消耗
if (nestScrollOneViewPagerBinding.viewPager.getCurrentItem() == 3
&& Math.abs(x) > Math.abs(y) && x < 0 && currentItem == 1) {
getParent().requestDisallowInterceptTouchEvent(false);
}
}
}
return super.dispatchTouchEvent(ev);
}
2、如果粉色ViewPager2和绿色ViewPager2同样逻辑处理,会遇到绿色ViewPager因为取消禁止拦截而获取不到滑动事件。因为requestDisallowInterceptTouchEvent内部有递归操作,导致绿色ViewPager的禁止拦截操作无效。换个思路,当粉色ViewPager2需要消耗滑动事件时调用禁止拦截,而在其他情况下不去做任何其他操作,让ACTION_DOWN的初始化触摸状态来清空禁止拦截状态。这样粉色ViewPager2就不需要调用取消禁止拦截方法,避免了上述的问题。
private float mLastX, mLastY;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
mLastX = ev.getX();
mLastY = ev.getY();
break;
}
case MotionEvent.ACTION_MOVE: {
float scrollX = ev.getX() - mLastX;
float scrollY = ev.getY() - mLastY;
if (Math.abs(scrollX) >= Math.abs(scrollY)) {
// 判断用户是左滑且粉色ViewPager2还未到最后一页,则需要横向滑动
if (scrollX < 0) {
if (getViewPager().getCurrentItem() != 1) {
getParent().requestDisallowInterceptTouchEvent(true);
}
}
// 判断用户是右滑且粉色ViewPager2还未到第一页,则需要横向滑动
if (scrollX > 0) {
if (getViewPager().getCurrentItem() != 0) {
getParent().requestDisallowInterceptTouchEvent(true);
}
}
}
}
}
return super.dispatchTouchEvent(ev);
}
~~
至此,我们解决来首页复杂需求存在的滑动冲突问题。如下示意图:滑动冲突本质也是归属于事件分发机制,当遇到滑动冲突,了解每个控件事件分发拦截流程,理清楚控件与控件之间的交互逻辑,选择正确的事件分发解决思路,就能很准确地解决此类问题。
牛年邀牛人
一起战斗、一起成长
技术、产品、UED、运营、职能等海量岗位
玩物得志期待你的加入