1. 项目概述与核心痛点
最近在做一个Unity项目,里面有个需求是要做一个类似Windows资源管理器左侧文件夹树那样的多级折叠菜单,而且菜单项数量可能很多,需要放在一个可滚动的区域里。这听起来不就是UGUI里ScrollRect加一堆Toggle或者Button的事吗?但真上手做,才发现到处都是坑。最头疼的就是内容动态变化时的刷新问题:当你点击一个父级菜单展开或收起其子项时,整个列表的高度变了,ScrollRect的Content需要重新布局,滚动位置也可能乱跳,子项的点击事件还会因为布局重建而“失灵”一下。网上搜了一圈,要么是简单的单级展开例子,要么就是用了些“邪道”方法,比如用Canvas.ForceUpdateCanvases()这种官方都不推荐的东西。折腾了好几天,我把能踩的坑基本都踩了一遍,也摸索出几种相对靠谱的刷新策略。这篇文章就聊聊怎么用UGUI的ScrollRect实现一个性能过得去、表现稳定的多级折叠菜单,重点分享那些坑和对应的“填坑”方法。
2. 整体设计与思路拆解
2.1 为什么选择ScrollRect与手动布局?
多级折叠菜单本质上是一个动态改变高度的垂直列表。Unity UGUI里做列表,常见的有几种方案:直接堆叠GameObject、使用ScrollRect、或者用更专业的列表插件(如EnhancedScroller、SuperScrollView)。对于这个需求,直接堆叠管理起来太麻烦;第三方插件功能强大但需要引入和学习成本。ScrollRect是UGUI原生组件,无需额外依赖,理解和使用成本最低,适合快速原型和中等复杂度的需求。
核心思路是:将ScrollRect的Content(一个RectTransform)作为菜单项的容器。每个菜单项都是一个预制体(Prefab),包含用于显示文本/图标的元素和一个Toggle(用于控制折叠状态)。子菜单项通过缩进(比如调整RectTransform的localPosition.x)来呈现层级关系。当某个菜单项的折叠状态改变时,我们需要:
- 显示或隐藏其所有的直接子项。
- 重新计算并设置所有受影响菜单项的位置(
anchoredPosition)。 - 调整
Content的总体高度,以确保ScrollRect能正确滚动。 - 确保这个过程流畅,不引起视觉闪烁或交互中断。
这里最大的挑战在于“重新计算位置”。UGUI的自动布局系统(VerticalLayoutGroup)在元素动态SetActive时确实能自动排列,但对于这种需要精确控制缩进、且频繁展开收起的场景,它的控制粒度不够细,性能开销也较大,尤其是在菜单项很多时。因此,我选择了**手动计算位置并设置anchoredPosition**的方案,虽然代码量多一些,但获得了最高的控制权和性能可预测性。
2.2 数据结构设计:如何组织菜单项?
在代码里,我们需要一个清晰的数据结构来映射UI。我为每个菜单项UI创建了一个MenuItemUI的MonoBehaviour脚本,挂载在预制体上。
public class MenuItemUI : MonoBehaviour { public RectTransform rectTransform; // 自身RectTransform public Button toggleButton; // 用于点击展开/收起的按钮(可能是一个箭头图标) public Text titleText; // 显示标题 public RectTransform childContainer; // 用于存放子项的容器(可以是自身或一个子物体) public int indentPixel = 20; // 每级缩进的像素数 // 数据模型引用 private MenuItemData _data; // 父项UI引用 private MenuItemUI _parentUI; // 子项UI列表 private List<MenuItemUI> _childUIs = new List<MenuItemUI>(); // 是否展开状态 private bool _isExpanded = false; // 在Content中的索引位置(用于计算位置) private int _siblingIndex = 0; public void Initialize(MenuItemData data, MenuItemUI parentUI, int siblingIndex) { _data = data; _parentUI = parentUI; _siblingIndex = siblingIndex; titleText.text = data.title; // 根据层级设置缩进 int currentIndent = (data.depthLevel * indentPixel); rectTransform.anchoredPosition = new Vector2(currentIndent, 0); // 先假设Y为0,后面统一计算 // 设置箭头按钮的旋转或图标状态 UpdateToggleVisual(); // 递归初始化子项(但先不创建UI) } }同时,需要一个数据模型MenuItemData来存储菜单的层级结构,它可以在编辑器中配置,或者从JSON等数据源加载。
[System.Serializable] public class MenuItemData { public string id; public string title; public int depthLevel; // 0表示根级 public List<MenuItemData> children; // ... 其他业务数据 }最上层需要一个管理器(例如FoldableMenuManager)脚本,挂载在ScrollRect的Content物体上。它负责:
- 解析
MenuItemData列表,递归实例化MenuItemUI预制体。 - 维护所有
MenuItemUI的扁平化列表(便于遍历和索引)。 - 在菜单项展开/收起时,协调所有UI项的位置刷新。
3. 核心坑点与奇葩刷新方法剖析
3.1 坑一:Canvas刷新时机与视觉闪烁
当你在一帧内连续执行SetActive、修改anchoredPosition、改变Content大小等操作时,可能会看到菜单项“跳动”或“闪烁”。这是因为UGUI的布局和渲染更新发生在特定的时机。
常见错误做法:在Toggle的onValueChanged事件响应函数中,直接进行隐藏子项、计算位置、设置位置这一系列操作。由于这些操作可能在同一帧触发多次布局计算,或者与Canvas的渲染更新步调不一致,导致中间状态被看到。
相对靠谱的方法:利用UnityEngine.UI.LayoutRebuilder或等待渲染帧结束。
- 方法A:标记脏刷新,延迟一帧。在改变状态(如
SetActive)后,不立即计算位置,而是设置一个bool _needRefreshLayout = true标志。在LateUpdate()或Update()中检查这个标志,如果为真,则执行真正的布局计算。这能确保所有Active状态的变化在本帧已生效,下一帧再基于最终状态计算位置。void Update() { if (_needRefreshLayout) { _needRefreshLayout = false; RefreshAllItemsLayout(); } } - 方法B:在
Canvas.willRenderCanvases事件中执行。这是一个在Canvas即将渲染前调用的回调,是进行UI布局更新的理想时机。你可以订阅这个事件,在事件回调中执行刷新逻辑,确保布局计算在渲染前一刻完成,避免闪烁。private void OnEnable() { Canvas.willRenderCanvases += RefreshLayout; } private void OnDisable() { Canvas.willRenderCanvases -= RefreshLayout; } private void RefreshLayout() { if (!_needRefreshLayout) return; _needRefreshLayout = false; // 执行布局计算 }
注意:网上有些老教程会使用
Canvas.ForceUpdateCanvases()。这个方法是强制立即执行所有Canvas的布局更新,它是一个同步的、开销较大的操作,可能会打断正常的UI渲染流程,导致性能问题,尤其是在同一帧内多次调用时。Unity官方文档也暗示其主要用于编辑器脚本。在运行时UI更新中,应避免使用。
3.2 坑二:Content高度计算与ScrollRect跳动
当折叠菜单展开时,Content需要变高以容纳新出现的项目;收起时则变矮。如果直接设置Content的sizeDelta.y,ScrollRect可能会因为Content的锚点(Pivot)或滚动位置的计算而出现突兀的跳动。
解决方案:精准计算与锚点设置
- 统一计算每个Item的高度:假设每个菜单项预制体的高度是固定的(例如
itemHeight = 40f)。那么一个菜单项及其所有可见后代所占的总高度就是itemHeight * (1 + 所有可见后代的数量)。我们需要一个递归函数来计算每个项从它开始向下的总显示高度。 - 顺序计算Y轴位置:从
Content的顶部开始(假设Pivot为(0.5, 1)即上中点),第一个项的位置Y为-itemHeight/2,第二个项的位置Y为第一个项的Y减去第一个项的总显示高度,以此类推。这是一个累加的过程。 - 设置Content高度:
Content的最终高度应该是所有根级菜单项的总显示高度之和。计算完毕后,设置Content的sizeDelta = new Vector2(contentWidth, totalHeight)。 - 关键技巧:维持滚动视图的“视觉锚点”。在刷新布局前,记录当前滚动区域顶部在
Content局部空间中的Y坐标(可以通过ScrollRect的verticalNormalizedPosition和Content的高度换算)。在刷新布局后,根据新的Content高度,反向计算出应设置的verticalNormalizedPosition,以保持用户正在查看的区域相对稳定。这能有效减少收起/展开时的突兀跳动感。
void RefreshAllItemsLayout() { float currentY = -_itemHeight / 2f; // 从顶部开始 float totalHeight = 0f; foreach (var rootItem in _rootItemUIs) { CalculateAndSetPosition(rootItem, ref currentY); } totalHeight = Mathf.Abs(currentY) + _itemHeight/2f; // 计算总高度 _contentRect.sizeDelta = new Vector2(_contentRect.sizeDelta.x, totalHeight); // 可选:恢复之前的滚动位置 RestoreScrollPosition(); } void CalculateAndSetPosition(MenuItemUI item, ref float currentY) { item.rectTransform.anchoredPosition = new Vector2(item.GetIndent(), currentY); currentY -= _itemHeight; // 减去自身高度 if (item.IsExpanded) { foreach (var child in item.GetChildren()) { CalculateAndSetPosition(child, ref currentY); } } }3.3 坑三:Item点击事件与Raycast过滤
在折叠/展开时,我们会频繁地SetActive(false/true)子项。当一个子项被隐藏又快速显示时,或者当布局重建导致UI元素位置变化时,EventSystem用于检测点击的Raycast可能会出现问题。你可能遇到点击了但没触发事件,或者点击了父项却触发了子项事件的情况。
解决方案:分层管理与Raycast优化
- 使用独立的Toggle/Button控制折叠:不要用整个菜单项区域来触发折叠,而是使用一个专门的箭头图标按钮。这样,点击文本区域可以用于选中菜单项(另一个功能),互不干扰。
- 合理设置Raycast Target:只为真正需要交互的元素(按钮、Toggle)的Image/Text组件勾选
Raycast Target。背景图片如果不需要点击,就取消勾选,减少Raycast计算量。 - 在刷新布局期间临时禁用Raycast:如果刷新布局的过程比较复杂(比如涉及大量GameObject的Active切换),可以在刷新开始前,将
Content或一个顶层遮罩的CanvasGroup的blocksRaycasts设为false,刷新完毕后再设为true。这可以防止用户在布局过程中误操作。_canvasGroup.blocksRaycasts = false; // ... 执行布局刷新 ... StartCoroutine(EnableRaycastNextFrame()); // 下一帧再启用,确保布局完全稳定 IEnumerator EnableRaycastNextFrame() { yield return null; _canvasGroup.blocksRaycasts = true; } - 考虑使用
GraphicRaycaster的层级过滤:确保你的ScrollRect和菜单项都在正确的Canvas层级下,避免被其他UI元素遮挡射线。
4. 一种稳定的刷新流程实现
结合以上分析,下面给出一个经过实践检验的相对稳定的刷新流程实现方案。我们假设使用“标记脏刷新+在Canvas.willRenderCanvases中执行”的策略。
4.1 管理器核心逻辑
public class FoldableMenuManager : MonoBehaviour { public ScrollRect scrollRect; public RectTransform contentRoot; public GameObject menuItemPrefab; public float itemHeight = 40f; private List<MenuItemUI> _allItems = new List<MenuItemUI>(); private bool _layoutDirty = false; private float _scrollPositionBeforeRefresh = 0f; void Start() { Canvas.willRenderCanvases += OnWillRenderCanvas; // 初始化数据并构建UI树... BuildMenuTree(); RefreshLayoutImmediate(); // 初始布局 } void OnDestroy() { Canvas.willRenderCanvases -= OnWillRenderCanvas; } // 由MenuItemUI在折叠状态改变时调用 public void ScheduleLayoutRefresh() { _layoutDirty = true; // 记录刷新前的滚动位置(基于顶部) _scrollPositionBeforeRefresh = scrollRect.content.anchoredPosition.y; } private void OnWillRenderCanvas() { if (!_layoutDirty) return; RefreshLayoutImmediate(); } private void RefreshLayoutImmediate() { _layoutDirty = false; // 1. 禁用Raycast,防止误操作 if (scrollRect.TryGetComponent<CanvasGroup>(out var cg)) { cg.blocksRaycasts = false; } // 2. 计算新布局 float posY = -itemHeight / 2f; float totalHeight = 0f; foreach (var rootItem in GetRootItems()) { CalculateItemPosition(rootItem, ref posY); } totalHeight = Mathf.Abs(posY) + itemHeight / 2f; // 3. 应用Content新高度 Vector2 newSize = new Vector2(contentRoot.sizeDelta.x, totalHeight); contentRoot.sizeDelta = newSize; // 4. 尝试恢复滚动位置(简化版:保持顶部内容不变) // 更复杂的实现需要计算并设置verticalNormalizedPosition TryRestoreScrollPosition(totalHeight); // 5. 下一帧启用Raycast StartCoroutine(EnableRaycastsNextFrame(cg)); } private void CalculateItemPosition(MenuItemUI item, ref float currentY) { // 设置水平缩进和垂直位置 float indent = item.GetIndentLevel() * item.indentPixel; item.rectTransform.anchoredPosition = new Vector2(indent, currentY); // 更新其全局索引,可用于查找 item.SetGlobalIndex(_allItems.IndexOf(item)); currentY -= itemHeight; // 如果展开,递归处理子项 if (item.IsExpanded) { foreach (var child in item.GetChildren()) { CalculateItemPosition(child, ref currentY); } } } private void TryRestoreScrollPosition(float newContentHeight) { // 这是一个简化示例。实际中,你需要根据之前记录的_viewportTopLocalY // 和新的contentHeight,计算出新的normalizedPosition。 // 这里仅做直接赋值,更佳实践是计算差值补偿。 // scrollRect.verticalNormalizedPosition = CalculateNewNormalizedPos(...); } IEnumerator EnableRaycastsNextFrame(CanvasGroup cg) { yield return null; // 等待一帧,确保渲染完成 if (cg != null) { cg.blocksRaycasts = true; } } // ... 其他方法:BuildMenuTree, GetRootItems等 }4.2 菜单项UI交互逻辑
public class MenuItemUI : MonoBehaviour { // ... 引用字段 [SerializeField] private Image arrowImage; [SerializeField] private Button foldButton; [SerializeField] private Button selectButton; // 选中项按钮 private FoldableMenuManager _manager; private bool _isExpanded = false; void Start() { _manager = GetComponentInParent<FoldableMenuManager>(); // 简单查找 foldButton.onClick.AddListener(OnFoldButtonClicked); selectButton.onClick.AddListener(OnSelectButtonClicked); } private void OnFoldButtonClicked() { _isExpanded = !_isExpanded; SetChildrenActive(_isExpanded); UpdateArrowVisual(); // 通知管理器需要刷新布局 _manager?.ScheduleLayoutRefresh(); } private void OnSelectButtonClicked() { // 处理菜单项被选中的逻辑,例如改变背景颜色,通知其他系统 Debug.Log($"Item selected: {titleText.text}"); } private void SetChildrenActive(bool active) { foreach (var childUI in _childUIs) { childUI.gameObject.SetActive(active); // 如果收起,也需要递归收起所有后代吗?取决于需求。 // 通常只收起直接子项,后代状态保持。如果需要递归: // if (!active) childUI.CollapseAllChildren(); } } private void UpdateArrowVisual() { // 简单旋转箭头 arrowImage.transform.rotation = Quaternion.Euler(0, 0, _isExpanded ? 0 : -90); // 或者切换精灵 } }5. 性能优化与进阶技巧
5.1 对象池化(Object Pooling)
如果菜单项数量非常多(比如成百上千),频繁地实例化和销毁预制体会产生GC(垃圾回收)压力。此时应该引入对象池。管理器在启动时预实例化一定数量的MenuItemUI对象放入池中,需要时从池中取出并初始化,不需要时放回池中并SetActive(false)。在折叠菜单中,由于项的总数是固定的(只是隐藏显示),池化主要优化初始化阶段的性能。但对于动态加载数据的无限滚动列表,池化是必须的。
5.2 部分刷新与脏矩形
上述方案在任一菜单项展开/收起时,都会刷新整个列表的位置。对于超长列表,这仍有性能开销。可以优化为部分刷新:当某个项折叠/展开时,只重新计算该项之后的所有兄弟项(同父级下的后续项)及其后代的位置。这需要更精细的索引管理,但能显著减少计算量。本质上,你需要一个能快速找到“下一个可见项”的数据结构(如扁平的列表配合展开状态标志)。
5.3 使用Unity的UIElements(UI Toolkit)
对于全新的项目或复杂的、需要大量动态UI的编辑器工具,可以考虑使用Unity较新的UI Toolkit。UI Toolkit的ListView和TreeView控件原生支持虚拟化(只渲染可见项),对于超长列表性能远优于UGUI的ScrollRect。TreeView更是直接为树形结构设计,实现折叠菜单几乎无需手动处理布局刷新。但UI Toolkit目前对运行时游戏UI的支持(尤其是屏幕空间Overlay模式下的输入和样式)与UGUI相比成熟度还有差距,需要评估项目需求。
5.4 避免在UI线程进行复杂计算
如果菜单项的数据计算非常复杂(例如,需要从数据库或网络加载),务必使用协程(Coroutine)或异步任务(async/await)将耗时操作从主线程中剥离,避免造成UI卡顿。在数据准备好之前,可以显示加载占位符。
6. 常见问题排查速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 点击折叠按钮无反应 | 1. 按钮事件未绑定。 2. 父级UI的Raycast被禁用或遮挡。 3. 刷新布局时Raycast被临时禁用后未正确启用。 | 1. 检查foldButton.onClick监听器。2. 检查按钮及其父物体的 CanvasGroup的blocksRaycasts和Raycast Target设置。3. 确保刷新后恢复Raycast的协程正常执行。 |
| 展开/收起时UI严重闪烁或跳动 | 1. 布局计算与Canvas渲染不同步。 2. Content高度变化导致ScrollRect滚动位置重置。3. 在同一帧内多次触发刷新。 | 1. 采用Canvas.willRenderCanvases事件或延迟一帧刷新。2. 实现滚动位置保持逻辑(记录并恢复 verticalNormalizedPosition)。3. 确保 ScheduleLayoutRefresh有防重复触发机制(如_layoutDirty标志)。 |
| 滚动时卡顿,尤其是项很多时 | 1. 每帧都在进行不必要的布局计算。 2. 菜单项预制体过于复杂(顶点过多)。 3. 没有使用对象池,存在大量Instantiate/Destroy。 | 1. 确保只在状态改变时标记脏刷新。 2. 使用Unity Profiler的UI模块分析顶点数和批次数,简化UI元素。 3. 对菜单项实施对象池管理。 |
| 子项位置错乱,重叠或间距不对 | 1. 位置计算逻辑有误(累加高度出错)。 2. 菜单项的锚点(Pivot)或轴心点设置不一致。 3. 刷新后未正确更新 Content的sizeDelta。 | 1. 调试输出每个项计算后的Y坐标,检查计算顺序和公式。 2. 确保所有菜单项预制体的 RectTransform的Pivot(例如(0.5, 0.5))和锚点设置一致。3. 确认 totalHeight计算正确并已赋值。 |
| 在编辑器下运行正常,打包后异常 | 1. 可能依赖了编辑器特有的执行顺序或API。 2. 资源引用丢失(预制体、字体等)。 3. 异步加载逻辑在构建后不同。 | 1. 检查代码中是否有#if UNITY_EDITOR的调试代码影响了逻辑。2. 使用 Resources.Load或Addressables确保运行时资源可用。3. 彻底测试打包后的版本,用日志输出关键步骤信息。 |
实现一个稳定的UGUI多级折叠菜单,关键在于理解UGUI的渲染和布局生命周期,避免在错误的时机进行激进的操作。手动计算布局虽然代码量多,但提供了最大的灵活性和控制力。记住核心三点:利用Canvas.willRenderCanvases同步刷新时机、精细计算并保持滚动视觉锚点、妥善管理Raycast避免交互中断。对于性能,始终对超长列表保持警惕,必要时引入对象池和部分刷新。希望这些踩坑经验能帮你少走弯路。