简介:Viwepager.zip 是一份面向 Unity3D 初中级开发者与 UI 交互学习者的图片轮播项目实例,围绕图片展示、切换动画与界面交互展开,可用于活动图集、产品展示页或信息轮播界面的学习参考。压缩包共 405 个文件,约 42.29MB,以 119 个 png 与 15 个 jpg 图片素材、17 个 C# 脚本、21 个 asset 配置及 meta、dll 插件文件为主,完整保留 ProjectSettings、Assets、Packages 等工程目录,便于直接导入 Unity 梳理项目组织方式。项目涉及 UI Canvas 搭建、脚本控制图片加载与切换、淡入淡出等过渡效果、触摸与鼠标事件适配,以及 Sprite Atlas、懒加载等性能优化思路,并包含 DOTween 相关扩展文件,可帮助读者理解动画与资源管理的落地写法。目前已有 2340 人学习,适合作为课程设计、个人练习或界面原型开发的对照案例。
1. Unity3D 横向翻页:一份 ViewPager 组件包能省掉哪三天
Unity3D 里做横向翻页,多数人第一反应是给 UGUI 的 ScrollRect 挂个 Content,拖拽能滑就交差。真到验收就露馅:松手停在哪全看手感,页与页之间没有吸附,滑到第一页和最后一页硬邦邦顶死,指示点还得自己算偏移量去同步。我见过一个小组为了「一划一页、边界回弹、无限循环」这三件事,前后改了三天,最后代码里堆了四个协程互相打断。Viwepager.zip 这份资源走的是另一条路——把安卓上 ViewPager 那套「按页吸附、速度判定、环形复用」的交互模型搬到 Unity 的 UGUI 体系里,做成可直接挂载的翻页容器。它适合正在做卡片轮播、引导页、商品横滑列表的 Unity3D 开发者,也适合想把翻页手感做扎实、又不想从零推导插值公式的人。
2. 拆包先看骨架:ViewPager 这套组件的脚本分工与导入顺序
拿到压缩包不要急着整个拖进 Assets。翻页类组件最容易出的问题不是功能不全,而是它跟工程里已有的 UI 框架抢同一批职责:谁来管 Content 的 anchoredPosition、谁来接管拖拽事件、谁负责子项的显隐。先把骨架拆清楚,后面调参才有支点。
2.1 解压后的目录结构与职责归类
包内命名以实际解压结果为准,这里按职责归类,方便你打开工程后对号入座。一个完整的翻页组件通常分成四层:容器层、页面项层、交互层、表现层。
| 层级 | 典型内容 | 职责 | 是否需要改 |
|---|---|---|---|
| 容器层 | 挂 ViewPager 主脚本的节点、Viewport、Content | 持有页数、当前页、间距、回弹参数,负责整体位移 | 参数必调 |
| 页面项层 | 单页 Prefab 及其绑定脚本 | 接收数据、刷新自身显示、上报点击 | 按业务改 |
| 交互层 | 拖拽/点击/惯性处理的脚本 | 把指针事件翻译成「目标页」 | 少量改 |
| 表现层 | 指示点、页码文本、缩放淡出效果 | 响应页码变化 | 按需接 |
先把压缩包解到工程外的临时目录,用文件管理器看一眼层级,确认没有嵌套的 Assets/Assets 双层结构——不少打包工具会把顶层目录一起压进去,直接拖进工程会多出一层无用文件夹,后面引用路径全对不上。
# 解压后在临时目录里看两层结构,确认没有重复的 Assets 目录 unzip -l Viwepager.zip | head -40 # 只看脚本文件,快速判断语言与命名空间 find ./Viwepager -name "*.cs" -maxdepth 4 | sortunzip -l只列清单不解压,用来确认压缩包内部路径;find加-maxdepth是为了避免翻进无关的示例工程资源目录。看.cs文件列表时重点看两件事:有没有namespace声明、有没有引用第三方补间库。前者决定你要不要在自己的业务脚本里加 using,后者决定导入后会不会一片编译错误。
2.2 导入顺序与依赖检查
导入顺序不是玄学,是编译顺序问题。正确做法是先放运行时脚本,再放 Prefab 与材质,最后放示例场景。原因是 Prefab 上挂的脚本如果还没进工程,Unity 会把它标记成 Missing Script,等你补进脚本后组件引用也不会自动恢复,得手动重挂。
# 按依赖顺序分批拷贝,避免 Missing Script cp -r ./Viwepager/Runtime ./Assets/Viwepager/ cp -r ./Viwepager/Prefabs ./Assets/Viwepager/ cp -r ./Viwepager/Samples ./Assets/Viwepager/这三条命令的关键在于分批:第一批只进运行时代码,等 Unity 编译完、Console 清空,再进 Prefab。如果包里有.asmdef程序集定义文件,注意它会把你这些脚本圈进独立程序集,业务层要引用就必须在自己的 asmdef 里加依赖,否则会出现「明明脚本在、就是找不到类型」的怪现象。我一般的检查顺序是:Console 有没有红字 → 示例场景能不能打开 → Prefab 上的脚本引用是否完整 → 再动业务代码。
提示:如果工程里已经有别的翻页实现,先禁用而不是删除。两套组件同时监听拖拽事件,表现是滑动时页面跳两格,排查起来非常费时间。
3. 最小可跑场景:从空场景到手指一划停一页
组件能编译不等于能用。翻页类资源最常见的失败是「场景搭出来了,但滑一下就飞出去」,根因基本都在 RectTransform 的锚点和尺寸上。这一章按「搭层级 → 配参数 → 接数据」的顺序走一遍最小闭环。
3.1 层级搭建与 RectTransform 参数
层级只有四层,别加第五层。多一层嵌套就多一次坐标换算,出问题时你会在三个坐标系之间来回怀疑人生。
| 节点 | Anchor | Pivot | 尺寸设置 | 说明 |
|---|---|---|---|---|
| PagerRoot | 居中 | 0.5, 0.5 | 固定宽高,如 1080×600 | 整个翻页区的可视范围 |
| Viewport | 拉伸铺满父级 | 0.5, 0.5 | 四边偏移 0 | 加 RectMask2D 裁剪 |
| Content | 左中 | 0, 0.5 | 高度铺满,宽度由页数算 | 承载所有页项 |
| PageItem | 左中 | 0, 0.5 | 宽 = 视口宽,高铺满 | 单页 Prefab |
关键点在 Content 的 Pivot 必须是0, 0.5,也就是左边缘中点。因为翻页位移是靠改anchoredPosition.x实现的,Pivot 在中心时,第 0 页的偏移量是-viewportWidth/2而不是 0,公式里会多一个常数项,调参时特别容易算错。Viewport 上挂RectMask2D而不是Mask,前者不产生额外的模板缓冲,页数多的时候差距明显。
3.2 初始化代码与数据绑定
下面这段是场景跑起来的最小闭环。接口名按包内实际脚本调整,逻辑顺序不变:先告诉容器有多少页,再设初始页,最后挂翻页回调。
using UnityEngine; using UnityEngine.UI; public class PagerDemo : MonoBehaviour { [SerializeField] private MonoBehaviour pager; // 场景里的翻页容器组件 [SerializeField] private Button nextButton; [SerializeField] private Text pageLabel; private readonly string[] titles = { "卡片A", "卡片B", "卡片C", "卡片D" }; private int current = 0; private void Start() { // 1. 先注入页数,容器据此计算 Content 总宽度与页项布局 InvokePager("Init", titles.Length); // 2. 再设初始页,第二个参数为 false 表示不做入场动画 InvokePager("SetPage", 0, false); RefreshLabel(0); nextButton.onClick.AddListener(Next); } private void Next() { current = Mathf.Min(current + 1, titles.Length - 1); InvokePager("SetPage", current, true); RefreshLabel(current); } private void RefreshLabel(int index) { pageLabel.text = $"{index + 1} / {titles.Length}"; } }Init和SetPage这两个方法名是这类组件的通用签名,实际以包内脚本为准。顺序不能反:先SetPage后Init,容器内部记录的页数是 0,会被钳到第 0 页,看起来像「初始化没生效」。第三个参数控制是否播插值动画,用于区分「用户滑动」和「代码跳转」两种场景——代码跳转时关掉动画,避免页面从第一页一路飞过去的诡异表现。
3.3 参数调试:间距、吸附阈值、回弹曲线
参数表贴在下面,这几个值决定手感,改之前先备份默认值。
| 参数 | 常用取值 | 调大的效果 | 调小的效果 |
|---|---|---|---|
| Spacing(页间距) | 20~40 px | 页与页之间留白明显,单屏可见页数变少 | 页挤在一起,卡片感消失 |
| FlickVelocity(速度阈值) | 800~1500 px/s | 需要更快的甩动才跳页,稳重但迟钝 | 轻轻一划就翻页,容易误触 |
| SnapDuration(吸附时长) | 0.15~0.25 s | 停下来的过程拖沓 | 生硬,像直接瞬移 |
| SnapBias(吸附偏移) | 0.3~0.5 | 位移超过半页才翻,偏保守 | 稍微一动就翻页 |
| VisibleRadius(可见半径) | 1~2 | 更多页保持激活,内存换流畅 | 边缘页显隐频繁,可能闪烁 |
调参的顺序建议是:先固定 Spacing,用它把卡片比例定下来;再调 FlickVelocity,这个最影响体感;最后动 SnapDuration 和 SnapBias 做微调。反过来的话,你会在间距变化后重新调一遍速度阈值,来回三轮就烦了。我在工程里一般把这几个值做成 ScriptableObject,美术和策划能直接在 Inspector 里改,不用每次找程序。
4. 吸附与无限循环:翻页手感差异到底出在哪一行
手搓 ScrollRect 的人几乎都踩过同一个坑:拖拽结束那一刻,内容还在惯性滑动,你算出来的「目标页」是个正在变化的值。等惯性停下来再算,用户已经看到页面滑过头又弹回来,体验很别扭。ViewPager 模型的解法是:拖拽一结束就冻结内容位移,自己接管后续插值,惯性由位移插值统一表达。
4.1 拖拽结束的判定:速度优先还是位移优先
判定目标页有两种依据,只用一个都不够。纯位移判定的问题是:用户快速轻甩一小段距离,位移不够半页,但意图明确是翻页,结果不翻,用户会觉得「没反应」。纯速度判定的问题是:慢慢拖过大半页再松手,速度接近 0,反而不翻,用户会觉得「明明拖过去了」。
合格的做法是两者取并集:速度超过阈值时按方向翻一页,速度不够时再看位移是否过半。
// 拖拽结束时调用:返回目标页索引 private int ResolveTargetPage(float stride, float contentX, float velocityX) { // stride = 页宽 + 间距,contentX 为内容层当前偏移 // 内容左移(contentX 变小)代表看右边的页,即页码增大 int basePage = Mathf.RoundToInt(-contentX / stride); int step = 0; if (Mathf.Abs(velocityX) > flickVelocity) { // velocityX 为指针拖动方向的相反数,取负号换算成页码方向 step = -velocityX > 0f ? 1 : -1; } else { float raw = -contentX / stride; // 连续页码 float frac = raw - Mathf.Floor(raw); // 小数部分 if (frac > 0.5f + snapBias) step = 1; else if (frac < 0.5f - snapBias) step = -1; } return Mathf.Clamp(basePage + step, 0, pageCount - 1); }stride必须用运行时的视口宽度加间距算出来,不能写死像素值,屏幕适配一改就错位。basePage用RoundToInt而不是Floor,因为内容偏移和页码是反向的负相关。snapBias控制「拖过半页才翻」的宽容度,取值越大越保守。最后的Clamp是防越界,非循环模式下必须有;循环模式下这行要换成取模。
4.2 无限循环的两种实现路线
无限循环的本质是「页项是有限的,页码是无限的」。两条常见路线,各有明确边界。
第一条是取模重排:所有页项常驻,每次翻页后按环形距离重新计算每个页项的anchoredPosition,然后把 Content 归零。优点是没有瞬移跳变,视觉连续;缺点是从第 0 页往回翻时,所有页项的位置会整体重排一次,如果页项数量少(比如只有 3 页),能看出一次不自然的跳位。
第二条是三页复用:永远只保留当前页、前一页、后一页三个实体,翻页时把最远的那一页挪到另一侧并换数据。优点是内存占用恒定,页数上千也不虚;缺点是数据绑定频率高,每一次翻页都要刷新 Prefab 内容,图集加载没做好的话会一卡一卡。
页数在 10 以内选第一种,代码量小、调试直观;页数可能上百,比如横滑的商品列表,直接上第二种配对象池。
// 取模重排:所有页项常驻,按环形距离重新摆放 private void ArrangeCircular(int centerPage) { int count = items.Count; float stride = viewport.rect.width + spacing; for (int i = 0; i < count; i++) { // 相对中心页的最短环形距离,结果落在 [-count/2, count/2) int d = i - centerPage; if (d > count / 2) d -= count; if (d < -count / 2) d += count; var rt = items[i].rect; rt.anchoredPosition = new Vector2(d * stride, 0f); // 只激活视野附近的页,其余的关掉省掉 Layout 与 Mask 计算 items[i].gameObject.SetActive(Mathf.Abs(d) <= visibleRadius); } content.anchoredPosition = Vector2.zero; // 位移全部由子项承担 }这里把 Content 的偏移归零是关键。如果重排之后还让 Content 带一段偏移,环形距离计算出来的位置会和实际渲染位置叠加上去,表现是每次翻页整体滑动量翻倍。visibleRadius取 1 时前后各留一页,取 2 时留两页——用 2 是因为快速滑动时能提前看到下一页的内容,用 1 在低速滑动时边缘可能会闪一下。
4.3 插值实现与协程生命周期
吸附动画必须可控可中断。用协程写插值的时候,一定要持有协程句柄,翻页逻辑一进来先把它停掉,否则用户快速连划三次,三个协程同时改同一个anchoredPosition,最后停在哪个位置完全随机。
private Coroutine snapCo; private void SnapTo(int targetPage) { // 先杀旧协程,避免多个插值同时写同一个属性 if (snapCo != null) StopCoroutine(snapCo); float targetX = -targetPage * (viewport.rect.width + spacing); snapCo = StartCoroutine(SnapRoutine(targetX, snapDuration)); } private System.Collections.IEnumerator SnapRoutine(float targetX, float duration) { float startX = content.anchoredPosition.x; float t = 0f; while (t < duration) { t += Time.unscaledDeltaTime; // 用非缩放时间,暂停不影响翻页手感 float k = Mathf.Clamp01(t / duration); k = 1f - Mathf.Pow(1f - k, 3f); // ease-out cubic,收尾自然 content.anchoredPosition = new Vector2(Mathf.Lerp(startX, targetX, k), 0f); yield return null; } content.anchoredPosition = new Vector2(targetX, 0f); // 收尾对齐,防浮点残留 snapCo = null; }收尾那一行赋值不能省。插值循环退出时k理论上是 1,浮点误差会让残留一点小数,页数多了以后误差累积,卡片边缘会出现一像素的接缝。用Time.unscaledDeltaTime是因为翻页属于 UI 交互,不该被子弹时间或暂停逻辑影响。
5. 五个翻车现场:翻页组件的避坑与排查清单
这一章是我在不同工程里接这套模型时反复撞到的墙,按「现象 → 原因 → 解决」记下来,方便你对照症状直接定位。
现象一:翻页后卡片总是错位半格,越往后越明显。原因是 stride 写死了像素值,而 Canvas 用的是 Scale With Screen Size,实际视口宽度随分辨率变化,两者不匹配。解决是把所有基于像素的位移计算都改成运行时读取viewport.rect.width,并且把这段计算放在布局刷新之后,也就是Canvas.ForceUpdateCanvases()或者下一帧再算。我在 Awake 里算过一版,拿到的是 0,因为那时 RectTransform 还没完成布局。
现象二:手指拖动时页面抖,松手后先弹一下才吸附。原因是 ScrollRect 自身的惯性还在跑,你的吸附插值也在跑,两套力同时作用。解决是关掉 ScrollRect 的 Inertia 和 Elastic,让它只负责把拖拽事件转成位移,速度从事件回调里自己取。如果组件包不依赖 ScrollRect 而是自己实现 IDragHandler,检查一下是否在OnEndDrag里正确记录了速度——用OnDrag两帧的位置差除以帧时间,比直接读Input.mousePosition稳。
现象三:快速连点「下一页」按钮,页面停在两页中间。原因是上一次的吸附协程还没结束,新的目标值把startX覆盖了,两次插值各走一半。解决是 4.3 里那套:协程句柄单例化,进入新翻页前先StopCoroutine。进阶一点的写法是在停止旧协程后,把内容位置直接对齐到当前最接近的页,再开始新的插值,避免从半路起跳。
现象四:无限循环下指示点乱跳,滑到第 4 页却点亮第 1 个点。原因是重排之后你拿到的是环形偏移量,不是真实数据索引。解决是把「位置索引」和「数据索引」彻底分开:重排只负责位置,回调上报的必须是数据索引。我在项目里会额外维护一个int[] slotToDataIndex数组,页项按下标取数据时走这层映射,翻页回调直接把映射后的值抛给表现层。
现象五:页数上到 50 以后滑动掉帧,Profiler 里 Layout 占大头。原因是一次性实例化了全部页项,且每个页项都挂了 LayoutGroup 或者 ContentSizeFitter。解决是上对象池加可见半径控制,同时把页项内部的自动布局换成手动锚点定位——翻页类 UI 的尺寸基本固定,自动布局每帧重算完全是浪费。
注意:排查顺序永远是先看 Console 报错,再看 RectTransform 实际值,最后动代码。翻页错位九成是坐标问题,不是算法问题。
6. 接到真实数据上:对象池与翻页性能的验证手法
示例场景永远只有四五张写死的卡片,真接进业务就原形毕露:数据从接口来、图片异步加载、页数不固定、用户还会来回猛划。把翻页组件接到真实数据上,核心就两件事——页项别反复创建销毁,以及翻页过程中别做重活。
6.1 数据驱动的页项复用
页项复用写成池子,取的时候优先复用、不够再实例化,用完不销毁只回收。
private readonly Stack<PageItem> pool = new Stack<PageItem>(); private PageItem Rent(int dataIndex) { PageItem item = pool.Count > 0 ? pool.Pop() : Instantiate(prefab, content); item.gameObject.SetActive(true); item.Bind(data[dataIndex], dataIndex); // 绑定数据并刷新文本/图片 return item; } private void Return(PageItem item) { item.Unbind(); // 主动解绑,断开图片引用防泄漏 item.gameObject.SetActive(false); pool.Push(item); }Unbind这一步很多人省掉,结果是回收的页项还持有上一张图的引用,图集卸载不掉,跑久了内存一路涨。绑定的时候也尽量只刷新变化的部分,文本没变就别赋值——UGUI 的 Text 赋值会触发重建,翻页时每帧重建一次,帧率肉眼可见地掉。
6.2 验证翻页是否真的稳
验证不看主观手感,看三组数据。第一组是 Profiler 里的Canvas.SendWillRenderCanvases,正常翻页过程中这个值应该平稳,如果每次翻页都尖峰,说明页项内部有自动布局在重算。第二组是页项激活数量,用content.childCount减去未激活数,应该稳定在2 * visibleRadius + 1附近,明显超出说明回收逻辑没生效。第三组是来回快速划动 30 秒后的内存曲线,稳态不涨才算通过。
一个具体技巧:在编辑器里给翻页容器加个快捷键,按住时把所有页项的当前页码和 anchoredPosition 打到 Console。翻页错位的时候,看这两个值对不对得上,比盯着屏幕猜快得多。我一般是Ctrl+Alt+P触发,用完就删掉,不留到正式版本里。
血泪经验是:翻页组件的参数一定要在目标分辨率下,也就是真机或者 Game 视图里按实际比例调,编辑器里看着刚好,打包出来就因为视口宽度差了几十像素全错位。从那以后我每次接这类组件,都强制走一遍「空场景搭最小闭环 → 真机跑一遍 → 再往业务里塞」的流程,中间那步省不得。希望帮到你。
本文还有配套的精品资源,点击获取