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

一、淘宝首页布局结构设计及实现

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

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <!-- top rcv -->
        <com.example.practicedemo.ui.nest.widget.TopRecyclerView
            android:id="@+id/rcv_top"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <com.google.android.material.tabs.TabLayout
                android:id="@+id/tablayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content" />

            <androidx.viewpager2.widget.ViewPager2
                android:id="@+id/viewpager_view"
                android:layout_width="match_parent"
                android:layout_height="match_parent" />
        </LinearLayout>
    </LinearLayout>
</ScrollView>

按照以上布局显示,发生嵌套冲突只有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),但运行时他是所有控件的父类(如图):
在这里插入图片描述
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();
            }

// 此条件判断事件是否被拦截,被拦截时onInterceptTouchEvent返回true,对应intercepted 为true,否则为false
            // 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<View> 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;
                            }

// 当前viewGroup不满足事件处理条件则跳出循环找下一个viewGroup
                            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);
                            // 当确定当前viewGroup处理事件则走里面的流程,并结束整个循环
                            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;
                    }
                }
            }

// 事件被拦截则会走此if条件下的流程
            // 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 ;
    ———如果循环后全部不处理,流程和上面被拦截一样;
  • 值得注意的一点是,事件分发处理时,也就是条件dispatchTransformedTouchEvent()满足时newTouchTarget的值的设置,代码中通过addTouchTarget()方法设置,其中mFirstTouchTarget初始值为null,所以当执行了addTouchTarget()后target.next对应的值为null,且mFirstTouchTarget被赋值,故而在MOVE事件判断
    if (mFirstTouchTarget ==
    null)时走else下的代码,其中的while循环一般情况下只会走一次,因为next被赋值为null,且最后被赋值给target,所以此时while条件的target
    != null不再满足
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
        final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
        target.next = mFirstTouchTarget;//null
        mFirstTouchTarget = target;
        return target;
    }

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, 在这之前需熟悉嵌套滑动的流程原理。

四、嵌套滑动流程

嵌套滑动流程图(重点)------RecyclerView(孩子) + NestedScrollView(父亲)
在这里插入图片描述

  • 首先,先执行是否可嵌套滑动,若可以则孩子是主动方;
  • 当 ACTION_DOWN 事件发生时,孩子触发startNestedScroll(), 具体逻辑实现在
    NestedScrollingChildHelper.startNestedScroll()中,并触发了父亲的onStartNestedScroll() 和 onNestedScrollAcepted()——对应流程图3-6

RecyclerView.java

 @Override
    public boolean startNestedScroll(int axes) {
        return getScrollingChildHelper().startNestedScroll(axes);
    }

NestedScrollingChildHelper.java

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;
            // 循环直到找到嵌套滑动最终的父亲
            while (p != null) {
            // 判断父亲是否支持嵌套滑动——调用父亲的onStartNestedScroll/onNestedScrollAccepted
                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;
    }

NestedScrollView.java

    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
            int type) {
        return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
            int type) {
        mParentHelper.onNestedScrollAccepted(child, target, axes, type);
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
    }
  • 当开始滑动孩子时(ACTION_MOVE),触发父亲的onNestedPreScroll()==>dispatchNestedPreScroll(),
    这里是将自己当作孩子将事件分发给自己的父亲,自己并没有滑!故在此处拦截,在自定义NestedScrollView中重写onNestedPreScroll();

NestedScrollView.java

@Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
            int type) {
        dispatchNestedPreScroll(dx, dy, consumed, null, type);
    }
@Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
            int type) {
        return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

拦截处理:

@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
    记录滑动速度 + 速度与距离的转换工具类
    自己滑动的距离=转换后的距离-孩子应该滑动的距离
    孩子滑动的距离再转换成速度
package com.example.practicedemo.ui.nest.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.widget.NestedScrollView;
import androidx.recyclerview.widget.RecyclerView;

import com.example.practicedemo.ui.nest.FlingHelper;

/**
 * @fileName: CustomNestedScrollView.java
 * @des: 自定义NestedScrollView
 */
public class CustomNestedScrollView extends NestedScrollView {
    private final static String TAG ="CustomNestedScrollView";
    private ViewGroup contentView;
    private View topView;
    private FlingHelper mFlingHelper;

+    int totalDy = 0;
    /**
     * 用于判断RecyclerView是否在fling
     */
+    boolean isStartFling = false;
    /**
     * 记录当前滑动的y轴加速度
     */
+    private int velocityY = 0;
    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();
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // 第一个子类的第二个子类
        // 根据实际布局:LineaLayout---RCV(第一个子类)
        //                         ---LinearLayout(第二个子类,及包含TabLayout+RCV))
        contentView = (ViewGroup) ((ViewGroup) getChildAt(0)).getChildAt(1);

        topView = ((ViewGroup) getChildAt(0)).getChildAt(0);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 调整contentView的高度为父容器高度,使之填充布局,避免父容器滚动后出现空白
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        ViewGroup.LayoutParams lp = contentView.getLayoutParams();
        lp.height = getMeasuredHeight();
        contentView.setLayoutParams(lp);
    }

    @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;
        }
    }

+    private void init() {
+        mFlingHelper = new FlingHelper(getContext());
+         setOnScrollChangeListener(new View.OnScrollChangeListener() {
+            @Override
+            public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
+                if (isStartFling) {
+                    totalDy = 0;
 +                   isStartFling = false;
+                }
+                if (scrollY == 0) {
+                    Log.i(TAG, "TOP SCROLL");
+                    // refreshLayout.setEnabled(true);
+                }
+                if (scrollY == (getChildAt(0).getMeasuredHeight() - v.getMeasuredHeight())) {
+                    Log.i(TAG, "BOTTOM SCROLL");
+                    dispatchChildFling();
+                }
+                //在RecyclerView fling情况下,记录当前RecyclerView在y轴的偏移
+                totalDy += scrollY - oldScrollY;
+            }
+        });
+    }

