
简介面向Unity3d开发者的UGUI轮播图功能源码包基于ScrollRect组件解决UGUI缺少现成轮播功能的痛点。方案完整覆盖自动/手动切换、平滑动画、导航指示器、无限轮播、鼠标悬停暂停、拖拽切换及竖向轮播等常见交互适用于需要快速集成轮播效果的UI开发场景。工程基于Unity 2020.3.28f1c1构建共408个文件、约3.47MB主要包含17个C#业务脚本、132张PNG图片素材、21个Asset资源文件以及XML配置、DLL动态库、Unity场景等配套内容脚本与预设可按需抽取复用。资源已有1242人学习浏览。压缩包内提供可直接参考的完整Unity工程目录通过查看Content位置计算、DOTweenPro动画调用等核心代码可理解基于ScrollRect实现轮播的完整思路并进一步扩展应用到自己的项目中。适合具备一定UGUI基础、希望自定义轮播效果的Unity开发者。1. 用 UGUI ScrollRect 做轮播图比你想的更省事在 Unity 项目里做轮播图很多人的第一反应是去 Asset Store 找现成插件或者自己拼一排 Image 再写开关逻辑。但实际把 UGUI 的 ScrollRect 滚动模型吃透之后你会发现轮播图最稳的实现路径恰恰是复用这套自带触摸滑动、惯性、边界处理的组件体系。它不是为轮播而生但它把轮播图最繁琐的交互层全包了你要补的只是页码定位、自动播放和循环回绕的 C# 逻辑。这篇笔记会把一条能直接落地的做法完整拆开覆盖 UI 搭建、脚本实现、参数调优和踩坑记录适合正在做 Unity 客户端的 UI 开发照着复用也适合刚接触 UGUI 的开发者理解滚动组件的本质。2. 吃透 ScrollRect 的组件结构轮播图到底在滚动什么2.1 Viewport 与 Content 的主从关系决定轮播图能否滚得动UGUI 的 ScrollRect 组件不复杂但新手翻车基本都翻在层级结构上。一个标准的 ScrollRect 层级是Canvas 下的 ScrollRect 节点带一个 Viewport 子节点Viewport 下面再挂 ContentContent 下面放真正的轮播图 Item。ScrollRect 组件本身挂在外层节点上Viewport 被指定为它的 viewport 字段Content 被指定为它的 content 字段两者缺一不可。滚动到底是怎么发生的ScrollRect 并不通过移动 Viewport 来实现滚动而是在内部实时修改 Content 的 anchoredPosition。换句话说Content 的 RectTransform 锚点属性会被 ScrollRect 接管你看到的所有滑动效果本质都是 Content 在可视窗口内被平移。所以轮播图滚动的本质就是把 Content 的 anchoredPosition 从当前位置平滑移动到目标位置目标位置由当前页码和单个 Item 的宽度共同决定。这里需要先认识 ScrollRect 的几个核心字段viewport 指定可视区域content 指定滚动区域horizontal / vertical 两个布尔开关决定滚动轴。做轮播图只关心水平轴vertical 必须关掉。实际项目里我一般直接取消 vertical 勾选而不是在代码里反复判方向因为 vertical 开着会让 Canvas 在竖屏设备上产生纵向滚动的干扰哪怕 Content 高度没有超出 Viewport组件内部也会多算一次纵向边界。如果你直接去看 UGUI 源码解析ScrollRect 的 LateUpdate 里会同时处理 x 和 y 两个方向的 clamped position横竖两个开关全开着会拖累性能。2.2 三个必调的滚动参数MovementType、Inertia、ScrollSensitivityMovementType 是轮播图里最关键的参数它控制 Content 滚出边界时的表现。Unrestricted 表示可以无限拖出边界Elastic 表示超出边界后有橡皮筋回弹Clamped 表示滚到最边缘就卡死。自动轮播图建议选 Clamped因为配合边缘对齐逻辑时它不会让 Content 被拖出可视区域页码计算会简单很多。如果你把 MovementType 设成 Elastic用户快速滑动到最后一页时会看到内容被拽出 Viewport 再弹回去视觉上非常掉价。Inertia 控制松手后的惯性滑动。做轮播图时 Inertia 必须保留否则手动滑动会变成“手指停画面立刻停”的生硬手感。但惯性也是自动播放最容易打架的地方用户快速一甩Content 还在减速滑动自动播放协程又去设置目标位置两个写入方同时操作 anchoredPosition画面就会抖动。解决方案不是关掉 Inertia而是在拖拽结束后等 ScrollRect 的 velocity 降到接近 0 再做页码对齐这个细节后面会专门讲。ScrollSensitivity 是滚动灵敏度控制鼠标滚轮和触摸滑动与 Content 位移的缩放比例。默认值 1 即可通常不需要动。如果你做的是那种一滑到底的整页翻页器可以把它调到 1.5 到 2 让手势更跟手但要注意它也会影响惯性结束后的 velocity 大小会让自动对齐的逻辑变得不稳定。参数推荐值作用MovementTypeClamped限制滚动边界避免露出空白底色Inertia开启保留松手后的惯性手感ScrollSensitivity1默认灵敏度调大则滑动手感更激进horizontal / verticalhorizontal 开vertical 关只保留水平轴滚动2.3 为什么要用 ScrollRect 而不是自己写手势轮播自己实现一套轮播图的手势交互最大的隐患不是滑动判定而是多点触控、屏幕坐标到 UI 坐标的换算、以及 Content 尺寸变化时的边界修正。Unity 的 ScrollRect 把这些底层问题都解决完了并且内部实现了惯性的自然衰减系数你只需要补齐轮播图业务逻辑翻页对齐、自动播放、循环跳转。这也是为什么 unity 之 ugui 系统基础里ScrollRect 是最值得深挖的组件之一它不是简单的列表控件而是一套完整的滚动状态机。3. 搭建轮播图的最小可运行版本从 UI 层级到自动播放脚本3.1 UI 层级与 RectTransform 关键尺寸设置场景搭建步骤如下。第一步在场景里新建 CanvasCanvas 的 Render Mode 保持默认的 Screen Space - Overlay这个模式下 RectTransform 的尺寸计算最直接适合纯 UI 的轮播图。第二步在 Canvas 下创建一个空物体命名 ScrollRectPage挂上 ScrollRect 组件再给它一个 Image 作为背景占位因为 ScrollRect 需要能接收射线检测没有 Graphic 的话拖拽事件会穿透到下层 UI。第三步在 ScrollRectPage 下创建 Viewport 子物体挂上 RectMask2D 组件这一步必须加不加 Mask 的话你会看到所有 Item 平铺显示在 Content 区域外面画面直接穿帮。第四步在 Viewport 下创建 Content。这里有个容易忽略的点Content 的锚点要设置为左上角也就是 anchorMin 和 anchorMax 都是 (0, 1)pivot 也设为 (0, 1)。这样 Content 的 anchoredPosition 就从左上角开始计算向左滚动时 x 为负值页码和偏移量的换算最直观。尺寸上Viewport 的宽高决定可视区域Content 的宽高必须大于 Viewport。比如 Viewport 宽 1400Content 里放 5 张图每张 1400 宽Content 总宽就应该是 7000。Content 的宽不要在编辑器手动填建议在脚本里算因为轮播图 Item 数量一变手填迟早出错。3.2 动态生成 Item 并缓存宽度下面的脚本负责按数据源生成 Item并维护 Content 的宽度。注意 Item 的锚点统一放在 Content 的左侧中点通过遍历累加 totalWidth 来平铺这样每个 Item 的 anchoredPosition 就是它相对于 Content 左边界的偏移量。using UnityEngine; using UnityEngine.UI; public class CarouselGenerator : MonoBehaviour { [SerializeField] private ScrollRect scrollRect; [SerializeField] private RectTransform content; [SerializeField private GameObject itemPrefab; [SerializeField] private float itemWidth 1400f; [SerializeField] private Sprite[] sprites; private void Start() { GenerateItems(); } private void GenerateItems() { // 先清掉 Content 下残留的测试节点 for (int i content.childCount - 1; i 0; i--) { Destroy(content.GetChild(i).gameObject); } // 按数据源数量生成 Item float totalWidth 0f; for (int i 0; i sprites.Length; i) { GameObject item Instantiate(itemPrefab, content); Image img item.GetComponentInChildrenImage(); img.sprite sprites[i]; RectTransform itemRect item.GetComponentRectTransform(); itemRect.sizeDelta new Vector2(itemWidth, content.sizeDelta.y); // 每个 Item 的锚点都指向 Content 的左侧中点按 x 坐标平铺 itemRect.anchorMin new Vector2(0f, 0.5f); itemRect.anchorMax new Vector2(0f, 0.5f); itemRect.pivot new Vector2(0f, 0.5f); itemRect.anchoredPosition new Vector2(totalWidth, 0f); totalWidth itemWidth; } // 更新 Content 宽度否则 ScrollRect 可滚动的范围是错的 content.sizeDelta new Vector2(totalWidth, content.sizeDelta.y); scrollRect.content content; } }这段代码的核心逻辑在最后几行。Item 的锚点统一放在 Content 的左侧中点通过累加 totalWidth 平铺Content 的宽度就是 itemWidth 乘以 Item 数量。为什么要手动设置锚点位置而不是挂 HorizontalLayoutGroup因为轮播图后期要做无限循环和回绕Content 的宽度和定位经常被脚本修改LayoutGroup 会在尺寸变化时自动重排子物体反而覆盖掉你为了做循环而临时设置的位置这里手动控制是最可靠的。参数方面itemWidth 不要直接用写死常量建议在 Inspector 里配置。美术资源分辨率一变写死的宽度就是第一个坑。content.sizeDelta.y 保持和 Viewport 高度一致Item 的 height 不要超过 Viewport 高度否则会产生纵向裁剪。3.3 自动轮播的核心协程与平滑移动接下来是自动播放脚本。我用协程而不是在 Update 里写 Lerp原因是协程可以方便地暂停和重启当用户开始拖拽时可以用同一个协程句柄停掉自动播放拖拽结束后再重新启动。Update 写法也能实现但状态切换的代码会绕很多可读性差。using System.Collections; using UnityEngine; using UnityEngine.UI; public class AutoCarousel : MonoBehaviour { [SerializeField] private ScrollRect scrollRect; [SerializeField] private RectTransform content; [SerializeField] private float itemWidth 1400f; [SerializeField] private float autoPlayInterval 3f; [SerializeField] private float smoothTime 0.3f; [SerializeField] private int totalPages 5; private int currentPage; private bool isDragging; private Coroutine autoPlayRoutine; private void OnEnable() { autoPlayRoutine StartCoroutine(AutoPlayLoop()); } private void OnDisable() { if (autoPlayRoutine ! null) { StopCoroutine(autoPlayRoutine); } } private IEnumerator AutoPlayLoop() { while (true) { yield return new WaitForSeconds(autoPlayInterval); // 手指按住时不自动翻页避免手动和自动冲突 if (isDragging) continue; currentPage (currentPage 1) % totalPages; yield return StartCoroutine(SmoothScrollToPage(currentPage)); } } private IEnumerator SmoothScrollToPage(int page) { float targetX -page * itemWidth; Vector2 startPos content.anchoredPosition; Vector2 targetVec new Vector2(targetX, startPos.y); float elapsed 0f; while (elapsed smoothTime) { elapsed Time.deltaTime; // SmoothStep 让滚动前段加速后段减速接近手指惯性手感 float t Mathf.SmoothStep(0f, 1f, elapsed / smoothTime); content.anchoredPosition Vector2.Lerp(startPos, targetVec, t); yield return null; } // 强制落点消除浮点误差导致的像素差 content.anchoredPosition targetVec; } public void OnBeginDrag() { isDragging true; } public void OnEndDrag() { isDragging false; currentPage Mathf.RoundToInt(-content.anchoredPosition.x / itemWidth); currentPage Mathf.Clamp(currentPage, 0, totalPages - 1); } }SmoothScrollToPage 用了 SmoothStep 对时间做曲线映射这样页面切换不会像幻灯片一样生硬。autoPlayInterval 建议取 3 到 5 秒小于 2 秒用户还没看清内容就翻了太久又显得轮播图迟钝。smoothTime 控制在 0.2 到 0.4 秒短了突兀长了反应慢。OnEndDrag 里用 RoundToInt 去拿当前最近的页码是因为手指松手后 Content 不一定正好停在整数页必须做就近对齐。4. 首尾无缝衔接无限循环轮播的实现与 Content 回绕技巧4.1 克隆首尾 Item实现视觉上的无限循环自动轮播到最后一页以后如果要继续循环直接跳回第一页就会看到明显的反向滚动动画体验很差。最常见的做法是克隆首尾 Item在 Content 末尾追加一张第一张图的克隆视觉上内容和第一页完全一致然后让轮播图继续向左滚动到克隆页。滚动到克隆页时画面和第一页相同此时瞬间把 Content 的 anchoredPosition 修正到第一页的位置用户看不出任何跳跃。以一个真实的 5 页轮播为例Content 里放 6 个 Item第 0 到第 4 页是真实图片第 5 页是第一张图的克隆。自动播放页码从 0 走到 5显示的内容依次是 图1、图2、图3、图4、图5、图1克隆当页码变成 5 时在滚动结束的那一帧直接让 currentPage 回到 0Content 的 x 坐标瞬间从 -5 * itemWidth 变成 0。因为第 5 页和第 0 页画面完全一致这个跳转对用户是不可感知的。4.2 回绕时的位置修正与 ScrollRect 惯性冲突回绕修正看起来简单真正落地的时候有个隐藏坑ScrollRect 的 LateUpdate 会持续处理 Content 的 velocity 和位置修正。如果你只是把 anchoredPosition 硬改成 0ScrollRect 内部还认为 Content 在末尾下一帧它可能会因为你设置的 velocity 或者内部边界值把 Content 又拉回去几百个像素表现为画面回绕之后又猛地弹一下。正确顺序是先调用 scrollRect.StopMovement() 把内部 velocity 清零再设置 Content 的 anchoredPosition。StopMovement() 存在某些老版本 Unity 中不可用那就手动设置 scrollRect.velocity Vector2.zero。这个顺序不能反。4.3 循环切换的完整实现代码下面的脚本在 3.3 的基础上增加回绕逻辑。真实页数 realPageCount 对应原始数据数量视觉 Item 数量是 realPageCount 1。自动播放协程在每次滚动完成后检查是否滚到了克隆页如果是就执行瞬间回绕。using System.Collections; using UnityEngine; using UnityEngine.UI; public class InfiniteCarousel : MonoBehaviour { [SerializeField] private ScrollRect scrollRect; [SerializeField] private RectTransform content; [SerializeField] private float itemWidth 1400f; [SerializeField] private float autoPlayInterval 3f; [SerializeField] private float smoothTime 0.3f; [SerializeField] private int realPageCount 5; private int currentPage; private bool isDragging; private Coroutine autoPlayRoutine; private void OnEnable() { autoPlayRoutine StartCoroutine(AutoPlayLoop()); } private void OnDisable() { if (autoPlayRoutine ! null) { StopCoroutine(autoPlayRoutine); } } private IEnumerator AutoPlayLoop() { while (true) { yield return new WaitForSeconds(autoPlayInterval); if (isDragging) continue; // 页码在 0 到 realPageCount 之间循环realPageCount 就是克隆页 currentPage (currentPage 1) % (realPageCount 1); yield return StartCoroutine(SmoothScrollToPage(currentPage)); // 滚到克隆页后瞬间回绕到第 0 页 if (currentPage realPageCount) { SnapToPage(0); } } } private IEnumerator SmoothScrollToPage(int page) { float targetX -page * itemWidth; Vector2 startPos content.anchoredPosition; Vector2 targetVec new Vector2(targetX, startPos.y); float elapsed 0f; while (elapsed smoothTime) { elapsed Time.deltaTime; float t Mathf.SmoothStep(0f, 1f, elapsed / smoothTime); content.anchoredPosition Vector2.Lerp(startPos, targetVec, t); yield return null; } content.anchoredPosition targetVec; } private void SnapToPage(int page) { // 先停掉惯性再改位置顺序反了会被 ScrollRect 拉回去 scrollRect.StopMovement(); scrollRect.velocity Vector2.zero; content.anchoredPosition new Vector2(-page * itemWidth, content.anchoredPosition.y); currentPage page; } public void OnBeginDrag() { isDragging true; } public void OnEndDrag() { isDragging false; currentPage Mathf.RoundToInt(-content.anchoredPosition.x / itemWidth); currentPage Mathf.Clamp(currentPage, 0, realPageCount); } }这段代码里的 SnapToPage 是回绕的核心。注意 Mathf.Clamp 的上界是 realPageCount因为克隆页索引就是 realPageCount。如果用户在克隆页手动松手currentPage 会是 realPageCount但不会有问题因为下一次自动播放会把它推回第 0 页。这里要注意一个业务细节如果产品要求支持从第 0 页往回滑动直接到最后一页克隆尾部的方案还不够需要在头部也克隆一张最后一页的 Item做双向回绕。那个方案的状态判断会多一倍大多数产品用单向循环就够了。5. 常见问题排查ScrollRect 轮播图的 5 个高频坑5.1 现象Content 拖不动手指滑动没有反应原因一般出在层级或者 Mask 上。Viewport 没挂 RectMask2D或者 Content 宽度小于等于 Viewport 宽度ScrollRect 内部判断可滚动范围为 0直接不响应拖拽。另一个常见原因是你给 ScrollRect 外层节点加的 Image 没有勾选 Raycast Target射线检测不到拖拽事件传不到 ScrollRect。解决先确认 Viewport 上有 RectMask2D再确认 Content 的 sizeDelta.x 比 Viewport 的宽度大最后确认 ScrollRect 外层节点有可接收射线的 Graphic。调试时可以在 Scene 视图把 Gizmos 打开看 Content 的可视范围是否超出 Viewport 边界。5.2 现象自动轮播和手动拖拽交叉后画面抖动自动播放协程还在运行用户开始拖拽两个写入方同时修改 Content 的 anchoredPosition画面自然抖动。这个坑的隐蔽之处在于即使用了 isDragging 标记协程可能已经进入了 SmoothScrollToPage 的 while 循环拖拽标记只能拦在翻页前拦不住正在执行的 Lerp。解决在 OnBeginDrag 里不仅仅设置 isDragging还要主动停掉正在执行的滚动协程并且把 autoPlayRoutine 引用置空确保拖拽期间没有任何协程在写 anchoredPosition。拖拽结束后再重新启动 AutoPlayLoop。5.3 现象滚到最后一页直接露出一大块透明空白原因MovementType 被设置成 Elastic或者 Content 的宽度只算了真实页数没有算克隆页。Elastic 模式下 Content 可以被拖出边界松开后回弹但在回弹前用户会看到 Viewport 底下的空白区域。如果是克隆页方案Content 宽度没加上克隆 Item 的 itemWidthScrollRect 就会认为滚到 -5 * itemWidth 已经超出边界强制把 Content 钳制回去。解决统一把 MovementType 改成 Clamped。Content 宽度必须等于 itemWidth 乘以视觉 Item 总数也就是 realPageCount 加 1。5.4 现象Lerp 滚动永远差几个像素到不了目标位置SmoothScrollToPage 的 while 循环退出条件用的是 elapsed smoothTime但因为浮点误差和 SmoothStep 的曲线特性最后一帧 Lerp 的结果不会精确等于 targetVec。如果不去修正Content 会停在目标位置附近下一次计算页码时取整产生偏差轮播几次后位置就偏移了。解决循环结束后强制 content.anchoredPosition targetVec把误差清零。如果是 Lerp 用 Time.deltaTime 插值的方式做的滚动每次帧率波动都会改变滚动速度最好换成协程加固定时长的方式就是前面代码里的写法。5.5 现象无限循环跑几圈之后页码错乱中间突然跳页原因currentPage 的取模边界写错。比如真实 5 页你写 currentPage (currentPage 1) % 5那 currentPage 永远到不了克隆页索引 5轮播图走到第 4 页后直接回跳第 0 页画面就会有一个明显的反向滚动。还有一种情况是回绕时没有调用 scrollRect.StopMovement()惯性把 Content 又推回末尾currentPage 已经变成 0 但实际坐标还在 -5 * itemWidth。解决统一用 realPageCount 1 作为取模和 Clamp 的上界回绕必须按照 4.3 代码里的顺序先停惯性再改坐标。6. 验证轮播图组件的方法与进一步扩展的方向验证这套轮播图组件能不能上线我一般会做三件事。第一在 Game 视图打开 Stats 面板滚动起来看帧率重点观察 SetVerticesDirty 的次数如果每帧都有 Canvas 重建说明图片资源或 Text 组件在频繁变化需要把 Item 里的静态元素全部烘焙减少重建开销。第二故意把 itemWidth 改大改小再重新运行确认每个 Item 都能完美居中对齐没有像素偏差。第三用自动播放长时间跑半小时观察是否出现页码漂移这是回绕逻辑最常见的隐患。进阶方向上值得做的是惯性对齐优化。现在 OnEndDrag 直接把页码取整用户快速滑动时一次只能翻一页手感比较死。可以在 OnEndDrag 读取 scrollRect.velocity.x 的方向和大小速度绝对值大于某阈值时直接跳转两页小于阈值时回退当前页。这个参数很玄学但调好的效果比整页翻更接近原生 App 的轮播手感。如果 Item 里放的是视频资源可以结合 unity3d 视频流的思路把 Image 换成 RawImage用 VideoPlayer 把纹理输出到 RawImage.texture。翻页切换后立刻暂停非当前页的视频播放否则同时跑多个视频解码会把移动端性能直接拖垮。这里要注意 RawImage 不受 Mask 裁剪的纹理 UV 影响放在 RectMask2D 下是安全的不用担心显示穿帮。最后说一下我自己的习惯这套轮播组件里最忌讳的是每做一个页面就复制一份脚本去改参数。我会把 itemWidth 和 autoPlayInterval 全部走配置化在编辑器里用 SerializeField 暴露数据源走一个统一的 List 接口这样后来接手的人只需要替换数据源和图片资源不用碰滚动逻辑。这个习惯帮我省掉了很多次 UI 改版的时间。以上这些调整都是我在项目里踩过坑之后总结的希望帮到你。本文还有配套的精品资源点击获取