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();
}
// 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(父亲)
- 首先,先执行是否可嵌套滑动,若可以则孩子是主动方;
- 当 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