0


Unity UI Tookite:实现窗体模板 [自定义元素]

文章目录


前言

最近在将Godot项目重写至Unity,首个问题是Unity无弹出窗体元素,网上搜罗后也没有发现相关实现。拙笔一篇


窗体结构

我希望窗体是这样的:
窗体示意图

在Unity的UI Builder中建立的元素结构如下
hierarchy和窗体

  1. 使用代码载入这个uxml,因此不需要添加根元素。
  2. Content元素是绝对定位,与父元素BodyContainer左右下各预留5px的距离,用于触发鼠标调整大小的逻辑。
  3. TitleBar和Body不使用绝对定位,Body大小将由代码控制
  4. 无需创建模板,因为使用代码自定义元素本身是模板的一种。
  5. 你可以随时更改这个uxml的样式,若创建模板则每次改动都需要重新覆盖。

自定义元素

创建一个Window类,其继承自VisualElement。

UnityEditor.UIElements

命名空间中有一种元素类型为

PopupWindow

,其本质是换了样式的

TextElement

。不建议继承,因为无论如何你都需要重写自己的样式。
官方注释也表明这一点:This element doesn’t have any functionality. It’s just a container with a border and a title, rather than a window or popup.

对于任意一个自定义元素,它的代码架构应该如下:

publicclassWindow:VisualElement{//UXML 工厂(这些工厂使用从 UXML 文件读取的数据实例化 VisualElement)//换句话说,让Unity可以识别你的自定义元素 注意第一个泛型参数为你自定义元素的本身publicnewclassUxmlFactory:UxmlFactory<Window,UxmlTraits>{}//用于解析 UXML 文件和生成 UXML 架构定义。//换句话说,定义你的元素的所有属性,以便于Unity自动装载它们。publicnewclassUxmlTraits:VisualElement.UxmlTraits{//定义接受哪些类型的子元素 - 仅用于生成UXML Schema的代码提示//换句话说,如果你不使用编辑器编写UXML文件,则无需override它publicoverrideIEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription
            {//接收任何类型的子元素get{yieldreturnnewUxmlChildElementDescription(typeof(VisualElement));}}//初始化过程//在这个过程中进行你的数据初始化,例如:读取属性,进行初步的运算等publicoverridevoidInit(VisualElement ve,IUxmlAttributes bag,CreationContext cc){base.Init(ve, bag, cc);}}//基类成员 用于标识子元素将附加到哪个元素之下(默认是这个自定义元素本身)//虽然一般元素不必重载它,但 !重要!我们之后会用到publicoverrideVisualElement contentContainer {get;}//构造函数 进行你的初始化操作 构造函数会在显示元素之前调用//(即使是鼠标悬停到元素选择栏时弹出的元素预览画面也会调用 这确保显示效果一致)publicWindow(){}}

当架构无问题且编译完成后,可以从UI Builder中的自定义看到定义的元素。
由于代码体为空,图中自定义元素的预览仅为效果示意
在这里插入图片描述

初始化元素变量

首先我们先在Window类中定义UI元素的变量,并在构造函数中加载窗体的uxml,附加到此自定义元素:

privateTemplateContainer _windowContainer;privateVisualElement _titleBar;privateLabel _titleLabel;privateButton _closeButton;privateButton _minimizeButton;privateButton _maximizeButton;privateVisualElement _bodyContainer;privateVisualElement _contentContainer;privateVisualElement _footerContainer;//构造函数-进行初始化publicWindow(){//加载uxml文件 也可以用Resource.Load
    _windowContainer = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/UI Toolkit/Custom/Template/WindowTemplate.uxml").Instantiate();//装载各个元素变量
    _titleBar = _windowContainer.Q<VisualElement>("TitleBarContainer");
    _titleLabel = _titleBar.Q<Label>("TitleLabel");
    _closeButton = _titleBar.Q<Button>("CloseButton");
    _minimizeButton = _titleBar.Q<Button>("MinimizeButton");
    _maximizeButton = _titleBar.Q<Button>("MaximizeButton");
    _bodyContainer = _windowContainer.Q<VisualElement>("BodyContainer");
    _contentContainer = _bodyContainer.Q<VisualElement>("Content");
    _footerContainer = _contentContainer.Q<VisualElement>("FooterContainer");//附加到此元素Add(_windowContainer);}

自定义属性

我们需要用户能自定义此Window关键的属性,例如窗体大小,是否可移动,是否可关闭等。

作为先行步骤,首先在Window类内进行定义C# property,并补充简单的逻辑:

publicstring Title {get=> _titleLabel.text;set=> _titleLabel.text =value;}publicbool Minimizable {get=>_minimizeButton.enabledSelf;set=>_minimizeButton.SetEnabled(value);}publicbool Maximizable {get=>_maximizeButton.enabledSelf;set=>_maximizeButton.SetEnabled(value);}publicbool Closable {get=>_closeButton.enabledSelf;set=>_closeButton.SetEnabled(value);}publicbool Resizable {get;set;}publicbool Draggable {get;set;}publicfloat Width {get=>style.width.value.value;set=>style.width =value;}//需要一个字段来保存实际高度privatefloat _height;publicfloat Height
{get=> _height;set{//设置高度时,需要设置_bodyContainer的高度属性 让其和标题栏恰好等于窗体高度//不建议_bodyContainer直接使用绝对定位,这会导致显示问题
        _bodyContainer.style.height =value- _titleBar.style.height.value.value;
        style.height =value;
        _height =value;}}

我已经在setter中完成了简单的逻辑。
有了以上的逻辑,现在只需把数据从UXML中读取到相应C# property即可。

在Window类的UxmlTraits中进行定义:

publicnewclassUxmlTraits:VisualElement.UxmlTraits{//所有属性name采用中划线命名法,例如:max-hp,text-value...//UxmlStringAttributeDescription表明他是一个stringprivatereadonlyUxmlStringAttributeDescription _title =new(){
        name ="title",
        defaultValue ="Window Title"};privatereadonlyUxmlBoolAttributeDescription _isMinimizable =new(){
        name ="minimizable",
        defaultValue =true};privatereadonlyUxmlBoolAttributeDescription _isMaximizable =new(){
        name ="maximizable",
        defaultValue =true};privatereadonlyUxmlBoolAttributeDescription _isClosable =new(){
        name ="closable",
        defaultValue =true};privatereadonlyUxmlBoolAttributeDescription _isResizable =new(){
        name ="resizable",
        defaultValue =true};privatereadonlyUxmlBoolAttributeDescription _isDraggable =new(){
        name ="draggable",
        defaultValue =true};//标明这个窗体的宽度privatereadonlyUxmlFloatAttributeDescription _width =new(){
        name ="width",
        defaultValue =300};//表明这个窗体的高度privatereadonlyUxmlFloatAttributeDescription _height =new(){
        name ="height",
        defaultValue =300};publicoverrideIEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription
    {get{yieldreturnnewUxmlChildElementDescription(typeof(VisualElement));}}publicoverridevoidInit(VisualElement ve,IUxmlAttributes bag,CreationContext cc){base.Init(ve, bag, cc);//在这里,把属性从Uxml读取到Window类的各个属性var window =(Window)ve;
        window.Title = _title.GetValueFromBag(bag, cc);
        window.Minimizable = _isMinimizable.GetValueFromBag(bag, cc);
        window.Maximizable = _isMaximizable.GetValueFromBag(bag, cc);
        window.Closable = _isClosable.GetValueFromBag(bag, cc);
        window.Resizable = _isResizable.GetValueFromBag(bag, cc);
        window.Draggable = _isDraggable.GetValueFromBag(bag, cc);
        window.Width = _width.GetValueFromBag(bag, cc);
        window.Height = _height.GetValueFromBag(bag, cc);}}

