欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

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

程序员文章站 2022-11-05 09:56:20
一、布局结构设计及实现仿淘宝首页的嵌套布局,TabLayout 上面放置一个 RecyclerView 模拟嵌套效果(TopRecyclerView 只是继承 RecyclerView 填充了几条静态数据,其他未做任何处理),ViewPager的Fragment 中只有一个 RecyclerView 控件,整体的布局是 ScrollView + RecyclerView + TabLayout + ViewPager + Fragment + RecyclerView。

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

仿淘宝首页的嵌套布局,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),但运行时他是所有控件的父类(如图):
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<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;
                            }

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

四、嵌套滑动流程

嵌套滑动流程图(重点)------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

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

本文地址:https://blog.csdn.net/qq_33613491/article/details/107377918

相关标签: UI控件及样式