先上几张效果图:
如果你需要的也是这种效果,那你就来对地方了!
目前,我们这个树形菜单展现出来的功能如下:
1、可以动态配置数据源;
2、点击每个元素的上下文菜单按钮(也就是图中的三角形按钮),可以收缩或展开它的子元素;
3、可以单独判断某一元素的复选框是否被勾选,或者直接获取当前树形菜单中所有被勾选的元素;
4、树形菜单统一控制其下所有子元素按钮的事件分发;
5、可自动调节的滚动视野边缘,根据当前可见的子元素数量进行横向以及纵向的伸缩;
一、首先,我们先制作子元素的模板(Template),也就是图中菜单的单个元素,用它来根据数据源动态克隆出多个子元素,这里的话,很显然我们的模板是由两个Button加一个Toggle和一个Text组成的,如下:
ContextButton TreeViewToggle TreeViewButton(TreeViewText)
图中的text是一个文本框,用于描述此元素的名称或内容,它们对应的结构就是这样:
二、我们的每个子元素都会携带一个TreeViewItem脚本,用于描述自身在整个树形菜单中与其他元素的父子关系,而整个树形菜单的控制由TreeViewControl来实现,首先,TreeViewControl会根据提供的数据源来生成所有的子元素,当然,改变数据源之后进行重新生成的时候也是这个方法,干的事情很简单,就是用模板不停的创建元素,并给他们建立父子关系:
/// <summary> /// 生成树形菜单 /// </summary> public void GenerateTreeView() { //删除可能已经存在的树形菜单元素 if (_treeViewItems != null) { for (int i = 0; i < _treeViewItems.Count; i++) { Destroy(_treeViewItems[i]); } _treeViewItems.Clear(); } //重新创建树形菜单元素 _treeViewItems = new List<GameObject>(); for (int i = 0; i < Data.Count; i++) { GameObject item = Instantiate(Template); if (Data[i].ParentID == -1) { item.GetComponent<TreeViewItem>().SetHierarchy(0); item.GetComponent<TreeViewItem>().SetParent(null); } else { TreeViewItem tvi = _treeViewItems[Data[i].ParentID].GetComponent<TreeViewItem>(); item.GetComponent<TreeViewItem>().SetHierarchy(tvi.GetHierarchy() + 1); item.GetComponent<TreeViewItem>().SetParent(tvi); tvi.AddChildren(item.GetComponent<TreeViewItem>()); } item.transform.name = "TreeViewItem"; item.transform.FindChild("TreeViewText").GetComponent<Text>().text = Data[i].Name; item.transform.SetParent(TreeItems); item.transform.localPosition = Vector3.zero; item.transform.localScale = Vector3.one; item.transform.localRotation = Quaternion.Euler(Vector3.zero); item.SetActive(true); _treeViewItems.Add(item); } }
三、树形菜单生成完毕之后此时所有元素虽然都记录了自身与其他元素的父子关系,但他们的位置都是在Vector3.zero的,毕竟我们的菜单元素在创建的时候都是一股脑儿的丢到原点位置的,创建君可不管这么多元素挤在一堆会不会憋死,好吧,之后规整列队的事情就交给刷新君来完成了,刷新君玩的一手好递归,它会遍历所有元素并剔除不可见的元素(也就是点击三角按钮隐藏了),并将它们一个一个的重新排列整齐,子排在父之后,孙排在子之后,以此类推......它会遍历每个元素的子元素列表,发现子元素可见便进入子元素列表,发现孙元素可见便进入孙元素列表:
/// <summary> /// 刷新树形菜单 /// </summary> public void RefreshTreeView() { _yIndex = 0; _hierarchy = 0; //复制一份菜单 _treeViewItemsClone = new List<GameObject>(_treeViewItems); //用复制的菜单进行刷新计算 for (int i = 0; i < _treeViewItemsClone.Count; i++) { //已经计算过或者不需要计算位置的元素 if (_treeViewItemsClone[i] == null || !_treeViewItemsClone[i].activeSelf) { continue; } TreeViewItem tvi = _treeViewItemsClone[i].GetComponent<TreeViewItem>(); _treeViewItemsClone[i].GetComponent<RectTransform>().localPosition = new Vector3(tvi.GetHierarchy() * HorizontalItemSpace, _yIndex,0); _yIndex += (-(ItemHeight + VerticalItemSpace)); if (tvi.GetHierarchy() > _hierarchy) { _hierarchy = tvi.GetHierarchy(); } //如果子元素是展开的,继续向下刷新 if (tvi.IsExpanding) { RefreshTreeViewChild(tvi); } _treeViewItemsClone[i] = null; } //重新计算滚动视野的区域 float x = _hierarchy * HorizontalItemSpace + ItemWidth; float y = Mathf.Abs(_yIndex); transform.GetComponent<ScrollRect>().content.sizeDelta = new Vector2(x, y); //清空复制的菜单 _treeViewItemsClone.Clear(); } /// <summary> /// 刷新元素的所有子元素 /// </summary> void RefreshTreeViewChild(TreeViewItem tvi) { for (int i = 0; i < tvi.GetChildrenNumber(); i++) { tvi.GetChildrenByIndex(i).gameObject.GetComponent<RectTransform>().localPosition = new Vector3(tvi.GetChildrenByIndex(i).GetHierarchy() * HorizontalItemSpace, _yIndex, 0); _yIndex += (-(ItemHeight + VerticalItemSpace)); if (tvi.GetChildrenByIndex(i).GetHierarchy() > _hierarchy) { _hierarchy = tvi.GetChildrenByIndex(i).GetHierarchy(); } //如果子元素是展开的,继续向下刷新 if (tvi.GetChildrenByIndex(i).IsExpanding) { RefreshTreeViewChild(tvi.GetChildrenByIndex(i)); } int index = _treeViewItemsClone.IndexOf(tvi.GetChildrenByIndex(i).gameObject); if (index >= 0) { _treeViewItemsClone[index] = null; } } }
我这里将所有的元素复制了一份用于计算位置,主要就是为了防止在进行一轮刷新时某个元素被访问两次或以上,因为刷新的时候会遍历所有可见元素,如果第一次访问了元素A(元素A的位置被刷新),根据元素A的子元素列表访问到了元素B(元素B的位置被刷新),一直到达子元素的底部后,当不存在更深层次的子元素时,那么返回到元素A之后的元素继续访问,这时在所有元素列表中元素B可能在元素A之后,也就是说元素B已经通过父元素访问过了,不需要做再次访问,他的位置已经是最新的了,而之后根据列表索引很可能再次访问到元素B,如果是这样的话元素B的位置又要被刷新一次,甚至多次,性能影响不说,第二次计算的位置已经不是正确的位置了。
四、菜单已经创建完毕并且经过了一轮刷新,此时它展示出来的就是这样一个所有子元素都展开的形状(我在demo中指定了数据源,关于数据源怎么设置在后面):
我们要在每个元素都携带的脚本TreeViewItem中对自身的那个三角形的上下文按钮监听,当鼠标点击它时它的子元素就会被折叠或者展开:
/// <summary> /// 点击上下文菜单按钮,元素的子元素改变显示状态 /// </summary> void ContextButtonClick() { if (IsExpanding) { transform.FindChild("ContextButton").GetComponent<RectTransform>().localRotation = Quaternion.Euler(0, 0, 90); IsExpanding = false; ChangeChildren(this, false); } else { transform.FindChild("ContextButton").GetComponent<RectTransform>().localRotation = Quaternion.Euler(0, 0, 0); IsExpanding = true; ChangeChildren(this, true); } //刷新树形菜单 Controler.RefreshTreeView(); } /// <summary> /// 改变某一元素所有子元素的显示状态 /// </summary> void ChangeChildren(TreeViewItem tvi, bool value) { for (int i = 0; i < tvi.GetChildrenNumber(); i++) { tvi.GetChildrenByIndex(i).gameObject.SetActive(value); ChangeChildren(tvi.GetChildrenByIndex(i), value); } }
IsExpanding做为每个元素的字段用于设置或读取自身子元素的显示状态,这里根据改变的状态会递归循环此元素的所有子元素及孙元素,让他们可见或隐藏。
五、对所有的子元素进行统一的事件分发,这里主要就有鼠标点击这一个事件:
每个元素都会注册这个事件:(TreeViewItem.cs)
void Awake() { //上下文按钮点击回调 transform.FindChild("ContextButton").GetComponent<Button>().onClick.AddListener(ContextButtonClick); transform.FindChild("TreeViewButton").GetComponent<Button>().onClick.AddListener(delegate () { Controler.ClickItem(gameObject); }); }
树形菜单控制器统一分发:(TreeViewControl.cs)
public delegate void ClickItemdelegate(GameObject item); public event ClickItemdelegate ClickItemEvent; /// <summary> /// 鼠标点击子元素事件 /// </summary> public void ClickItem(GameObject item) { ClickItemEvent(item); }
六、获取元素的复选框状态判断是否被勾选:
根据元素名称进行筛选,获取此元素的选中状态,如果存在同名元素的话这个可能不好使:
/// <summary> /// 返回指定名称的子元素是否被勾选 /// </summary> public bool ItemIsCheck(string itemName) { for (int i = 0; i < _treeViewItems.Count; i++) { if (_treeViewItems[i].transform.FindChild("TreeViewText").GetComponent<Text>().text == itemName) { return _treeViewItems[i].transform.FindChild("TreeViewToggle").GetComponent<Toggle>().isOn; } } return false; }
返回树形菜单中所有被勾选的子元素名称集合:
/// <summary> /// 返回树形菜单中被勾选的所有子元素名称 /// </summary> public List<string> ItemsIsCheck() { List<string> items = new List<string>(); for (int i = 0; i < _treeViewItems.Count; i++) { if (_treeViewItems[i].transform.FindChild("TreeViewToggle").GetComponent<Toggle>().isOn) { items.Add(_treeViewItems[i].transform.FindChild("TreeViewText").GetComponent<Text>().text); } } return items; }
七、接下来是我们的数据格式TreeViewData,树形菜单的数据源是由这个格式组成的集合:
/// <summary> /// 当前树形菜单的数据源 /// </summary> [HideInInspector] public List<TreeViewData> Data = null;
每一个TreeViewData代表一个元素,Name为显示的文本内容,ParentID为它指向的父元素在整个数据集合中的索引,从0开始,-1代表不存在父元素的根元素,当然有时候数据源并不是这个样子的,可能是XML,可能是json,不过都可以通过解析数据源之后再变换成这种方式:
/// <summary> /// 树形菜单数据 /// </summary> public class TreeViewData { /// <summary> /// 数据内容 /// </summary> public string Name; /// <summary> /// 数据所属的父ID /// </summary> public int ParentID; }
八、属性面板的参数:
Template:当前树形菜单的元素模板;
TreeItems:当前树形菜单的元素根物体,自动指定的,这个别去动;
VerticalItemSpace:相邻元素之间的纵向间距;
HorizontalItemSpace:不同层级元素之间的横向间距;
ItemWidth:元素的宽度,若自行修改过Template,这里的值需要自己去计算Template的大概宽度;
ItemHeight:元素的高度,若自行修改过Template,这里的值需要自己去计算Template的大概高度;
九、我已经将TreeView打包成了一个插件,在Unity中导入他,便可以直接使用TreeView:
导入TreeView.unitypackage以后,先在场景中创建一个Canvas(画布),然后右键直接创建TreeView:
之后在其他脚本中拿到这个TreeView,直接为他指定数据源(我这里是手动生成,篇幅有点长):
//生成数据 List<TreeViewData> datas = new List<TreeViewData>(); TreeViewData data = new TreeViewData(); data.Name = "第一章"; data.ParentID = -1; datas.Add(data); data = new TreeViewData(); data.Name = "1.第一节"; data.ParentID = 0; datas.Add(data); data = new TreeViewData(); data.Name = "1.第二节"; data.ParentID = 0; datas.Add(data); data = new TreeViewData(); data.Name = "1.1.第一课"; data.ParentID = 1; datas.Add(data); data = new TreeViewData(); data.Name = "1.2.第一课"; data.ParentID = 2; datas.Add(data); data = new TreeViewData(); data.Name = "1.1.第二课"; data.ParentID = 1; datas.Add(data); data = new TreeViewData(); data.Name = "1.1.1.第一篇"; data.ParentID = 3; datas.Add(data); data = new TreeViewData(); data.Name = "1.1.1.第二篇"; data.ParentID = 3; datas.Add(data); data = new TreeViewData(); data.Name = "1.1.1.2.第一段"; data.ParentID = 7; datas.Add(data); data = new TreeViewData(); data.Name = "1.1.1.2.第二段"; data.ParentID = 7; datas.Add(data); data = new TreeViewData(); data.Name = "1.1.1.2.1.第一题"; data.ParentID = 8; datas.Add(data); //指定数据源 TreeView.Data = datas;
然后生成树形菜单,连带刷新一次:
//重新生成树形菜单 TreeView.GenerateTreeView(); //刷新树形菜单 TreeView.RefreshTreeView();
然后注册子元素的鼠标点击事件(委托类型为返回值void,带一个Gameobject类型参数,参数item为被鼠标点中的那个元素的gameobject):
//注册子元素的鼠标点击事件 TreeView.ClickItemEvent += CallBack; void CallBack(GameObject item) { Debug.Log("点击了 " + item.transform.FindChild("TreeViewText").GetComponent<Text>().text); }
以及要获取某一元素的勾选状态:
bool isCheck = TreeView.ItemIsCheck("第一章"); Debug.Log("当前树形菜单中的元素 第一章 " + (isCheck?"已被选中!":"未被选中!"));
和获取所有被勾选的元素:
List<string> items = TreeView.ItemsIsCheck(); for (int i = 0; i < items.Count; i++) { Debug.Log("当前树形菜单中被选中的元素有:" + items[i]); }
效果图如下:
插件链接:已经上传,等审核过了贴链接