引入
如果你还不知道什么是Floating UI,看这里就可以啦。
书接上文。Floating UI是使用的NBT数据来定义布局数据的。试想一下,假设我们想要做一个滚动的列表,内容是玩家的背包物品,我们应该怎么做呢?
list中有一个child列表字段,其中定义的就是列表中要显示的元素。所以,我们只需要遍历玩家背包列表,然后根据内容添加sprite控件数据就好了。
嗯……“只需要”,说得好像遍历很方便一样(
众所周知,在MC中要完成一个遍历操作非常的麻烦,只能依靠递归完成。而且,很明显这种需求是很常见的,我们就需要写很多次重复的代码。虽然在数据包里面这是不得不评鉴的一环,但是咱不是Mojang不会让你去品鉴一坨东西,所以肯定需要提供一个很方便的东西啦。
让我们看看,在其他的UI框架中,是怎么解决这类问题的。在宇宙最强Windows桌面开发框架WPF中,提供了模板(Template)和数据绑定(Data Binding)两个东西来优雅地解决这样的问题。
<!-- ItemsControl用于显示数据集合,ItemsSource绑定到ViewModel的数据源 -->
<ItemsControl x:Name="listControl" ItemsSource="{Binding ItemList}">
<!-- 定义每个数据项的显示模板 -->
<ItemsControl.ItemTemplate>
<DataTemplate>
<!-- 每个数据项显示为带边框的文本块 -->
<Border Margin="5" Padding="10" Background="LightBlue">
<TextBlock Text="{Binding Name}" FontSize="16"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>// MainWindow.xaml.cs
using System.Collections.Generic;
using System.Windows;
namespace WpfApp
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
// 创建测试数据
var items = new List<Item>
{
new Item { Name = "项目1" },
new Item { Name = "项目2" },
new Item { Name = "项目3" },
// 可继续添加更多项目测试滚动效果
};
// 设置数据上下文
DataContext = new ViewModel { ItemList = items };
}
}
// 数据模型类
public class Item
{
public string Name { get; set; }
}
// ViewModel类
public class ViewModel
{
public List<Item> ItemList { get; set; }
}
}看不懂?没关系喵。简单的说,就是定义了一个模板,这个模板会根据数据源中的数据来生成对应的UI元素。是不是很方便呀?于是,我们也要做一个这样的功能才行。
成果
在Floating UI中,有list和stackpanel两个控件拥有child列表字段,这两个控件都支持模板和数据绑定。如果把child字段定义为一个数据源,同时额外定义一个template字段,Floating UI就会根据模板和数据源自动生成UI元素。
{
"type": "list",
"size":[5f,5f],
"template": { // 模板
"type":"button",
"size":[1.2f,1.2f],
"item":{
"id":"apple"
}
},
"child": {
"path": "temp qwq.value", //数据源
"binds": [ //绑定关系
{
"source": "id",
"target": "item.id",
}
]
}
}在上述例子中,template中是一个控件,也就是一个模板,而child本来应该是一个列表,这里却定义为了一个复合标签,表示是一个数据源引用。child.path是一个storage路径,空格隔开的前半部分是storage的命名空间ID,后半部分则是在这个storage中的nbt路径,必然对应一个列表。child.binds列表描述了一个绑定关系,其中的source字段表示数据源中的路径,target字段表示模板中的路径。在这个绑定关系中,将会把minecraft:temp中qwq.value列表作为数据源,其中每一个元素的id字段都会作为模板中item.id字段的值,从而生成一个按钮。
到这一步,仍然只是从已有的一个数据中生成UI,若数据源中的内容发生更改,UI还是不会自动更新。这个时候,就是set_property函数登场的时候了。通过使用floating_ui:datasource/set_property函数设置数据源中的内容,可以自动触发UI的更新。当然,必须要通过这个函数设置才行,毕竟,咱不可能每个tick都轮询数据源中的内容,这样的开销太大了。事实上在WPF中,也是使用了SetProperty这样的方法来触发事件,从而通知UI进行更新的。
像这样来使用set_property函数:
data modify storage floating_ui:temp binding.path set value "minecraft:temp qwq.value"
data modify storage floating_ui:temp binding.value set from entity @p Inventory
floating_ui:datasource/set_property就是这样的简单~
原理
以stackpanel为例。在其_new函数中,会判断child字段是否是一个列表。如果不是列表,说明是一个数据源,那么就可能使用了数据绑定,因此进入./template/append_template函数中。
# template: (string|compound)
# 如果不是内联数据,则获取数据模板
execute unless data storage floating_ui:input temp.template.type run return run function log:_error {msg: "无效的模板"}
# temp.child: {value: [...], path:xxx, binds: [source:xxx, target: xxx]}
# 若有绑定,则注册绑定,并获取绑定数据,储存在source.value中。如果没有binding,则说明直接声明了数据源,不参与绑定
execute if data storage floating_ui:input temp.child.path if function floating_ui:element/stackpanel/template/register_binding run function floating_ui:element/stackpanel/template/set_source with storage floating_ui:temp binding
# 解析保存在temp.child.value中的源数据
function floating_ui:element/stackpanel/template/update_source这个函数分为三步。第一步,注册数据绑定;第二步,初次解析数据源中的内容;第三步,根据数据源的内容更新UI。
注册数据绑定
首先,通过if function子命令调用floating_ui:element/stackpanel/template/register_binding函数。
# floating_ui:element/stackpanel/template/register_binding
#注册绑定
data modify storage floating_ui:temp binding.path set from storage floating_ui:input temp.child.path
function floating_ui:datasource/register_binding
#在实体中写入绑定信息
function floating_ui:element/stackpanel/template/register_binding_1 with storage floating_ui:input temp.child
return 1# floating_ui:element/stackpanel/template/register_binding_1
$data modify entity @s item.components."minecraft:custom_data".register_binding."$(path)" set value 'function floating_ui:element/stackpanel/template/before_update_source'floating_ui:datasource/register_binding函数用于全局注册一个数据绑定,将这个UI控件绑定到这个路径中。我们稍后来看这个函数的详细内容。而floating_ui:element/stackpanel/template/register_binding_1是一个宏函数。先前_new函数是以当前控件对应的展示实体作为上下文,所以在宏函数中,就写入了数据绑定事件的信息——当$(path)对应的数据源中的内容发生改变的时候,会执行floating_ui:element/stackpanel/template/before_update_source函数。
TIP
你可能会发现,Floating UI中凡是涉及到宏函数相关的内容,几乎都会单独开一个函数,保证单个宏函数中的命令量尽可能少。这是因为在宏函数中,即使普通的命令也会占用宏的解析事件,简短的宏函数对整体执行效率的提升有很大的帮助。
回过头来看看floating_ui:datasource/register_binding函数。需要记住的是,这个函数执行的上下文同样应该是控件对应的这个展示实体。
# floating_ui:datasource/register_binding
execute store result score _ int run function floating_ui:datasource/get_or_create_data_id with storage floating_ui:temp binding
#设置实体绑定
function floating_ui:datasource/register_binding_1# floating_ui:datasource/get_or_create_data_id
$execute unless data storage floating_ui:data binding.id."$(path)" store result storage floating_ui:data binding.id."$(path)" int 1.0 run scoreboard players add _static_index floating_ui.data_id 1
$return run data get storage floating_ui:data binding.id."$(path)"function floating_ui:datasource/register_binding_1
#这个控件有数据绑定
scoreboard players set @s floating_ui.data_id 0
execute unless score @s floating_ui.data_id_0 matches -2147483648..2147483647 run return run scoreboard players operation @s floating_ui.data_id_0 = _ int
execute unless score @s floating_ui.data_id_1 matches -2147483648..2147483647 run return run scoreboard players operation @s floating_ui.data_id_1 = _ int
# ...穷举部分省略
execute unless score @s floating_ui.data_id_20 matches -2147483648..2147483647 run return run scoreboard players operation @s floating_ui.data_id_20 = _ int
function log:_error {msg: "Failed to register binding: No data_id is available"}
#绑定失败,移除绑定标记
scoreboard players reset @s floating_ui.data_idget_or_create_data_id函数会获取数据源(其实就是路径)的唯一ID值,如果没有,则创建ID。比较遗憾的是,如果用计分板储存ID,这里的性能本应该可以大大提升,但是我们的数据源中含有空格,而计分板的积分项不能含有空格。所以嘛,只能用storage来储存ID了。函数使用return命令返回了这个数据源的ID,并在register_binding中暂存起来。
接下来,在register_binding_1中,就是把控件(即展示实体)和这个数据源(即路径)的ID绑定起来。实体的floating_ui.data_id_x值对应了它所绑定的数据源。实体有20个data_id计分板,从data_id_0到data_id_20,也就是说一个控件最多可以支持21个数据源的绑定。如果没有空余的绑定位,就会绑定失败并给出提示。事实上,这样的做法相当于是使用了一个静态数组,其长度为21。从泛用性的考虑来说,这里应该使用一个可变长度的列表,也就是使用一个列表类型的NBT来储存。但是列表的访问代价高昂,在大多数情况下21个绑定位已经足够。
初次获取数据源中的内容
回到一开始的append_template函数。接下来就是使用function floating_ui:element/stackpanel/template/set_source with storage floating_ui:temp binding来解析数据源中的内容。这一步非常简单,只使用了一条宏命令。
$data modify storage floating_ui:input temp.child.value set from storage $(source)它将解析的结果暂存在了value字段中。稍后的解析部分就是根据这个内容来更新UI的。
更新数据源中的内容
我们先不说解析,先说在更新数据源的时候会发生什么。因为不管是初始化的时候,还是更新的时候,解析都是调用的同一个函数,所以不如稍后一起说。
更新数据源的内容是使用function floating_ui:datasource/set_property函数完成的。我们之前说,在使用这个函数之前,需要先给floating_ui:temp binding中的path和value复制,分别代表了数据源路径和要赋值的内容。这个函数是这样的:
# floating_ui:temp binding
# {path: xxx, value: xxx}
execute store result score _ int run function floating_ui:datasource/get_or_create_data_id with storage floating_ui:temp binding
#设置值
function floating_ui:datasource/set_value with storage floating_ui:temp binding
execute if score isChanged _ matches 0 run return 0
#通知所有UI刷新
scoreboard players operation now floating_ui.notify_id = SOURCE_UPDATE floating_ui.notify_id
execute as @e[tag=floating_ui_control] run function floating_ui:datasource/set_property_1首先还是熟悉的floating_ui:datasource/get_or_create_data_id,获取了数据源的唯一ID。随后就是使用一个简单的宏命令来设置数据源的值。这里使用了一个小技巧,也就是如果要设置的值和原来的值一样,data命令就会返回失败。通过获取命令的返回值,我们就能知道数据源在设置前后是否发生了更改,从而决定是否刷新UI,这样来节约性能。
之后,就是通知所有的UI进行刷新了。从拓展性出发,考虑到除了数据源更新以外,以后可能有其他的通知事件,这里用floating_ui.notify_id计分板表示事件ID,而SOURCE_UPDATE常量则表示了数据源更新事件。之后,就是遍历所有UI,通知绑定了对应数据源的UI进行刷新,也就是set_property_1函数。这个函数还是一个冗长的穷举过程,看一眼就能明白了。
# 依次检查绑定槽,判断是否绑定了该数据源
execute if score @s floating_ui.data_id_0 = _ int run return run function floating_ui:macro/notify with entity @s item.components."minecraft:custom_data".data.ui
execute if score @s floating_ui.data_id_1 = _ int run return run function floating_ui:macro/notify with entity @s item.components."minecraft:custom_data".data.ui
# ...
execute if score @s floating_ui.data_id_20 = _ int run return run function floating_ui:macro/notify with entity @s item.components."minecraft:custom_data".data.uifloating_ui:macro/notify的内容是这样的:
$function floating_ui:element/$(type)/_notified这其实是一个类似多态的戏法。每个控件都储存了一个type字段表示控件的类型,根据这个字段构建的命令就可以调用对应控件的函数。对于stackpanel来说,它的函数是这样的:
function floating_ui:element/control/_notified
#0 - 源更新通知
execute if score now floating_ui.notify_id = SOURCE_UPDATE floating_ui.notify_id run function floating_ui:element/list/binding/update_source首先第一步调用其基控件(父类)的函数,因为一般情况下子控件应该继承了父控件的事件处理逻辑,随后才是自己的逻辑,也就是处理数据源更新的通知事件,调用floating_ui:element/list/binding/update_source函数。
#获取绑定数据的更新行为
function floating_ui:element/list/binding/update_source_1 with storage floating_ui:temp binding
#执行更新
function floating_ui:macro/action with storage floating_ui:temp binding_info依然是从拓展性角度考虑,由于可能会有多种绑定,而不一定所有字段的绑定都是调用一个方法,或者应该说,只有child字段的绑定才会去调用更新列表控件的函数,所以首先需要通过update_source_1宏函数获取到绑定数据的更新行为。我们之前在注册数据绑定的时候,往实体里面写入的东西这里就排上用场了。
$data modify storage floating_ui:temp binding_info.action set from entity @s item.components."minecraft:custom_data".register_binding."$(path)"之后是简短的floating_ui:macro/action工具函数,只是用来执行binding_info.action中储存的命令。
$$(action)(真的很简短喵)
所以我们实际上会去调用先前写入实体中的目标函数,也就是floating_ui:element/stackpanel/template/before_update_source函数。
#floating_ui:temp binding
#{path: xxx, value: xxx}
data modify storage floating_ui:input temp.template set from entity @s item.components."minecraft:custom_data".data.ui.template
data modify storage floating_ui:input temp.source.binds set from entity @s item.components."minecraft:custom_data".data.ui.source.binds
data modify storage floating_ui:input temp.source.value set from storage floating_ui:temp binding.value
# 移除已有的所有子控件
#删除子节点
execute on passengers run function floating_ui:dispose_control with entity @s item.components.minecraft:custom_data.data.ui
#更新源
function floating_ui:element/stackpanel/template/update_source这里是为了兼容floating_ui:element/stackpanel/template/update_source所需要的NBT数据结构模式进行的一系列赋值,以及移除现在控件上已有的子控件,为后续更新做准备。最后,调用update_source函数来更新UI。
现在,我们终于可以说说update_source函数了。
解析
floating_ui:element/stackpanel/template/update_source的内容如下:
# floating_ui:input temp.child: {value: [...], path:xxx, binds: [{source:xxx, target: xxx}]}
#遍历函数,确定参数
data modify storage floating_ui:temp temp.source.value set from storage floating_ui:input temp.source.value
execute unless data storage floating_ui:temp temp.source.value[0] run return run function log:_error {"message":"Data source must be a list"}
#覆盖手动定义的子元素
data modify storage floating_ui:input temp.child set value []
function floating_ui:element/stackpanel/template/update_source/loop
scoreboard players set isUpdate _ 1
#子元素
function floating_ui:element/stackpanel/child没错哦,繁琐的遍历就是在这里完成的。这里总共有两个遍历过程,第一个是遍历value列表中的数据,第二个是遍历binds列表中的绑定关系,并将每个关系都应用到模板,得到新的子空间的布局数据,按照储存在child列表中。
相关函数
# floating_ui:element/stackpanel/template/update_source/loop
#没有元素了,返回
execute unless data storage floating_ui:temp temp.source.value[0] run return 0
#复制一份模板
data modify storage floating_ui:temp temp.template set from storage floating_ui:input temp.template
#复制绑定参数表
data modify storage floating_ui:temp temp.source.binds set from storage floating_ui:input temp.source.binds
# 绑定替换
function floating_ui:element/stackpanel/template/update_source/params_loop
# 得到了模板,加入child列表
data modify storage floating_ui:input temp.child append from storage floating_ui:temp temp.template
data remove storage floating_ui:temp temp.source.value[0]
function floating_ui:element/stackpanel/template/update_source/loop# function floating_ui:element/stackpanel/template/update_source/params_loop
#没有元素了,返回
execute unless data storage floating_ui:temp temp.source.binds[0] run return 0
# 绑定替换
function floating_ui:element/stackpanel/template/update_source/get_source with storage floating_ui:temp temp.source.binds[0]
data remove storage floating_ui:temp temp.source.binds[0]
function floating_ui:element/stackpanel/template/update_source/params_loop# function floating_ui:element/stackpanel/template/update_source/get_source
$data modify storage floating_ui:temp temp.template.$(target) set from storage floating_ui:temp temp.source.value[0].$(source)当所有的子控件布局数据写入child列表后,调用function floating_ui:element/stackpanel/child函数,生成子控件。这一步的话,就和直接声明child列表是一样的了,这里就不多说啦。
总结
通过数据绑定和模板,我们可以很方便地根据数据源动态生成UI元素,并且在数据源发生更改的时候,自动更新UI。这样一来,我们就能很方便地实现滚动列表之类的功能。在这一套框架下,不止是child字段,其他的属性,例如text,item等等,也都可以使用数据绑定来动态更新,只是目前尚未实现而已。未来,Floating UI还会继续完善这方面的功能,让大家能更方便地使用数据绑定来实现动态UI。
