Android高级UI之仿淘宝首页嵌套滑动及吸顶效果实现

一、淘宝首页布局结构设计及实现 仿淘宝首页的嵌套布局,TabLayout 上面放置一个 RecyclerView 模拟嵌套效果(TopRecyclerView 继承 RecyclerView 填充了几条静态数据且不能滑动),ViewPager的Fragment 中只有一个 RecyclerView 控件,整体的布局是 ScrollView + RecyclerView + TabLayout + ViewPager + Fragment + RecyclerView。


按照以上布局显示,发生嵌套冲突只有ViewPager下的RecyclerView能滑动,而且滑动空间仅限于ViewPager范围内,TopRecyclerView展示全部数据不能滑动。
题外话:ViewPager2(19年11月正式发布)
以上布局中用到了ViewPager2,相对原来的ViewPager API有如下改动:
  • 继承自ViewGrop,是ViewPager2被声明成final。意味着我们不可能再像ViewPager一样通过继承来修改ViewPager2的代码;
  • FragmentStatePagerAdapter被FragmentStateAdapter 替代;
  • PagerAdapter被RecyclerView.Adapter替代;
  • addPageChangeListener被registerOnPageChangeCallback替代。(ViewPager的addPageChangeListener接收的是一个OnPageChangeListener的接口,当要监听页面变化时需要重写接口中的三个方法。而ViewPager2的registerOnPageChangeCallback方法接收的是OnPageChangeCallback的抽象类,因此可选择性的重写需要的方法即可);
  • 移除了setPargeMargin方法。
使用时需添加依赖包:
// androidx viewPager2 implementation "androidx.viewpager2:viewpager2:1.0.0-alpha01" // ViewPager2 与 TabLayout联动 implementation 'com.google.android.material:material:1.2.0-alpha03'

二、解决不能一起滑动的问题------点击事件冲突源码分析 解决问题之前首先要清楚为什么会有此现象,所以先来理一下事件冲突源码。
1、事件类型及说明
MotionEvent事件 说明
ACTION_DOWN 手指初次接触到屏幕时触发
ACTION_MOVE 手指在屏幕上滑动时触发,会多次触发
ACTION_UP 手指离开屏幕时触发
ACTION_CANCEL 事件被上层拦截时触发
2、View只能处理事件,ViewGroup才能分发事件(先分发再处理,需包含子View)
有一点要明白,在代码层面View是ViewGroup的父类,但在运行时ViewGroup是View的父类(studio中用Tools–layout Inspector查看),以下 2.1 流程中的DecorView继承自FrameLayout(继承自ViewGroup),但运行时他是所有控件的父类(如图):
Android高级UI之仿淘宝首页嵌套滑动及吸顶效果实现
文章图片

2.1 事件分发处理流程前奏
  • 点击事件后先走 Activity 的 dispatchTouchEvent()
  • Window ==> PhoneWindow 的 superDispatchTouchEvent()
  • DecorView的superDispatchTouchEvent(),其中直接调的是super.dispatchTouchEvent()
  • DecorView继承自FrameLayout,但其并未重写dispatchTouchEvent()方法,故调用的是ViewGroup中的dispatchTouchEvent()--------事件分发的逻辑在此方法中
