如何应对Android面试官->嵌套滚动原理大揭秘,实战京东首页二级联动

前言

本章节主要从 嵌套滑动、滑动冲突方案解决来入手,这里不是基于协调者布局;

嵌套滑动

我们先来看一个示例:

可以看到首页是有一个嵌套滑动的效果,上推的时候,频道先跟着移动,然后置顶不动,这种效果基于协调者布局可以实现,但是如果没有协调者布局的情况下,如何实现呢?

界面如何布局

在不使用 协调者 布局的情况下,界面最终的布局效果应该是类似下面这样的一层结构;

最外层 ScrollVIew/NestedScrollView

嵌套:不能滑动的RecyclerView、TabLayout、ViewPager

经过分析,我们来搭建一个这样的布局来看下

ini 复制代码
<ScrollView    
    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">
        <!-- 这里要稍微改造下,改成不能滑动的 RecyclerView -->     
        <androidx.recyclerview.widget.RecyclerView            
            android:id="@+id/combo_top_view"            
            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>

然后我们编译执行下,看下最终的滑动效果,是否能达到我们的预期:

可以看到,并没有达到我们期望的联动效果,那么原因是什么呢?

View 和 ViewGroup 的关系

  1. 代码层面:ViewGroup 继承 View;
  2. 运行层面:ViewGroup 嵌套 View;

事件分发是从运行时角度来分析的;

事件分发的流程

Activity dispatchTouchEvent ->

scss 复制代码
public boolean dispatchTouchEvent(MotionEvent ev) {    
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {        
        onUserInteraction();    
    }    
    if (getWindow().superDispatchTouchEvent(ev)) {        
        return true;    
    }    
    return onTouchEvent(ev);
}

PhoneWindow superDispatchTouchEvent ->

csharp 复制代码
public boolean superDispatchTouchEvent(MotionEvent event) {
    return mDecor.superDispatchTouchEvent(event);
}

DecorView superDispatchTouchEvent ->

typescript 复制代码
public boolean superDispatchTouchEvent(MotionEvent event) {
    return super.dispatchTouchEvent(event);
}

DecorView 的父类是 FrameLayout,其父类是 ViewGroup,最终调度到 ViewGroup 的 dispatchTouchEvent 方法

这 dispatchTouchEvent 方法中,先进行是否需要拦截判断,通过 onInterceptTouchEvent 方法

java 复制代码
public boolean dispatchTouchEvent(MotionEvent ev) {
    //
    ...
    // 省略部分代码
    // 首先问询自身要不要进行事件的拦截
    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;
    }
}

final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;

这个是用来判断子 View 有没请求父 View(当前View)不要进行事件的拦截

子 View 通过调用这个方法,设置 mGroupFlags 的值,来修改 disallowIntercept 的逻辑;

如果子 View 没有要求父 View 不拦截,则父View(当前View)执行 onInterceptTouchEvent 来问询自身要不要拦截;

如果自身不拦截,则

嵌套滚动的实现原理

需要两个角色,一个角色用来实现 NestedScrollingParent3,一个角色需要实现 NestedScrollingChild3 接口,用来表示一个是父亲,一个是孩子,以此来表示它们之间的嵌套滑动;

那么,有人就提出了,使用 NestedScrollView,因为它实现了 NestedScrollingParent3,那么我们将布局改成 NestedScrollView :

ini 复制代码
<androidx.core.widget.NestedScrollView    
    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">
        <!-- 这里要稍微改造下,改成不能滑动的 RecyclerView -->     
        <androidx.recyclerview.widget.RecyclerView            
            android:id="@+id/combo_top_view"            
            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>
</androidx.core.widget.NestedScrollView>

我们运行看下效果:

虽然实现了嵌套滚动,但是 TabLayout 没有吸顶,随着滑动一起滚出了屏幕,还是不符合预期;

那么如何实现吸顶来达到我们的预期呢?

我们可以将 NestedScrollView 的内容元素进行拆分,将不能滑动的区域作为 NestedScrollView 的 header 部分,将 TabLayout 和 RecyclerView 整体划分成一部分作为 NestedScrollView 的最后一个 View,并且最后一个 View 的高度是整个屏幕的高度,这样当 NestedScrollView 滑动到最后一个 View 的时候,整个View 是充满屏幕的,那么 TabLayout 就能置顶了;

那么我们就需要这个 NestedScrollView 来判断最后是不是一个 View,以及获取最后一个View之后,设置它的高度为屏幕高度;

继承 NestedScrollView 实现下面两个方法:

scss 复制代码
@Override
protected void onFinishInflate() {    
    super.onFinishInflate();    
    // 根据 xml 的层级结构,我们需要获取的是第一个子 View 下的 第二个子 View    
    contentView = ((ViewGroup) getChildAt(0)).getChildAt(1);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {    
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);    
    // 重新设置 contentView 的高度为屏幕高度    
    ViewGroup.LayoutParams layoutParams = contentView.getLayoutParams();    
    layoutParams.height = getMeasuredHeight();    
    contentView.setLayoutParams(layoutParams);
}

