从零构建UGUI TreeView:巧用VerticalLayoutGroup实现高效折叠

张开发
2026/4/17 20:18:40 15 分钟阅读

分享文章

从零构建UGUI TreeView:巧用VerticalLayoutGroup实现高效折叠
1. 为什么需要自己实现TreeView在Unity开发中TreeView树形视图是一个非常常见的UI组件常用于文件浏览器、配置面板、技能树等场景。虽然Unity Asset Store中有不少现成的TreeView插件但很多时候它们要么功能过于复杂要么性能不够理想要么定制化程度不够高。我在实际项目中就遇到过这样的情况需要一个轻量级、高性能的TreeView来展示PDF文档列表但找了一圈发现没有特别合适的插件。传统实现TreeView的方式通常会在Unity中构建与数据结构完全对应的层级关系也就是让UI的父子关系与数据结构的父子关系保持一致。这种方法虽然直观但在处理折叠/展开时需要进行复杂的递归高度计算性能开销大代码也容易变得臃肿。后来我发现利用UGUI的VerticalLayoutGroup和ContentSizeFitter组件配合简单的节点禁用机制可以非常优雅地实现TreeView的核心功能。2. 核心实现原理2.1 关键组件的作用实现这个TreeView的核心在于巧妙利用两个UGUI组件VerticalLayoutGroup自动将子物体垂直排列省去了手动计算位置的麻烦ContentSizeFitter根据子物体自动调整Content区域的大小确保滚动条正常工作这两个组件组合使用时有个非常重要的特性它们会自动忽略被禁用的子物体。这意味着当我们折叠一个节点时只需要禁用它的所有子节点VerticalLayoutGroup就会自动将后面的节点提上来ContentSizeFitter也会自动调整Content区域的大小。2.2 与传统实现的对比传统实现TreeView通常采用递归计算高度的方式展开时递归计算所有子节点的高度并累加折叠时递归隐藏所有子节点并减去相应高度这种方法有以下几个缺点计算复杂度高特别是对于深层级树结构需要手动维护每个节点的位置展开/折叠时容易出现布局抖动而我们采用的新方法完全依赖UGUI的自动布局系统折叠时只需禁用子节点展开时启用无需手动计算任何高度或位置性能更好代码更简洁3. 具体实现步骤3.1 基础结构搭建首先创建一个基本的TreeView结构// TreeView.cs public class TreeView : MonoBehaviour { [SerializeField] private RectTransform m_Content; [SerializeField] private GameObject m_ItemPrefab; [SerializeField] private float m_IndentationWidth 20f; private ListTreeItem m_Items new ListTreeItem(); public TreeItem AppendItem(string text, Sprite icon, TreeItem parent null) { GameObject itemObj Instantiate(m_ItemPrefab, m_Content); TreeItem item itemObj.GetComponentTreeItem(); item.Initialize(this, text, icon, parent); m_Items.Add(item); return item; } }3.2 TreeItem的实现每个树节点的核心逻辑// TreeItem.cs public class TreeItem : MonoBehaviour { [SerializeField] private RectTransform m_ContentPanel; [SerializeField] private Button m_ExpandButton; [SerializeField] private Text m_Text; [SerializeField] private Image m_Icon; private TreeView m_Tree; private TreeItem m_Parent; private ListTreeItem m_Children new ListTreeItem(); private int m_IndentationLevel -1; public void Initialize(TreeView tree, string text, Sprite icon, TreeItem parent) { m_Tree tree; m_Text.text text; m_Icon.sprite icon; Parent parent; // 这会触发重新计算位置 } public TreeItem Parent { get { return m_Parent; } set { if (m_Parent ! value) { // 从原父节点移除 if (m_Parent ! null) m_Parent.m_Children.Remove(this); // 设置新父节点 m_Parent value; // 计算新位置 int index m_Parent ! null ? m_Parent.LastSiblingIndex : 0; transform.SetSiblingIndex(index); // 添加到新父节点的子列表 if (m_Parent ! null) m_Parent.m_Children.Add(this); // 重新计算缩进 RecalcIndentation(); } } } public int LastSiblingIndex { get { if (m_Children.Count 0) return m_Children[m_Children.Count - 1].LastSiblingIndex; return transform.GetSiblingIndex(); } } private void RecalcIndentation() { m_IndentationLevel m_Parent ! null ? m_Parent.m_IndentationLevel 1 : 0; Vector2 offset m_ContentPanel.offsetMin; offset.x m_Tree.IndentationWidth * m_IndentationLevel; m_ContentPanel.offsetMin offset; foreach (var child in m_Children) child.RecalcIndentation(); } }3.3 折叠/展开逻辑折叠和展开的实现非常简单// 在TreeItem.cs中继续添加 private bool m_IsExpanded true; public bool IsExpanded { get { return m_IsExpanded; } set { if (m_IsExpanded ! value) { m_IsExpanded value; UpdateChildrenActiveState(); } } } private void UpdateChildrenActiveState() { foreach (var child in m_Children) { child.gameObject.SetActive(m_IsExpanded); if (m_IsExpanded) child.UpdateChildrenActiveState(); } }4. 性能优化技巧在实际使用中我发现以下几个优化点可以显著提升TreeView的性能4.1 避免频繁的布局重建UGUI的布局重建是比较耗时的操作。当我们需要批量添加或移动节点时可以暂时禁用ContentSizeFitter等所有操作完成后再启用public void BeginBatchOperation() { LayoutRebuilder.MarkLayoutForRebuild(m_Content); m_Content.GetComponentContentSizeFitter().enabled false; } public void EndBatchOperation() { m_Content.GetComponentContentSizeFitter().enabled true; LayoutRebuilder.ForceRebuildLayoutImmediate(m_Content); }4.2 对象池技术对于动态更新的TreeView使用对象池可以避免频繁的Instantiate和Destroy操作private StackGameObject m_ItemPool new StackGameObject(); private GameObject GetItemFromPool() { if (m_ItemPool.Count 0) return m_ItemPool.Pop(); return Instantiate(m_ItemPrefab); } private void ReturnItemToPool(GameObject item) { item.SetActive(false); m_ItemPool.Push(item); }4.3 延迟加载对于大型树结构可以采用延迟加载策略只有当父节点展开时才加载其子节点public class LazyTreeItem : TreeItem { private bool m_IsLoaded false; protected override void OnExpand() { if (!m_IsLoaded) { LoadChildren(); m_IsLoaded true; } base.OnExpand(); } private void LoadChildren() { // 从数据源加载子节点 } }5. 实际应用案例5.1 文件浏览器实现下面是一个简单的文件浏览器实现示例public class FileBrowser : MonoBehaviour { [SerializeField] private TreeView m_Tree; [SerializeField] private Sprite m_FolderIcon; [SerializeField] private Sprite m_FileIcon; private void Start() { string path Application.dataPath; BuildTree(null, path); } private void BuildTree(TreeItem parent, string path) { try { // 添加目录 foreach (var dir in Directory.GetDirectories(path)) { TreeItem item m_Tree.AppendItem(Path.GetFileName(dir), m_FolderIcon, parent); item.IsExpanded false; // 递归构建子目录 BuildTree(item, dir); } // 添加文件 foreach (var file in Directory.GetFiles(path)) { if (!file.EndsWith(.meta)) // 忽略Unity的meta文件 { m_Tree.AppendItem(Path.GetFileName(file), m_FileIcon, parent); } } } catch (Exception e) { Debug.LogError($加载目录失败: {e.Message}); } } }5.2 配置面板实现TreeView也非常适合用于复杂的配置面板public class SettingsPanel : MonoBehaviour { [SerializeField] private TreeView m_Tree; private void Start() { TreeItem graphics m_Tree.AppendItem(图形设置, null); m_Tree.AppendItem(分辨率, null, graphics); m_Tree.AppendItem(画质等级, null, graphics); m_Tree.AppendItem(垂直同步, null, graphics); TreeItem audio m_Tree.AppendItem(音频设置, null); m_Tree.AppendItem(主音量, null, audio); m_Tree.AppendItem(音乐音量, null, audio); m_Tree.AppendItem(音效音量, null, audio); TreeItem controls m_Tree.AppendItem(控制设置, null); m_Tree.AppendItem(键盘设置, null, controls); m_Tree.AppendItem(鼠标设置, null, controls); m_Tree.AppendItem(手柄设置, null, controls); } }6. 常见问题与解决方案6.1 节点顺序错乱问题在使用transform.SetSiblingIndex()时有时会出现节点顺序不正确的情况。这是因为在设置一个节点的位置时它的子节点可能还没有被正确放置。解决方案是确保在设置父节点时先设置所有子节点的位置public TreeItem Parent { set { if (m_Parent ! value) { // 先处理子节点 foreach (var child in m_Children) child.Parent null; // 原来的父节点逻辑... // 重新设置子节点 foreach (var child in m_Children) child.Parent this; } } }6.2 折叠/展开动画卡顿如果需要添加折叠/展开动画直接修改子节点的active状态可能会导致卡顿。可以使用CanvasGroup来替代private IEnumerator ToggleAnimation(bool show) { CanvasGroup canvasGroup GetComponentCanvasGroup(); float targetAlpha show ? 1f : 0f; float duration 0.2f; float elapsed 0f; while (elapsed duration) { canvasGroup.alpha Mathf.Lerp(canvasGroup.alpha, targetAlpha, elapsed / duration); elapsed Time.deltaTime; yield return null; } canvasGroup.alpha targetAlpha; gameObject.SetActive(show); }6.3 大数据量性能问题当树结构非常大时如超过1000个节点即使有对象池和延迟加载性能也可能受到影响。这时可以考虑虚拟滚动只渲染可视区域内的节点分页加载每次只加载一定数量的节点多级缓存缓存已加载的节点数据7. 扩展功能实现7.1 多选功能实现TreeView的多选功能需要维护一个选中的节点列表public class MultiSelectTreeView : TreeView { private ListTreeItem m_SelectedItems new ListTreeItem(); public void SelectItem(TreeItem item, bool additive false) { if (!additive) ClearSelection(); if (!m_SelectedItems.Contains(item)) { m_SelectedItems.Add(item); item.SetSelected(true); } } public void ClearSelection() { foreach (var item in m_SelectedItems) item.SetSelected(false); m_SelectedItems.Clear(); } }7.2 拖拽排序实现节点的拖拽排序功能public class DraggableTreeItem : TreeItem, IBeginDragHandler, IDragHandler, IEndDragHandler { private Transform m_OriginalParent; private int m_OriginalIndex; public void OnBeginDrag(PointerEventData eventData) { m_OriginalParent transform.parent; m_OriginalIndex transform.GetSiblingIndex(); transform.SetAsLastSibling(); // 确保拖拽时显示在最上层 } public void OnDrag(PointerEventData eventData) { transform.position eventData.position; } public void OnEndDrag(PointerEventData eventData) { // 检查是否拖到了另一个节点上 TreeItem newParent FindDropTarget(eventData); if (newParent ! null newParent ! this !IsChildOf(newParent)) { Parent newParent; } else { // 恢复原位置 transform.SetParent(m_OriginalParent); transform.SetSiblingIndex(m_OriginalIndex); } } private TreeItem FindDropTarget(PointerEventData eventData) { // 通过射线检测找到目标节点 ListRaycastResult results new ListRaycastResult(); EventSystem.current.RaycastAll(eventData, results); foreach (var result in results) { TreeItem item result.gameObject.GetComponentTreeItem(); if (item ! null) return item; } return null; } private bool IsChildOf(TreeItem item) { TreeItem parent Parent; while (parent ! null) { if (parent item) return true; parent parent.Parent; } return false; } }7.3 搜索过滤为TreeView添加搜索过滤功能public class SearchableTreeView : TreeView { public void ApplyFilter(string searchText) { foreach (var item in m_Items) { bool match string.IsNullOrEmpty(searchText) || item.Text.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) 0; item.gameObject.SetActive(match); // 如果匹配确保所有父节点都是展开的 if (match) { TreeItem parent item.Parent; while (parent ! null) { parent.IsExpanded true; parent parent.Parent; } } } } }8. 最佳实践与建议在实际项目中使用这个TreeView组件时我总结了以下几点经验保持树结构扁平化虽然我们的实现支持深层级但建议不要超过5层否则用户体验会变差合理设置缩进宽度通常20-30像素的缩进比较合适太大会浪费空间太小会难以分辨层级使用图标区分节点类型比如文件夹图标、文件图标等可以大大提高可读性添加悬停效果为节点添加悬停高亮效果提升交互体验考虑添加键盘导航支持上下箭头选择、左右箭头展开/折叠等键盘操作性能监控在编辑器中添加性能统计如节点数量、刷新频率等便于优化这个TreeView实现虽然简单但经过多次项目验证性能表现非常出色特别是在处理动态更新的树结构时相比传统实现方式有显著优势。它的核心思想是利用UGUI已有的布局系统而不是自己重新实现一套这既减少了代码量又保证了性能。

更多文章