让你的 RecyclerView 实现「梦幻联动」

前端开发专家 @ 雪球

背景

最近雪球 Android 团队针对基金详情页缓慢帧问题进行了一系列优化,其中一项主要工作是通过 RecyclerView 实现分屏加载,实现过程中需要解决的重点问题是“底部讨论浮层联动效果”,本文总结了其中遇到的问题和解决过程,在这里和大家分享。

这种联动效果在很多场景里能得到应用,目前可以参考的资料不多,对外输出也是希望能够给其他开发者提供一些帮助,让大家少走一些弯路。

图片

实现

由于这个问题本身有一些复杂,为了让读者能够快速理解其实现方式,在阅读此文章之前,推荐大家可以先阅读以下几篇文章:

  • Android 自定义 Behavior[1]

  • BottomSheetBehavior 的使用[2]

  • 嵌套滑动机制[3]

整体布局采用 RecyclerView+CoordinatorLayout+ViewPager 实现,从效果图可以看出,讨论浮层是 CoordinatorLayout+ViewPager,讨论浮层下面是 RecyclerView,当 RecyclerView 滑到底的时候会关联讨论浮层一起滑动,同时讨论浮层也可以上下自由拖动,关于联动效果的实现,我们需要重点解决以下问题:

  • 讨论浮层如何实现上下自由拖动?

  • 滑动 RecyclerView 或者 CoordinatorLayout,滑到一定距离 RecyclerView 和 CoordinatorLayout 如何做到相互关联和一起滚动?

  • 向下滑动 RecyclerView 或者 CoordinatorLayout 到底部如何取消联动效果?

图片

针对以上问题,让我们来看一下具体解决方案吧!

自由拖动

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

    <...SNBObservableNestedRecycleView
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            ...
            android:orientation="horizontal"
            app:behavior_peekHeight="54dp"
            app:layout_behavior="...BottomSheetBehavior" />
    </androidx.coordinatorlayout.widget.CoordinatorLayout>

</RelativeLayout>

复制代码

下面这张图展示 CoordinatorLayout+Behavior 设计方案3D结构:

图片

这个布局结构看上去并不复杂,讨论浮层在上面,默认隐藏一部分内容,浮层跟随手势自由拖动的效果使用系统提供的 BottomSheetBehavior 实现,不了解 BottomSheetBehavior 的同学可以参考 BottomSheetBehavior的使用[4] ,我们看一下实现效果:

图片

观察效果图我们发现,单独使用 BottomSheetBehavior ,可以实现讨论浮层的上下自由拖动,但是无法做到 RecyclerView 和 CoordinatorLayout 关联滚动,因此我们需要对 BottomSheetBehavior 进行扩展,来实现联动效果。

联动效果

布局结构中的 SNBObservableNestedRecycleView 和 SNBBottomSheetBehavior 是联动效果的重要实现类,基于系统 BottomSheetBehavior 扩展了如下两个功能:

  • 当 RecyclerView 滑到底,如果继续上下拖动 RecyclerView,可开始关联 CoordinatorLayout 一起上下滚动

图片

  • 当已经处在 RecyclerView 和 CoordinatorLayout 关联滚动状态时,向下滑动 RecyclerView/CoordinatorLayout,滚动到一定距离,取消关联滚动效果,CoordinatorLayout 放置在底部,RecyclerView 可以继续滑动

图片

这个看上去有点绕,但其实不难理解,大家可以对比上面两个效果图帮助领会。

Behavior

在介绍具体实现之前,先来说一下 CoordinatorLayout 的一个内部类 Behavior,该实现效果最重要的就是针对 Behavior 的扩展,关于 Behavior,我们重点讨论以下几个问题:

  • Behavior 是什么?

  • Behavior 关键方法有哪些?

  • Behavior 基本原理是怎样的?

  • 什么是嵌套滑动机制?

Behavior 是 Material Design 库里面的 API ,其主要作用是用来协调 CoordinatorLayout 里面直接 Child Views 之间交互行为,Behavior 只能作用于 CoordinatorLayout 的直接子 View,也就是说 Behavior 作用于子父 View 之间,所有事件由子 View 发起,父 View 接收并响应,这里说的事件通常就是拖动、滑动等基础事件,也就是官方说的 “These interactions may include drags, swipes, flings, or any other gestures”,子父 View 的定义如下:

  • 仅实现 NestedScrollingChild 接口的类是子 View,也就是事件的发起者

  • 仅实现 NestedScrollingParent 接口的类是父 View,也就是事件接收响应者

