
做Unity客户端开发的朋友应该都遇到过这种需求一个横向滚动的列表希望滑动停稳后正中间那个Item能自动放大突出显示旁边的逐渐缩小形成一种“3D轮播”的视觉层次感。最常见的就是商店的皮肤展示、关卡选择、角色选择界面。项目里UI设计师给的参考图一出来放大效果、渐隐效果全部拉满看着确实高级但真到了实现的时候很多人才发现这玩意儿并没有想象中那么简单。我当时接到这个需求时花了不少时间主要精力都耗在“如何精确拿到每个Item距离视口中心的偏移量”这个核心问题上。把横竖方向、边界情况都处理完之后我把它封装成了一个通用组件在项目里给商店页和关卡页都用上了。今天就把这套完整思路和可复用代码分享出来包括中间踩过的坑希望能帮各位少走弯路。1. 滑动放大的本质一切围绕“距离”做文章很多同学一上来就想着监听每个Item的坐标、计算速度、或者在Update里轮询所有子物体这种思路能做但写出来的代码在Item数量一大之后就特别臃肿而且方向一换就得重写。其实这个功能的核心逻辑只有一句话每个Item的缩放值取决于它中心点离Viewport中心点的距离。离得越近缩得越大离得越远缩得越小。搞清楚这个底层逻辑剩下的工作就清晰了我们只需要在ScrollRect的滚动回调里算出每个Item的中心点与Viewport中心点的偏移量再把偏移量映射成一个缩放比例即可。这个功能之所以让很多人头疼我分析下来通常卡在三个点第一搞不清应该用哪个坐标系来计算“中心点”World坐标、Local坐标、相对Content的坐标经常把人绕晕。第二不知道如何做到“横竖通用”写了一套横向逻辑竖过来就失效了。第三缩放后布局错乱、首尾无法对齐、层级遮挡顺序不对等细节问题。这篇文章我会把每一个问题单独拆开讲。如果你只想要一份能直接用的代码可以先看第3章的通用组件然后回来看原理补课。1.1 应用场景与需求解析先说清楚这套方案能做什么。我在项目里主要做了三个场景的复用商店/皮肤选择列表横向滑动当前选中的皮肤居中放大并展示详情两侧皮肤缩小、变暗视觉上形成主次分明的关系。关卡选择界面横向滑动关卡节点当前关卡居中放大其余关卡缩小方便玩家一眼锁定当前进度。装备/角色纵向列表竖向滑动时处于视口中间位置的Item放大突出常用在大型副本选择或角色养成界面。本质上这套“居中放大、两侧缩小”的交互模式就是帮用户建立“当前位置”的认知锚点。用户不需要仔细看滚动条在哪只需要看哪个Item最大就知道自己现在选中的是谁。1.2 为什么用“距离映射”而不是“位置区间判断”方案选型时有两种常见做法。一种是直接判断Item的本地坐标是否落在某个区间内在区间内就放大。这种做法看起来很直接但实际上有两个明显的坑一是边界时刻的过渡不平滑Item突然变大又突然变小视觉上很生硬二是区间边界值跟Content的尺寸、Item的数量、间距强耦合一旦数据变化参数就得重新调。另一种就是我要讲的“距离映射”方案核心是把偏移量通过一个函数比如Lerp比如AnimationCurve映射成缩放值。这种做法是连续渐变的Item滑动经过中心时会自然放大离开时自然缩小丝滑程度比区间判断高出几个档次而且跟Item数量和Content尺寸完全解耦数据怎么变都适用。这也是我为什么最终选择这种方案的核心原因。2. 核心原理如何正确计算Item到中心的距离这一章是整个功能的基石。很多网上代码让人抄了也白抄就是因为没搞清楚坐标转换的细节。这里我尽量把人话说清楚。2.1 坐标转换的“三角关系”先明确一个关键点Item在Content里滚动本质上是Content这个节点在Viewport坐标系里移动。所以Item的世界坐标Transform.position会随着滚动发生变化但Item相对Content的位置RectTransform.anchoredPosition / localPosition是固定不变的。我们要拿来做距离计算的是Item在Viewport本地坐标系下的坐标。这个坐标可以通过下面的方式拿到Vector2 localPoint viewportRect.InverseTransformPoint(itemRect.position);InverseTransformPoint的作用是“把一个世界坐标转换成为指定节点本地坐标系下的坐标”。因为我们传进去的是item的position世界坐标所以拿到的localPoint就相当于“从Viewport的坐标原点去看Item中心它落在什么位置”。这里有一个很容易踩坑的地方Viewport的Pivot轴点如果不是(0.5, 0.5)坐标原点就不在Viewport中心。有些工程项目里为了让Content适配安全区、留出边距会把Viewport的锚点或Pivot改掉。如果你直接拿localPoint的x值去计算距离会发现距离中心永远差一截。正确做法是用localPoint减去Viewport矩形的中心点即viewRect.center这样无论Pivot怎么改计算都是准确的float offset localPoint.x - viewRect.center.x;同理竖向滚动时用y轴float offset localPoint.y - viewRect.center.y;2.2 横竖通用的关键写法理论上讲横向列表只关心x轴偏移竖向列表只关心y轴偏移。但如果每次都要判断方向再写两套代码维护成本高而且容易出现分支遗漏。我的做法是把偏移量当成一个向量但不取它的“模”或用单轴投影而是做一个方向判断float distance scrollRect.vertical ? Mathf.Abs(localPoint.y - viewRect.center.y) : Mathf.Abs(localPoint.x - viewRect.center.x);这样一来定义一个bool或者直接判断ScrollRect的vertical/ horizontal属性即可横竖通用。如果你还想做得更万能一些可以封装方法时传入一个方向枚举默认根据ScrollRect的配置自动选择。2.3 距离与缩放的映射公式拿到距离之后剩下的工作就是映射。最朴素的做法是用线性映射float t 1 - Mathf.Clamp01(distance / maxDistance); float scale Mathf.Lerp(minScale, maxScale, t);这里的maxDistance是一个关键参数它决定了“多远开始缩到最小”。我一般取Viewport宽度横向或高度竖向的一半再乘以一个系数具体值可以放在Inspector里面调。如果maxDistance设得过大你滑动到边缘时Item还保持比较大的缩放设得太小两侧的Item会迅速变小画面显得有点秃。不过在实际项目里纯线性映射的观感偏“机械”。人的视觉对变化率更敏感你希望中心附近的Item缩放变化平滑而微妙两侧远端的Item有被“推开”的感觉。这种时候我会用AnimationCurve来做映射效果会好得多。3. 完整可运行的通用组件原理说得再多不如直接给一份能跑的代码。下面这个组件我实测过横竖通用配合ScrollRect使用。3.1 代码ScrollViewScaleEffectusing UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(ScrollRect))] public class ScrollViewScaleEffect : MonoBehaviour { [Header(缩放参数)] public float maxScale 1.2f; public float minScale 0.8f; public float maxDistanceFactor 0.8f; [Header(缩放曲线可选)] public AnimationCurve scaleCurve AnimationCurve.Linear(0f, 1f, 1f, 0f); [Header(自动吸附)] public bool snapToCenter true; public float snapDuration 0.15f; private ScrollRect scrollRect; private RectTransform viewportRect; private RectTransform contentRect; private bool isInitialized false; private void Awake() { Initialize(); } private void Initialize() { if (isInitialized) return; scrollRect GetComponentScrollRect(); viewportRect scrollRect.viewport ! null ? scrollRect.viewport : scrollRect.GetComponentRectTransform(); contentRect scrollRect.content; if (viewportRect null || contentRect null) { Debug.LogError(ScrollViewScaleEffect: 找不到Viewport或Content请检查ScrollRect结构); return; } scrollRect.onValueChanged.AddListener(OnScrollChanged); isInitialized true; } private void OnScrollChanged(Vector2 normalizedPosition) { UpdateScale(); } public void UpdateScale() { if (contentRect null || viewportRect null) return; Vector2 viewCenter viewportRect.rect.center; for (int i 0; i contentRect.childCount; i) { RectTransform itemRect contentRect.GetChild(i) as RectTransform; if (itemRect null) continue; Vector2 localPoint viewportRect.InverseTransformPoint(itemRect.position); float distance scrollRect.vertical ? Mathf.Abs(localPoint.y - viewCenter.y) : Mathf.Abs(localPoint.x - viewCenter.x); float maxDistance (scrollRect.vertical ? viewportRect.rect.height : viewportRect.rect.width) * maxDistanceFactor; float t Mathf.Clamp01(distance / maxDistance); float curveValue scaleCurve.Evaluate(t); float targetScale Mathf.Lerp(maxScale, minScale, curveValue); itemRect.localScale Vector3.one * targetScale; } if (snapToCenter) StopAllCoroutines(); } }上面这段代码是精简可用的版本默认使用AnimationCurve做映射。要注意的是我在UpdateScale里写了一句StopAllCoroutines()它跟自动吸附的协程配合使用避免滚动过程中吸附协程不断叠加导致抖动。完整版的吸附逻辑我放在第5章单独讲。3.2 UI层级搭建与配置这套组件的UI结构很简单遵循ScrollRect的标准结构就行Canvas └── ScrollView (带ScrollRect和ScrollViewScaleEffect) ├── Viewport │ └── Content (带HorizontalLayoutGroup / VerticalLayoutGroup) │ ├── Item_1 │ ├── Item_2 │ └── Item_3 └── Scrollbar Horizontal (可选)搭建时有几个关键点Viewport必须存在。ScrollRect默认会有一个Viewport子节点不要在ScrollView的RectTransform上直接挂Content。InverseTransformPoint是针对Viewport做的没有Viewport整个坐标基准就乱了。Viewport的RectTransform需要正确填充ScrollViewAnchor四个角拉满或者使用Stretch模式这样其rect.center就是可视区域的真实中心。Content上挂布局组件如HorizontalLayoutGroup并开启ChildAlignment为MiddleCenter之类的居中方式。同时打开Control Child Size的Width和Height让Content宽度根据子物体数量自适应。Item的锚点建议统一设置在中心0.5, 0.5这样缩放时以Item自身中心为基准不会出现跑偏。3.3 参数含义与推荐初始值几个参数在代码里的作用这里单独说明一下maxScale中心Item的缩放值。1.2表示放大到原来的1.2倍再大需要注意遮挡和溢出问题。minScale最远端的缩放值。0.8比较温和0.6视觉冲击强但容易显得稀疏。maxDistanceFactor控制“缩放衰减的距离尺度”。0.8表示以Viewport宽/高的80%作为maxDistance。这个值越大Item保持较大缩放的有效范围越宽滚起来越“黏”越小缩放变化越剧烈滚动时效果越干脆。scaleCurvex轴是归一化距离ty轴是缩放比例。当t0时在中心y值建议是1表示取maxScale当t1时在最大距离处y值建议是0表示取minScale。默认给了一条线性递减曲线实际项目我一般调成“先平后陡”的形状中心附近保持较大缩放远端快速缩小这样视觉重点更突出。4. 参数调优实战让效果“不再生硬”代码跑通之后大部分人都会发现效果跟自己想象的不太一样可能是缩放幅度太夸张可能是滚动阻尼不对可能是边缘有些别扭。这一章集中讲调优方向和技巧。4.1 滚动手感与ScrollRect设置缩放效果是否好看很大程度取决于滚动手感。这里有几个ScrollRect自身的参数建议Movement Type建议用Clamped或Elastic。如果用UnrestrictedContent可以无限拖动Item会超出视口很远距离计算没有边界感观感比较散。Inertia建议开启。惯性带来比例增益配合缩放变化更自然。不过Inertia过大容易导致停不稳我一般把Deceleration Rate调到0.135这个数值附近手感偏干脆。Horizontal / Vertical只开你需要的那个方向。双向同时开而Content又不够大时会发生斜向拖动触发距离计算混乱。4.2 曲线调整让缩放有“呼吸感”再来单独说说曲线。默认的Linear曲线不是不能用但效果比较死板。我推荐两个调整方向如果希望“中心区有一个明显的平坦区域”把曲线左侧t接近0的位置拉平让Item滑动到中心附近时缩放变化很小制造一种“稳稳选中”的感觉。如果希望“滑动经过中心时有一种蓄力弹出感”把曲线中部稍微上调让缩放变化的峰值稍微滞后于滚动距离变化这样手指快速划动时中心Item的放大动作会稍微“追”一下滚动视觉上高级很多。曲线调整完建议在Game视图里把窗口调小反复拖拽感受这种微调只能靠手感和动画曲线编辑器一点点磨没有统一标准。4.3 关于层级遮挡一个常见的视觉问题是中心Item放大了但旁边的Item依然盖在它上面导致中心放大效果被遮挡。根本原因是UGUI的渲染顺序默认跟节点树顺序一致后绘制的会盖住先绘制的。既然在Content的子物体中Item之间是同级的我们要做的就是让中心Item排到最上层。最简单的做法是在UpdateScale里根据距离排序把最近的Item的SetAsLastSibling()。但这会破坏原有数据顺序如果Content下还有编辑器业务逻辑引用固定Index容易出错。我的折中方案是只对视觉层做排序业务层通过别的方式绑定数据。如果实在不想动子物体顺序也可以用Canvas组件的overrideSorting给当前中心Item挂一个独立Canvas并提高sortingOrder。这种方式不影响Transform层级代价是多出来的Canvas开销数据量不大时完全可接受。5. 进阶细节自动吸附与优化方案基础缩放做出来只能算完成一半真正落地到产品还要满足“滚动停止后自动选中最近一个Item”这种要求。毕竟你也不希望用户最终停在两个Item中间主次不明的状态。5.1 自动吸附算法实现实现吸附的思路不复杂在滚动停止惯性结束时找到距离中心最近的Item然后计算Content应该移动多少距离让它正好位于中心再做一次平滑补间。这里分享一个关键Trick不要试图直接改Content的anchoredPosition因为不同布局、不同锚点下这个数值的参照系不同很容易改错。我的做法是反过来算先记录Item当前位置和中心位置的偏移再把这个偏移量通过viewport.InverseTransformDirection换算成Content需要调整的向量最后用Coroutine做Damped插值或直接用DOTween。public IEnumerator SnapToNearest() { RectTransform nearest GetNearestItem(); if (nearest null) yield break; Vector2 itemWorldPos nearest.position; Vector2 viewWorldCenter viewportRect.position (Vector3)viewportRect.rect.center; Vector3 delta viewWorldCenter - itemWorldPos; Vector2 localDelta viewportRect.InverseTransformDirection(delta); Vector2 startPos contentRect.anchoredPosition; Vector2 targetPos scrollRect.vertical ? new Vector2(contentRect.anchoredPosition.x, contentRect.anchoredPosition.y localDelta.y) : new Vector2(contentRect.anchoredPosition.x localDelta.x, contentRect.anchoredPosition.y); float t 0f; while (t 1f) { t Time.unscaledDeltaTime / snapDuration; contentRect.anchoredPosition Vector2.Lerp(startPos, targetPos, Mathf.SmoothStep(0f, 1f, t)); UpdateScale(); yield return null; } }这段代码要注意一点viewportRect.position是Viewport中心的世界坐标但在拉伸、多分辨率适配下跟随Canvas缩放带来的位移需要额外小心。如果Canvas是Screen Space - Overlay模式这套换算完全准确如果是Camera模式还需要把相机偏移考虑进去建议在Canvas的RectTransform上重新做屏幕坐标转换。5.2 性能优化只处理可视范围内的Item如果Content下面挂了上百个Item并且全部调用InverseTransformPoint和Curve.Evaluate虽然单个开销不大但Update或滚动回调里每帧跑一遍手机上也会有可见的卡顿。我做过性能统计100个Item的遍历大约需要1~2毫秒这还没算排序和层级操作。优化的核心思路是只处理Viewport可视区域内的Item。每个Item离中心点超过一定阈值后直接设置成minScale并跳过详细计算。更进一步可以用对象池管理Item只实例化可视区域的Item但这是另一个话题这里不展开。另一种常用优化是只在onValueChanged里触发UpdateScale不要放在Update里无脑轮询。onValueChanged本身就是滚动事件驱动静止时不会调用可以理解为免费的节流。5.3 抵抗“缩放对点击区域的影响”还有一个低频率踩坑但遇到就很头疼的问题Item放大后按钮的点击区域会变大缩小时点击区域变小。如果Item上挂着Button用户以为点到了边缘结果没反应体验很差。解决方式有两种思路一种是不管缩放让点击区域始终保持原始大小做法是给按钮上的Image挂一个独立的RectTransform缩放时只缩放这个Image而拿到Button的RectTransform始终保持原样但这样视觉上按钮范围跟内容不匹配需要额外调试。另一种是反着来把缩放效果作用在Container上Container大小恒定但内部元素图标、文字的localScale变化。这样Button本身的可点击区域不变缩放效果只体现在视觉层。这个方案更干净但需要改变Item内部的层级结构每Item需要多一层Container。具体取舍看项目需求我一般推荐第二种。6. 常见问题与排查技巧这个方案里我积累了几个高频问题整理成速查表大家遇到类似情况可以直接对照排查。问题现象根本原因解决方案中心Item总是对不齐偏左或偏右Viewport的Pivot不是(0.5, 0.5)计算距离时统一用rect.center不直接使用localPoint缩放后Item位置轻微跳动目标Item的锚点不在中心将Item的Pivot和Anchor统一设置为(0.5, 0.5)首尾两个Item无法滚到中心Content的Padding或间距设置不对称检查Content的Padding保证首尾留白与Item间距存在对应关系缩放看起来“漂移”离中心忽大忽小同时开了Horizontal和Vertical滚动关闭不需要的方向或改用参数传入滚动方向惯性停止后停在两个Item中间缺少自动吸附逻辑增加第5章的吸附Coroutine中心Item被旁边Item遮挡渲染顺序问题用Canvas排序或者调整SiblingIndex注意业务影响6.1 连接数据后Item宽度变了怎么办如果你的Item是通过动态加载生成的而且宽度取决于内容的长度比如一个图片卡片带标题那么Content宽度、Item间距、Content的Padding都可能变化。这种情况下maxDistanceFactor建议使用Viewport的宽/高作为基准而不是Item的宽度。因为只有Viewport的尺寸是稳定的Item宽度变来变去不会影响距离尺度的稳定性。6.2 和ContentSizeFitter的配合问题有时候项目里会给Content挂ContentSizeFitter或者用LayoutGroup动态计算宽度。这种情况下你必须在布局计算完成之后再获取Content的尺寸不然在Awake阶段拿到的宽高可能是旧值。我的经验是在Start里延迟一帧调用UpdateScale或者监听RectTransform的尺寸变化事件用RectTransformUtility配合回调再刷新。还有一个隐藏问题LayoutGroup在Content尺寸变化时会让子物体重新排列看起来就像Item“瞬移”了一下。如果缩放后视觉有明显跳变先排查一下是不是ContentSizeFitter的布局更新时机比缩放更新时机晚导致的。6.3 多分辨率适配的坑手机屏幕分辨率差异大缩放效果如果只在固定分辨率下调试很容易在别的手机上走样。距离计算中用的viewportRect.rect尺寸是实际像素单位会随着Canvas缩放系数变化因此maxDistanceFactor乘以这个尺寸后在不同分辨率下基本能保持比例一致这是这套方案相对稳定的原因。需要注意的特殊情况是Canvas的CanvasScaler设置为“Scale With Screen Size”且Match Width or Height值不是0.5。当屏幕比例极端比如折叠屏时Viewport的实际尺寸会跟着缩放此时maxDistanceFactor可能需要动态微调。可以在运行时根据宽高比动态覆盖float factor Screen.width Screen.height ? 0.8f : 0.9f;这个数值很难有统一答案建议真机测试后再确定。7. 个人实现心得与最后的建议我最初实现这个效果时兜兜转转走了不少弯路尤其是在坐标转换那一块。后来把“中心点偏移量”的计算彻底理顺之后整个方案的通用性一下就打开了。现在项目里新增任何需要滚动放大效果的界面直接挂这同一个脚本调一调曲线就能上基本不用大改。最后分享一个细节如果你想让效果更精致可以配合缩放值动态调整Item的透明度或CanvasGroup.alpha距离中心越远透明度越低。强烈建议把透明度和缩放放在同一个曲线映射里保证视觉节奏统一。这样整个列表滑动起来层次感会特别强观感非常高级。再提一个很多教程不会讲的点滚动的“顺滑感”很大程度跟Content的惯性参数有关而不是缩放代码本身。如果你最终觉得效果不对劲先别急着调曲线试着把ScrollRect的Deceleration Rate数值从小往大微调你会发现手感和视觉之间其实有个黄金平衡点找到它比强行调代码有效得多。