拖放,UI交互逻辑中重要的一环。天然的适合排序和插槽的交互实现。Godot的所有Control及其子类型节点都可以实现拖放逻辑。
Godot的编辑器基于其各种控件和容器实现,因此其自身的一些功能也是基于这样的拖放逻辑,比如从“文件系统”面板拖入一个文件或资源赋值给属性面板的属性插槽,就是基于控件之间的拖放逻辑。
另外,Godot的MainLoop提供了处理从外部,比如Windows的资源管理器拖入的文件和文件夹的处理。同样的你可以看到它被应用于我们从资源管理器拖入的文件和文件夹到Godot“文件系统”面板。
学习和深入理解拖放,并灵活的运用它到Godot应用程序和编辑器插件编写,会让使用者获得更如鱼得水的体验,并实现一些简单逻辑下不能实现的东西。
Godot的MainLoop提供了_drop_files,什么是MainLoop呢,可以认为,Godot运行后的窗口实例就是一个MainLoop。它是游戏运行的主体,用于接收用户输入,并进行场景显示、场景切换、游戏程序的暂停、终结等等。等等,这不就是SceneTree吗?没错,SceneTree是MainLoop的具体实现。
MainLoop 是 Godot 项目中游戏循环的抽象基类。它被 SceneTree 继承,后者是 Godot 项目中使用的游戏循环的默认实现。
这是内置文档对MainLoop介绍的原话。
MainLoop提供了名为_drop_files的虚函数,而SceneTree则提供了files_dropped信号。因为我们通常是不会直接创建MainLoop的继承或实例的。所以,更多是用SceneTree提供的files_dropped信号来处理外部文件、文件夹的拖入。
我们创建一个简单的场景和脚本来进行测试。
我们用一个Control控件作为根节点,并将其布局为“整个矩形”。
然后添加如下代码:
extends Control
func _ready():
get_tree().connect("files_dropped",self,"_files_dropped") # 绑定SceneTree的files_dropped信号
pass
# 信号处理函数
func _files_dropped(files:PoolStringArray,from_screen:int):
print(files)
print(from_screen)
此时,运行场景后,从外部拖入文件或文件夹,可以看到Godot编辑器底部打印出相应的files数组的内容和from_screen的数值。
[C:\Users\Lenovo\Downloads\导出.png, C:\Users\Lenovo\Downloads\导出表单-导出结果_操作_jurassic.png]
0
那么,这有什么实际意义呢?
你或许看过Hi小胡的视频,它也是最早讲Godot拖放相关内容的人。他的下面这个视频就是讲外部文件拖入的。
【Godot】实现拖拽文件到游戏窗口并加载内容 - Hi小胡 - Bilibili
当然,我们知道,这种拖放,无非就是把外部文件的路径以数组的形式传入进来了,至于我们如何利用这些路径是没有限制的。也就是拖入后的行为和展现是不固定的。
“拖放”操作都涉及“拖起”、“拖动”和“放下”3个动作,以及源和目标两个对象。源被“拖起”,然后经过“拖动”发生位移,最后被“放到”目标。但这只是我们感性和直观的理解,其实,拖起的并不是“源”,有可能是它的一个副本,或者仅仅是关于它的信息,放下的也只是源的信息。因此,拖放可以理解为是在源和目标之间传递数据的一种交互方式。
外部拖入就是将Windows资源管理器的文件及文件夹路径传入我们设定好的控件目标中。外部文件和文件夹被“拖起来”,然后经过我们的”拖动“发生位移,从屏幕的一处移动到另一处,甚至是跨越了不同的程序窗口。最终放到了我们的Godot运行场景的窗口实例相应的控件区域。
特殊的一点是,这是一种单向的拖放,无法将Godot窗口实例的内容拖放到Windows资源管理器中。
控件之间的拖放逻辑与外部文件拖入并没有本质区别,只是传递的数据更自由,更可自定义化。
上面的只是一个简化的情形,实际的情况很有可能复杂的多。但是我们首先先讨论这种简单情形,然后再逐渐的进行难度的增加。
上面的情形是一个简单的“单向拖放”,有一个明确的源:控件A,有一个明确的目标:控件B。
那么为了实现这样的拖放我们需要做什么呢?
Godot为所有的Control及其子类型,也就是各种控件和容器,都提供了以下的方法来实现拖动。
其中get_drag_data()实现“拖起”,can_drop_data()、drop_data()用于实现“放下”。
因为源要实现“拖起”,所以需要在控件A的代码中实现get_drag_data(),而要实现在控件B中处理“放下”,所以需要在控件B的代码中实现an_drop_data()和drop_data()。
我们以实际的例子举例:
创建如下结构的测试场景,其中Button作为我们的控件A,也就是要拖起来的内容,而TextEdit作为控件B,也就是我们要放下Button的地方(或者叫“区域”,因为每个控件的可视部分其实对应一个矩形区域)
反直觉的,或者不我们往常做的那样,我们不会在根节点Control上创建代码,而是要分别在Button和TextEdit节点上创建代码。
我们设置代码如下:
extends Button
func get_drag_data(position):
var data = self.text
set_drag_preview(self.duplicate())
return data
extends TextEdit
# 决定控件接不接受源的“放下”
func can_drop_data(position, data):
var bol
if data is String and data != "":
bol = true
return bol
# 如果can_drop_data 为 true 才执行
# 放下后的响应
func drop_data(position,data):
self.text = data
实际运行效果却并不是我们想要的结果。
可以看到,拖放后,TextEdit的内容并没有变成Button的text。貌似,Godot在拖放部分还有一定的潜规则。
我们试着用用数组来封装要传递的数据。
extends Button
func get_drag_data(position):
var data = [self.text]
set_drag_preview(self.duplicate())
return data
extends TextEdit
# 决定控件接不接受源的“放下”
func can_drop_data(position, data):
var bol
if data[0] is String and data[0] != "":
bol = true
return bol
# 如果can_drop_data 为 true 才执行
# 放下后的响应
func drop_data(position,data):
self.text = data[0]
运行后,可以看到,程序实现了我们想要的结果。
而如果你尝试用字典来封装传递的数据,也是没有问题的。
所以这里的潜规则就是,传递的数据必须以数组或字典形式封装。单独的使用字符串、数值或布尔值等,都可能会出现意料之外的情形,那么这种情况下Godot是如何处理的呢?
我们将代码改成如下形式:
extends Button
func get_drag_data(position):
var data = self.text
set_drag_preview(self.duplicate())
return data
extends TextEdit
# 决定控件接不接受源的“放下”
func can_drop_data(position, data):
var bol
if data is String and data != "":
bol = true
return bol
可以看到在传递的数据形式不是数组或字典的情况下,Godot对控件的拖放有一套自己的默认处理逻辑。以这里的Button拖入TextEdit为例,是将Button的text插入到TextEdit的光标处。
我怀疑,Godot在显式的给我们提供了data封装这条路之外,有一条隐藏的数据封装和传递,以及处理的路径。
之所以将这个“坑”说出来,是为了让大家更规范的使用数组和字典来封装数据。
补充:其实这里除了数组和字典之外,Godot是支持将控件自身的引用作为拖放数据传递的。
所以更进一步的总结一下的话,图就变成了下面这样:
上面的Button拖到TextEdit的实例,是个很抽象和很原始的演示实例。很多人可能并不能明白为什么要这么做,以及这么做的意义是什么。那么我们在上面的实例的基础上做一个实际中可能会遇到的应用情形。
我们创建多个按钮,然后通过拖入到TextEdit,将其text插入到光标所在处,来实现一个简单的常用字符串辅助插入功能。
这里我们将根节点Control的类型改为了HBoxContainer,并为其创建主题和设置中文字体。用VBoxContainer来竖向罗列所有的按钮实例。TextEdit放大最大。
按钮的代码还是不变,采用数组形式封装要传递的数据。
extends Button
func get_drag_data(position):
var data = [self.text]
set_drag_preview(self.duplicate())
return data
而TextEdit的drop_data()下,我们使用TextEdit的insert_text_at_cursor()方法来将按钮的文本插入到TextEdit的光标处。
extends TextEdit
# 决定控件接不接受源的“放下”
func can_drop_data(position, data):
var bol
if data[0] is String and data[0] != "":
bol = true
return bol
# 如果can_drop_data 为 true 才执行
# 放下后的响应
func drop_data(position,data):
insert_text_at_cursor(data[0])
我们将场景的结构修改如下,主要是修改各个节点的类型。我们实现从ColorRect到ColorRect的拖放,实际效果就像是把颜色从A拖到B。
为了区分代码名称,我们将GridContainer下的ColorRect改名为color。
然后修改color和ColorRect节点的代码,如下:
extends ColorRect
func get_drag_data(position):
var data = [self.color]
set_drag_preview(self.duplicate())
return data
extends ColorRect
# 决定控件接不接受源的“放下”
func can_drop_data(position, data):
var bol
if data[0] is Color:
bol = true
return bol
# 如果can_drop_data 为 true 才执行
# 放下后的响应
func drop_data(position,data):
color = data[0]
此时我们运行场景:
拖动color节点到ColorRect节点,会发现ColorRect改变了颜色,仿佛将颜色拖放给了ColorRect一样。
同样的,我们可以Ctrl+D复制出多个color节点,并设置不同的颜色。这样我们就可以更有选择的设置我们大区域的ColorRect的颜色了。
运行后:
上面的实例,其实归纳起来都是一种简单的通过拖放赋值的形式。与单击或双击后赋值没啥本质的区别。
这里我们实现一个复杂一点的案例,实现属性插槽的赋值效果。
这里我用到了自制的FSOTreeView组件,并设置属性如下:
并为其添加如下get_drag_data()方法和代码:
func get_drag_data(position):
var sel:TreeItem = get_selected()
var data = {path = sel.get_metadata(0)["full_path"]}
var ctl = ToolButton.new()
ctl.icon = sel.get_icon(0)
ctl.text = sel.get_text(0)
ctl.set("custom_colors/font_color",Color("#000000"))
set_drag_preview(ctl)
return data
可以看到,因为Tree的拖放涉及Tree自身和TreeItem的拖放,而这里我们要实现的是单个文件的拖放,所以我们要实现TreeItem的拖放。我们传递的数据是文件或文件夹的完整路径。
但是TreeItem并不是一个控件类型,而是一个Object类型,所以无法直接duplicate,只能另外构造一个相似的元素作为预览。
这里我们构造了一个ToolButton实例,因为它ToolButton本身隐藏了Button的背景,同时又可以像TreeItem一样显示一个图标和文本,是最合适的选择。
Button作为属性值的槽,代码如下:
extends Button
signal text_changed(text)
func can_drop_data(position, data):
var bol
if data.has("path"):
bol = true
return bol
# 放下后的响应
func drop_data(position,data):
text = data["path"]
emit_signal("text_changed",text)
其中,我们接受拖放后,将按钮的文本设为我们获得的TreeItem代表的文件路径。同时触发自定义信号text_changed,并把路径传递出去。
然后我们在根节点Control上添加代码,并连接和处理Button的自定义信号text_changed。用来加载相应路径的图片,赋值给TextureR节点的texture属性,从而实现图片的显示或更改。
extends HBoxContainer
func _on_Button_text_changed(text):
$TextureRect.texture = load(text)
pass
运行后效果如下:
你能想到什么?没错,就是Godot编辑器中“文件系统”面板拖放图片资源给Sprite或其他节点的Texture属性的效果。它通过了属性面板这个“中转站”来改变某个对象的某个属性,而不是直接的赋值改变。
"属性插槽"是我自创的一个词,但是也是非常贴切的。
"属性插槽"的逻辑实际上就变成了下面这样:
实际上,Godot编辑器自身的拖放也是基于控件之间的拖放逻辑,比如上面我们提到的从“文件系统”面板,拖放文件到“检视器”面板(也就是俗称的“属性”面板)的具体属性的属性值插槽上。
为了探索和验证,我们创建一个测试场景,而这个测试场景将要以Godot编辑器面板的形式显示。这里我使用的是myAdd v3插件(我自己正在做的一个插件)中的其中一个面板——“快捷面板”(如下图右),而这个面板基于一个叫BtnGroup的组件。这是一个按钮分组组件,我们将代码写到每个分组标题所在的按钮上。
代码如下:
tool
extends Button
func can_drop_data(position, data):
return true
func drop_data(position, data):
print(data)
这样,任何Godot编辑器界面能够拖放的内容都可以拖放到这些分组按钮上,并打印其Data形式和内容。
我们从当前场景面板拖放节点到“快捷”面板的任意一个分组标题按钮上。打印输出的data,也就是拖放所传递的数据封装如下:
{nodes:[/root/EditorNode/@@596/@@597/@@605/@@607/@@611/@@615/@@616/@@617/@@633/@@634/@@643/@@644/@@6618/@@6450/@@6451/@@6452/@@6453/@@6454/@@6455/BtnGroup/GupBtn], type:nodes}
可以看到,这是一个字典,nodes字段存储一个数组,用于存放拖动前选中的节点。type是一个标明类型的字段。
我们拖放文件系统”面板的单个文件或文件夹,会打印出如下的封装数据:
{files:[res://], from:@@4455:[Tree:8689], type:files_and_dirs}
{files:[res://addons/myTreeAdd/Button.gd], from:@@4455:[Tree:8689], type:files}
{files:[res://icon.png], from:@@4455:[Tree:8689], type:files}
{files:
[res://GodotEngine-Godot3attheCapitoledulibre_8664765.jpeg,
res://House2.tscn,
res://House.tscn,
res://icon.png
],
from:@@4455:[Tree:8689],
type:files
}
{files:
[res://1.tres,
res://360截图20220919201410658.png,
res://docs/,
res://imgs/
],
from:@@4455:[Tree:8689],
type:files_and_dirs
}
在脚本界面,我们甚至可以拖动脚本列表的脚本文件或内置文档列表项
{script_list_element:ScriptTextEditor11:[ScriptTextEditor:179071], type:script_list_element}
{script_list_element:EditorScript:[EditorHelp:23840], type:script_list_element}
通过这一系列测试,可以看到Godot对“文件系统”面板和“场景”面板以及其他可以拖放的部分与“检视器”面板等的拖放交互过程也是采取了控件之间拖放的逻辑。你也可以探究其各种Drag封装的Data形式。
之所以探索这部分,是因为在制作编辑器插件(EditorPlugin)时可以利用起来。
拖放在游戏中很常见的一个应用,就是背包和存储系统,背包是由可拖放的插槽组成,插槽里存放了物品的图片和名称以及数量等数据。插槽与插槽之间的拖放便是背包和存储系统的基本交互方式。
除了背包和储物箱之外,装备插槽也是适用拖放的场景,并且与背包和存储系统是相互联系的。
背包系统的总结,可见于《参数化库存组件》一文。这里不在赘述。
另外关于建筑物放置类型的逻辑,也会在单独的文章中进行阐述。
拖放的另一个应用场景是列表中同级项或层级列表中不同级之间排序的。这也需要一个单独的文章去描述。
Godot的Dialogic插件、Quicker的自定义组合动作,某些RPA软件。以及Scratch那样的游戏制作软件。都涉及拖放操作。
拖放作为一种交互操作,可以很好地的扩充你使用UI控件或编写UI组件,以及编写编辑器插件时的交互手段,甚至是实现一些难以用常规交互手段获取的功能。学习和掌握拖放,你肯定吃不了亏,上不了当。