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.

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

    public class Window : VisualElement
    {
        //UXML 工厂(这些工厂使用从 UXML 文件读取的数据实例化 VisualElement)
        //换句话说,让Unity可以识别你的自定义元素 注意第一个泛型参数为你自定义元素的本身
        public new class UxmlFactory : UxmlFactory<Window,UxmlTraits> {}

        //用于解析 UXML 文件和生成 UXML 架构定义。
        //换句话说,定义你的元素的所有属性,以便于Unity自动装载它们。
        public new class UxmlTraits : VisualElement.UxmlTraits
        {
        
            //定义接受哪些类型的子元素 - 仅用于生成UXML Schema的代码提示
            //换句话说,如果你不使用编辑器编写UXML文件,则无需override它
            public override IEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription
            {
                //接收任何类型的子元素
                get{ yield return new UxmlChildElementDescription(typeof(VisualElement)); }
            }
            //初始化过程
            //在这个过程中进行你的数据初始化,例如:读取属性,进行初步的运算等
            public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
            {
                base.Init(ve, bag, cc);
            }
            
        }

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

        //构造函数 进行你的初始化操作 构造函数会在显示元素之前调用
        //(即使是鼠标悬停到元素选择栏时弹出的元素预览画面也会调用 这确保显示效果一致)
        public Window()
        {
            
        }
    }

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

初始化元素变量

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

