文章目录
前言
最近在将Godot项目重写至Unity,首个问题是Unity无弹出窗体元素,网上搜罗后也没有发现相关实现。拙笔一篇
窗体结构
我希望窗体是这样的:
在Unity的UI Builder中建立的元素结构如下
- 使用代码载入这个uxml,因此不需要添加根元素。
- Content元素是绝对定位,与父元素BodyContainer左右下各预留5px的距离,用于触发鼠标调整大小的逻辑。
- TitleBar和Body不使用绝对定位,Body大小将由代码控制
- 无需创建模板,因为使用代码自定义元素本身是模板的一种。
- 你可以随时更改这个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();}});}
此时,鼠标指针应该可以根据鼠标所处边缘位置而改变,同时拖拽可修改窗体位置。
但是…好像出了问题。最后一刻鼠标突然脱离了窗体边缘。
由于调整窗体的逻辑发生于_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等现成组件可大大增加其泛用性。
版权归原作者 qq_36288357 所有, 如有侵权,请联系我们删除。