
做弧线滑动列表这事最早是在一个装备选择界面被美术逼出来的。策划要求列表横向滑动但中间项要放大突出两边自然收窄下沉形成一条弧线整个交互像“转盘选人”一样。因为Unity自带的ScrollRect只做矩形平移没法直接满足这种效果我在项目里踩了不少坑最后沉淀出一套不需要额外插件、基于UGUI就能实现弧线滑动列表的方案。这套方案在角色选择、关卡选择、物品轮盘、数字孪生设备切换这类场景里都能直接用本文把核心原理、完整代码、平台适配和排查经验一次说清楚。1. 需求拆解与方案选型1.1 弧线滑动列表到底在解决什么问题先说清楚这个需求本质。常规ScrollRect的滑动行为就是Content在Viewport里做矩形平移Item之间保持固定的水平间距位置关系永远是平的。弧线滑动列表要做的是列表在滑动过程中Item不再停留在同一水平线上而是根据自身与视口中心的距离动态改变自己的位置、缩放、旋转和透明度让整体形成一条弧线或轮盘式的视觉结构。这种交互的价值很直接中间项被放大并抬高信息层级非常清楚用户不用读文字就知道当前选中了谁两边Item被压缩变小、向内侧倾斜又能暗示用户“后面还有更多内容”滑动结束时如果配合回中吸附动画操作手感会明显更舒服适合快速切换类选择界面。很多移动端游戏的角色选择、抽卡结果、关卡地图都是这个套路。1.2 常见实现方案横向对比围绕这个需求业界主流方案大致有四类我整理了一张对比表方便看清差异方案类型实现方式优点缺点适用场景RectTransform权重驱动在Update中遍历Item按距离修改位置/缩放/旋转实现简单依赖少完全兼容UGUI事件体系逻辑全在C#Item数量大时遍历成本会上升列表项几十个以内主选方案顶点变形方案继承BaseMeshEffect修改UI网格顶点或使用自定义Shader性能高适合整块面板弧面弯曲调试复杂Image默认网格顶点太少曲线不平滑Mask配合有坑整块UI弯曲或Item本身有曲面诉求3D UIWorld Space Canvas把Canvas设为World SpaceItem在Z轴上错开形成深度有真实透视感适合MR/VR环境需要额外处理射线交互、遮挡排序Pico、MR、全3D选人场景第三方插件FancyScrollView等现成库功能丰富曲线预设多省时间依赖黑盒改起来费劲部分插件收费对定制要求不高工期紧1.3 我为什么选择RectTransform权重驱动项目里最终采用的是第一种方案理由很实际。首先它不需要引入任何第三方依赖完全基于UGUI的ScrollRect、RectTransform和AnimationCurveUnity 2018到Unity 2022的版本都能跑。其次它可控性极高所有动画效果都可以用一条AnimationCurve来决定美术和策划调整起来直观我不用改代码就能把曲线高度、影响半径调成他们想要的样子。最后就是排查问题容易每帧做的事情就是“遍历Item按距离写入Transform”逻辑清晰出问题一眼能定位。如果说这套方案有什么短板那就是Item数量过大时性能会有压力。我的处理思路也很简单只在可见范围内遍历Item把不可见的Item重置成初始状态。这个优化手段后面单独展开讲。2. 核心算法原理距离权重驱动的弧线分布2.1 从线性列表到弧线列表的关键转变理解弧线滑动的突破口是把注意力从“Content怎么移动”转移到“Item本身怎么变形”。Unity的ScrollRect在拖动时实际移动的是Content的anchoredPositionItem作为Content的子物体跟着移动但它们自身的本地坐标是不变的。也就是说一个Item放在content的第3个位置它一直占据第3个槽位不会因为滑动就跑到第5个槽位。既然如此要让Item产生“弧线”效果就不需要再去管Content的位置只需要在每一帧重新计算“每个Item当前离viewport中心有多远”然后把这个距离映射成Item的y轴偏移、缩放和旋转即可。距离近的Item放大抬高距离远的缩小下沉并倾斜自然就形成弧线了。2.2 核心参数与权重曲线设计实现中需要用到的参数我整理成了一张表方便大家对照理解每一项的作用参数名含义建议初始值itemSpacingItem之间的固定水平间距200curveHeight中间项相对两边的最大上浮高度120radius弧线影响半径单位是“间距的倍数”2.2maxScale中心项的最大缩放值1.25minScale边缘项的最小缩放值0.8maxRotate边缘项的最大倾斜角度14rotateDirection旋转方向1或-1-1这些参数中最核心的是radius和weightCurve。radius决定了“多大范围内的Item会受影响”比如设置为2.2就意味着以视口中心为圆心左右各2.2个itemSpacing范围内的Item都会参与弧线分布超过这个范围的Item统统回到初始状态否则列表两端会一直悬着变形看起来很怪。weightCurve是一条AnimationCurve负责把归一化后的距离权重映射为最终变形权重。为什么要多这一层曲线因为如果直接用线性关系中心放大的过程会很生硬边缘Item消失得也突兀。用EaseInOut曲线让靠近中心的Item变化平缓、边缘变化快速视觉上更柔和。2.3 距离归一化的具体计算每帧计算Item距离时最稳妥的方式不是用Item的anchoredPosition而是用viewport本地坐标来算。伪代码如下viewportLocalPos viewport.InverseTransformPoint(item.position) distance viewportLocalPos.x - viewport.rect.center.x这里用世界坐标反变换到viewport本地坐标可以规避Canvas缩放的干扰。如果你在Canvas里面设置过CanvasScaler的ReferenceResolution又开启了ScaleWithScreenSize那么屏幕上看到的坐标和本地坐标可能差异很大。直接在viewport本地坐标中计算视觉比例始终是对的。拿到绝对距离后再根据itemSpacing和radius归一化maxRange itemSpacing * radius weight 1 - Clamp01(绝对值(distance) / maxRange) weight weightCurve.Evaluate(Clamp01(weight))这个weight就是核心权重。中心Item距离为0weight接近1边缘Item距离超过maxRangeweight接近0。后面所有y轴偏移、缩放、旋转全部由weight驱动。3. 开箱即用的ArcScrollList组件实现3.1 UI层级搭建与参数准备在Hierarchy中创建结构如下CanvasScreen Space - OverlayCanvasScaler按项目规范设置ScrollView挂ArcScrollList脚本或者挂ScrollRect之后再挂本脚本Viewport带Image用于设置Mask裁剪ContentRectTransformPivot设置为(0.5,0.5)Item_0Item_1Item_2...有几个容易踩的细节需要注意。Viewport上要挂Mask组件保证Item滑出显示区域后被裁剪否则弧线列表会穿帮。Content不要挂HorizontalLayoutGroup也不要挂ContentSizeFitter布局完全由代码控制否则LayoutGroup会在每帧布局时把Item的位置强制覆盖弧线效果出不来。Item的锚点需要统一设置为(0.5,0.5)Pivot也设为(0.5,0.5)这样在代码里做缩放旋转时不会产生额外的偏移。3.2 完整脚本与逐段解析直接给一份可以直接使用的完整脚本我把它写成继承ScrollRect的形式这样在Inspector上看起来就是一个增强版ScrollRect不需要额外挂组件。using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class ArcScrollList : ScrollRect { [Header(布局参数)] public float itemSpacing 200f; [Header(弧线参数)] public AnimationCurve weightCurve AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); public float curveHeight 120f; public float radius 2.2f; public float maxScale 1.25f; public float minScale 0.8f; public float maxRotate 14f; public int rotateDirection -1; [Header(回中参数)] public bool snapToCenter true; public float snapSpeed 12f; private readonly ListRectTransform items new ListRectTransform(); private bool dragging; protected override void Awake() { base.Awake(); horizontal true; vertical false; movementType MovementType.Unrestricted; inertia false; foreach (RectTransform child in content) { child.anchorMin Vector2.one * 0.5f; child.anchorMax Vector2.one * 0.5f; child.pivot Vector2.one * 0.5f; items.Add(child); } LayoutItems(); } protected override void Start() { base.Start(); SetCenteredIndex(0); } private void LayoutItems() { if (items.Count 0) return; content.sizeDelta new Vector2(items.Count * itemSpacing, content.sizeDelta.y); for (int i 0; i items.Count; i) { Vector2 pos items[i].anchoredPosition; pos.x i * itemSpacing; items[i].anchoredPosition pos; } } public void SetCenteredIndex(int index) { if (items.Count 0) return; index Mathf.Clamp(index, 0, items.Count - 1); content.anchoredPosition new Vector2(-index * itemSpacing, content.anchoredPosition.y); } public override void OnBeginDrag(PointerEventData eventData) { base.OnBeginDrag(eventData); dragging true; } public override void OnDrag(PointerEventData eventData) { base.OnDrag(eventData); } public override void OnEndDrag(PointerEventData eventData) { dragging false; base.OnEndDrag(eventData); } protected void Update() { if (items.Count 0) return; if (!dragging snapToCenter) { SnapToCenter(); } RefreshItems(); } private void SnapToCenter() { int nearest GetNearestIndex(); float targetX -nearest * itemSpacing; float minX Mathf.Min(0f, viewport.rect.width - content.rect.width); float maxX 0f; targetX Mathf.Clamp(targetX, minX, maxX); float curX content.anchoredPosition.x; float newX Mathf.Lerp(curX, targetX, Time.deltaTime * snapSpeed); if (Mathf.Abs(newX - targetX) 0.05f) { newX targetX; } content.anchoredPosition new Vector2(newX, content.anchoredPosition.y); } private int GetNearestIndex() { float exact -content.anchoredPosition.x / itemSpacing; return Mathf.Clamp(Mathf.RoundToInt(exact), 0, items.Count - 1); } private void RefreshItems() { for (int i 0; i items.Count; i) { RectTransform item items[i]; Vector2 viewportLocal (Vector2)viewport.InverseTransformPoint(item.position); float distance viewportLocal.x - viewport.rect.center.x; float maxRange itemSpacing * radius; float rawWeight 1f - Mathf.Clamp01(Mathf.Abs(distance) / maxRange); float weight weightCurve.Evaluate(Mathf.Clamp01(rawWeight)); Vector2 pos item.anchoredPosition; pos.y weight * curveHeight; item.anchoredPosition pos; float scale Mathf.Lerp(minScale, maxScale, weight); item.localScale new Vector3(scale, scale, 1f); float rotate rotateDirection * Mathf.Sign(distance) * Mathf.Lerp(maxRotate, 0f, weight); item.localRotation Quaternion.Euler(0f, 0f, rotate); } } }代码不长但有几个细节我特别说明下。第一movementType必须设为Unrestricted同时inertia要设为false。如果保持默认的Elastic模式列表滑动到边界时会自己弹回这个弹回机制会和我们自己的回中逻辑冲突列表会出现来回抖动。干脆全部交给自己的SnapToCenter来控制。第二LayoutItems()里手动设置了Item的x坐标之后RefreshItems只改y、scale和rotation绝不去碰x坐标。这是整个方案稳定运行的重要前提。Item的x坐标只在初始化时设置一次滑动过程中不会发生任何变化这样Content的宽度计算始终准确回中吸附也不会错位。第三viewport.InverseTransformPoint(item.position)这一步是关键。它把Item的世界坐标转换到viewport的本地坐标然后减去viewport.rect.center.x得到相对中心的距离。这样无论CanvasScaler如何缩放、无论Content移动到哪里距离计算都是准确的。第四rotateDirection参数在实际调效果时很有用。有的美术希望左边缘Item向顺时针方向倾斜有的希望反方向直接在Inspector里改成1或-1就能翻转不用改代码。3.3 在Unity中使用这套组件使用步骤非常简单把上述脚本挂到ScrollView节点上自动替换掉原来的ScrollRect组件。将items的预设体作为Content的子物体放好多少都行。在Inspector里把itemSpacing设为与Item实际宽度接近的值比如Item宽度是180间距可以设200。点击Play初始状态下第0项应该居中放大拖着滑动时其他项会呈现弧线分布。调整weightCurve看一下曲线形态是否满意。默认的EaseInOut曲线偏柔和如果希望更“弹”可以在曲线中间加一个高于1的点。如果你希望动态新增Item在代码里调用AddItem(RectTransform newItem)把新节点加入items列表后重新执行LayoutItems()即可。我习惯把这一层封装为对外接口不建议在运行时直接往Content下挂节点那样会绕过ArcScrollList的布局管理。3.4 快速变形扩展竖向弧线、左右不对称这套脚本稍微改几行就能支持竖向弧线列表。把horizontalfalse; verticaltrue;然后RefreshItems里把distance换成viewportLocal.y与rect.center.y的差y轴偏移改成x轴偏移旋转改成绕Y轴转效果就是竖向轮盘。如果你想要左右不对称的弧线比如左边Item保持平铺、右边Item才弧线收拢可以把权重计算改成只处理distance大于0的那一侧if (distance 0f) { ... 应用弧线权重 ... } else { ... 使用线性权重或直接重置 ... }这种需求多出现在“左侧是详情列表、右侧是引导箭头”的半屏布局里。原理不变只需要调整weight的计算区间即可。4. 进阶变形方案与平台优化4.1 用BaseMeshEffect实现真正的面板弧面弯曲RectTransform权重方案只能逐Item变形它不能把整块Content区域做成一个连续弧面。如果你想让整个滑动面板像手机曲面屏一样弯曲那就需要上顶点级方案。思路是继承BaseMeshEffect并重写ModifyMesh让UI顶点按x坐标映射成y偏移。一个很简单的示例public class CurvedPanelEffect : BaseMeshEffect { public float curvature 100f; public override void ModifyMesh(VertexHelper vh) { ListUIVertex verts new ListUIVertex(); vh.GetUIVertexStream(verts); for (int i 0; i verts.Count; i) { UIVertex v verts[i]; float t Mathf.Clamp01(v.position.x / rectTransform.rect.width); v.position.y Mathf.Sin(t * Mathf.PI) * curvature; verts[i] v; } vh.Clear(); vh.AddUIVertexTriangleStream(verts); } }这个方案的问题是Unity默认的Image网格只有4个顶点sin曲线在4个点上做插值根本不够平滑边缘会有明显折线。要真正平滑要么用带高多边形网格的Sprite要么实现一个自定义MaskableGraphic把面板细分成很多段。工作量不小而且在微信小游戏和WebGL环境下顶点的GPU运算和Mask裁剪都可能出现兼容问题。我的建议是普通滑动列表优先用RectTransform权重方案只有当整块UI面板需要像弧形屏幕一样弯曲时才考虑网格顶点或Shader方案并且提前在目标平台做兼容性测试。4.2 对象池与可见项裁剪优化如果你列表项达到上百个每帧遍历所有Item改Transform会造成不必要的CPU开销。优化方式很简单在RefreshItems里只处理可见区间的Item。int centerIndex Mathf.RoundToInt(-content.anchoredPosition.x / itemSpacing); int visibleCount Mathf.CeilToInt(viewport.rect.width / itemSpacing / 2f) 2; int startIndex Mathf.Max(0, centerIndex - visibleCount); int endIndex Mathf.Min(items.Count, centerIndex visibleCount); for (int i 0; i startIndex; i) ResetItem(items[i]); for (int i startIndex; i endIndex; i) RefreshSingleItem(items[i]); for (int i endIndex; i items.Count; i) ResetItem(items[i]);这里的visibleCount多加2是为了保证屏幕外稍微远一点的Item也提前进入弧线状态避免滑动过程中突然“跳”出来。ResetItem就是把Item重置成y0、scale1、rotation0的状态确保看不远处的Item不会残留在变形状态。如果列表项是动态生成且数量不固定建议配合对象池使用。滑动时只维护中心附近若干Item远处的Item直接隐藏需要时再从对象池拉取。这样即使列表有几万个关卡节点性能也稳得住。4.3 Pico/MR环境下的适配经验Pico这类MR设备上做弧线选人最合适的方案通常是World Space Canvas。ArcScrollList的代码在World Space Canvas下依然能跑因为viewport.InverseTransformPoint本来就是世界坐标运算不依赖Screen Space Overlay的模式。但有几个点需要额外注意。第一MR环境里UI通常不是看着屏幕点鼠标而是用手柄射线或眼球注视来交互所以Item上要挂可以响应射线事件的脚本并且确认EventSystem使用的是XR Ray Interactor不是普通鼠标输入。第二World Space Canvas的尺寸要按实际世界单位设置scale不能太大也不能太小否则手柄射线很难精确点中。第三弧线列表如果包含深度偏移比如Item绕Z轴旋转后互相遮挡需要在Canvas的SortingOrder或Item的层级上做规划否则可能出现后一个Item盖住前一个Item的视觉错误。4.4 移动端与微信小游戏的调优清单移动端性能瓶颈通常不在这几十个RectTransform的遍历上而在于Canvas的重建开销和Mask的裁剪计算。弧线滑动列表里每一帧都有Item在改Transform这些改动会触发Canvas的脏标记导致CanvasRebuild频繁发生。实测下来十几二十个Item的列表不会有什么压力但如果同一屏幕存在多个弧线列表、又叠加了粒子特效和大量UI动画就需要做减法。我给几项实际管用的调优建议关闭Canvas上的Pixel Perfect选项这个选项在移动端会额外增加采样开销。Mask组件尽量不要嵌套一个弧线列表只在外层Viewport挂一个Mask不要在Content上再挂Mask。把弧线列表使用的图片尽量打进同一张图集减少Material切换次数。微信小游戏打包时注意Texture压缩格式UI图集建议用ASTC或ETC2避免内存峰值过高。避免在RefreshItems里产生临时GC比如不要每帧new List、不要频繁ToString。代码里只有几个Vector2变量的局部运算GC压力很小但如果自己二次开发时加入了Debug.Log就要小心。5. 常见问题排查与避坑实录5.1 Item全部挤在一起或位置错乱出现这个现象十有八九是Content上有LayoutGroup或者Item的锚点不统一。HorizontalLayoutGroup会接管Item的水平位置把它自己的排序逻辑强加给Content导致你在LayoutItems里设置的x位置被覆盖。解决办法是移除Content上的所有LayoutGroup与ContentSizeFitter同时把Content的Pivot和Anchor统一到(0.5,0.5)。我调试时还遇到过一种情况Item没有全部作为Content的直接子物体中间多了一层容器。ArcScrollList只遍历content的直接子物体如果Item被包了一层自然就识别不到。保持Item是Content的直接子物体否则需要调整遍历逻辑。5.2 弧线高度异常或比例不对如果弧线效果出现但看起来很夸张或者完全看不出弧线效果先检查CanvasScaler的缩放和viewport的rect宽度。因为我们用的是viewport本地坐标计算距离scale factor的影响已经被排除但viewport.rect.width本身是随屏幕宽高变化的。在编辑器中把Game视图切到不同分辨率观察弧线范围是否符合预期。另一种可能是radius设置太小。比如itemSpacing是200radius是2.2那么影响范围是440像素。如果Viewport宽度只有600那么在viewPort中心左右各440像素刚好覆盖所有Item但如果Item总共只排了3个两个边缘Item可能一直处于被压扁的状态。调整时优先把radius设置到可视Item数量的一半左右再根据效果微调。5.3 点击Item没有反应弧线列表因为改变了Item的旋转和缩放有时候会发生点击区域和视觉位置对不上的问题。大多数情况下是ScrollRect自身的拖拽判定吞掉了点击而不是Button失效。你可以检查EventSystem上的DragThreshold设置在真机上适当调小或者给Item的Button自己处理PointerClick事件。如果中心Item被其他Item遮挡点击会的优先级会出问题。我的经验是给不同层级的Item设置不同的sortingOrder中心项最大就让它排在最上层两边缩小后自然排后面。在UGUI里没有严格意义的深度排序但可以通过调整Item在Content下的兄弟顺序来实现越后面的子物体渲染越靠前。5.4 回中抖动或滑动时出现顿挫回中抖动最常见的原因是系统回弹和手动回弹同时生效。我建议始终把movementType设为Unrestricted并且关掉inertia。如果你需要惯性滑动效果可以在OnEndDrag里根据eventData.delta.x设置content的velocity然后自己写一个速度衰减逻辑不要依赖ScrollRect自带惯性。另外回中动画的Mathf.Lerp用的是Time.deltaTime帧率不稳时抖动会比较明显这时可以改用固定帧率的时间插值或者把SnapSpeed调低。实测SnapSpeed设置在10到15之间比较合适太小拖沓太大容易过冲。5.5 常见问题速查表现象可能原因处理办法Item垂直堆叠成一行Content上挂了VerticalLayoutGroup移除所有LayoutGroup只用代码布局弧线不连续中间有跳变Item间距和itemSpacing不一致确认itemSpacing与初始化设置的x坐标一致边缘Item完全消失radius过小Item被移出viewport外增大radius或检查viewport宽度弧线在Start后错位初始化SetCenteredIndex时Item还没布局在LayoutItems之后再调用SetCenteredIndex微信小游戏上文字模糊Dynamic字体在低质量渲染开启Font的DynamicDensity或使用图集字列表滑到边界无法回正Content宽度和viewport宽度计算不对用Clamp限制targetX在[minX,0]区间6. 一点实际使用中的体会弧线滑动列表看起来是个视觉特效本质还是距离权重映射的问题。把“距离”这个概念想清楚Item的变形就水到渠成剩下的都是参数调优。真正让这套方案从“能跑”变成“好用”的还是那几条经验Item的x坐标只设一次距离计算统一走viewport本地坐标移动端优先裁剪不可见项。这几个决策让我在后续好几个项目里都没有回头再填坑。最后再分享一个小技巧使用ArcScrollList时建议把weightCurve单独存成一个AnimationCurve资产文件而不是保留在场景里。这样策划在调整曲线形状时不会污染场景数据多个列表共用一条曲线时也方便统一调整。如果需要在编辑器里直接预览效果可以在脚本中加一个[ExecuteInEditMode]标签但记得用if (!Application.isPlaying) LayoutItems();做保护避免编辑器反复执行布局逻辑。