0%

也许是最贴近京东首页体验的嵌套滑动吸顶效果

作者:Oz

v 信号: Mojitok8275

版权声明:本文图文为博主原创,转载请注明出处。

吸顶效果是各家 App 或多或少都会用的一个交互,这种交互也常见于 PC、H5,可以说是一种通用性很强的前端交互体验,相比较来说京东首页的嵌套滑动吸顶效果是各个类似效果中体验比较好的一个,因为在嵌套布局中滑动连贯性处理得非常好,今天我们就来实现同样的交互效果。

这篇文章我会讲些什么

  • 对于吸附效果实现的思路
  • 3 个版本的 NestedScrollingParent、NestedScrollingChild 简单介绍
  • 嵌套组件滑动连贯性(一致性)的处理(事件分发、Fling 等)
  • 交互的优化问题

首先,先看一下我们实现的效果图:

这里只介绍我们实现过程中的思路,以及部分代码,源码请查看 NestedCeilingEffect,欢迎建议、Issues、Star

实现滑动吸顶效果的简单分析

对于吸顶效果,首先我们要先创造出 “顶” 来,那么如何创造 “顶” 呢?常见的实现方式有很多,比如:

  • 通过两个相同的顶部控件显示或隐藏来实现
  • 通过动态 layout 顶部控件来实现
  • 通过重写 ItemDecoration 来实现
  • 通过 CoordinatorLayout 协调子布局来实现

这些方式或多或少在某些方面有一些场景上的限制,这次我们采用另外一种方式来实现 “顶” 的效果,这里先卖一个关子。

如果我们已经成功的创造出 “顶” 来,那么接下来就是处理好控件的滑动效果,应该就可以实现我们想要的效果了,说来简单,我们不妨通过 Layout Inspector 工具来看一下相关 App 的布局,帮助我们整理思路,我们查看京东首页的布局,发现的确是采用两层 RecyclerView 嵌套来实现的,所以我们同样可以构建这样一个布局结构:

NestedLayout

那么接下来我们就来实现它。

创造 “顶” 的效果

我们上面提到了几种构建 “顶” 的方式,我们这里采用固定高度的方式来实现。在我们的布局中整个蓝色区域其实就是最后一个 item ,那么我们只需要对最后整个 item 固定高度为父布局的高度即可,对于 NestedParentRecyclerView 来说它滑动到底部时不能再向上滑动了,而此时蓝色部分正好充满父布局,从而实现了 “顶” 的效果。

相比较来说,我们确定最后一个 item 高度的时机选择在 onChildAttachedToWindow 比较合适。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class NestedParentRecyclerView extends RecyclerView {
...
@Override
public void onChildAttachedToWindow(@NonNull View child) {
if (isTargetPosition(child)) {
ViewGroup.LayoutParams lp = child.getLayoutParams();
lp.height = getMeasuredHeight();
child.setLayoutParams(lp);
mContentView = (ViewGroup) child;
}
}

@Override
public void onChildDetachedFromWindow(@NonNull View child) {
if (isTargetPosition(child)) {
mContentView = null;
}
}

protected boolean isTargetPosition(View child) {
if (mLayoutManager != null && mAdapter != null) {
int position = mLayoutManager.getPosition(child);
return position + 1 == mAdapter.getItemCount();
}
return false;
}
}

因为 RecyclerView 本身的复用回收效果所以我们在 onChildDetachedFromWindow 时要将引用置空。

嵌套布局滑动连贯性的处理

在开始介绍滑动连贯性的处理之前,我们先简单介绍一下 NestedScrollingParentNestedScrollingChild 的使用,随着不断的发展这套 API 已经有了个版本,目前主要用 2和3

NestedScrollingChild NestedScrollingParent 说明
startNestedScroll onStartNestedScroll child 的调用会触发 parent 回调, onStartNestedScroll 返回值决定了后续嵌套滑动事件是否传递给 parent 处理
onNestedScrollAccepted 如果 onStartNestedScroll 返回 true,则回调此方法
dispatchNestedPreScroll onNestedPreScroll child 滑动前触发 parent 回调,parent 根据自身情况决定是否要滑动
dispatchNestedScroll onNestedScroll
dispatchNestedPreFling onNestedPreFling child Fling 前触发 parent 回调
dispatchNestedFling onNestedFling
stopNestedScroll onStopNestedScroll
getNestedScrollAxes 获得滑动方向,此方法为主动调用的方法

