Unity UGUI 卡片流缩放滚动与居中吸附实现
做商店界面或者抽卡选人界面的时候,几乎一定会碰到这个需求:一条横向滚动的卡片列表,中间那张最大最醒目,越往两边越小,手一松还会自动吸附到居中位置。在 Unity 里拿 UGUI 做这件事,很多人的第一反应是去翻 ScrollRect 的 Inspector,翻完发现根本没有和 scale 有关的选项,然后就卡在那儿了。这正是带缩放效果的滚动视图最典型的问题:ScrollRect 只负责让内容在视口里位移,并且附带惯性与回弹,它完全不关心内容长什么样。缩放属于内容自己的视觉表现,得由你额外写逻辑去驱动。所以这项工作本质上是一套“位移加视觉联动”的组合方案,工程量不大,但有几个参数和层级上的坑,踩过一遍就再也不会忘。下面这些内容适合已经能用 UGUI 搭出普通滚动列表、想再往前走一步的开发者,也适合第一次做卡片流界面、想找一份能直接抄作业的方案的人。整套逻辑的核心只有一句话:根据每个卡片到视口中心的距离,反推它的缩放比例。把这句话实现清楚,剩下的都是装饰。
1. 先把需求拆开:带缩放的滚动视图究竟在做什么
1.1 三种常见形态与实际使用场景
同样是“滚动加缩放”,不同界面的表现方式差别其实挺大,动手之前先分清楚自己要哪一种,能省掉大量返工。
第一种是中心放大式卡片流,也是最常见的一种。所有卡片尺寸一致、间距一致,排成一条横线,唯一的变量就是缩放。离屏幕中心越近的卡片越大,越远越小,滑到哪儿,哪张卡片就恢复到原始比例。抽卡结果展示、英雄选择、皮肤预览、关卡选择基本都长这样。它的实现最简单,只需要处理 scale 一个通道,视觉上已经足够有层次。
第二种是层叠式(Cover Flow),卡片不仅有缩放,还有 Y 轴旋转、位置偏移,视觉上像老式唱机里翻唱片。这种形态多用在音乐播放器、数字藏品展示、产品图册里。它比第一种多了两个通道:一是绕 Y 轴的旋转角度,二是竖直方向的位移,通常还要配合层叠排序,让中间的卡片盖住两边的卡片。
第三种是列表项轻微放大,常见于设置页、商城列表、任务列表。滚动过程中,靠近视口中心的条目会放大约 5% 到 10%,同时透明度略微提高,滑出视口时再缩回去。这种做法的目的是引导视线,而不是做视觉主体,所以缩放幅度必须克制,超过 15% 就会显得很廉价,文字还会因为缩放产生亚像素模糊。
我在实际项目里见过一种比较尴尬的混合形态:开发者想让卡片有缩放效果,但又希望它保持左右完全对齐,于是给每张卡片都套了单独的 Layout Group,结果一滚动整排卡片开始跳舞。问题的根源在于,缩放和布局是两套不同的机制,混用之前必须想清楚谁说了算。
1.2 ScrollRect 默认行为中缺了什么,需要补哪些内容
把 ScrollRect 拆开来看,它其实只做三件事:监听拖拽事件、修改 Content 的 anchoredPosition、在松手后用惯性模拟继续修改 anchoredPosition 直到速度衰减到零。除此之外,它对 Content 的子物体一无所知。
所以要做缩放效果,缺的部分非常明确:
一是位置采样。至少要知道每个卡片当前离视口中心有多远,这个距离决定了它的缩放系数。
二是缩放写入。根据上一步算出的距离,把缩放值应用到卡片的 localScale 上,并且每一帧滚动都要更新。
三是吸附收敛。用户松手后,内容会停在任意位置,你需要判断“离哪个卡片最近”,然后把 Content 平滑地推到让那张卡片居中的位置。
四是遮挡排序(只有做层叠效果时才需要)。放大的卡片会盖住相邻卡片,需要调整渲染顺序。
前三点是所有缩放滚动视图的公共基础,第四点看具体形态。把这四件事拆开写,代码结构会非常清晰,也方便后续单独调参。
这里有个观念上的提醒:不要试图去改 ScrollRect 的源码来“原生支持缩放”。UGUI 的 ScrollRect 已经足够稳定,魔改它的内部逻辑会让后续升级和多人协作变得非常痛苦。正确做法是在它外面加一层视觉驱动脚本,把 ScrollRect 当成一个只会报告“内容偏移了多少”的数据源。
2. 场景搭建:把层级和参数一次性配对
2.1 从零搭出一个可用的层级骨架
层级结构搭错,后面写再多代码都是白费。标准骨架一共四层,从上往下依次是:
Canvas └── Scroll View (RectTransform + Image + ScrollRect) └── Viewport (RectTransform + RectMask2D) └── Content (RectTransform + HorizontalLayoutGroup + ContentSizeFitter) ├── Item 0 (RectTransform + Image + CanvasGroup) ├── Item 1 └── Item 2 ...几个必须锁死的细节。第一,Viewport 必须是 Scroll View 的直接子物体,并且 ScrollRect 组件上的 Viewport 字段要手动拖进去,不能留空。留空的话 ScrollRect 会去自己找,找到的不一定是对的,而且视口矩形算错之后,拖拽的灵敏度和边界判断都会跟着错。
第二,Viewport 上挂RectMask2D,不要用Mask。Mask 走的是模板缓冲,会额外增加两个 Draw Call,而且对移动端的 GPU 不友好;RectMask2D 是纯矩形裁剪,性能开销几乎为零。这一条在卡片数量多的时候差别非常明显。
第三,Content 的pivot和anchorMin/anchorMax推荐统一设成(0, 0.5),也就是左边缘中点。这个设置决定了后面吸附公式里 anchoredPosition 的取值区间,选定了就别改,改了公式要跟着改。
第四,每个 Item 上挂一个CanvasGroup。后面做边缘透明度淡出的时候会用到,即使暂时不做,先挂上也没坏处,省得以后加特效再回来改预制体。
需要注意的一个细节:Item 的 pivot 一定要设成
(0.5, 0.5)。如果 pivot 是(0, 0.5),缩放的时候卡片会以左边缘为基准向外撑开,视觉上会有一个“往右跑”的位移感,看起来像在抖。居中 pivot 才能保证缩放围绕自身中心进行。
2.2 ScrollRect 的关键参数逐项说明
ScrollRect 的参数面板不长,但每一个都影响最终手感,我按重要性排一下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Content | 拖入 Content | 必填,否则组件不工作 |
| Horizontal | 勾选 | 横向卡片流必备 |
| Vertical | 取消勾选 | 不取消会导致斜向滑动,手感很怪 |
| Movement Type | Elastic | 越界时会有回弹,配合吸附更自然 |
| Elasticity | 0.1 左右 | 太大像橡皮筋,太小越界后回弹迟滞 |
| Inertia | 勾选 | 惯性,必须有,否则松手即停很生硬 |
| Deceleration Rate | 0.135 默认值起调 | 数值越小滑得越远,0.05 会滑出很远 |
| Scroll Sensitivity | 25 到 40 | 鼠标滚轮灵敏度,触屏影响不大 |
Deceleration Rate是手感调优里最值得花时间的参数。它决定了用户快速一划之后内容还能滑多远。做卡片流的时候,因为还需要吸附,我一般会把它调得比默认值稍大一点(比如 0.2),让惯性更快衰减,吸附介入得更早,整体节奏更干脆。如果做的是长列表浏览,反而要调小,让用户一次能滑过更多内容。
Movement Type选 Elastic 还是 Unrestricted 也是有讲究的。Unrestricted 允许内容无限滑出去,回来的时候得靠吸附逻辑硬拉,视觉上会很突兀。Elastic 自带边界回弹,和吸附逻辑配合时不会打架,因为吸附的目标位置永远在合法区间内。
2.3 Content 的布局组配置与首尾留白
Content 上挂HorizontalLayoutGroup和ContentSizeFitter,这是让卡片自动排列、Content 宽度自动撑开的标准组合。几个容易配错的开关:
Child Control Width取消勾选,Child Force Expand Width也取消勾选。这两个一旦勾上,Layout Group 会强行改写子物体的宽度,卡片尺寸就不受你控制了。Child Control Height和Child Force Expand Height同理取消。Spacing设成卡片间距,比如 20。Child Alignment设成Middle Left。ContentSizeFitter的Horizontal Fit设成Preferred Size,Vertical Fit设成Unconstrained。
然后是最关键、也是最多人漏掉的一步:给 Layout Group 的 Padding 左右各留出空白。留多少?公式很简单:
padding.left = padding.right = (viewportWidth - itemWidth) / 2为什么必须留?因为 ScrollRect 的滚动范围是由 Content 宽度减去 Viewport 宽度决定的。如果 Content 宽度正好等于所有卡片的总宽,那么 Content 左边缘对齐 Viewport 左边缘时,第一张卡片的中心在 Viewport 左侧四分之一的位置,永远到不了正中间。用户滑到最左边,第一张卡片就是不在中心,这个体验非常别扭。
补上左右 padding 之后,Content 比内容本身宽了一整张卡片的位置,第一张和最后一张就都能滚到正中间了。这个公式里的viewportWidth是 Viewport 的 RectTransform 宽度,itemWidth是单张卡片的宽度,都是运行时会确定的实际像素值。
举个具体数字验证一下:假设 Viewport 宽 500,卡片宽 100,间距 20,一共 3 张。padding 每边就是 (500 - 100) / 2 = 200。Content 总宽 = 200 + 100 + 20 + 100 + 20 + 100 + 200 = 740。可滚动距离 = 740 - 500 = 240。第一张卡片中心在 Content 局部坐标的 250 处,第三张在 490 处。Content 偏移 0 时,第一张卡片中心在 Viewport 内的 250 处,正好是 Viewport 宽度的一半。Content 偏移 -240 时,第三张卡片中心在 490 - 240 = 250,也正好在中间。完全对得上。
3. 核心实现:由中心距离驱动的缩放逻辑
3.1 从距离到缩放的数学推导
这是整套方案里唯一需要动脑子的地方,推导一遍之后就再也不会忘。
设 Viewport 的世界坐标中心为 C,某张卡片的世界坐标中心为 P。只考虑水平滚动,所以取 X 分量的差:
d = |P.x - C.x|d 越大,说明卡片离中心越远,应该越小。把 d 归一化到 0 到 1 之间,除以一个参考半径 R:
t = clamp01(d / R)R 取多少合适?理论上取 Viewport 宽度的一半就够了,因为卡片一旦超出可视区域,缩放是多少都看不见。但我实际调下来,取viewportWidth * 0.5会让边缘卡片缩得有点狠,取viewportWidth * 0.6到0.7会更柔和。这个值我一般做成可配置字段,方便不同分辨率下微调。
有了 t,缩放值就是一次线性插值:
scale = lerp(maxScale, minScale, t)但纯线性插值有个问题:卡片从中心往两边移动时,缩放下降的速度是匀速的,看起来像机器在动。人眼对“渐变”的感知更接近指数曲线,所以给 t 加一个指数:
curve = pow(t, falloff) scale = lerp(maxScale, minScale, curve)falloff取 1 就是线性,取大于 1 会让中心附近的缩放变化更平缓、边缘更陡,取小于 1 则相反。实测下来 1.3 到 1.6 之间最舒服,卡片在中心附近有一段“保持大”的区间,然后才快速缩小,视觉上更有主体感。
如果还想做边缘透明度,用同一个 curve 再插一次 alpha 就行,不需要额外计算距离。
3.2 一份可以直接挂上去跑的完整脚本
下面这份脚本挂到 Scroll View 的 GameObject 上即可(因为它实现了拖拽接口,挂在 Content 上会收不到事件)。
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class ScaleScrollView : MonoBehaviour, IBeginDragHandler, IEndDragHandler { [Header("引用")] [SerializeField] private ScrollRect scrollRect; [SerializeField] private RectTransform viewport; [SerializeField] private RectTransform content; [Header("缩放")] [SerializeField] private float maxScale = 1f; [SerializeField] private float minScale = 0.65f; [SerializeField] private float falloff = 1.4f; [SerializeField] private float falloffRadius = -1f; [Header("透明度")] [SerializeField] private bool fadeEdge = false; [SerializeField] private float minAlpha = 0.55f; [Header("吸附")] [SerializeField] private bool enableSnap = true; [SerializeField] private float snapTime = 0.25f; [SerializeField] private float snapVelocityThreshold = 60f; [SerializeField] private AnimationCurve snapCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); private readonly List<RectTransform> _items = new List<RectTransform>(); private readonly List<CanvasGroup> _groups = new List<CanvasGroup>(); private bool _cacheDirty = true; private int _lastChildCount = -1; private bool _dragging; private bool _snapping; private float _snapElapsed; private float _snapFrom; private float _snapTo; private void Awake() { if (scrollRect == null) scrollRect = GetComponent<ScrollRect>(); if (scrollRect != null) { if (viewport == null) viewport = scrollRect.viewport; if (content == null) content = scrollRect.content; } } private void Update() { if (content == null) return; if (content.childCount != _lastChildCount) _cacheDirty = true; if (_cacheDirty) RebuildCache(); if (!enableSnap) return; if (!_dragging && !_snapping && scrollRect.velocity.magnitude < snapVelocityThreshold) { TryStartSnap(); } if (_snapping) StepSnap(); } private void LateUpdate() { ApplyScale(); } private void RebuildCache() { _items.Clear(); _groups.Clear(); for (int i = 0; i < content.childCount; i++) { RectTransform child = content.GetChild(i) as RectTransform; if (child == null || !child.gameObject.activeSelf) continue; _items.Add(child); _groups.Add(child.GetComponent<CanvasGroup>()); } _lastChildCount = content.childCount; _cacheDirty = false; } private void ApplyScale() { if (_items.Count == 0) return; Vector3 vpCenter = viewport.TransformPoint(viewport.rect.center); float radius = falloffRadius > 0f ? falloffRadius : viewport.rect.width * 0.6f; if (radius < 0.01f) radius = 0.01f; for (int i = 0; i < _items.Count; i++) { RectTransform item = _items[i]; if (item == null) continue; float dx = item.TransformPoint(item.rect.center).x - vpCenter.x; float t = Mathf.Clamp01(Mathf.Abs(dx) / radius); float curve = Mathf.Pow(t, falloff); float s = Mathf.LerpUnclamped(maxScale, minScale, curve); item.localScale = new Vector3(s, s, 1f); if (fadeEdge && _groups[i] != null) { _groups[i].alpha = Mathf.LerpUnclamped(1f, minAlpha, curve); } } } private int FindNearestIndex() { float current = content.anchoredPosition.x; float best = float.MaxValue; int bestIndex = 0; for (int i = 0; i < _items.Count; i++) { float target = GetTargetAnchoredX(i); float diff = Mathf.Abs(target - current); if (diff < best) { best = diff; bestIndex = i; } } return bestIndex; } private float GetTargetAnchoredX(int index) { RectTransform item = _items[index]; Vector3 vpCenter = viewport.TransformPoint(viewport.rect.center); Vector3 itemCenter = item.TransformPoint(item.rect.center); float delta = vpCenter.x - itemCenter.x; float target = content.anchoredPosition.x + delta; // 前提:content 的 anchorMin/anchorMax = (0, 0.5),pivot = (0, 0.5) if (content.rect.width > viewport.rect.width) { float minX = viewport.rect.width - content.rect.width; target = Mathf.Clamp(target, minX, 0f); } return target; } private void TryStartSnap() { if (_items.Count == 0) return; int nearest = FindNearestIndex(); float target = GetTargetAnchoredX(nearest); float current = content.anchoredPosition.x; if (Mathf.Abs(target - current) < 0.5f) { content.anchoredPosition = new Vector2(target, content.anchoredPosition.y); return; } scrollRect.StopMovement(); _snapFrom = current; _snapTo = target; _snapElapsed = 0f; _snapping = true; } private void StepSnap() { _snapElapsed += Time.unscaledDeltaTime; float t = Mathf.Clamp01(_snapElapsed / snapTime); float e = snapCurve.Evaluate(t); Vector2 pos = content.anchoredPosition; pos.x = Mathf.LerpUnclamped(_snapFrom, _snapTo, e); content.anchoredPosition = pos; if (t >= 1f) _snapping = false; } public void OnBeginDrag(PointerEventData eventData) { _dragging = true; _snapping = false; } public void OnEndDrag(PointerEventData eventData) { _dragging = false; } }几个实现上的选择值得说清楚。
为什么用世界坐标算距离而不是局部坐标。局部坐标要处理父级链上每一层的缩放和偏移,尤其当 Canvas 用了Screen Space - Camera或者挂在会被缩放的父物体下时,局部坐标的差值根本不对应屏幕像素。TransformPoint(rect.center)两步走虽然多一次矩阵运算,但结果永远可靠。卡片数量在几十个以内时,这点开销完全可以忽略。
为什么缩放放在 LateUpdate。ScrollRect 自己在 LateUpdate 里根据惯性修改 Content 的位置。如果缩放逻辑放在 Update,算出来的是上一帧的位置,快速滑动时卡片缩放会明显滞后于位置。放在 LateUpdate 能拿到本帧的最新位置,视觉上跟手。
为什么吸附放在 Update。因为吸附期间我们会主动写content.anchoredPosition,如果放在 LateUpdate 和 ScrollRect 抢执行顺序,容易互相覆盖。Update 里先写完,ScrollRect 的 LateUpdate 发现 velocity 已经是零,就不会再动位置了。TryStartSnap里那句StopMovement()就是让 velocity 归零,同时停掉 ScrollRect 内部可能还在跑的惯性协程。
为什么缓动用unscaledDeltaTime。很多项目会做暂停(Time.timeScale = 0),但 UI 动画不应该被暂停冻住。用 unscaled 版本可以保证暂停菜单打开时,列表还在正常吸附结束。
3.3 层级遮挡与透明度联动
缩放本身不会改变渲染顺序,渲染顺序只由siblingIndex决定。所以当卡片放大到和相邻卡片重叠时,可能出现的尴尬画面是:左边那张小卡片盖住了中间这张大卡片的一角。
最直接的办法是不要重叠。把 Spacing 调大,或者把 minScale 调高,让视觉上根本不产生交叠。这是最省事的方案,绝大多数项目到这一步就够了,而且性能和代码都最干净。
如果确实需要层叠遮挡,有两条路:
第一条是改 siblingIndex。按距离排序,把离中心最近的卡片放到 Content 的最后一个子节点位置。但这条路上有个大坑:HorizontalLayoutGroup是严格按 siblingIndex 顺序来排列子物体的。你一改顺序,卡片在屏幕上的左右位置就全乱了,画面会直接崩掉。所以只要用了 Layout Group,就不能动 siblingIndex。这是我第一版实现里踩得最狠的一个坑,卡了整整一个下午才反应过来。
第二条是不用 Layout Group,自己算位置。放弃自动布局,在脚本里手动给每张卡片设置 anchoredPosition,此时 siblingIndex 可以随便排。代价是卡片数量变化时要手动重排,代码量增加三十行左右。具体做法是按索引算出每张卡片的 X 坐标,累加宽度和间距,最后设置 Content 的 sizeDelta。做完这一步,你能同时控制 scale、位置和渲染顺序,层叠效果就完全放开了。
透明度这块相对简单,CanvasGroup.alpha每帧改值不会触发重建,性能代价很小。需要注意的是,CanvasGroup的 alpha 会作用于整个子层级,如果卡片里有粒子特效或者半透明遮罩,它们的最终透明度会跟着变,需要在美术层面确认一下效果。
4. 吸附对齐:让卡片停在该停的位置
4.1 目标位置的推导与边界处理
吸附的核心是回答一个问题:松手之后,Content 应该偏移到哪个位置,才能让某张卡片正好居中?
推导其实很朴素。当前状态下,卡片中心在世界坐标里的 X 是itemCenter.x,视口中心是vpCenter.x,两者相差delta = vpCenter.x - itemCenter.x。如果我把 Content 整体往右推 delta,卡片中心就和视口中心重合了。而 Content 往右推 delta,对应的就是anchoredPosition.x加上 delta。
所以目标值就是:
target = content.anchoredPosition.x + delta这个公式完全不依赖 pivot、anchor、Layout Group 的内边距是多少,只要 Content 和 Item 还没有被旋转(绕 Z 轴转 90 度之类),结果就是对的。
唯一的补充是边界钳制。目标位置不能超出 ScrollRect 允许的滚动范围,否则会把内容推到视口外面,看起来像穿帮。范围是多少取决于 Content 的 pivot 配置,按前面推荐的(0, 0.5)来算,就是[viewportWidth - contentWidth, 0]。如果 Content 比 Viewport 还窄,那就没有滚动空间,不做钳制,否则会算出正数导致内容跳到右边去。
至于“哪张卡片最近”,遍历所有卡片算一遍目标位置,取和当前偏移差值最小的那个就行。卡片数量在百级以内时,这个循环的开销可以忽略不计。
4.2 缓动参数怎么调才不别扭
吸附用固定时长加曲线的方式最可控,不要用Mathf.Lerp直接往目标值插值,那样永远是“先快后慢”,结束时会有一小段几乎不动的拖沓感。
snapTime从 0.2 秒开始调。小于 0.15 秒会显得很突兀,像是被硬拽过去的;大于 0.35 秒会让人觉得系统反应慢。0.22 到 0.28 之间是甜点区,具体取决于卡片的间距和移动距离。
snapCurve用AnimationCurve.EaseInOut就够了。如果想要一点“落定”的感觉,可以拖成尾部轻微过冲再回正的形状,但幅度一定要小,超过 3% 的过冲会让人觉得卡片在晃。
还有一个容易被忽略的细节:速度阈值。脚本里用的是scrollRect.velocity.magnitude < 60。这个值决定了吸附什么时候介入。设得太大,用户还在快速滑动就被硬拉住,手感很差;设得太小,惯性已经彻底停了才慢慢吸过去,中间有一段静止,看起来像卡了一下。60 左右是我在几个项目里试出来的通用值,如果你把Deceleration Rate调得比较小,可以适当提高这个阈值。
提示:如果发现吸附完成后,卡片中心总是差几个像素,先检查 Content 的 Layout Group 是不是在最右边多算了一次 Spacing。HorizontalLayoutGroup 的 Padding 里的 Right 值和 Spacing 是两码事,别把两者搞混。
5. 踩坑实录:缩放滚动视图的高频问题排查
5.1 缩放被裁掉与边缘发虚
RectMask2D是严格按照 Viewport 的矩形来裁剪的。卡片放大到 1.0 倍时是完整的,缩到 0.65 倍时也完整,但如果 maxScale 设得比 1 大,比如 1.2,那张在中心的卡片就会被裁掉上下两条边。
解决办法有两个。一是把 Viewport 的矩形放大,然后给 ScrollRect 的 Content 补上对应的负边距,让可滚动范围保持原样。二是干脆不加 RectMask2D,靠卡片间距和相机可视范围来遮挡,适合卡片不会滚动出屏幕的场景。
边缘发虚是另一个问题。UGUI 的图片在缩放后,采样是按缩放后的尺寸走的。如果缩放值是 0.65 这种非整数,纹理采样会落在像素之间,边缘会有轻微模糊。如果卡片上有文字,模糊感会更明显,因为 Text 组件的字形缓存是按原始字号生成的,缩放相当于对位图做插值。
实测有效的做法是:卡片中的文字不要跟着卡片一起缩放,而是单独挂一个脚本,在卡片缩放时反向抵消,让文字始终保持 1 倍渲染。或者干脆用两套字号,中心卡片用大字号、边缘卡片用小字号,切换时机和缩放同步。前者实现简单,后者视觉更锐利但需要更多美术资源。
5.2 布局抖动、首帧错位与顺序冲突
首帧错位是最常见的问题:游戏刚进场景时,所有卡片都挤在左上角,或者尺寸全是零,要等一帧甚至几帧才恢复正常。原因是 Layout Group 的布局计算是在 Canvas 更新阶段才执行的,而脚本的 Awake 或 Start 阶段读到的content.rect.width还是旧值。
标准的修法是在读取任何布局结果之前,强制执行一次重建:
Canvas.ForceUpdateCanvases(); LayoutRebuilder.ForceRebuildLayoutImmediate(content);这两句写在 Awake 的最后,或者写在第一次 RebuildCache 之前。前者执行所有 Canvas 的更新,后者只重建指定的布局组件,通常两个一起用最保险。
布局抖动则多发生在卡片数量动态变化的时候。比如列表打开时先放了 3 个占位,异步加载完成后替换成 10 个真实数据,此时 Content 宽度突变,Layout Group 会重新计算,卡片位置瞬间跳变。处理方式是在数据变更时,先gameObject.SetActive(false)把 Content 关掉,改完数据再打开,用一帧的代价换掉可见的抖动。
顺序冲突前面提过了,再强调一次:只要用了 HorizontalLayoutGroup 或 VerticalLayoutGroup,就绝对不能修改子物体的 siblingIndex,否则排列顺序会被打乱。这条规则没有例外。
5.3 性能表现与移动端适配要点
缩放滚动视图的性能瓶颈通常不在缩放本身,而在两处:每帧的 TransformPoint 遍历,以及 RectMask2D 触发的批次拆分。
每帧遍历所有卡片做坐标变换,在 10 到 30 张卡片的量级下毫无压力,手机上跑满 60 帧完全没问题。但如果卡片数量上百,就需要做可视区域剔除:先用 Content 的偏移量算出大致的可见索引区间,只对这个区间内的卡片做缩放计算,区间外的直接恢复成 minScale 或者干脆跳过。这个优化能带来数倍的性能提升。
批次拆分的问题来自 RectMask2D。一旦启用裁剪,UGUI 会把裁剪区域内的元素和区域外的元素拆成不同的 Draw Call,而且不同 Material 或者不同 Texture 的图片无法合批。卡片的图集一定要打在一起,哪怕每张卡片内容不同,也用同一个图集加不同的 Sprite。我在一个项目里因为美术把卡片图分了三张图集,同样的界面 Draw Call 从 5 涨到了 18,在低端机上直接掉帧。
移动端还有一个细节:snapCurve用的是AnimationCurve,