我们编译运行看下:

可以看到,达到了我们期望的效果,实现了吸顶,但是还是有滑动冲突的地方,没有达到预期的效果,触摸滑动的时候 NestedScrollView 没有先滑动,而是 RecyclerView 滑动了一些距离才进行 NestedScrollView 的滑动;

我们可以根据嵌套滑动流程图来分析下:

根据流程图,可以知道,调用 startNestedScrolled 的时候,说明开始滑动了,我们进入 RecyclerView 的 startNestedScroll 方法看一下:

java 复制代码
@Override
public boolean startNestedScroll(int axes) {    
    return getScrollingChildHelper().startNestedScroll(axes);
}

这里通过一个 Helper 帮助类,来处理滑动事件,我们进入这个 Helper 看下

less 复制代码
public boolean startNestedScroll(@ScrollAxis int axes) {    
    return startNestedScroll(axes, TYPE_TOUCH);
}

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {    
    if (hasNestedScrollingParent(type)) {        
        // Already in progress        
        return true;    
    }
    // 判断有没有开启嵌套滑动    
    if (isNestedScrollingEnabled()) {
        // 获取当前 View 的 parent        
        ViewParent p = mView.getParent();        
        View child = mView;        
        while (p != null) {
            // 问询这个 parent 是否支持嵌套滑动            
            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 所以肯定支持嵌套滑动,这里遇到 NestedScrollView 的时候就会 return true;

当手指滑动的时候,正常应该是先让 NestedScrollView 滑动,等它不能滑动的时候,RecyclerView 才进行滑动;

开始滑动的时候,RecyclerView 会执行 dispatchNestedPreScroll,而 NestedScrollView 则会执行 onNestedPreScroll 方法,来判断并且决定 NestedScrollView 要不要执行滑动

less 复制代码
@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);
}

我们发现在 NestedScrollView 中它又去问询它的父亲能不能滑动了,这里就和 RecyclerView 冲突了,这是因为 NestedScrollView 即可以当父亲也可以当孩子导致,那么上面的问题原因也就找到了,我们把 NestedScrollView 当成了父亲,那么它就不需要再去问询自己的父亲了,只需要判断下自己能不能滑动,能滑动就自己滑动,同时记录消费的距离;

所以我们复写 NestedScrollView 的 onNestPresScroll 方法,在 RecyclerView 滑动之前,自己先处理滑动;

less 复制代码
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {    
    // 我们需要处理不可滑动的区域的可见高度    
    boolean hideTop = dy > 0 && getScrollY() < topView.getMeasuredHeight();    
    if (hideTop) {        
        scrollBy(0, dy);        
        consumed[1] = dy;    
    }
}

编译后效果如下:

可以看到,已经可以吸顶,并且先滑动顶部,再滑动 RecyclerView;但是在快速滑动之后的惯性滑动还是没有;我们需要加上惯性滑动,来保证体验的完美;

我们需要接入 Fling 来实现,重写 fling 方法,让子View RecyclerView fling 同样的值;

scss 复制代码
public void fling(int velocityY) {    
    super.fling(velocityY);    
    if (velocityY > 0) {        
        ViewPager2 viewPager2 = getChildView(this, ViewPager2.class);        
        if(viewPager2 != null) {                      
            RecyclerView childRecyclerView = getChildView(((ViewGroup)viewPager2.getChildAt(0)), RecyclerView.class);            
            if (childRecyclerView != null) {                
                childRecyclerView.fling(0, velocityY);            
            }        
        }    
    }
}

我们运行看下效果:

可以看到,实现了惯性滑动的效果,perfect~~

简历润色

简历上可写:深度理解嵌套滑动实现原理,可实现复杂的嵌套滚动效果;

下一章预告

事件冲突与解决方案大揭秘;

欢迎三连

来都来了,点个关注点个赞吧,你的支持是我最大的动力~~~

相关推荐
沙子迷了蜗牛眼20 小时前
当展示列表使用 URL.createObjectURL 的创建临时图片、视频无法加载问题
java·前端·javascript·vue.js
ganshenml20 小时前
【Android】 开发四角版本全解析:AS、AGP、Gradle 与 JDK 的配套关系
android·java·开发语言
我命由我1234520 小时前
Kotlin 运算符 - == 运算符与 === 运算符
android·java·开发语言·java-ee·kotlin·android studio·android-studio
小途软件20 小时前
ssm327校园二手交易平台的设计与实现+vue
java·人工智能·pytorch·python·深度学习·语言模型
alonewolf_9921 小时前
Java类加载机制深度解析:从双亲委派到热加载实战
java·开发语言
追梦者12321 小时前
springboot整合minio
java·spring boot·后端
云游21 小时前
Jaspersoft Studio community edition 7.0.3的应用
java·报表
帅气的你21 小时前
Spring Boot 集成 AOP 实现日志记录与接口权限校验
java·spring boot
zhglhy21 小时前
Spring Data Slice使用指南
java·spring
win x21 小时前
Redis 主从复制
java·数据库·redis