而调用关系其实并不复杂,所有的调用都跟 Touch 事件脱离不开,这里附上一张流程图,方便大家理解

NestedScrolling

子布局的滑动传递处理

本身 RecyclerView 已经实现了 NestedScrollingChild ,而我们选择外层布局也使用了 RecyclerView 为了能够处理子布局的嵌套滑动时间,我们让 NestedParentRecyclerView 实现 NestedScrollingParent3 以及 NestedScrollingParent2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class NestedParentRecyclerView extends RecyclerView implements NestedScrollingParent3, NestedScrollingParent2 {
...
// NestedScrollingParent3

@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
onNestedScrollInternal(dyUnconsumed, type, consumed);
}

private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
final int oldScrollY = computeVerticalScrollOffset();
scrollBy(0, dyUnconsumed);
final int myConsumed = computeVerticalScrollOffset() - oldScrollY;

if (consumed != null) {
consumed[1] += myConsumed;
}
final int myUnconsumed = dyUnconsumed - myConsumed;

dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}

// NestedScrollingParent2

@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
boolean isParentScroll = dispatchNestedPreScroll(dx, dy, consumed, null, type);
// 在父嵌套布局没有滑动时,处理此控件是否需要滑动
if (!isParentScroll) {
// 向上滑动且此控件没有滑动到底部时,需要让此控件继续滑动以保证滑动连贯一致性
boolean needKeepScroll = dy > 0 && !isScrollEnd();
if (needKeepScroll) {
scrollBy(0, dy);
consumed[1] = dy;
}
}
}
... // 其他省略请参看源码
}

经过以上的处理, NestedParentRecyclerView 作为父嵌套布局已经能够处理子嵌套布局传递过来的事件,整体连贯性上就比较像一个整体的控件。

父布局的滑动传递处理

而对于父布局的滑动传递处理,我们就需要覆写 onTouchEvent 单独处理滑动传递了,我们的目的是让两个控件滑动连贯性更好,所以当父控件已经滑动到底部,需要让子嵌套布局滑动剩余的距离,其实代码也比较简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class NestedParentRecyclerView extends RecyclerView {
...
@Override
public boolean onTouchEvent(MotionEvent e) {
if (e.getAction() == MotionEvent.ACTION_DOWN) {
mLastY = e.getY();
mNestedYOffsets = 0;
mVelocityY = 0;
stopScroll();
}
RecyclerView child = FindTarget.findChildScrollTarget(mContentView);
boolean handle = false;
if (child != null) {
// 如果此控件已经滑动到底部,需要让子嵌套布局滑动剩余的距离
// 或者子嵌套布局向下还未到顶部,也需要让子嵌套布局先滑动一段距离
int deltaY = (int) (mLastY - e.getY());
if (isScrollEnd() || (handle = !isChildScrollTop(child))) {
child.scrollBy(0, deltaY);
}
}
mLastY = e.getY();
return handle || super.onTouchEvent(e);
}
}

当然,除了以上对滑动连贯性的处理还有对于 Fling 连贯性的处理,详细的部分见源码,这里就不在一一展开。

准确的状态回调

通常在触顶或者脱离的时候我们需要处理 UI 的变化,例如触顶时,显示返回 Top 的按钮等,这个时候就需要我们的控件能够提供准确的状态回调,所以这里我们定义一个接口 OnChildAttachStateListener :

1
2
3
4
5
6
7
8
9
10
11
12
public interface OnChildAttachStateListener {

/**
* 子布局吸附到顶部时回调
*/
void onChildAttachedToTop();

/**
* 子布局从顶部脱离时回调
*/
void onChildDetachedFromTop();
}