CoordinatorLayout 实现了 NestedScrollingParent 接口,CoordinatorLayout 通常作为父 View 也就是事件接收响应者,而 RecyclerView 是 NestedScrollingChild 的子类,也就是本文中所有的事件发起者,下面这张图介绍了 NestedScrollingChild、NestedScrollingParent、CoordinatorLayout 以及 Behavior 之间的执行流程,帮助大家理解这四者之间的关系:

图片

Behavior 的核心方法如下:

当子 View(直接或间接)调用 startNestedScroll 时,Behavior 会回调 onStartNestedScroll 方法,返回值表示父控件是否接收该嵌套滑动事件:

@Override
public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) {
    return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type);
}

复制代码

子 View 调用 dispatchNestedPreScroll 的时候回调 Behavior 的 onNestedPreScroll 方法,父 View 优先响应滑动操作,消费部分或全部滑动距离:

@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
}

复制代码

父 View 接收子 View 处理完滑动后的滑动距离信息, 在这里父控件可以选择是否处理剩余的滑动距离,如果想要该方法得到回调,onStartNestedScroll 必须返回 true:

@Override
public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
    super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed);
}

复制代码

嵌套滑动结束(ACTION_UP 或 ACTION_CANCEL)调用 onStopNestedScroll:

@Override
public void onStopNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int type) {
    super.onStopNestedScroll(coordinatorLayout, child, target, type);
}

复制代码

介绍完 Behavior,下面我们来说一下什么是嵌套滑动机制

嵌套滑动机制

嵌套滑动机制是用来处理嵌套滑动(子父 View 可以同时滑动)的一种方式,一般来说处理嵌套滑动有三种方式:

  • 传统的事件分发机制,该方式处理非常麻烦,耦合严重

  • 基于 NestedScrollingChild 和 NestedScrollingParent 实现

  • 使用 CoordinatorLayout 和 Behavior 实现

本文所使用的是第三种方案,它是基于第二种方案实现的。在嵌套滑动机制中所有 NestedScrollingChild 发起的事件,CoordinatorLayout 作为父 View 都有对应的实现,而 Beahvior 就是父 View 所有逻辑的具体实现类,换句话说,Beahvior 可以理解成是对第二种方案的封装,基本原理如下:

  • 当子 View 收到滑动事件,准备滑动时,会先通知父控件 (startNestedScroll)

  • 在子 View 滑动之前,会先询问父控件是否要滑动(dispatchNestedPreScroll)

  • 如果父控件响应该事件进行了滑动,那么就会通知子控件它具体消耗了多少滑动距离,交由子控件处理剩余的滑动距离

  • 子控件滑动结束后,如果滑动距离还有剩余,会再次询问父控件是否需要继续滑动剩下的距离(dispatchNestedScroll)...

子父 View 之间调用关系如下图所示:

图片

具体实现

关于具体实现我们分两种情况讨论:

  • 情况一:当滑动 RecyclerView 时,如何关联浮层一起滚动?

  • 情况二:当滑动浮层时,如何关联 RecyclerView 一起滚动?

情况一

基于上述原理和调用流程,我们先看一下第一种情况的实现方案:

首先我们监听 RecyclerView 的滑动事件,当 RecyclerView 滑动到底并且继续上下滑动时候,调用 setSlideHeight 方法,改变 Behavior 状态,setSlideHeight 会调用子 View 的 requestLayout 从而触发 onLayoutChild 回调,我们在 onLayoutChild 中改变子 View 的位置,关键方法如下:

addOnScrollListener(object : OnScrollListener() {   
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
          super.onScrolled(recyclerView, dx, dy)
              totalDy += dy
              if (!scrollByOutsideView) {
                  bottomSheetBehavior?.let {
                      //省略部分代码

                      //计算滑动偏移量
                      val slideHeight = measuredHeight - ((RecyclerView的高 - totalDy) + it.peekHeight
                      //表示滑到底部
                      if (isPlaceholderViewVisible) {
                          //省略改变Behavior状态的部分代码
                          this.slideOffset = parentHeight - slideHeight
                          childView.requestLayout()
                      }
                  }
              }
              scrollByOutsideView = false
          }
     })