private TemplateContainer _windowContainer;
private VisualElement _titleBar;
private Label _titleLabel;
private Button _closeButton;
private Button _minimizeButton;
private Button _maximizeButton;
private VisualElement _bodyContainer;
private VisualElement _contentContainer;
private VisualElement _footerContainer;
//构造函数-进行初始化
public Window()
{
    //加载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,并补充简单的逻辑:

public string Title { get => _titleLabel.text; set => _titleLabel.text = value; }
public bool Minimizable { get=>_minimizeButton.enabledSelf;set=>_minimizeButton.SetEnabled(value); }
public bool Maximizable { get=>_maximizeButton.enabledSelf;set=>_maximizeButton.SetEnabled(value); }
public bool Closable { get=>_closeButton.enabledSelf;set=>_closeButton.SetEnabled(value); }
public bool Resizable { get; set; }
public bool Draggable { get; set; }
public float Width { get=>style.width.value.value; set=>style.width = value; }

//需要一个字段来保存实际高度
private float _height;
public float 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中进行定义:

public new class UxmlTraits : VisualElement.UxmlTraits
{
    //所有属性name采用中划线命名法,例如:max-hp,text-value...
    //UxmlStringAttributeDescription表明他是一个string
    private readonly UxmlStringAttributeDescription _title = new()
    {
        name = "title",
        defaultValue = "Window Title"
    };
    private readonly UxmlBoolAttributeDescription _isMinimizable = new()
    {
        name = "minimizable",
        defaultValue = true
    };
    private readonly UxmlBoolAttributeDescription _isMaximizable = new()
    {
        name = "maximizable",
        defaultValue = true
    };

    private readonly UxmlBoolAttributeDescription _isClosable = new()
    {
        name = "closable",
        defaultValue = true
    };

    private readonly UxmlBoolAttributeDescription _isResizable = new()
    {
        name = "resizable",
        defaultValue = true
    };

    private readonly UxmlBoolAttributeDescription _isDraggable = new()
    {
        name = "draggable",
        defaultValue = true
    };
    //标明这个窗体的宽度
    private readonly UxmlFloatAttributeDescription _width = new()
    {
        name = "width",
        defaultValue = 300
    };
    //表明这个窗体的高度
    private readonly UxmlFloatAttributeDescription _height = new()
    {
        name = "height",
        defaultValue = 300
    };

    public override IEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription
    {
        get{ yield return new UxmlChildElementDescription(typeof(VisualElement));}
    }

    public override void Init(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< 事件 >:

//构造函数
public Window()
{
    //加载uxml文件 也可以用Resource.Load
    ...
    //装载各个元素变量
    ...
    //附加到此元素
    Add(_windowContainer);
    //绑定标题栏的鼠标位移事件
    _titleBar.RegisterCallback<MouseMoveEvent>(evt =>
    {
        //可移动 且 当前鼠标正在按下 且 鼠标按下的键是左键(左键为0)
        if (!Draggable || evt.pressedButtons == 0 || evt.button != 0) return;
        //移动窗体某个偏移量
        MoveWindowDelta(evt.mouseDelta);
    });
}
//移动窗体某个偏移量
private void MoveWindowDelta(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用户记录当前鼠标的位置状态(位于何种边缘)

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

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

//StyleCursor 是style指针格式 用于修改_bodyContainer的style指针样式
private static readonly StyleCursor HorzCursor = new(new Cursor
{
    texture = Resources.Load<Texture2D>("Cursor/horz"),
    hotspot = new Vector2(16,16)
});

private static readonly StyleCursor VertCursor = new(new Cursor
{
    texture = Resources.Load<Texture2D>("Cursor/vert"),
    hotspot = new Vector2(16,16)
});

private static readonly StyleCursor RdCursor = new(new Cursor
{
    texture = Resources.Load<Texture2D>("Cursor/dgn1"),
    hotspot = new Vector2(16,16)
});

private static readonly StyleCursor LdCursor = new(new Cursor
{
    texture = Resources.Load<Texture2D>("Cursor/dgn2"),
    hotspot = new Vector2(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;
        }else if (!atBottom && (atLeft || atRight))
        {
            _cursorType = atLeft ? CursorType.Left : CursorType.Right;
            _bodyContainer.style.cursor = HorzCursor;
        }else if (atBottom && atLeft)
        {
            _cursorType = CursorType.LeftDown;
            _bodyContainer.style.cursor = LdCursor;
        }else if (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:
                throw new ArgumentOutOfRangeException();
        }
    });
}

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

#region BufferArea
private VisualElement _bufferArea;
private Action<Vector2> _bufferAction;
//建立并初始化缓冲区
private void CreateBufferArea()
{
    _bufferArea = new VisualElement()
    { 
        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);
}
//启用缓冲区 持续检测鼠标移动,并执行对应操作
private void ActivateBufferArea(Action<Vector2> action)
{
    if (action == null) return;
    _bufferAction = action;
    _bufferArea.style.width = 100;
    _bufferArea.style.height = 100;
    _bufferArea.style.display = DisplayStyle.Flex;
}
//移动缓冲区域到指定位置
private void MoveBufferArea(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:
                throw new ArgumentOutOfRangeException();
        }
        //启用缓冲区
        ActivateBufferArea(action);
        //将缓冲区移动到当前鼠标 位置以Window的原点为原点,因为bufferArea元素位置在Window的根目录下
        MoveBufferArea(this.worldToLocal(evt.mousePosition));
    });
    //最后调用CreateBufferArea(),确保让Buffer排序在最前面
    CreateBufferArea();
}

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

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

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

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

    #region Window Function

    private Vector2 _prevSize;
    private Vector2 _prevPos;
    private bool _isMaximized;
    private void Maximize()
    {
        //避免数据发生混淆 解除额外的状态
        if (_isMinimized) Minimize();
        
        if (_isMaximized)
        {
            Width = _prevSize.x;
            Height = _prevSize.y;
            style.top = _prevPos.y;
            style.left = _prevPos.x;
            _isMaximized = false;
        }
        else
        {
            _prevSize = new Vector2(Width, Height);
            _prevPos = new Vector2(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;
        }

    }
    private bool _isMinimized;
    private void Minimize()
    {
        //避免数据混淆 解除额外的状态
        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 = new Vector2(Width, Height);
            _prevPos = new Vector2(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;
        }
    }
    private void Close()
    {
        SendEvent(new CloseRequestEvent(this));
    }
    #endregion

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

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

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

注意:CloseRequestEvent事件的定义/使用方法可能并不正规,如果您有见解欢迎留言指正。直接暴露close按钮进行RegisterCallback可能是个选择。

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

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

//基类成员 用于标识子元素将附加到哪个元素之下(默认是这个自定义元素本身)
//虽然一般元素不必重载它,但 !重要!我们之后会用到
public override VisualElement 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等现成组件可大大增加其泛用性。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值