现在Window外貌可以从属性面板中控制了。
我暂时使用口代替全窗口按钮符号

逻辑实现——移动窗体

移动窗体的逻辑最为简单:

鼠标在标题栏按下->窗体跟随鼠标移动

为了能够捕获标题栏的鼠标事件信息,我们需要对_titleBar使用RegisterCallback< 事件 >:

//构造函数publicWindow(){//加载uxml文件 也可以用Resource.Load...//装载各个元素变量...//附加到此元素Add(_windowContainer);//绑定标题栏的鼠标位移事件
    _titleBar.RegisterCallback<MouseMoveEvent>(evt =>{//可移动 且 当前鼠标正在按下 且 鼠标按下的键是左键(左键为0)if(!Draggable || evt.pressedButtons ==0|| evt.button !=0)return;//移动窗体某个偏移量MoveWindowDelta(evt.mouseDelta);});}//移动窗体某个偏移量privatevoidMoveWindowDelta(Vector2 delta){//移动窗体即为设置子元素的位置信息
    style.top = style.top.value.value+ delta.y;
    style.left = style.left.value.value+ delta.x;}

此时,我们的窗体可以移动了
在这里插入图片描述

逻辑实现——窗体大小调整

大小调整是一个略微复杂的操作:

鼠标位于边缘按下->边缘跟随鼠标移动

其中如何检测鼠标是否位于边缘?检测是否位于bodyContainer和content元素的间隔位置即可。
前文提到,content为绝对定位,其与父元素左右下间隔5px(上边沿除外)
我将bodyContainer改为黄色,content改为红色。用于突出显示边缘
在这里插入图片描述
检测是否位于两个元素之间?换个角度思考就是:如何获悉鼠标在与不在bodyContainer的时刻?
使用MouseOverEvent,MouseOutEvent事件可以做到这一点,与HTML一样。

当鼠标:
从父元素bodyContainer移动到子元素(或其他元素)时,MouseOutEvent会被触发,
从子元素(或其他元素)进入父元素bodyContainer时,MouseOverEvent会被触发。

建立枚举类型CursorType用户记录当前鼠标的位置状态(位于何种边缘)

privateenumCursorType{
    Normal,//正常
    Left,//位于左边缘
    Right,//位于右边缘
    Down,//位于下边缘
    LeftDown,//位于左下角
    RightDown//位于右下角}privateCursorType _cursorType;

同时为增加视觉反馈,导入几张图片作为鼠标各个状态下的指针。

//StyleCursor 是style指针格式 用于修改_bodyContainer的style指针样式privatestaticreadonlyStyleCursor HorzCursor =new(newCursor{
    texture = Resources.Load<Texture2D>("Cursor/horz"),
    hotspot =newVector2(16,16)});privatestaticreadonlyStyleCursor VertCursor =new(newCursor{
    texture = Resources.Load<Texture2D>("Cursor/vert"),
    hotspot =newVector2(16,16)});privatestaticreadonlyStyleCursor RdCursor =new(newCursor{
    texture = Resources.Load<Texture2D>("Cursor/dgn1"),
    hotspot =newVector2(16,16)});privatestaticreadonlyStyleCursor LdCursor =new(newCursor{
    texture = Resources.Load<Texture2D>("Cursor/dgn2"),
    hotspot =newVector2(16,16)});