复制代码
@Override
public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) {
    //省略部分代码...
    if (slideByOutsideView) {
        //表示NestedScrollView滑动到底,并且继续向上滑动的状态
        ViewCompat.offsetTopAndBottom(child, slideOffset);
        dispatchOnSlide(slideOffset);
    } else {
        //省略部分代码...
    }
    return true;
}

复制代码

关联子 View 滚动的关键代码是 ViewCompat.offsetTopAndBottom(child, slideOffset); 计算滑动距离是使用该方法最重要的步骤,这个 slideOffset 是指子 View 要滑到什么位置,滑动距离计算方法如下:

int slideHeight = measuredHeight - (RecyclerView实际的高度 - scrollY()) + peekHeight;
slideOffset = parentHeight - slideHeight;

复制代码

图片

情况二

下面我们再看一下第二种情况的实现方案:

当滑动讨论浮层(CoordinatorLayout 的子 View)的时候,关联 RecyclerView 一起滑动的情况,实现这一步的基本原理如下:

  • 当滑动浮层的时候,在 onTouchEvent 对应的 MOVE 事件中,调用 dispatchNestedPreScroll

  • 回调父 View 的 onNestedPreScroll 方法,在这里可以拿到父 View 滑动距离 distanceY

  • 调用 RecyclerView.scrollBy(0, distanceY) 方法实现联动

当滑动讨论浮层的时候,子 View 会调用 dispatchNestedPreScroll 方法,从而回调 Behavior 的 onNestedPreScroll 方法,在 onNestedPreScroll 中我们可以拿到滑动距离,然后调用 RecyclerView.scrollBy 方法实现关联滚动,核心代码如下:

@Override
public void onNestedPreScroll() {
    
    //省略部分代码...

    int currentTop = child.getTop();
    int newTop = currentTop - dy;
    if (dy > 0) { //向上滚动
        if (newTop < getExpandedOffset()) {
            consumed[1] = currentTop - getExpandedOffset();
            ViewCompat.offsetTopAndBottom(child, -consumed[1]);
            //关联RecyclerView滚动
            recyclerView.scrollBy(0, consumed[1]);
            setStateInternal(STATE_EXPANDED);
        } else {
            consumed[1] = dy;
            ViewCompat.offsetTopAndBottom(child, -dy);
            //关联RecyclerView滚动
            recyclerView.scrollBy(0, dy);
            setStateInternal(STATE_DRAGGING);
        }
    } else if (dy < 0) { //向下滚动
        if (!target.canScrollVertically(-1) || (slideByOutsideView && state == STATE_DRAGGING)) {
            if (newTop <= collapsedOffset || hideable) {
                consumed[1] = dy;
                ViewCompat.offsetTopAndBottom(child, -dy);
                //关联RecyclerView滚动
                recyclerView.scrollBy(0, dy);
                setStateInternal(STATE_DRAGGING);
            } else {
                consumed[1] = currentTop - collapsedOffset;
                ViewCompat.offsetTopAndBottom(child, -consumed[1]);
                //关联RecyclerView滚动
                recyclerView.scrollBy(0, consumed[1]);
                setStateInternal(STATE_COLLAPSED);
            }
        }
    }
}

复制代码

小结

内容回顾:

  • 效果演示和整体结构概述

  • 讨论浮层上下自由拖动的实现

  • Behavior 和滑动嵌套机制的原理讲解

  • RecyclerView 和 CoordinatorLayout 关联滑动的实现

实际上 Behavior 的使用极其灵活强大,可以实现很多复杂的效果,本文所使用的也不过是 Behavior 的部分功能,但不管多么复杂的情况,只要我们先去了解其原理,都能很快明白其实现方式。道阻且长,行则将至。

本文 Demo 地址:github.com/liuyak/Nest…

还有一件事

雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。

热招岗位:大前端架构师、Android/iOS/FE 技术专家、推荐算法工程师、Java 开发工程师。

参考资料

[1]

Android自定义Behavior: www.jianshu.com/p/b987fad8f…

[2]

BottomSheetBehavior的使用: www.jianshu.com/p/99c6813e5…

[3]

嵌套滑动机制: www.jianshu.com/p/cb3779d36…

[4]

BottomSheetBehavior的使用: developer.android.google.cn/reference/c…

文章分类
Android
文章标签