如何应对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~~

简历润色

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

下一章预告

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

欢迎三连

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

相关推荐
东阳马生架构2 分钟前
商品中心—1.B端建品和C端缓存的技术文档
java
Chan165 分钟前
【 SpringCloud | 微服务 MQ基础 】
java·spring·spring cloud·微服务·云原生·rabbitmq
LucianaiB8 分钟前
如何做好一份优秀的技术文档:专业指南与最佳实践
android·java·数据库
面朝大海,春不暖,花不开32 分钟前
自定义Spring Boot Starter的全面指南
java·spring boot·后端
得过且过的勇者y32 分钟前
Java安全点safepoint
java
夜晚回家1 小时前
「Java基本语法」代码格式与注释规范
java·开发语言
斯普信云原生组1 小时前
Docker构建自定义的镜像
java·spring cloud·docker
wangjinjin1801 小时前
使用 IntelliJ IDEA 安装通义灵码(TONGYI Lingma)插件,进行后端 Java Spring Boot 项目的用户用例生成及常见问题处理
java·spring boot·intellij-idea
wtg44521 小时前
使用 Rest-Assured 和 TestNG 进行购物车功能的 API 自动化测试
java
白宇横流学长2 小时前
基于SpringBoot实现的大创管理系统设计与实现【源码+文档】
java·spring boot·后端