+    private void dispatchChildFling() {
+        if (velocityY != 0) {
+            Double splineFlingDistance = mFlingHelper.getSplineFlingDistance(velocityY);
+            if (splineFlingDistance > totalDy) {
+                childFling(mFlingHelper.getVelocityByDistance(splineFlingDistance - Double.valueOf(totalDy)));
+            }
  +      }
+        totalDy = 0;
+        velocityY = 0;
+    }

+    private void childFling(int velY) {
+        RecyclerView childRecyclerView = getChildRecyclerView(contentView);
+        if (childRecyclerView != null) {
+            childRecyclerView.fling(0, velY);
+        }
+    }

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

+    private RecyclerView getChildRecyclerView(ViewGroup viewGroup) {
+        for (int i = 0; i < viewGroup.getChildCount(); i++) {
+            View view = viewGroup.getChildAt(i);
+            if (view instanceof RecyclerView) {
+                return (RecyclerView) viewGroup.getChildAt(i);
+            } else if (viewGroup.getChildAt(i) instanceof ViewGroup) {
+                ViewGroup childRecyclerView = getChildRecyclerView((ViewGroup) viewGroup.getChildAt(i));
 +               if (childRecyclerView instanceof RecyclerView) {
 +                   return (RecyclerView) childRecyclerView;
 +               }
 +           }
 +           continue;
 +       }
 +       return null;
+    }
}

五、基于事件冲突源码实现内部拦截及外部拦截的实例

实例一:ViewPager + ListView

package com.example.practicedemo.ui.event;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.ListView;

/**
 * @fileName: CustomListView.java
 * @des: 自定义ListView通过内部拦截法解决事件冲突
 */
public class CustomListView extends ListView {
    private int mLastX, mLastY;
    public CustomListView(Context context) {
        super(context);
    }

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

    // 内部拦截法:子view处理事件冲突
    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        // 返回true/false时 listview不能上下滑动
       // return true;
       // return false;

      /*  int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
        // DOWN状态下修改disallowIntercept的值为true使得子View不走onInterceptTouchEvent
            case MotionEvent.ACTION_DOWN: {
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            }
            // 垂直滑动则listView处理,水平滑动设置ViewPager处理
            //==》垂直方向时disallowIntercept的值还是true,不能被父类拦截上下可滑动,
            // 水平方向设置使得走拦截方法(此时返回true),故事件不分发,按理可以左右滑动
            // ——问题并未解决(能左右滑不能上下滑) 因为DOWN事件会清除所有标志位重置
            //==》需要父类辅助处理,DOWN状态下父View拦截方法返回true则不能分发给子View,故需额外设置返回false,事件分发给子View
//                ——分析源码move事件的cancelChild为true(对应dispatchTransformedTouchEvent方法会将事件设为cancel状态),mFirstTouchTarget为null,
//                ——故走第二个move事件时不会分发,及ViewPager将事件抢回(结论:DOWN事件没有处理,MOVE事件也不能处理此现象只适用于叶节点)
//                ——父容器可以抢子View的事件,子View不能抢父容器事件(子View拿到事件,再由谁处理由子View决定)

            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;
            }
            default:
                break;
        }
        mLastX = x;
        mLastY = y;*/
        return super.dispatchTouchEvent(event);
    }
}

package com.example.practicedemo.ui.event;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.ViewPager;

/**
 * @fileName: CustomViewPager.java
 * @des: 自定义ViewPager通过外部拦截法解决事件冲突
 */
public class CustomViewPager extends ViewPager {
    private int mLastX, mLastY;

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

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

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        // 返回true 不能上下滑动  可左右滑动——
       // return true;
        // 返回false 不能左右滑动  可上下滑动
       // return false;

        // 内部拦截配合实现代码
        // 从源码分析 DOWN状态下此处返回true则不会分发事件,所以为了让事件分发给子类,此处要改为返回false
        /*if (event.getAction() == MotionEvent.ACTION_DOWN){
            super.onInterceptTouchEvent(event);
            return false;
        }
        return true;*/


     // 外部拦截
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mLastX = (int) event.getX();
                mLastY = (int) event.getY();
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    return true;
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;
            }
            default:
                break;
        }

        return super.onInterceptTouchEvent(event);
    }
}

实例二:SwipeRefreshLayout + ViewPager

package com.example.practicedemo.ui.event.demo2;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;

public class CustomSwipeRefreshLayout extends SwipeRefreshLayout {
    public CustomSwipeRefreshLayout(@NonNull Context context) {
        super(context);
    }

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

    // 外部拦截方式与demo1一样
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return super.onInterceptTouchEvent(ev);
    }
}

package com.example.practicedemo.ui.event.demo2;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.viewpager.widget.ViewPager;

public class CustomViewPager extends ViewPager {
    private int mLastX, mLastY;
    public CustomViewPager(@NonNull Context context) {
        super(context);
    }

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

    // 内部拦截
    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                // 因为SwipeRefreshLayout中重写了requestDisallowInterceptTouchEvent,导致拦截失败
                //==》方式一:修改条件至不拦截
                //==》方式二:通过反射修改标记 mGroupFlag——重写requestDisallowInterceptTouchEvent
                ViewCompat.setNestedScrollingEnabled(this,true);
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;

            }
            default:
                break;
        }

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

相关推荐
©️2020 CSDN 皮肤主题: 像素格子 设计师:CSDN官方博客 返回首页