前言
最近在将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.
对于任意一个自定义元素,它的代码架构应该如下:
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();
}
});
}
此时,鼠标指针应该可以根据鼠标所处边缘位置而改变,同时拖拽可修改窗体位置。
但是…好像出了问题。最后一刻鼠标突然脱离了窗体边缘。
由于调整窗体的逻辑发生于_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等现成组件可大大增加其泛用性。