Unity 自定义TreeView UI 支持自定义JSON数据的自动解析生成
前言
最近一个项目需要实现对一个产品结构的展示,该产品有多级结构,且需要对结构信息进行配置,结合网上一些资料,自己写了个TreeView的组件,采用双向链表结构来存储产品的结构数据,并且支持每一产品结构节点信息的存储,支持与产品模型的Animation、拖拽控制、零组件信息显示等等需求进行配合。仅使用UGUI自带的组件进行实现。
效果图
TreeView 支持多级结构展示。
展开效果:
折叠效果:
TreeViewItem Prefab设计
TreeViewItem视作一个节点,包括子物体Content和Children,分别表示该节点的信息和子节点集合,如下图:
UI风格可以自定义,可以增加toggle之类的组件,我这里设置了节点名称的Text、节点层级的标识Image和折叠Button。
注意:Content和Children的Anchor应设置为top-center,这样方便子节点开展时的向下扩展,同时Children应添加Vertical Layout Group和RectMask2D组件,方便对子节点进行排列和隐藏。如下图
同时为该预制体添加自定义的TreeViewNode脚本,并将相应参数拖拽赋值,这里可以自定义节点等级标识的颜色。脚本代码
如下:.
public class TreeViewNode : MonoBehaviour
{
[SerializeField]
[Header("折叠按钮")]
private Button BtnArrow;
[SerializeField]
[Header("节点按钮")]
private Button BtnElement;
[SerializeField]
[Header("节点等级Tf")]
private Transform LevelTf;
[SerializeField]
[Header("子节点Tf")]
public Transform ChildrenTf;
[SerializeField]
[Header("标识颜色")]
public Color[] LevelColors;
/// <summary>
/// 该节点的子节点列表
/// </summary>
[HideInInspector]
public List<TreeViewNode> Children;
/// <summary>
/// 该节点的父节点
/// </summary>
[HideInInspector]
public TreeViewNode Parent;
/// <summary>
/// 设置的节点高度 当前:51
/// </summary>
private float nodeHeight = 51f;
/// <summary>
/// 获取节点高度
/// </summary>
public float NodeHeight { get { return nodeHeight; } }
/// <summary>
/// 是否为根节点
/// </summary>
private bool isRoot;
/// <summary>
/// 是否有子节点
/// </summary>
private bool hasChild;
/// <summary>
/// 该节点是否处于折叠状态
/// </summary>
private bool isFold;
private PartInfo partInfo;
/// <summary>
/// 该节点的信息对象
/// </summary>
public PartInfo PartInfo { get { return partInfo; } }
/// <summary>
/// 父节点序号存储
/// </summary>
private Dictionary<NodeType,int> parentsIndex;
private void Awake()
{
nodeHeight = Mathf.Floor(transform.GetComponent<RectTransform>().sizeDelta.y);
//初始状态默认展开
isFold = false;
}
private void Start()
{
BtnElement.onClick.AddListener(OnBtnElementClick);
BtnArrow.onClick.AddListener(FoldElementNode);
//延迟.1s 执行
DOVirtual.DelayedCall(.1f, SetInteraction);
}
private void OnBtnElementClick()
{
AnimeData animeData = new AnimeData
{
PartName = partInfo.PartName,
Type = partInfo.NodeType,
Index = partInfo.Index,
ParentIndex = parentsIndex,
};
EventCenter.ModelEvent.TriggerModelAnime(animeData);
EventCenter.UIEvent.UpdatePartInfo(partInfo);
}
/// <summary>
/// 展开/折叠 该节点的子节点
/// </summary>
private void FoldElementNode()
{
BtnArrow.interactable = false;
if (!hasChild) return;
float start = 0f;
float target = 0f;
if (isFold)
{
target = CalChildrenHeight();
BtnArrow.transform.DOLocalRotate(new Vector3(0, 0, -90f), .3f);
}
else
{
start = CalChildrenHeight();
BtnArrow.transform.DOLocalRotate(Vector3.zero, .3f);
}
DOTween.To(() => start, x => {
transform.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, x + nodeHeight);
ChildrenTf.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, x);
if (!isRoot) Parent.ModifyNodeHeight();
},target,.3f).OnComplete(()=>
{
BtnArrow.interactable = true;
isFold = !isFold;
EventCenter.UIEvent.ChangeTreeViewHeight();
});
}
/// <summary>
/// 添加UI子节点
/// </summary>
/// <param name="child"></param>
public void AddChild(TreeViewNode child)
{
if (Children == null)
{
Children = new List<TreeViewNode>();
}
Children.Add(child);
hasChild = true;
}
/// <summary>
/// 计算该节点Children元素的高度(所有子一级节点的高度和)
/// </summary>
/// <returns></returns>
public float CalChildrenHeight()
{
if (Children == null) return nodeHeight;
float height = 0f;
foreach (TreeViewNode node in Children)
{
height += node.GetComponent<RectTransform>().sizeDelta.y;
}
return height;
}
/// <summary>
/// 更新本节点的高度 和Children元素的高度 (递归)
/// </summary>
/// <param name="modify">修改量</param>
public void ModifyNodeHeight()
{
float temp = CalChildrenHeight();
transform.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, nodeHeight + temp);
ChildrenTf.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, temp);
if (Parent != null) Parent.ModifyNodeHeight();
}
/// <summary>
/// 初始化-节点信息
/// </summary>
/// <param name="name">节点名称</param>
/// <param name="nodeType">节点类型</param>
/// <param name="root">是否为根节点</param>
public void InitNodeSet(PartInfo partInfo,bool root = false)
{
BtnElement.GetComponentInChildren<Text>().text = partInfo.PartName;
this.partInfo = partInfo;
GetNodeParentsIndexList();
float leftPadding = 20f * (int)partInfo.NodeType;
BtnElement.transform.GetComponentInRealChildren<RectTransform>().offsetMin = new Vector2(10f + leftPadding, 0f);
BtnArrow.transform.localEulerAngles = new Vector3(0, 0, -90f);
if (partInfo.NodeType == NodeType.Product) LevelTf.gameObject.SetActive(false);
else
{
LevelTf.gameObject.SetActive(true);
LevelTf.GetComponent<Image>().color = LevelColors[(int)partInfo.NodeType - 1];
LevelTf.GetComponentInRealChildren<Text>().text = ((int)partInfo.NodeType).ToString();
}
isRoot = root;
}
/// <summary>
/// 初始化-递归设置所有节点中transform和Children的高度
/// </summary>
/// <returns></returns>
public float SetAllNodeHeight()
{
if (Children == null || Children.Count == 0) return nodeHeight;
float temp = 0;
foreach (TreeViewNode item in Children)
{
temp += item.SetAllNodeHeight();
}
ChildrenTf.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, temp);
transform.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, temp + nodeHeight);
return temp + nodeHeight;
}
/// <summary>
/// 设置是否需要折叠按钮 (延迟执行)
/// </summary>
private void SetInteraction()
{
if(Children == null || Children.Count == 0)
{
BtnArrow.gameObject.SetActive(false);
}
}
/// <summary>
/// 设置父节点(父父节点...)的序号列表
/// </summary>
private void GetNodeParentsIndexList()
{
parentsIndex = new Dictionary<NodeType, int>();
if (partInfo.NodeType == NodeType.Product) return;
TreeViewNode temp = Parent;
while(temp.PartInfo.NodeType > 0)
{
parentsIndex.Add(temp.PartInfo.NodeType, temp.PartInfo.Index);
temp = temp.Parent;
}
}
}
TreeView 控制
首先定义节点的数据对象格式,以便于JSON数据解析,如下:
//节点类型枚举
public enum NodeType
{
Product = 0,
First = 1,
Second = 2,
Third = 3,
Fourth = 4
}
//产品节点数据对象
public class ProductNode
{
public string Name;
public NodeType Type;
public int Index;
public Dictionary<string,string> ParaInfo;
public string Desc;
public List<ProductNode> Children;
public void AddChild(ProductNode child)
{
if (Children == null)
{
Children = new List<ProductNode>();
}
Children.Add(child);
}
}
其次,需定义一个与产品数据类型一致的JSON文件,示例如下
{
"Name": "xx产品",
"Type": "Product",
"Index": 0,
"ParaInfo": {
"长度": "xx m",
"直径": "xx m"
},
"Desc": "产品描述",
"Children": [
{
"Name": "组件一",
"Type": "First",
"Index": 1,
"Desc": "组件一描述",
"Children": [
{
"Name": "零件一",
"Type": "Second",
"Desc": "零件一描述"
},
{
"Name": "零件二",
"Type": "Second",
"Desc": "零件二描述"
}
]
},
{
"Name": "组件二",
"Type": "First",
"Index": 2,
"Desc": "组件二描述",
"Children": [
{
"Name": "零件三",
"Type": "Second",
"Desc": "零件三描述"
}
]
},
{
"Name": "组件三",
"Type": "First",
"Index": 3,
"Desc": "组件三描述",
"Children": [
{
"Name": "零件四",
"Type": "Second",
"Desc": "零件四描述"
},
{
"Name": "零件五",
"Type": "Second",
"Desc": "零件五描述"
}
]
}
]
}
准备工作完成后,开始写TreeView的控制逻辑,先根据json得到一个ProductNode根对象,其包括了产品的所有数据信息。其次根据ProductNode对象逐级生成TreeViewItem实例对象,并进行节点数据初始化,逻辑代码如下:
public class TreeViewController : SingletonMonoBase<TreeViewController>
{
/// <summary>
/// 根节点
/// </summary>
private TreeViewNode rootScript;
private void Start()
{
EventCenter.UIEvent.ChangeTreeViewHeightAction += ChangeTreeViewHeight;
CreateTreeView();
ModelControl.Instance.InitBillAfterCreateRoot(rootScript);
}
private void OnDestroy()
{
EventCenter.UIEvent.ChangeTreeViewHeightAction -= ChangeTreeViewHeight;
}
private void ChangeTreeViewHeight()
{
transform.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, GetRootHeight());
transform.parent.GetComponent<RectTransform>().SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, GetRootHeight());
}
/// <summary>
/// 结构树根节点数据
/// </summary>
private ProductNode rootData;
private void CreateTreeView()
{
rootData = ParseJson();
GameObject nodeGo = GameObject.Instantiate(Resources.Load("Prefabs/UI/TreeViewItem")) as GameObject;
nodeGo.transform.SetParent(transform, false);
TreeViewNode rootUINode = nodeGo.GetComponent<TreeViewNode>();
this.rootScript = rootUINode;
rootUINode.InitNodeSet(new PartInfo { PartName = rootData.Name, NodeType = rootData.Type, Index = rootData.Index, ParaInfo = rootData.ParaInfo, Desc = rootData.Desc }, true);
CreateTreeNodeUI(rootData, rootUINode);
rootUINode.SetAllNodeHeight();
ChangeTreeViewHeight();
EventCenter.UIEvent.UpdatePartInfo(rootUINode.PartInfo);
}
/// <summary>
/// 生成除根节点外的所有结构树节点UI
/// </summary>
/// <param name="dataNode"></param>
/// <param name="uiNode"></param>
private void CreateTreeNodeUI(ProductNode dataNode, TreeViewNode uiNode)
{
if (dataNode.Children != null)
{
foreach (ProductNode child in dataNode.Children)
{
GameObject nodeGo = GameObject.Instantiate(Resources.Load("Prefabs/UI/TreeViewItem")) as GameObject;
nodeGo.transform.SetParent(uiNode.ChildrenTf, false);
TreeViewNode childUINode = nodeGo.GetComponent<TreeViewNode>();
//构建双向链
uiNode.AddChild(childUINode);
childUINode.Parent = uiNode;
//初始化节点
childUINode.InitNodeSet(new PartInfo { PartName=child.Name,NodeType = child.Type, Index = child.Index, ParaInfo = child.ParaInfo,Desc = child.Desc});
//递归到子级
CreateTreeNodeUI(child, childUINode);
}
}
}
/// <summary>
/// 解析Json生成ProductNode根节点数据
/// </summary>
/// <returns></returns>
private ProductNode ParseJson()
{
//node为产品信息树根节点
TextAsset ta = Resources.Load<TextAsset>("Json/ProductInfo");
ProductNode node = JsonConvert.DeserializeObject<ProductNode>(ta.text);
return node;
}
/// <summary>
/// 通过零件模型名称获取父级(到根据节点)物体名称组
/// </summary>
/// <param name="partName"></param>
/// <returns></returns>
public List<string> GetParentInfoByPartName(string partName)
{
return FindParentNames(rootScript, partName);
}
/// <summary>
/// 获取该节点的所有父节点
/// </summary>
/// <param name="currentNode"></param>
/// <param name="targetNodeName"></param>
/// <returns></returns>
public List<string> FindParentNames(TreeViewNode currentNode, string targetNodeName)
{
var parentNames = new List<string>();
// 如果当前节点为空
if (currentNode == null)
{
return parentNames;
}
// 如果当前节点的名称与目标节点名称匹配,则开始收集父节点名称
if (currentNode.PartInfo.PartName == targetNodeName)
{
parentNames.Add(targetNodeName);
while (currentNode.Parent != null)
{
parentNames.Add(currentNode.Parent.PartInfo.PartName); // 添加父节点名称到列表中
currentNode = currentNode.Parent; // 移动到上一级父节点
}
// 反转列表,以便它按从根节点到自身的顺序排列
parentNames.Reverse();
return parentNames;
}
// 如果当前节点不是目标节点,则对其子节点进行递归搜索
foreach (var child in currentNode.Children)
{
var result = FindParentNames(child, targetNodeName);
if (result.Count > 0) // 如果在子树中找到了目标节点,则返回结果
{
return result;
}
}
// 如果在所有子节点中都没有找到目标节点,则返回空列表
return parentNames;
}
/// <summary>
/// 更新零件信息的显示panel
/// </summary>
/// <param name="partName"></param>
public void UpdatePartInfoByName(string partName)
{
EventCenter.UIEvent.UpdatePartInfo(GetPartNodeByName(partName).PartInfo);
}
/// <summary>
/// 根据零件名称获取零件节点 没有返回 null
/// </summary>
/// <param name="partName"></param>
/// <returns></returns>
public TreeViewNode GetPartNodeByName(string partName)
{
return GetPartTreeNode(rootScript, partName);
}
/// <summary>
/// 递归寻找节点
/// </summary>
/// <param name="node"></param>
/// <param name="partName"></param>
/// <returns></returns>
private TreeViewNode GetPartTreeNode(TreeViewNode node, string partName)
{
if (node.PartInfo.PartName == partName) return node;
if (node.Children == null || node.Children.Count == 0) return null;
foreach (var child in node.Children)
{
var res = GetPartTreeNode(child, partName);
if (res != null) return res;
}
return null;
}
/// <summary>
/// 获取整个tree的高度
/// </summary>
/// <returns></returns>
private float GetRootHeight()
{
return rootScript.NodeHeight + rootScript.ChildrenTf.GetComponent<RectTransform>().sizeDelta.y;
}
}
这里将该脚本作为一个单例(SingletonMonoBase),方便其他地方直接调用内部的方法,如何写继承于MonoBehaviour的单例,网上有很多案例。这里有点绕的就是关于折叠时的节点高度变化了,因为预制体中的物体都设置了Anchor,所以其高度调整需要用GetComponent().SetSizeWithCurrentAnchors() 方法来进行设置。折叠的UI动作用到了Dotween插件,很常用的一个插件,不多说了。
扩展
控制脚本中写了个GetPartNodeByName()方法,可以根据零件名称获取到对应的零件节点的TreeViewNode。如果你的产品模型中的名称与产品Json中一致且唯一的话,就可以打通模型与数据的关联通道了,比如点击TreeView中的某个节点,自动定位到对应的零件,并且播放相应的Animation,同时显示该零件的参数信息等。