尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

自定义RecyclerView LayoutManager实现卡片堆叠效果完整指南

自定义RecyclerView LayoutManager实现卡片堆叠效果完整指南 前阵子公司做一个内容社区App产品经理拿了个参考图过来要求“首页卡片像一摞照片放在桌面上手指一滑最上面那张飞出去底下那张弹上来再一滑又飞一张”。我一看到这个交互就明白了这不就是俗称的卡片堆叠效果吗类似Apple Wallet里那张卡往下拉一层一层摞着的感觉也像部分浏览器里标签页堆叠的效果。这类效果在iOS上用UIKit的卡牌手势做起来不算太难但放到Android这边选型的分歧就大了有人张口就是ViewPager2嵌套RecyclerView有人直接甩出第三方库还有人打算用FrameLayout全家桶自己写手势。我当时选的方案是自定义RecyclerView.LayoutManager也就是标题里说的android-pile-layout这路思路。这个选择不是说其他方案不行而是从性能、复用、交互扩展空间三个维度来看LayoutManager是最贴合RecyclerView生态的做法。毕竟RecyclerView已经把View回收复用的机制做得很成熟了你不去用反而自己拿FrameLayout手动管理一堆子View滑几下就OOM或者卡到掉帧那就太亏了。这篇文章我就把这套自定义LayoutManager实现卡片堆叠效果的完整思路、核心公式、关键代码和踩过的坑都梳理一遍。无论你是刚接触LayoutManager的初学者还是在项目里已经写过RecyclerView但没碰过自定义LayoutManager的老手看完应该都能自己动手搭一个能跑的堆叠卡片效果出来。1. 整体设计思路堆叠效果的本质是“位置随滑动进度变化”1.1 卡片堆叠和普通列表的差别在哪先把效果拆开看。普通的线性列表LayoutManager每个item占据一个矩形区域位置主要由position算出第n个item在垂直方向上的top就是n乘以item高度加上间距和滑动偏移量一起决定最终绘制位置。这个逻辑很直接RecyclerView的LinearLayoutManager就是这么干的。但堆叠效果不一样。我看了参考图之后把需求拆成三个特征多个卡片在垂直方向上“摞”在一起不是垂直平铺而是共享同一块中心区域。卡片之间存在偏移量也就是每张卡相对于下一张卡在y轴方向上有一定的位移让你能看到底下那张卡的上边缘或者视觉上像一叠倾斜的纸牌。滑动时不是整个列表滚走而是最上层的卡片随着手指移动其它卡片几乎是静止的或者有微小的缩放、位移反馈直到手势结束最上面那张要么滑走要么弹回原位。这三个特征合在一起就意味着传统的“scrollY加上固定偏移”这种布局策略行不通。因为列表中的item不再各自占据一个独立位置而是全部重叠在一个区域里它们之间靠y方向上的增量来区分层次。滑动一个距离受影响的不应该是所有item而是最顶层的少数几项。1.2 为什么选自定义LayoutManager而不是别的方案我当时也对比过几种实现路线这里直接说结论FrameLayout 手势监听把所有卡片add进去手动监听ACTION_MOVE改TranslationY、rotation、scale。这种写法做三张以内的静态堆叠没问题但card一多每一帧所有View都要重新计算draw效率低内存也会随卡片数量线性增长。而且一旦要在中间插入、删除卡片自己管理View复用就是灾难。ViewPager2它本质上还是RecyclerView只是内部封装了LinearLayoutManager和手势逻辑。问题是ViewPager2的页面切换模式太固定虽然能利用SnapHelper但要做到一张一张卡片飞出去、剩余卡片堆叠还有缩放和透明度差异你得做很多外部适配得不偿失。自定义LayoutManager布局逻辑完全掌握在自己手里所有item都交给RecyclerView管理回收复用的机制天然在滑动偏移自己控制动画插值自己定义甚至后续要接ItemTouchHelper做拖拽都比较顺畅。唯一要付出的成本就是把LayoutManager的几个核心方法弄明白。所以我的决定是写一个PileLayoutManager继承RecyclerView.LayoutManager自己负责把所有卡片按堆叠规则摆到屏幕上。1.3 我需要提前定好的交互细节动手之前我先把交互细节固定下来避免实现到一半产品和开发互相扯皮最多同时可见多少张卡片我定为4张。超过的部分不绘制也不布局靠RecyclerView的复用机制缓存住。卡片之间的Y轴偏移每张卡片露出高度我定为20dp最上面那张完全展示底下的露出上边缘一条形成“叠叠乐”的层次感。滑动方向垂直滑动。手指向上滑最上面的卡片逐渐上移加旋转手指向下滑最上面卡片下移底下卡片露出更多。手势结束后如果位移超过阈值卡片高度的1/3就认为这张卡片要被“移除”滑出屏幕并触发数据移除否则弹回原位。就这样所有的布局计算都围绕一个核心变量当前滑动进度。接下来我会详细拆解这个进度怎么定义、怎么驱动布局。2. 核心细节解析从滑动偏移到卡片布局的数学建模2.1 把每个item的位置写成“进度”的函数假设当前可见的item有4个position从0到3。0号是最上面的卡3号是最底下的。那么每一张卡的默认位置未滑动时可以表示成0号卡top 0, left 0, right width, bottom height1号卡top 0 pileOffset, bottom height pileOffset2号卡top 0 2 * pileOffset, bottom height 2 * pileOffset3号卡top 0 3 * pileOffset, bottom height 3 * pileOffsetpileOffset就是上面说的每张卡片露出的高度我设为20dp。但滑动变换的核心不是直接改这个位置而是要引入一个“滑动进度progress”。每张卡片的实际位置都是它的基础位置加上一个由progress驱动的偏移量。比如当手指向上滑动了deltaY时我计算出一个progress deltaY / cardHeight。这个progress值从0到1。当progress等于0时卡片保持原位当progress等于1时最上面的卡片已经完全移出屏幕第二张卡顶上来了。实际上我会让每一张卡都受到progress的影响但影响系数不同最上面的卡片position 0位移量 progress * fullDistance比如半屏高度还叠加一个rotation角度。第二张卡片position 1位移量 progress * secondCardOffset大概只有最上面卡片的1/3让它有一种“被带起来一点”的感觉。第三张、第四张卡片位移量很小或者完全没有只轻微缩放增强层次感。2.2 不是所有卡片都需要参与布局上面这个公式意味着每个item的实际绘制位置不是固定的它和progress强相关。所以LayoutManager每次layout时不能像LinearLayoutManager那样直接按position计算完位置就完事了。我需要在onLayoutChildren里对可见item做一遍遍历根据它们各自的基础位置和当前progress算出实时位置再调用layoutDecoratedWithMargins。这里有一个很关键的设计并不是每一帧我们都要对所有item调用layout。RecyclerView的机制是只有在你主动requestLayout的时候才会触发onLayoutChildren。我如果只在手势滑动的过程中不断地requestLayout那绘制频率跟手指移动频率绑定加上View的measure和layout也不轻性能会有点压力。所以我的优化策略是滑动过程中只更新滑动偏移值通过scrollBy然后用getChildAt去拿到当前可见的item直接调用itemView.setTranslationX/setTranslationY/setRotation而不是每次走onLayoutChildren重新布局。onLayoutChildren只负责在item插入、删除、数据集变化时做一次整体布局。这样把“布局”和“滑动中的位移动效”拆开性能会好很多。2.3 滑动范围的计算方式LayoutManager有两个方法一个是scrollVerticallyBy一个是可以向上/向下滚动的范围。如果我不重写scrollVerticallyByRecyclerView的纵向滑动就无法工作。堆叠效果的滑动范围和普通列表不一样。普通列表是“所有item总高度减去可视高度”堆叠效果是“可滑出卡片数量乘以单张卡片需要滑出的距离”。假设总共有8张卡片最多同时展示4张那理论上最多有5张卡片可以被“滑出去”。每张卡片滑出去需要的距离我用dismissDistance表示比如300px。那最大滑动范围就是 5 * dismissDistance。这个值在scrollVerticallyBy里需要返回给RecyclerView告诉它最多还能滑多远。我可以重写computeScrollRange、computeScrollOffset等方法来配合但很多时候只需要在scrollVerticallyBy里对delta做一下钳制就够了。2.4 关于旋转角度的设计为了让堆叠效果更自然我在每张卡上叠加了一个小的rotation角度。底层卡片默认有个0.5度到1度的轻微偏转像一摞纸牌随意摊开的样子。最上面那张卡在滑动时rotation会从0度逐渐过渡到4度左右模拟“被手指带起来飞出去”的感觉。这个角度不建议设太大我试过5度以上视觉上会显得很“晃”用户会觉得卡面不是平整地飞出而是翻滚反而不真实。3. 实操过程PileLayoutManager的核心方法逐个写3.1 onLayoutChildren初识布局先把可见卡片摆上去这是自定义LayoutManager绕不开的入口。onLayoutChildren在RecyclerView需要布局子View时被调用。我的基本思路是先调用detachAndScrapAttachedViews把所有子View临时分离然后根据当前数据源size和当前滑动位置计算出需要布局哪些position的卡片最后逐个attachView并调用layoutDecoratedWithMargins确定每个View的位置。伪代码如下Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { if (getItemCount() 0) { detachAndScrapAttachedViews(recycler); return; } detachAndScrapAttachedViews(recycler); int startPosition getStartPosition(); // 根据当前已移除的卡片数计算 int visibleCount Math.min(getItemCount() - startPosition, MAX_VISIBLE_COUNT); for (int i 0; i visibleCount; i) { int position startPosition i; View view recycler.getViewForPosition(position); addView(view); measureChildWithMargins(view, 0, 0); int width getDecoratedMeasuredWidth(view); int height getDecoratedMeasuredHeight(view); int left (getWidth() - width) / 2; int top (getHeight() - height) / 2 i * pileOffset; layoutDecoratedWithMargins(view, left, top, left width, top height); } }这里最关键的是startPosition的概念。普通的LayoutManager数据源第一条就是position 0永远不动。但堆叠效果中卡片会被移除一旦一张卡被滑出整叠卡片要整体前进一格下一张卡片要变成新的顶卡。所以我用了一个成员变量mCurrentTopIndex来记录当前堆叠最上方卡片对应的数据position。每次有卡片被滑出mCurrentTopIndex加1然后调用notifyItemRemoved或者直接改变内部状态并触发重新布局。这里要注意检查一下getItemCount()时如果mCurrentTopIndex已经大于等于总数说明没有可显示的卡片了onLayoutChildren里直接返回空布局别让getViewForPosition越界。3.2 scrollVerticallyBy处理滑动也要处理钳制scrollVerticallyBy是纵向滑动的入口LayoutManager在这里要决定消费多少滑动距离并对可见子View进行位移。Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { int travelY dy; if (getChildCount() 0 || getItemCount() 0) { return 0; } // 向上滑dy0时不能超过最大可滑出张数 if (dy 0 mCurrentTopIndex 0) { travelY 0; } // 向下滑dy0时如果已经滑出了所有可滑出的卡片也不能再滑 if (dy 0 mCurrentTopIndex getItemCount() - 1) { travelY 0; } for (int i 0; i getChildCount(); i) { View child getChildAt(i); // 根据当前滑动距离计算子View的translationY float progress getProgressForChild(child); child.setTranslationY(progress * child.getHeight()); child.setAlpha(1 - progress * 0.8f); child.setRotation(progress * maxRotation); } return travelY; }上面是粗略版本。实际写的时候我维护了一个mTotalScrollY变量用来记住总的滑动距离。progress的计算是基于“从本次滑动开始到现在最顶端卡片已经滑出的比例”。比如最上面那张卡progress mTotalScrollY / dismissDistance。progress0时位置正常progress1时刚好滑出屏幕。对于第二张卡它的progress不是直接用mTotalScrollY而是用 (mTotalScrollY - baseOffset) / dismissDistance其中baseOffset是它和顶卡之间的基础间距。也就是说第二张卡要等顶卡先滑出一段后自己再开始移动形成“跟手但慢半拍”的效果。3.3 手势结束后的状态判定弹回还是滑出滑动结束时我重写onStopDragging或者用RecyclerView的OnFlingListener配合处理。更稳的方案是重写RecyclerView的onTouchEvent或者通过GestureDetector处理但为了简单我是直接监听RecyclerView的ScrollStateChangeListener在滑动状态变为SCROLL_STATE_IDLE时判断当前mTotalScrollY和阈值。如果mTotalScrollY小于dismissDistance * 0.33说明没滑到阈值所有卡片应该弹回原位。做法是用ValueAnimator把mTotalScrollY从当前值动画到0在动画过程中通知卡片的translationY逐步变化。如果超过了阈值就认为卡片要被移除。做法是用ValueAnimator把mTotalScrollY从当前值动画到dismissDistance动画结束后执行以下逻辑mCurrentTopIndex加1。把mTotalScrollY重置为0。触发onLayoutChildren重新布局。同时通知Adapter移除对应数据或者不对Adapter操作仅仅由布局层的状态迁移来控制显示。这里有一个细节如果只改mCurrentTopIndex但Adapter的数据没有变getItemCount()不会变从RecyclerView的角度看数据没变动所以不会自动触发重新布局。我需要显式调用requestLayout()或者通过notifyItemRangeChanged等方法来驱动重建。我实际更喜欢用一个自己定义的接口回调把“卡片被移除”的事件抛出去由外部决定到底同步改数据还是仅控制UI展示。3.4 回收复用LayoutManager也要讲究View复用自定义LayoutManager最大的好处就是天然接入了Recycler机制。在onLayoutChildren里使用recycler.getViewForPosition(position)拿View使用addView把View加到RecyclerView的层级中。当某张卡片不可见时调用removeAndRecycleView(child, recycler)把它回收。不过要注意时机。堆叠效果中卡片滑出后我并不会立刻回收那张View因为动画还在播放。如果立刻回收下一帧动画就找不到这个View了。我的做法是等滑动动画完全结束、状态确定后再执行回收操作。具体来说当最上面的卡片通过动画滑出屏幕后这个View已经不在可见区域内了我可以在动画的onAnimationEnd回调里拿到这个View并调用removeAndRecycleView。另外还要处理一种边界情况当数据源很长但可见卡片只有4张时RecyclerView不会去创建整个数据源的View它只会创建可见的4张这天然省内存。这也是我选择使用LayoutManager而不是FrameLayout方案的最重要原因。4. 让堆叠效果更有质感层级、动画与数据同步4.1 卡片层级管理为什么我用elevation而不是addView顺序卡片是堆叠的所以后添加的View默认在层级上层。但如果我每次在onLayoutChildren里按position从小到大addView那position为0的最上面那张卡其实是第一个添加的它会被后面添加的底下卡片盖住这就是个bug。解决层级问题有两种方案第一种是控制addView的顺序。比如反过来遍历从底下的卡片开始add这样后添加的顶卡会覆盖住底卡。这个方案简单有效也是很多demo的做法。第二种是用ViewCompat.setZ(view, zValue)或者view.setElevation()。我推荐用这个因为堆叠效果中卡片还会旋转和位移如果只靠addView顺序控制层级一旦动画过程中某个View的z值需要临时变化就得重新排序很麻烦。用elevation之后层级完全由数值控制上面卡片的elevation大底下卡片elevation小滑动时动态改一下即可。我实际用的组合方案是还是按从底到顶的顺序addView同时给每个item设置elevation position * 2dp。这样底部卡片在底层顶部卡片在上层而且就算某一帧顶部卡片挪开了它的elevation依然大于底下卡片不会被盖住。4.2 缩放和透明度让堆叠感更真实只靠Y轴偏移堆叠效果会显得比较“愣”像三张纸片硬叠在一起。我加上缩放变化之后整个视觉层次立刻变得自然了。具体做法是从最上面的卡片往下每往下一层scaleX/scaleY缩小0.04到0.05。也就是说第二张卡是0.96第三张是0.92第四张是0.88。这样从视觉上卡片会形成一种“向屏幕深处延伸”的透视感。透明度处理则是最上面卡片alpha为1底部卡片alpha可以降到0.85左右但不能太低。我一开始设过0.5发现底下的卡片几乎看不清了反而失去了“还有这一叠”的视觉提示。在滑动过程中随着顶卡progress增大底下卡片的scale可以从0.96逐渐变到1.0也就是手一滑底下的卡像被唤醒一样“浮上来”。这个细节是整个效果的点睛之笔。4.3 数据同步什么时候真正移除数据卡片堆叠效果有两种数据交互模式这个我实现之后才搞清楚该做哪种一种是纯UI展示模式。比如首页的热门内容卡片数据源不动用户滑一张UI上卡片前进一格但列表数据还是那几条。这种模式比较简单mCurrentTopIndex变动后无需改动Adapter的数据集。缺点是如果往下滑回去会出现同一张卡被重复展示的情况。另一种是数据联动模式。比如卡片确实是当前列表的数据项用户把卡片滑走代表“跳过这个内容”滑到最后一条就没有卡片了。这时候我不仅需要mCurrentTopIndex加1还要从Adapter的数据集中移除对应数据并调用notifyItemRemoved。我在这两种模式之间选择了一种折中方案数据层不动但每次卡片滑动移除后我会把移除的那条数据放到mRemovedList里如果用户向下滑动再把它们补回来。这样既保持了数据完整性又不会出现重复展示的诡异现象。5. 常见问题与排查技巧实录5.1 卡片位置错乱尤其是滑动后第一张卡和第二张卡重叠这个bug我在初版实现里遇到最多。原因基本集中在onLayoutChildren的布局逻辑上mCurrentTopIndex已经加了但旧View没有清干净导致新旧卡片混在一起。排查思路很简单在onLayoutChildren开头打日志打印当前getChildCount()、mCurrentTopIndex、state.getItemCount()看看是不是有多余的旧View还挂着。处理办法是在onLayoutChildren开头强制detachAndScrapAttachedViews让所有子View先回到scrap池再全部重建。注意不要用removeAllViewsInLayout因为那样会丢失ViewHolder的回收状态。5.2 滑动动画结束后View被回收导致画面闪一下这个坑的根源是RecyclerView的回收机制比我预期得更积极。当我把某个View的translationY设置得很大它虽然还在RecyclerView的childList里但RecyclerView可能认为它不可见了主动调用removeView。解决这个问题我重写了isLayoutPartiallyInvisible或者说是通过复写getExtraLayoutSpace来控制。更直接的办法是在滑动过程中不依赖RecyclerView原始可见性判断而是在onLayoutChildren里每次都为可能出现的卡片多申请一个position利用addDisappearingView或者保留多余的子View。我最终是添加了一个mExtraVisibleCount的变量让布局时比正常可见数量多准备1到2个View这样动画过程中的位置变化不会触发回收。5.3 和NestedScrollView滑动冲突堆叠卡片如果放在一个可以上下滚动的大页面里手势冲突是必然的。RecyclerView的scrollVerticallyBy会在大容器里被嵌住。这个问题的标准解法是卡片区域的外部不要使用NestedScrollView改用CoordinatorLayout AppBarLayout或者直接使用普通的LinearLayout并加上嵌套滑动处理。如果只能用NestedScrollView那么就要在RecyclerView的onInterceptTouchEvent里做判断当当前没有可滑出的卡片时把事件交给外层处理。我的做法比较省事在scrollVerticallyBy的钳制逻辑里如果dy 0且mCurrentTopIndex 0直接返回0让事件向上冒泡。RecyclerView返回0时NestedScrollView会接管这个滑动事件页面就能正常往下滚了。5.4 数据集变化后索引错位如果在Adapter数据更新时没同步mCurrentTopIndex很容易出现position越界。比如数据原本有8条用户划掉了3条此时mCurrentTopIndex3如果外部通过notifyDataSetChanged刷新数据新数据可能有10条这个没问题但如果是notifyItemRemoved触发的刷新position的映射就会错位。我在数据刷新接口里做了一个保护逻辑每次绑定新的数据源时如果mCurrentTopIndex大于新数据源size就重置为0并让mTotalScrollY归零。同时对外暴露了一个resetPile()方法方便外部在切换页面、切换用户时调用。5.5 性能不够滑动时掉帧堆叠效果正常来说不会太卡但如果每张卡都带有圆角、阴影、毛玻璃背景就很容易掉帧。我的经验是圆角不要用clipToOutline去做尽量在图片加载层面直接输出圆角图。阴影不要用elevation硬堆尤其叠加层数多时会触发多次的paint。换成预先画好的阴影背景图。卡片上的内容如果包含大量文字滑动时setTranslationY不会触发重绘但如果setAlpha会。所以透明度变化要谨慎尽量只对最上层卡做alpha变化。开启RecyclerView的setHasFixedSize(true)避免数据变化时重新测量。虽然不是所有场景都适用但在数据长度固定、卡片尺寸固定的场景里收益明显。6. 我个人在实际操作中的体会做完这个自定义LayoutManager我最大的感受就是LayoutManager并没有想象中那么神秘。它的核心就三件事管好布局位置、管好滑动偏移、管好View回收。难点不在API本身而在于你要用“不死板的列表思维”去覆盖原有的列表逻辑。如果你只是想在项目里临时堆一个卡片效果网上有很多现成的第三方库可以直接用改改参数就能出效果。但如果你想要的效果需要深度定制——比如卡片可以左右滑出、可以多级嵌套、可以和多层页面联动——那自己写LayoutManager一定是长期成本最低的方案。另外提醒一句LayoutManager的代码里最容易藏bug的就是状态同步。滑动距离、当前位置、数据索引这三者必须保持单一数据源千万别搞出好几个变量互相赋值后面维护起来绝对酸爽。我现在的实现里状态统一由mCurrentTopIndex mTotalScrollY两个变量驱动所有的动画、布局、回收都从这两个值推导再也没出过玄学问题。
返回列表