
RecyclerView做3D画廊这事我最早是在一个音乐 App 里看到的。当时那个专辑封面切换效果卡片带透视、带旋转、还有层次感我盯了好久没想明白是怎么做的。后来自己动手研究才发现核心其实并不复杂一个自定义 LayoutManager加一个 PageTransformer再配合 Android 的 3D 坐标系和 Canvas 层级关系就能搭出一个相当唬人的效果。这篇文章就把我踩过的坑、验证过的思路、以及最后沉淀出来的完整实现方案给你掰碎讲清楚。不管你是刚接触 RecyclerView 的进阶选手还是已经在项目里遇到嵌套点击事件偶尔没反应这种诡异问题的老手这篇都值得花十分钟看完。1. 动效之前先弄清 Android 的 3D 坐标系做 3D 画廊这种效果第一个绕不开的东西就是 Android 的 3D 坐标系。很多人上来就写rotationY 45f结果发现旋转出来的效果跟想象完全不一样其实就是没搞懂坐标系的方向和旋转中心。1.1 坐标系三轴方向别再死记硬背上手画一下Android 的 3D 坐标系原点在 View 的左上角x 轴向右y 轴向下z 轴指向屏幕外也就是朝着你的方向。这个跟你在数学课上学的右手坐标系不一样y 轴是反的。x 轴水平方向往右是正y 轴垂直方向往下是正z 轴垂直屏幕向外正值意味着离你更近这里最容易搞混的是 z 轴。很多人一开始以为 z 轴正方向是朝屏幕里面结果在做旋转的时候就发现方向全反了。另外整个 3D 变换的摄像机位置也是关键。Android 里View的旋转、缩放、位移都发生在Canvas被绘制之前系统通过一个 Camera不是硬件相机而是android.graphics.Camera这个类来执行 3D 变换。这个 Camera 默认位置在屏幕中心偏上一点的位置你可以用setLocation()调整。1.2 rotationX、rotationY、rotationZ 各管什么先看一段最基础的代码view.setRotationX(45f); // 绕x轴旋转效果是上下翻转 view.setRotationY(45f); // 绕y轴旋转效果是左右翻转左右翘起 view.setRotationZ(45f); // 绕z轴旋转效果是平面内的旋转rotationX绕 x 轴转你会看到 View 的上边远离你、下边靠近你形成一种低头的效果。rotationY绕 y 轴转左边远离你、右边靠近你形成一种侧身的效果。3D 画廊里用的最多的是这个。rotationZ绕 z 轴转相当于你把卡片平放在桌上转它只是一个平面旋转。做 3D 画廊效果时核心是rotationY。当你给卡片设置rotationY -30f它的左边会翘起来、右边压下去。如果多个卡片排成一排每个卡片的 rotationY 不同整体就形成一个弧面这就是画廊效果的基础视觉逻辑。1.3 pivotX 和 pivotY 决定绕哪个点转默认情况下View 的旋转中心是它自己的中心点也就是pivotX 宽度/2pivotY 高度/2。但在嵌套布局里这个默认值经常出问题。比如你给 ItemView 设置旋转结果发现它旋转时绕着父布局的中心转那十有八九是 pivot 被改变了。做 3D 画廊时我建议显式设置 pivotview.setPivotX(view.getWidth() / 2f); view.setPivotY(view.getHeight() / 2f);注意getWidth()在onMeasure完成之前拿不到正确的值。如果你在PageTransformer里拿不到可以post到下一帧再设置或者直接用固定的设计稿宽度。1.4 Camera distance决定 3D 效果的纵深强度这个东西很多人不知道。你设置rotationY后如果觉得效果太平或者翻折感太强本质上是 Camera 距离的问题。// 获取 View 的 Camera调整距离 // 注意官方 View 没有直接暴露这个方法需要反射或通过 Matrix实际上大部分情况下你不需要直接操作 Camera 距离因为rotationY、translationX、scaleX这些属性已经能在绝大多数设备上产生不错的 3D 效果。Camera 的默认距离是8 * density在部分高分辨率设备上默认距离会显得太近导致 3D 效果特别夸张。这个后面调优时会提到。2. 方案选型为什么我选了 PageTransformer 自定义 LayoutManager实现 3D 画廊网上有几种主流方案。我把它们的原理和优缺点对比一下你就知道为什么最终我选了组合拳。2.1 方案对比三种主流实现方式方案原理优点缺点ViewPager2 PageTransformerViewPager2 原生支持 Transformer直接对 page 做属性动画代码量少内置 recycle 机制必须用 ViewPager2多 Item 横向布局不灵活自定义 LayoutManager复写onLayoutChildren对每个 Item 设置旋转、偏移自由度高完全掌控布局和动效需要处理回收复用、缓存逻辑复杂度高RecyclerView PagerSnapHelper PageTransformer用 PagerSnapHelper 实现翻页吸附再配合 ItemAnimator 或 View 属性实现 3D保留了 RecyclerView 的灵活性和复用优势需要手动处理 Item 位置与动效的映射关系我最终的方案用的是第三种RecyclerView PagerSnapHelper 自定义 PageTransformer 风格的 Item 动效。为什么不直接 ViewPager2因为项目里这个控件要嵌套在另一个 RecyclerView 的 Item 里ViewPager2 在这种嵌套场景下对触摸事件的抢占太霸道老出现竖滑冲突问题。RecyclerView 反而更温和你能精准控制事件分发。2.2 PagerSnapHelper 解决吸附问题3D 画廊最核心的体验是滑动后自动吸附到最近的卡片而且滑动过程中要有跟随手指的位移效果。如果我直接用SnapHelper吸附是有效果的但中间过程没有动画很生硬。所以我的方案是PagerSnapHelper snapHelper new PagerSnapHelper(); snapHelper.attachToRecyclerView(recyclerView);PagerSnapHelper的作用是当你松手时它通过calculateDistanceToFinalSnap()计算出当前需要修正的距离然后调用smoothScrollBy()把列表平滑地吸到最近的 Item 上。这个吸附的动画是系统帮你做好的比较流畅。2.3 为什么不用 ItemAnimator 做动效很多人会想到用ItemAnimator来做切换动画。但 ItemAnimator 的触发时机是数据变化插入、删除、移动而不是滚动偏移。你手指滑动时动效需要跟随滚动实时变化ItemAnimator 做不到这一点。所以真正的核心流程是在 RecyclerView 滚动时根据每个 Item 与 RecyclerView 中心点的距离实时计算它的 rotationY、scaleX、scaleY、translationX。这相当于在OnScrollListener或addOnScrollListener里做一轮计算。3. 完整实操从零写出 3D 画廊 RecyclerView现在进入核心实操。我按先搭骨架、再加动效、最后调细节的顺序来。3.1 第一步基础 RecyclerView 搭建这一步没什么特别但有几个关键配置项一定要做对。RecyclerView galleryView findViewById(R.id.gallery_view); galleryView.setLayoutManager(new LinearLayoutManager( this, LinearLayoutManager.HORIZONTAL, false )); galleryView.setAdapter(new GalleryAdapter(dataList));三个必要条件LinearLayoutManager.HORIZONTAL横向滑动因为画廊都是左右滑的Item 的宽度不能是match_parent要留出左右空间给旁边的卡片露出来。我用的是wrap_content加固定宽度比如250dp父布局要设置clipChildrenfalse否则旁边的卡片会被裁切掉父布局的关键代码FrameLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:clipChildrenfalse android:clipToPaddingfalse androidx.recyclerview.widget.RecyclerView android:idid/gallery_view android:layout_widthmatch_parent android:layout_heightwrap_content android:paddingLeft60dp android:paddingRight60dp android:clipToPaddingfalse/ /FrameLayoutclipToPadding的作用是让 Item 能滑进 padding 区域。否则加了左右 padding 后Item 滑到边界时会直接被裁剪旁边的卡片就露不出来了。注意clipChildren要设置在 RecyclerView 的所有父布局上。如果你的 RecyclerView 外面套了多层布局每一层都要设置android:clipChildrenfalse否则某一层会在绘制时把溢出部分裁掉。3.2 第二步滚动监听实时计算 Item 位置这一步是 3D 画廊的灵魂。核心思路监听 RecyclerView 的滚动获取每个 Item 的中心点坐标计算它与 RecyclerView 中心点的距离再根据距离映射到旋转角度、缩放比例、位移量。galleryView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); updateGalleryTransform(); } }); private void updateGalleryTransform() { LinearLayoutManager manager (LinearLayoutManager) galleryView.getLayoutManager(); if (manager null) return; int firstVisible manager.findFirstVisibleItemPosition(); int lastVisible manager.findLastVisibleItemPosition(); int centerPoint galleryView.getWidth() / 2; for (int i firstVisible; i lastVisible; i) { View itemView manager.findViewByPosition(i); if (itemView null) continue; int itemCenter (itemView.getLeft() itemView.getRight()) / 2; int offset itemCenter - centerPoint; // 关键根据偏移量计算动效参数 float percent offset / (float) galleryView.getWidth(); applyTransform(itemView, percent, offset); } }这里计算出的offset就是Item 中心点和RecyclerView 中心点的距离差。最中心的卡片 offset 0两边的卡片 offset 为正或负。3.3 第三步核心动效矩阵——旋转、缩放、位移怎么配先直接上一个我最终调好的参数版本然后再解释每个参数的来历。private void applyTransform(View itemView, float percent, int offset) { // 1. 旋转角度最大 60 度离中心越远转得越狠 float rotationY percent * -60f; // 2. 缩放中心卡片 1.0边缘卡片最大缩到 0.8 float scale 1f - Math.min(Math.abs(percent) * 0.5f, 0.2f); // 3. 水平位移让旁边卡片更靠近中间形成半包围感 float translationX Math.abs(percent) * 80f * Math.signum(percent); // 4. 透明度越边缘越淡 float alpha 1f - Math.min(Math.abs(percent) * 0.4f, 0.5f); // 5. 纵深位移加一点点 Z 轴上的距离感 float translationZ -Math.abs(percent) * 200f; itemView.setRotationY(rotationY); itemView.setScaleX(scale); itemView.setScaleY(scale); itemView.setTranslationX(translationX); itemView.setAlpha(alpha); itemView.setTranslationZ(translationZ); // 6. 层级控制中心卡片浮在最上层 itemView.bringToFront(); }等等为什么translationX是往正方向而不是往中间靠拢因为要营造两侧卡片向中心聚拢的感觉。假如没有translationX两边的卡片只是单纯旋转和缩放会出现中间卡片和旁边卡片间距过大的断层感。给两侧卡片加一个向内的位移视觉上它们就像是从两侧包抄过来一样。实际的translationX到底怎么计算需要微调。你可以先跑起来看着效果再调。3.4 第四步bringToFront 的坑层级比想象中更难搞bringToFront()在 RecyclerView 里使用要格外小心。你如果在一个 Item 上直接调用bringToFront()它会改变这个 Item 在 RecyclerView 子 View 列表中的顺序。但 Item 是复用的一旦这个 View 被回收顺序回到原位下次复用时它又变回底层了。所以正确做法是不要直接改子 View 顺序而是给 RecyclerView 设置setChildDrawingOrderCallback。galleryView.setChildDrawingOrderCallback(new RecyclerView.ChildDrawingOrderCallback() { Override public int onGetChildDrawingOrder(int childCount, int i) { // 找到最中心的 View把它放在最后绘制 View centerView null; int centerPoint galleryView.getWidth() / 2; int minDistance Integer.MAX_VALUE; for (int j 0; j childCount; j) { View child galleryView.getChildAt(j); int childCenter (child.getLeft() child.getRight()) / 2; int distance Math.abs(childCenter - centerPoint); if (distance minDistance) { minDistance distance; centerView child; } } if (centerView null) return i; int centerIndex galleryView.indexOfChild(centerView); if (i childCount - 1) { return centerIndex; } if (i centerIndex) { return childCount - 1; } return i; } });这段代码的逻辑是在绘制子 View 时把最靠近中心的那个 View 放到最后绘制。Android 绘制规则是越晚绘制越在上面这样中心卡片就自然浮在两侧卡片之上了。不过要注意bringToFront()在写ChildDrawingOrderCallback之后就没必要再调了否则顺序会被再次打乱。4. 高级动效扩展让画廊效果活起来基础版的 3D 画廊效果已经出来了滑动有 3D 翻转、有缩放、有透明度变化。但如果只是这些你会觉得差点意思。差在哪差在过度和细节。4.1 视差背景动效增强沉浸感3D 画廊最抓眼球的一点是背景会跟着滑动呼吸。这个效果做起来不难在外面套一层FrameLayout里面放一个背景 View比如渐变层或模糊图再在滑动时做一层反向位移。galleryView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); // 背景视差滚动快背景慢 float offset recyclerView.computeHorizontalScrollOffset(); backgroundView.setTranslationX(-offset * 0.3f); } });这个0.3f是视差系数。系数越小背景滚动越慢纵深感越强系数太大就没有视差感了跟前景一起动效果就没了。4.2 卡片内元素分层移动模拟 3D 空间感更高阶的技巧是让卡片内部的不同元素移动速度不同。比如卡片正中间有一张封面图左上角有标题右下角有标签做 3D 动效时给它们设置不同的translationX和scale。常见做法是在applyTransform()里把动效参数往下传给卡片内的子 Viewprivate void applyTransform(View itemView, float percent, int offset) { ImageView cover itemView.findViewById(R.id.cover); TextView title itemView.findViewById(R.id.title); // 封面图比卡片多旋转一点 cover.setRotationY(rotationY * 1.1f); // 标题跟着卡片移动但加一点横向漂移 title.setTranslationX(percent * 20f); title.setAlpha(alpha); }这样做的原理是人眼通过遮挡和位置偏移来判断物体之间的相对距离。当背景、卡片、卡片内元素三层运动速度不一致时大脑会脑补出很强的 3D 空间感。4.3 触摸反馈动效让卡片跟随手指很多 3D 画廊作品里卡片在按下去的时候会有一点点压扁再弹回来的效果。这个效果我用OnItemTouchListener实现galleryView.addOnItemTouchListener(new RecyclerView.SimpleOnItemTouchListener() { Override public boolean onInterceptTouchEvent(NonNull RecyclerView rv, NonNull MotionEvent e) { if (e.getAction() MotionEvent.ACTION_DOWN) { View child rv.findChildViewUnder(e.getX(), e.getY()); if (child ! null) { child.animate() .scaleX(0.95f) .scaleY(0.95f) .setDuration(100) .start(); } } if (e.getAction() MotionEvent.ACTION_UP || e.getAction() MotionEvent.ACTION_CANCEL) { View child rv.findChildViewUnder(e.getX(), e.getY()); if (child ! null) { child.animate() .scaleX(1f) .scaleY(1f) .setDuration(150) .start(); } } return false; } });这里有个细节按压缩放时scaleX和scaleY的动画会覆盖掉滚动时设置的缩放值。所以按下去弹起来之后那个 Item 的 scale 已经变成 1.0 了如果它不在中心位置数值就错了。解决办法是弹起来之后重新调一次applyTransform()。或者干脆不在按下时改 scale而是改elevation和translationZ用阴影变化代替缩放反馈省掉数值错乱的麻烦。4.4 惯性滚动与吸附策略PagerSnapHelper自带的吸附功能在 3D 画廊效果下有个小问题它吸附的动画时长和差值曲线是固定的有时候你觉得吸得太快或吸过去之后还弹了一下。要更精细地控制吸附动画可以重写PagerSnapHelperclass GallerySnapHelper extends PagerSnapHelper { Override public int[] calculateDistanceToFinalSnap( NonNull RecyclerView.LayoutManager layoutManager, NonNull View targetView) { int[] out super.calculateDistanceToFinalSnap(layoutManager, targetView); // 手动缩放吸附距离让吸附更粘一点 out[0] out[0] / 2; return out; } }注意calculateDistanceToFinalSnap返回的距离是最终需要滑动的距离。如果你直接除以 2SnapHelper 会认为你只需要滑动一半的距离就会停在一个并不是完全居中的位置。这个技巧是用于不完全吸附的场景比如画廊效果你想让卡片在滑动停止后错开一点而不是严格居中。如果只是想让吸附过程更平滑建议直接调用smoothScrollToPosition并用LinearSmoothScroller控制速度RecyclerView.SmoothScroller smoothScroller new LinearSmoothScroller(this) { Override protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { return 30f / displayMetrics.densityDpi; // 数值越大滚动越慢 } }; smoothScroller.setTargetPosition(targetPosition); layoutManager.startSmoothScroll(smoothScroller);calculateSpeedPerPixel是 LinearSmoothScroller 提供的速度控制方法表示每滚动一个像素需要多少毫秒。默认值是25f / densityDpi数值越大滚动越慢。如果你想有一种优雅缓慢地滑过去的感觉把 30f 改成 50f 或 80f。5. 常见的坑与细节调优嵌套点击事件失灵、阴影丢失等问题做 3D 画廊尤其是嵌在其他页面里的时候问题比想象中多。我把踩过的坑里最典型的几个列出来。5.1 嵌套 RecyclerView 点击事件有时无反应这是我被问得最多的问题。网上有个说法叫 brave recyclerview 嵌套点击事件有时候无反应。场景是外层是一个纵向 RecyclerView内层是横向的 3D 画廊 RecyclerView。点画廊里的卡片时经常点了没反应或者要等一会儿才弹出来。原因分析我排查了很久RecyclerView 的OnItemTouchListener与点击事件冲突PagerSnapHelper会拦截滑动事件如果松手时还在判断是否是点击可能导致ACTION_UP被吞掉子 RecyclerView 与外层 RecyclerView 的触摸事件竞争子视图在requestDisallowInterceptTouchEvent中处理不及时外层 RecyclerView 会把事件抢走TranslationZ影响触摸区域3D 动效里给 Item 设置了translationZ这个属性会影响HitTest点击测试的顺序和精度可能导致点击不命中解决办法分三步缺一不可第一步子 RecyclerView 明确请求不拦截galleryView.setOnTouchListener(new View.OnTouchListener() { Override public boolean onTouch(View v, MotionEvent event) { if (event.getAction() MotionEvent.ACTION_MOVE) { v.getParent().requestDisallowInterceptTouchEvent(true); } return false; } });第二步不要在OnItemTouchListener的ACTION_UP里做耗时操作或弹窗。如果要做点击后动画用postDelayed延迟到触摸事件完全结束后再处理。第三步检查translationZ与elevation导致的阴影层级问题。如果 Item 在动效中设置了translationZ但是你点击的其实是它旁边的卡片这时点击可能落到别的 View 上。建议在ACTION_DOWN时把目标 Item 的translationZ临时置 0。5.2 阴影丢失画上去的和真实阴影是两回事3D 画廊里经常给卡片加阴影增加立体感。但elevation阴影在rotationY旋转之后会丢失。这是因为 elevation 阴影是 View 系统在与屏幕平行的平面上绘制的阴影旋转后这个平面不再与屏幕平行阴影就不对了。解决办法是放弃elevation改用自定义 Drawable 绘制阴影// 阴影背景 GradientDrawable shadowDrawable new GradientDrawable(); shadowDrawable.setShape(GradientDrawable.RECTANGLE); shadowDrawable.setCornerRadius(16 * density); shadowDrawable.setColor(Color.parseColor(#22000000)); // 然后作为背景或遮罩画在一层我是用两层布局模拟阴影的底层是一张带圆角灰色渐变图上面是实际内容卡片。旋转的时候两层一起转因为二者没有 Z 轴位移差所以视觉上阴影会跟着卡片一起动不会像 elevation 那样丢失。5.3 大列表性能优化Conversion 回收策略3D 画廊动效里每个 Item 需要频繁设置rotationY、scaleX、scaleY、translationX、translationZ等属性。这些操作会触发频繁的重绘尤其是滑动的时候几十个 Item 同时开属性动画GPU 和 CPU 压力都不小。优化手段对 ItemView 的所有属性变化统一收敛在setTranslationZ和setRotationY上不要频繁改 pivot避免在onScrolled里做findViewById。使用 ViewHolder 时把需要动效的子 View 引用缓存到 ViewHolder 字段里在使用translationZ时注意这个属性会触发硬件层Hardware Layer的创建、销毁。频繁切换会导致性能波动。建议滚动时对 Item 设置LAYER_TYPE_HARDWARE滚动结束再切回LAYER_TYPE_NONEitemView.setLayerType(View.LAYER_TYPE_HARDWARE, null);注意硬件层对内存开销很大如果列表 Item 特别多超过 30 个建议只在当前可见的 3-5 个卡片上开启其他的保持默认。5.4 测量与卡片宽度比不要让画廊变成隧道卡片宽度直接决定画廊的观感。太宽两侧卡片被挤压看起来不自然太窄每个 Item 间距过大没有连续感。我的经验值卡片宽度屏幕宽度 * 0.7左右卡片高度卡片宽度 * 1.4左右竖版卡片Item 左右 margin-30dp到-50dp左右负 margin 让卡片视觉上更紧凑为什么用负 margin因为 CardView 或普通 Item 的根布局都会有默认间距或阴影区域负 margin 可以让卡片在处于两侧时略微重叠在一起营造出层次感。RecyclerView.LayoutParams params (RecyclerView.LayoutParams) itemView.getLayoutParams(); params.leftMargin (int) (-30 * density); params.rightMargin (int) (-30 * density); itemView.setLayoutParams(params);但这个操作有副作用负 margin 会让 Item 之间的触摸区域重叠。如果卡片上有按钮点击边缘时可能点到隔壁卡片的按钮。所以负 margin 只建议在纯展示场景用要可点击的话老老实实用正 margin 或 0 margin。5.5 在运行时动态调节 Camera让 3D 效果更柔和或更锐利前面提到View的 3D 变换默认使用系统 Camera。但很多时候 3D 效果做得相当夸张或者在低端设备上看起来会很生硬。这时可以通过 matrix 调整 Camera 的 z 距高。一个常见的做法是通过反射拿到 View 内部 Camera 并修改位置try { // 反射拿 View 的 Camera Class? viewClass View.class; java.lang.reflect.Field cameraField viewClass.getDeclaredField(mCamera); cameraField.setAccessible(true); android.graphics.Camera camera (android.graphics.Camera) cameraField.get(itemView); camera.setLocation(0, 0, -30 * density); // 调大负数距离更远 } catch (Exception ignored) { // 反射可能失败不影响功能 }但是这个方法在 Android 8.0 后部分 ROM 上会失效因为厂商可能会混淆类名或移除字段。所以这个技巧只作为锦上添花的调优方法不推荐作为唯一方案。更可靠的方案是使用矩阵代替 Camera 变换Matrix matrix new Matrix(); matrix.setRotate(rotationY, pivotX, pivotY); itemView.setScaleX(scale); itemView.setScaleY(scale); itemView.setMatrix(matrix);不过setMatrix在一些自定义 ViewGroup 里会覆盖掉 Canvas 上的其他变换容易踩坑。建议仅在单独测试环境里用线上还是用标准属性动效。6. 动效适配与硬件加速低端机怎么保流畅3D 画廊效果对设备性能要求不低。同样的代码在旗舰机上 120 帧丝滑在低端机上可能卡成 PPT。性能瓶颈主要在 GPU 绘制和 CPU 属性计算上。6.1 硬件加速的开关与限制RecyclerView 的长列表滚动本身是重度场景叠加 3D 变换后如果不开硬件加速Canvas 的软件渲染完全撑不住。在 AndroidManifest 的 application 节点下默认是开启硬件加速的但个别页面如果手动关了一定要打开application android:hardwareAcceleratedtrue ... /application即使开启了硬件加速我仍然建议在滚动监听里做一层降级保护。当frameTimeDelta大于某个阈值比如 30ms时自动关闭透明度动画只保留旋转和缩放。6.2 滚动过程中的 CPU 调度优化在OnScrollListener里计算时避免做任何对象分配。上面的代码里我创建了int[]数组这在滚动频繁时是可以通过复用对象来避免的。private final int[] tmpOut new int[2];另外findViewByPosition比findChildViewUnder更高效优先用前者。6.3 保持 TODO 最低帧率降低背景透明度的绘制成本背景层如果使用了模糊或半透明渐变绘制成本很高。一个很实用的技巧是将背景拆成两个图层——一个完全不透明的静态底图 一个半透明的动态模糊层。动态模糊层用setAlpha控制淡入淡出减少每帧的像素填充量。6.4 动画取消失控意外转场怎么办当 Tab 切换或页面销毁时PagerSnapHelper可能还在执行滚动这时你的动效 listener 还在处理 Item 的旋转。如果不处理视觉上会出现 Item 乱转的问题。我通常在页面不可见时Override public void onPageInvisible() { galleryView.stopScroll(); galleryView.clearOnScrollListeners(); }stopScroll能立即终止吸附动画clear 监听避免在不可见时做无意义计算。7. 一个完整的 3D 画廊 Demo 代码骨架最后把前面所有零散代码整合成一个可以直接跑起来的 Demo 骨架。这里的代码只保留了核心逻辑你拿到项目里把 layout 和 adapter 补全就能直接看到效果。public class GalleryItemDecoration extends RecyclerView.ItemDecoration { Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { int position parent.getChildAdapterPosition(view); if (position 0) { outRect.left 100; outRect.right 20; } else if (position parent.getAdapter().getItemCount() - 1) { outRect.left 20; outRect.right 100; } else { outRect.left 20; outRect.right 20; } } }public class GalleryActivity extends AppCompatActivity { private RecyclerView galleryView; private ListString dataList new ArrayList(); Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_gallery); for (int i 0; i 10; i) { dataList.add(Item i); } galleryView findViewById(R.id.gallery_view); LinearLayoutManager manager new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false); galleryView.setLayoutManager(manager); galleryView.setAdapter(new GalleryAdapter(dataList)); galleryView.addItemDecoration(new GalleryItemDecoration()); // 吸附 PagerSnapHelper snapHelper new PagerSnapHelper(); snapHelper.attachToRecyclerView(galleryView); // 绘制顺序中心卡片浮在上面 galleryView.setChildDrawingOrderCallback(new RecyclerView.ChildDrawingOrderCallback() { Override public int onGetChildDrawingOrder(int childCount, int i) { View centerView null; int centerPoint galleryView.getWidth() / 2; int minDistance Integer.MAX_VALUE; for (int j 0; j childCount; j) { View child galleryView.getChildAt(j); int childCenter (child.getLeft() child.getRight()) / 2; int distance Math.abs(childCenter - centerPoint); if (distance minDistance) { minDistance distance; centerView child; } } if (centerView null) return i; int centerIndex galleryView.indexOfChild(centerView); if (i childCount - 1) return centerIndex; if (i centerIndex) return childCount - 1; return i; } }); // 核心动效 galleryView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); updateGalleryTransform(); } }); } private void updateGalleryTransform() { LinearLayoutManager manager (LinearLayoutManager) galleryView.getLayoutManager(); if (manager null) return; int first manager.findFirstVisibleItemPosition(); int last manager.findLastVisibleItemPosition(); int centerPoint galleryView.getWidth() / 2; for (int i first; i last; i) { View itemView manager.findViewByPosition(i); if (itemView null) continue; int itemCenter (itemView.getLeft() itemView.getRight()) / 2; int offset itemCenter - centerPoint; float percent offset / (float) galleryView.getWidth(); applyTransform(itemView, percent, offset); } } private void applyTransform(View itemView, float percent, int offset) { float rotationY percent * -60f; float scale 1f - Math.min(Math.abs(percent) * 0.5f, 0.2f); float translationX Math.abs(percent) * 80f * Math.signum(percent); float alpha 1f - Math.min(Math.abs(percent) * 0.4f, 0.5f); float translationZ -Math.abs(percent) * 200f; itemView.setRotationY(rotationY); itemView.setScaleX(scale); itemView.setScaleY(scale); itemView.setTranslationX(translationX); itemView.setAlpha(alpha); itemView.setTranslationZ(translationZ); } }这段代码你直接复制进项目改一改 Adapter 和布局就能看到一个最基础的 3D 画廊效果。之后再根据自己的设计稿去调 rotationY 的最大角度、缩放比例、透明度衰减系数这些参数。关于调参我的个人经验是一次只调一个参数把每个参数对视觉效果的影响吃透再动下一个。比如你先固定 rotationY 为 ±60 度把缩放从 1.0 慢慢降到 0.7看看什么时候觉得层次感合适然后再调 rotationY 的角度感受它对立体感的影响。不要一上来就六个参数一起乱调否则到后面你根本不知道哪个参数负责哪个效果改坏了也找不回之前的状态。3D 画廊这个效果本质上就是坐标系理解 数学映射 动效手感这三件事。坐标系理解清楚了你在 Item 上想怎么转都行数学映射调顺了滑动手感就自然了动效手感这东西只能靠一遍遍真机调试喂出来。希望这篇文章能帮你少走点弯路。