该指针图标下载自:Windows11概念光标-致美化 若要在Unity中使用需转为其他格式(如PNG)
鼠标指针

在Window类构造函数中进行事件回调注册:

Window(){//其他函数....//注册鼠标进入事件
    _bodyContainer.RegisterCallback<MouseOverEvent>(evt =>{//localMousePosition是鼠标以事件所属元素(_bodyContainer)的原点为原点的位置//位于下边缘var atBottom = evt.localMousePosition.y >= _bodyContainer.style.height.value.value-10;//位于右边缘var atRight = evt.localMousePosition.x >= Width -10;//位于左边缘var atLeft = evt.localMousePosition.x <=10;if(atBottom &&!atLeft &&!atRight){
            _cursorType = CursorType.Down;//将更改鼠标指针:即为修改style指针悬浮的样式
            _bodyContainer.style.cursor = VertCursor;}elseif(!atBottom &&(atLeft || atRight)){
            _cursorType = atLeft ? CursorType.Left : CursorType.Right;
            _bodyContainer.style.cursor = HorzCursor;}elseif(atBottom && atLeft){
            _cursorType = CursorType.LeftDown;
            _bodyContainer.style.cursor = LdCursor;}elseif(atBottom){
            _cursorType = CursorType.RightDown;
            _bodyContainer.style.cursor = RdCursor;}});//注册鼠标移除事件
    _bodyContainer.RegisterCallback<MouseOutEvent>(evt =>{//鼠标离开了区域
        _cursorType = CursorType.Normal;});//注册鼠标移动事件
    _bodyContainer.RegisterCallback<MouseMoveEvent>(evt =>{if(!Resizable || evt.pressedButtons ==0|| evt.button !=0)return;switch(_cursorType){case CursorType.Right:
                Width += evt.mouseDelta.x;break;case CursorType.Left:
                Width -= evt.mouseDelta.x;
                style.left = style.left.value.value+ evt.mouseDelta.x;break;case CursorType.Down:
                Height += evt.mouseDelta.y;break;case CursorType.LeftDown://结合Left和Down的逻辑
                Width -= evt.mouseDelta.x;
                style.left = style.left.value.value+ evt.mouseDelta.x;
                Height += evt.mouseDelta.y;break;case CursorType.RightDown://结合Right和Down的逻辑
                Width += evt.mouseDelta.x;
                Height += evt.mouseDelta.y;break;case CursorType.Normal:break;default:thrownewArgumentOutOfRangeException();}});}