2.2 View的事件处理流程
先看一个案例,根据打印log来看当setOnTouchListener中 return false 时 onClick 方法会执行,但 return true时 onClick 方法不会执行,这是为什么呢?
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); btn_click = findViewById(R.id.btn_click); btn_click.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Log.e(TAG, "onClick"); } }); btn_click.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { Log.e(TAG, "onTouch: " + event.getAction()); // return true; return false; } }); }

源码中View 的 dispatchTouchEvent()方法,主要看 # 标记的代码:
  • 判断条件是一个短路与,只要前面有不成立的则直接跳出,条件不满足;
  • 经过分析,只要设置了onTouch的点击监听事件前三个条件都为true,所有最后一个条件onTouch返回的值直接影响 result 的值;
  • 再往下走,# 号标记的最后两行判断又是一个短路与,只要result 为 true 则不会走第二个判断条件onTouchEvent(),情况和上面代码中的onClick() 执行流程结果一样,所以大胆猜测 onClick() 是否在 onTouchEvent() 中;
  • 果然 onTouchEvent() ==》(MotionEvent.ACTION_UP)performClickInternal() ==》 performClick(),走到这之后以上案例结果的原因也明了了。
  • 另外,performClick() 方法中当执行了 onClick() 对应 result 为 true(处理了事件),否则为 false(未处理事件),这也能解释为什么 onTouch() 有返回值而 onClick() 中无需返回值。
public boolean dispatchTouchEvent(MotionEvent event) { // If the event should be handled by accessibility focus first. if (event.isTargetAccessibilityFocus()) { // We don't have focus or no virtual descendant has it, do not handle the event. if (!isAccessibilityFocusedViewOrHost()) { return false; } // We have focus and got the event, then use normal event dispatch. event.setTargetAccessibilityFocus(false); }boolean result = false; if (mInputEventConsistencyVerifier != null) { mInputEventConsistencyVerifier.onTouchEvent(event, 0); }final int actionMasked = event.getActionMasked(); if (actionMasked == MotionEvent.ACTION_DOWN) { // Defensive cleanup for new gesture stopNestedScroll(); }if (onFilterTouchEventForSecurity(event)) { if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) { result = true; } //noinspection SimplifiableIfStatement #ListenerInfo li = mListenerInfo; #if (li != null && li.mOnTouchListener != null #&& (mViewFlags & ENABLED_MASK) == ENABLED #&& li.mOnTouchListener.onTouch(this, event)) { #result = true; #}#if (!result && onTouchEvent(event)) { #result = true; } }if (!result && mInputEventConsistencyVerifier != null) { mInputEventConsistencyVerifier.onUnhandledEvent(event, 0); }// Clean up after nested scrolls if this is the end of a gesture; // also cancel it if we tried an ACTION_DOWN but we didn't want the rest // of the gesture. if (actionMasked == MotionEvent.ACTION_UP || actionMasked == MotionEvent.ACTION_CANCEL || (actionMasked == MotionEvent.ACTION_DOWN && !result)) { stopNestedScroll(); } return result; }

2.3 ViewGroup 的事件分发流程(事件冲突关键点)
//ViewGroup 中 事件分发流程 @Override public boolean dispatchTouchEvent(MotionEvent ev) { if (mInputEventConsistencyVerifier != null) { mInputEventConsistencyVerifier.onTouchEvent(ev, 1); }// If the event targets the accessibility focused view and this is it, start // normal event dispatch. Maybe a descendant is what will handle the click. if (ev.isTargetAccessibilityFocus() && isAccessibilityFocusedViewOrHost()) { ev.setTargetAccessibilityFocus(false); }boolean handled = false; if (onFilterTouchEventForSecurity(ev)) { final int action = ev.getAction(); final int actionMasked = action & MotionEvent.ACTION_MASK; // Handle an initial down. if (actionMasked == MotionEvent.ACTION_DOWN) { // Throw away all previous state when starting a new touch gesture. // The framework may have dropped the up or cancel event for the previous gesture // due to an app switch, ANR, or some other state change. cancelAndClearTouchTargets(ev); resetTouchState(); }// Check for interception. final boolean intercepted; 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; } } else { // There are no touch targets and this action is not an initial down // so this view group continues to intercept touches. intercepted = true; }// If intercepted, start normal event dispatch. Also if there is already // a view that is handling the gesture, do normal event dispatch. if (intercepted || mFirstTouchTarget != null) { ev.setTargetAccessibilityFocus(false); }// Check for cancelation. final boolean canceled = resetCancelNextUpFlag(this) || actionMasked == MotionEvent.ACTION_CANCEL; // Update list of touch targets for pointer down, if needed. final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0; TouchTarget newTouchTarget = null; boolean alreadyDispatchedToNewTouchTarget = false; if (!canceled && !intercepted) {// If the event is targeting accessibility focus we give it to the // view that has accessibility focus and if it does not handle it // we clear the flag and dispatch the event to all children as usual. // We are looking up the accessibility focused host to avoid keeping // state since these events are very rare. View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus() ? findChildWithAccessibilityFocus() : null; if (actionMasked == MotionEvent.ACTION_DOWN || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN) || actionMasked == MotionEvent.ACTION_HOVER_MOVE) { final int actionIndex = ev.getActionIndex(); // always 0 for down final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex) : TouchTarget.ALL_POINTER_IDS; // Clean up earlier touch targets for this pointer id in case they // have become out of sync. removePointersFromTouchTargets(idBitsToAssign); final int childrenCount = mChildrenCount; if (newTouchTarget == null && childrenCount != 0) { final float x = ev.getX(actionIndex); final float y = ev.getY(actionIndex); // Find a child that can receive the event. // Scan children from front to back. final ArrayList preorderedList = buildTouchDispatchChildList(); final boolean customOrder = preorderedList == null && isChildrenDrawingOrderEnabled(); final View[] children = mChildren; for (int i = childrenCount - 1; i >= 0; i--) { final int childIndex = getAndVerifyPreorderedIndex( childrenCount, i, customOrder); final View child = getAndVerifyPreorderedView( preorderedList, children, childIndex); // If there is a view that has accessibility focus we want it // to get the event first and if not handled we will perform a // normal dispatch. We may do a double iteration but this is // safer given the timeframe. if (childWithAccessibilityFocus != null) { if (childWithAccessibilityFocus != child) { continue; } childWithAccessibilityFocus = null; i = childrenCount - 1; }if (!child.canReceivePointerEvents() || !isTransformedTouchPointInView(x, y, child, null)) { ev.setTargetAccessibilityFocus(false); continue; }newTouchTarget = getTouchTarget(child); if (newTouchTarget != null) { // Child is already receiving touch within its bounds. // Give it the new pointer in addition to the ones it is handling. newTouchTarget.pointerIdBits |= idBitsToAssign; break; }resetCancelNextUpFlag(child); if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { // Child wants to receive touch within its bounds. mLastTouchDownTime = ev.getDownTime(); if (preorderedList != null) { // childIndex points into presorted list, find original index for (int j = 0; j < childrenCount; j++) { if (children[childIndex] == mChildren[j]) { mLastTouchDownIndex = j; break; } } } else { mLastTouchDownIndex = childIndex; } mLastTouchDownX = ev.getX(); mLastTouchDownY = ev.getY(); newTouchTarget = addTouchTarget(child, idBitsToAssign); alreadyDispatchedToNewTouchTarget = true; break; }// The accessibility focus didn't handle the event, so clear // the flag and do a normal dispatch to all children. ev.setTargetAccessibilityFocus(false); } if (preorderedList != null) preorderedList.clear(); }if (newTouchTarget == null && mFirstTouchTarget != null) { // Did not find a child to receive the event. // Assign the pointer to the least recently added target. newTouchTarget = mFirstTouchTarget; while (newTouchTarget.next != null) { newTouchTarget = newTouchTarget.next; } newTouchTarget.pointerIdBits |= idBitsToAssign; } } }// Dispatch to touch targets. #if (mFirstTouchTarget == null) { #// No touch targets so treat this as an ordinary view. #handled = dispatchTransformedTouchEvent(ev, canceled, null, #TouchTarget.ALL_POINTER_IDS); } else { // Dispatch to touch targets, excluding the new touch target if we already // dispatched to it.Cancel touch targets if necessary. TouchTarget predecessor = null; TouchTarget target = mFirstTouchTarget; while (target != null) { final TouchTarget next = target.next; if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) { handled = true; } else { final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted; if (dispatchTransformedTouchEvent(ev, cancelChild, target.child, target.pointerIdBits)) { handled = true; } if (cancelChild) { if (predecessor == null) { mFirstTouchTarget = next; } else { predecessor.next = next; } target.recycle(); target = next; continue; } } predecessor = target; target = next; } }// Update list of touch targets for pointer up or cancel, if needed. if (canceled || actionMasked == MotionEvent.ACTION_UP || actionMasked == MotionEvent.ACTION_HOVER_MOVE) { resetTouchState(); } else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) { final int actionIndex = ev.getActionIndex(); final int idBitsToRemove = 1 << ev.getPointerId(actionIndex); removePointersFromTouchTargets(idBitsToRemove); } }if (!handled && mInputEventConsistencyVerifier != null) { mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1); } return handled; }

分发流程:
ACTION_DOWN情况下
  • 先判断是否拦截,通过 onInterceptTouchEvent()的返回值判断(true 已拦截,false 未拦截),其中 TouchTarget 是一个链表,disallowIntercept标记位是用于判断是否被父类拦截;
  • 如果被拦截(没有子View)则会走 dispatchTransformedTouchEvent(),其中的 child 参数为null–>View 的事件处理dispatchTouchEvent();
  • 若未被拦截则会有 buildTouchDispatchChildList() 对子View做排序(倒序取出,布局中排在前面的先处理),取出第一个子View后对其做判断,第一判断是否能接受事件(可见/动画),第二判断其是否在点击范围内,不满足则会 continue 结束当前路径再循环找下一个子View;
  • 以上条件满足则走dispatchTransformedTouchEvent()分发事件
    ———如果分发事件,其中child是ViewGroup则继续走dispatchTouchEvent,child是View则走事件处理dispatchTouchEvent()==》ViewGroup走分发流程,View走消费流程;
    ———如果事件不分发则会 继续循环找下一个子View ;
    ———如果循环后全部不处理,流程和上面被拦截一样;
ACTION_MOVE情况下------dispatchTouchEvent 流程
move 不再分发事件,事件冲突的处理只能在此流程中
内部拦截法:子View处理事件冲突
通过重写 onInterceptTouchEvent() 修改对应返回的值设置是否拦截;
通过重写 dispatchTouchEvent() 修改 requestDisallowInterceptTouchEvent() 的值(true/false), 设置由谁来处理;
注意标志位清除处理导致的问题
多个move事件
外部拦截法:父容器处理事件冲突
通过重写 onInterceptTouchEvent() 处理;
好,以上分析了事件监听及冲突的流程,现在开始来解决问题。
嵌套滑动有两个角色(父亲 / 孩子),在这个案例中父亲是ScrollView(但因为源码中未实现NestedScrollingParent,故不是合格的父亲,修改为NestedScrollView), 孩子是RecyclerView,更改后能全部一起滑动,但没有吸顶效果。
public class ScrollView extends FrameLayout

public class NestedScrollView extends FrameLayout implements NestedScrollingParent3, NestedScrollingChild3, ScrollingView

public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2, NestedScrollingChild3

三、实现吸顶效果 实现吸顶效果思路:将TabLayout + ViewPager 设置为屏幕高度
要实现此效果,重写NestedScrollView:
package com.example.practicedemo.ui.nest.widget; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.widget.NestedScrollView; public class CustomNestedScrollView extends NestedScrollView { private ViewGroup contentView; private View topView; public CustomNestedScrollView(Context context) { this(context, null); init(); }public CustomNestedScrollView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); init(); }public CustomNestedScrollView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); init(); }public CustomNestedScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr); init(); }private void init() { }@Override protected void onFinishInflate() { super.onFinishInflate(); // 第一个子类的第二个子类 // 根据实际布局:LineaLayout---RCV(第一个子类) //---LinearLayout(第二个子类,及包含TabLayout+RCV)) contentView = (ViewGroup) ((ViewGroup) getChildAt(0)).getChildAt(1); }@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 调整contentView的高度为父容器高度,使之填充布局,避免父容器滚动后出现空白 super.onMeasure(widthMeasureSpec, heightMeasureSpec); ViewGroup.LayoutParams lp = contentView.getLayoutParams(); lp.height = getMeasuredHeight(); contentView.setLayoutParams(lp); } }

以上虽然能实现吸顶效果,但是滑动的状态不对,需要ScrollView先滑直到TabLayout上面布局都显示完则实现吸顶效果再滑动下面的RecyclerView, 在这之前需熟悉嵌套滑动的流程原理。
四、嵌套滑动流程 【Android高级UI之仿淘宝首页嵌套滑动及吸顶效果实现】嵌套滑动流程图(重点)------RecyclerView(孩子) + NestedScrollView(父亲)
Android高级UI之仿淘宝首页嵌套滑动及吸顶效果实现
文章图片

  • 首先,先执行是否可嵌套滑动,若可以则孩子是主动方;
  • 当 ACTION_DOWN 事件发生时,孩子触发startNestedScroll(), 具体逻辑实现在
    NestedScrollingChildHelper.startNestedScroll()中,并触发了父亲的onStartNestedScroll() 和 onNestedScrollAcepted();
  • 当开始滑动孩子时(ACTION_MOVE),触发父亲的onNestedPreScroll()==>dispatchNestedPreScroll(),
    这里是将自己当作孩子将事件分发给自己的父亲,自己并没有滑!故在此处拦截,在自定义NestedScrollView中重写onNestedPreScroll();
@Override public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) { super.onNestedPreScroll(target, dx, dy, consumed, type); // 向上滑动。若当前topview可见,需要将topview滑动至不可见 boolean hideTop = dy > 0 && getScrollY() < topView.getMeasuredHeight(); if (hideTop) { scrollBy(0, dy); consumed[1] = dy; } }

  • 还有最后一点,不属于嵌套滑动,手指滑动(TYPE_TOUCH)和惯性滑动(TYPE_NON_TOUCH)—— fling
五、基于事件冲突源码实现内部拦截及外部拦截的实例

    推荐阅读