那我们接下来就要思考如何准确的判断这个状态了, RecyclerView 滑动时判断会不断的触发 onScrolled 的回调,这里监听状态的变化最合适不过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class NestedParentRecyclerView extends RecyclerView {
...
private boolean mIsChildAttachedToTop = false;
private boolean mIsChildDetachedFromTop = true;
private final ArrayList<OnChildAttachStateListener> mOnChildAttachStateListeners =
new ArrayList<>();
...
public void addOnChildAttachStateListener(OnChildAttachStateListener listener) {
mOnChildAttachStateListeners.add(listener);
}
...
@Override
public void onScrolled(int dx, int dy) {
...
boolean attached = dy > 0 && isScrollEnd();
if(attached && mIsChildDetachedFromTop) {
mIsChildAttachedToTop = true;
mIsChildDetachedFromTop = false;
final int listenerCount = mOnChildAttachStateListeners.size();
for (int i = 0; i < listenerCount; i++) {
OnChildAttachStateListener listener = mOnChildAttachStateListeners.get(i);
listener.onChildAttachedToTop();
}
}

boolean detached = dy < 0 && !isScrollEnd();
if (detached && mIsChildAttachedToTop) {
RecyclerView child = FindTarget.findChildScrollTarget(mContentView);
if (child == null || isChildScrollTop(child)) {
mIsChildDetachedFromTop = true;
mIsChildAttachedToTop = false;
final int listenerCount = mOnChildAttachStateListeners.size();
for (int i = 0; i < listenerCount; i++) {
OnChildAttachStateListener listener = mOnChildAttachStateListeners.get(i);
listener.onChildDetachedFromTop();
}
}
}
}
}

其他优化问题

临界状态父布局触摸事件分发后再次接管滑动时的跳动问题处理

为了保证控件的滑动连贯性,我们对滑动事件进行了分发,这个问题出现的原因在于父布局向子布局分发向上滑动的事件后,手指又向下滑动,这是由于事件又回到本身处理时,在 super.onTouchEvent(e) 中 MotionEvent 的坐标与手指出现了偏离,导致了跳帧的现象。所以,我们只需要对 MotionEvent 偏移量进行更新即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class NestedParentRecyclerView extends RecyclerView {
@Override
public boolean onTouchEvent(MotionEvent e) {
...
RecyclerView child = FindTarget.findChildScrollTarget(mContentView);
boolean handle = false;
if (child != null) {
if (isScrollEnd() || (handle = !isChildScrollTop(child))) {
int deltaY = (int) (mLastY - e.getY());
child.scrollBy(0, deltaY);
if (handle) {
// 子嵌套布局向下滑动时,要记录y轴的偏移量
mNestedYOffsets += deltaY;
}
}
}
mLastY = e.getY();
// 更新触摸事件的偏移位置,以保证视图平滑的连贯性
e.offsetLocation(0, mNestedYOffsets);
return handle || super.onTouchEvent(e);
}
}

加速度问题的优化

为了避免过快加载内容,一定程度上能减少 App 卡顿掉帧,我们同样对于加速度进行了限制

1
2
3
4
5
6
7
8
9
10
11
public final class FlingHelper {
public FlingHelper(Context context, float factor) {
...
mMaxFlingVelocity = (int) (ViewConfiguration.get(context).getScaledMaximumFlingVelocity() * factor);
}

/* 限制加速的最大值 */
public int getFlingVelocity(int velocity) {
return Math.max(-mMaxFlingVelocity, Math.min(velocity, mMaxFlingVelocity));
}
}

子布局脱离顶部时的状态恢复

这个优化点是为了保证众多 Tab 中从顶部脱离时,所有的 Tab 都要回归起始位置,当然这部分需要在状态监听里去处理了。

1
2
3
4
5
6
fun resetToTop() {
recyclerView?.let {
val mLayoutManager = it.layoutManager as StaggeredGridLayoutManager
mLayoutManager.scrollToPositionWithOffset(0, 0)
}
}

其他已知问题

  • Fling 状态传递偶有略微延迟(跟递归查找滑动子 View 有关,如果要优化可从外部传入状态,省去查找时间,为了保持灵活性暂未处理)

总结

对于设计一个复用性较强的控件,需要考虑的问题比较多,有些比较细节的问题处理得当的话对于交互体验会有舒适感上升的感觉,虽说这对用户体验是一个隐性的提升,但是就是众多细节的优化积累使得整体的用户体验得到大幅提示。