此时,鼠标指针应该可以根据鼠标所处边缘位置而改变,同时拖拽可修改窗体位置。
Resize
但是…好像出了问题。最后一刻鼠标突然脱离了窗体边缘。
Bug
由于调整窗体的逻辑发生于_bodyContainer的MouseMoveEvent之上。
当鼠标移动较快,MouseMoveEvent事件还未结束(或到达)时鼠标已经离开了_bodyContainer边缘的范围,无法再次触发下一次MouseMoveEvent事件。
为了解决此类问题,使用缓冲区策略。当鼠标按下时生成一片范围较大的缓冲区域,在这个区域内进行鼠标的移动检测,并代替行使原始逻辑。

#region BufferAreaprivateVisualElement _bufferArea;privateAction<Vector2> _bufferAction;//建立并初始化缓冲区privatevoidCreateBufferArea(){
    _bufferArea =newVisualElement(){ 
        style ={
            position = Position.Absolute,
            width =0, 
            height =0,
            opacity =0,//始终是不可见的
            display = DisplayStyle.None,}};//鼠标移动: 持续触发窗体更新的事件
    _bufferArea.RegisterCallback<MouseMoveEvent>(evt =>{
        _bufferAction?.Invoke(evt.mouseDelta);//坐标转换 以Window元素的原点为原点MoveBufferArea(this.WorldToLocal(evt.mousePosition));});//鼠标松开: 终止本次拖动,设置大小为0并隐藏
    _bufferArea.RegisterCallback<MouseUpEvent>(evt =>{//将大小设置为0
        _bufferArea.style.width =0;
        _bufferArea.style.height =0;
        _bufferAction =null;
        _bufferArea.style.display = DisplayStyle.None;});Add(_bufferArea);}//启用缓冲区 持续检测鼠标移动,并执行对应操作privatevoidActivateBufferArea(Action<Vector2> action){if(action ==null)return;
    _bufferAction = action;
    _bufferArea.style.width =100;
    _bufferArea.style.height =100;
    _bufferArea.style.display = DisplayStyle.Flex;}//移动缓冲区域到指定位置privatevoidMoveBufferArea(Vector2 pos){//将缓冲区的中心移动到pos
    _bufferArea.style.top = pos.y - _bufferArea.style.height.value.value/2;
    _bufferArea.style.left = pos.x - _bufferArea.style.width.value.value/2;}#endregion

在Window类的构造函数中,保留_bodyContainer的MouseOverEvent,MouseOutEvent事件回调。
注册_bodyContainer的MouseDownEvent的事件回调:

Window(){//其他代码...//注册MouseDownEvent回调:
    _bodyContainer.RegisterCallback<MouseDownEvent>(evt =>{//仅当点击左键时触发if(evt.button !=0)return;//如果并非在边缘处按下则无需处理if(_contentContainer.ContainsPoint(_contentContainer.WorldToLocal(evt.mousePosition)))return;Action<Vector2> action =null;//根据鼠标的不同状态分配不同的更新逻辑switch(_cursorType){case CursorType.Normal:return;case CursorType.Left:
                action = mouseDelta =>{
                    Width -= mouseDelta.x;
                    style.left = style.left.value.value+ mouseDelta.x;};break;case CursorType.Right:
                action = mouseDelta =>{
                    Width += mouseDelta.x;};break;case CursorType.Down:
                action = mouseDelta =>{
                    Height += mouseDelta.y;};break;case CursorType.LeftDown:
                action = mouseDelta =>{
                    Width -= mouseDelta.x;
                    Height += mouseDelta.y;
                    style.left = style.left.value.value+ mouseDelta.x;};break;case CursorType.RightDown:
                action = mouseDelta =>{
                    Width += mouseDelta.x;
                    Height += mouseDelta.y;};break;default:thrownewArgumentOutOfRangeException();}//启用缓冲区ActivateBufferArea(action);//将缓冲区移动到当前鼠标 位置以Window的原点为原点,因为bufferArea元素位置在Window的根目录下MoveBufferArea(this.worldToLocal(evt.mousePosition));});//最后调用CreateBufferArea(),确保让Buffer排序在最前面CreateBufferArea();}
注意:

在Add(_windowContainer)方法之后调用CreateBufferArea,确保让Buffer排序在最前面

现在窗体缩放不会因为鼠标位移速度过快而中断了。
为bufferArea设定了背景色以突出显示
正常情况

逻辑实现——窗体基础逻辑

这部分较为简单,即为最大化,最小化,关闭逻辑。
简单来说,在最大化,最小化之前存储当前位置/大小信息,当第二次单击时还原。

#region Window FunctionprivateVector2 _prevSize;privateVector2 _prevPos;privatebool _isMaximized;privatevoidMaximize(){//避免数据发生混淆 解除额外的状态if(_isMinimized)Minimize();if(_isMaximized){
            Width = _prevSize.x;
            Height = _prevSize.y;
            style.top = _prevPos.y;
            style.left = _prevPos.x;
            _isMaximized =false;}else{
            _prevSize =newVector2(Width, Height);
            _prevPos =newVector2(style.left.value.value, style.top.value.value);var w = Screen.width;var h = Screen.height;
            Width = w;
            Height = h;
            style.top =0;
            style.left =0;
            _isMaximized =true;}}privatebool _isMinimized;privatevoidMinimize(){//避免数据混淆 解除额外的状态if(_isMaximized)Maximize();if(_isMinimized){
            _bodyContainer.style.display = DisplayStyle.Flex;
            Height = _prevSize.y;
            Width = _prevSize.x;
            style.top = _prevPos.y;
            style.left = _prevPos.x;
            _isMinimized =false;}else{
            _prevSize =newVector2(Width, Height);
            _prevPos =newVector2(style.left.value.value, style.top.value.value);
            Height = TitleBarHeight;//测量窗体标题的长度,加上按钮栏的长度 即为最小长度
            Width = _titleLabel.MeasureTextSize(_titleLabel.text,0, MeasureMode.Undefined,0, MeasureMode.Undefined).x
                + _buttonContainer.worldBound.width;
            _bodyContainer.style.display = DisplayStyle.None;
            _isMinimized =true;}}privatevoidClose(){SendEvent(newCloseRequestEvent(this));}#endregion

