嵌套滑动吸顶效果

130 篇文章 3 订阅
9 篇文章 0 订阅

作者:blue

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

这篇文章我会讲些什么

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

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

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

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

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

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

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

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

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

2、创造 “顶” 的效果

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

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

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 时要将引用置空。

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

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

NestedScrollingChildNestedScrollingParent说明
startNestedScrollonStartNestedScrollchild 的调用会触发 parent 回调, onStartNestedScroll 返回值决定了后续嵌套滑动事件是否传递给 parent 处理
onNestedScrollAccepted如果 onStartNestedScroll 返回 true,则回调此方法
dispatchNestedPreScrollonNestedPreScrollchild 滑动前触发 parent 回调,parent 根据自身情况决定是否要滑动
dispatchNestedScrollonNestedScroll
dispatchNestedPreFlingonNestedPreFlingchild Fling 前触发 parent 回调
dispatchNestedFlingonNestedFling
stopNestedScrollonStopNestedScroll
getNestedScrollAxes获得滑动方向,此方法为主动调用的方法

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

3.1 子布局的滑动传递处理

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

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 作为父嵌套布局已经能够处理子嵌套布局传递过来的事件,整体连贯性上就比较像一个整体的控件。

3.2 父布局的滑动传递处理

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

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 连贯性的处理,详细的部分见源码,这里就不在一一展开。

4、准确的状态回调

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

public interface OnChildAttachStateListener {

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

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

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

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();
 }
 }
 }
 }
}

5、其他优化问题

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

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

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);
 }
}
5.2 加速度问题的优化

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

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));
 }
}
5.3 子布局脱离顶部时的状态恢复

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

fun resetToTop() {
 recyclerView?.let {
 val mLayoutManager = it.layoutManager as StaggeredGridLayoutManager
 mLayoutManager.scrollToPositionWithOffset(0, 0)
 }
}
5.4 其他已知问题
  • Fling 状态传递偶有略微延迟(跟递归查找滑动子 View 有关,如果要优化可从外部传入状态,省去查找时间,为了保持灵活性暂未处理)

总结

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

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Jetpack Compose 是一种用于构建 Android UI 的现代工具包。要实现吸顶效果,可以使用 Jetpack Compose 的 ConstraintLayout 组件。 首先,在你的布局文件中引入 ConstraintLayout 组件。然后,在需要实现吸顶效果的视图组件前方放置一个占位符组件,作为吸顶效果的起点。 接下来,使用 ConstraintLayout 的 createModifier 函数来创建一个修饰符(Modifier),并将其应用到需要实现吸顶效果的视图组件上。在修饰符中,通过调用该组件的位置限制函数来设置视图组件的约束条件。 例如,使用 Modifier.offset() 函数将吸顶组件固定在布局的顶部: ```kotlin val modifier = Modifier.constrainAs(view) { top.linkTo(parent.top) } ``` 或者,使用 Modifier.padding() 函数来设置视图组件距离父布局顶部的间距: ```kotlin val modifier = Modifier.padding(top = offset) ``` 其中,offset 可以是一个动态的数值,用于实现吸顶效果的变化。你可以根据滚动位置来调整 offset 的值,实现吸顶视图随滚动而变化的效果。 最后,将该修饰符应用到你的视图组件上: ```kotlin Box( modifier = Modifier .fillMaxSize() .wrapContentSize() .offset { IntOffset(0, offset) } ) { // 吸顶视图组件 } ``` 这样,你就可以实现吸顶效果了。通过调整约束条件或距离顶部的间距,你可以自定义吸顶效果的具体表现。注意,吸顶效果通常与滚动视图组件(如 ListView 或 ScrollView)结合使用,以便根据滚动位置来动态调整吸顶视图的位置和显示状态。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值