只需要在初始化阶段和对应按钮绑定ClickEvent事件回调即可,此处不再赘述。
不过其中的关闭事件比较特殊,他并不会真正关闭窗体,相反什么都不会发生。他只会发送一个CloseReqeuestEvent事件。

publicclassCloseRequestEvent:EventBase<CloseRequestEvent>{publicCloseRequestEvent(){}publicCloseRequestEvent(Window window){
            Window = window;
            target = window;//该事件的目标}publicWindow Window {get;}}

在使用此Window元素时,用window.RegisterCallback< CloseRequestEvent>捕获此事件。

注意:CloseRequestEvent事件的定义/使用方法可能并不正规

,如果您有见解欢迎留言指正。直接暴露close按钮进行RegisterCallback可能是个选择。

逻辑实现——自动附加元素到指定子元素

在使用自定义元素时,所有由代码加载/生成的元素都将不可更改,也不可附加其他元素。
直接拖拽新元素到此window元素上时,新添加的元素会排在末尾,这与预期不符。
错误的附加位置
我们期望元素能够附加到指定的位置,就像socket接口一样。
这就要用到第一节提到的

//基类成员 用于标识子元素将附加到哪个元素之下(默认是这个自定义元素本身)//虽然一般元素不必重载它,但 !重要!我们之后会用到publicoverrideVisualElement contentContainer {get;}

只需要覆盖contentContainer的值,便可指定新加入元素的安插位置

注意!contentContainer 也会影响this.Add(...)的结果
因此务必确保 **在Add(_windowContainer)方法之后** 修改contentContainer

所以在构造函数内,首先指定contentContainer为本身,最后指定contentContainer为目标的窗体:

Window(){//首先指定contentContainer为本身(此时Add()方法将附加到根节点)
    contentContainer =this;//进行AssetDatabase.LoadAssetAtPath....//初始化相关数据.........//最后一行//指定contentContainer为窗体主容器
    contentContainer = _contentContainer;}

此时,在UI Builder中拖拽元素到window中,将会安插到正确位置。
在这里插入图片描述

至于样式…

主要方式是:

AddToClassList(ussClassName)

添加自定义的类名

EnableInClassList(ussClassName,newValue)

设定类是否启用

样式表直接在uxml中导入uss文件,之后只需要更新此uxml的样式便可管理所有窗体实例。
不过此文章应该可以结尾了。如果不够用,我会再更新。

总之以上便是一个简陋的窗体模板的实现逻辑。嵌入如ScrollView等现成组件可大大增加其泛用性。
在这里插入图片描述

标签: unity ui 游戏引擎

本文转载自: https://blog.csdn.net/qq_36288357/article/details/142486808
版权归原作者 qq_36288357 所有, 如有侵权,请联系我们删除。

“Unity UI Tookite:实现窗体模板 [自定义元素]”的评论:

还没有评论