Manuals
Manuals




This translation is community contributed and may not be up to date. We only maintain the English version of the documentation. Read this manual in English

编辑器脚本和UI

本手册解释了如何使用用Lua编写的编辑器脚本在编辑器中创建交互式UI元素。要开始使用编辑器脚本,请参阅编辑器脚本手册。您可以找到完整的编辑器API参考这里。目前,只能创建交互式对话框,尽管我们希望将来将UI脚本支持扩展到编辑器的其余部分。

Hello world

所有与UI相关的功能都存在于editor.ui模块中。这是一个带有自定义UI的编辑器脚本的最简单示例,可以帮助您入门:

local M = {}

function M.get_commands()
    return {
        {
            label = "Do with confirmation",
            locations = {"View"},
            run = function()
                local result = editor.ui.show_dialog(editor.ui.dialog({
                    title = "Perform action?",
                    buttons = {
                        editor.ui.dialog_button({
                            text = "Cancel",
                            cancel = true,
                            result = false
                        }),
                        editor.ui.dialog_button({
                            text = "Perform",
                            default = true,
                            result = true
                        })
                    }
                }))
                print('Perform action:', result)
            end
        }
    }
end

return M

此代码片段定义了一个View → Do with confirmation命令。当您执行它时,您将看到以下对话框:

Hello world dialog

最后,在按Enter(或点击Perform按钮)后,您将在编辑器控制台中看到以下行:

Perform action:	true

基本概念

组件

编辑器提供了各种UI组件,可以组合这些组件来创建所需的UI。按照惯例,所有组件都使用一个称为props的表进行配置。组件本身不是表,而是编辑器用于创建UI的不可变用户数据

Props

Props是定义组件输入的表。Props应被视为不可变的:原地修改props表不会导致组件重新渲染,但使用不同的表会。当组件实例接收到与前一个表浅不相等的props表时,UI会更新。

对齐

当组件在UI中被分配一些边界时,它将消耗整个空间,但这并不意味着组件的可见部分会拉伸。相反,可见部分将占据它所需的空间,然后它将在分配的边界内对齐。因此,大多数内置组件定义了alignment属性。

例如,考虑这个标签组件:

editor.ui.label({
    text = "Hello",
    alignment = editor.ui.ALIGNMENT.RIGHT
})

可见部分是Hello文本,它在分配的组件边界内对齐:

Alignment

内置组件

编辑器定义了各种可以一起使用来构建UI的内置组件。组件大致可以分为3类:布局、数据展示和输入。

布局组件

布局组件用于将其他组件彼此相邻放置。主要的布局组件是horizontalverticalgrid。这些组件还定义了paddingspacing等属性,其中padding是从分配边界边缘到内容的空白空间,而spacing是子组件之间的空白空间:

Padding and Spacing

编辑器定义了smallmediumlargepadding和spacing常量。当涉及到spacing时,small用于单个UI元素的不同子元素之间的间距,medium用于单个UI元素之间的间距,而large是元素组之间的间距。默认spacing是mediumlarge的padding值表示从窗口边缘到内容的padding,medium是从重要UI元素边缘的padding,而small是上下文菜单和工具提示等小UI元素边缘的padding(尚未实现)。

horizontal容器将其子组件一个接一个地水平放置,始终使每个子组件的高度填充可用空间。默认情况下,每个子组件的宽度保持最小,但可以通过在子组件上将grow属性设置为true来使其占用尽可能多的空间。

vertical容器与水平容器类似,但是轴切换了。

最后,grid是一个容器组件,将其子组件布置在2D网格中,就像表格一样。网格中的grow设置适用于行或列,因此它不是在子组件上设置,而是在列配置表上设置。此外,网格中的子组件可以配置为使用row_spancolumn_span属性跨越多行或多列。网格对于创建多输入表单很有用:

editor.ui.grid({
    padding = editor.ui.PADDING.LARGE, -- 在对话框边缘添加padding
    columns = {{}, {grow = true}}, -- 使第2列增长
    children = {
        {
            editor.ui.label({ 
                text = "Level Name",
                alignment = editor.ui.ALIGNMENT.RIGHT
            }),
            editor.ui.string_field({})
        },
        {
            editor.ui.label({ 
                text = "Author",
                alignment = editor.ui.ALIGNMENT.RIGHT
            }),
            editor.ui.string_field({})
        }
    }
})

上面的代码将产生以下对话框表单:

New Level Dialog

数据展示组件

编辑器定义了4个数据展示组件:

  • label — 文本标签,旨在与表单输入一起使用。
  • icon — 图标;目前,它只能用于呈现一小组预定义图标,但我们打算将来允许更多图标。
  • heading — 文本元素,旨在呈现例如表单或对话框中的标题行文本。editor.ui.HEADING_STYLE枚举定义了各种标题样式,包括HTML的H1-H6标题,以及编辑器特定的DIALOGFORM
  • paragraph — 文本元素,旨在呈现一段文本。与label的主要区别是段落支持自动换行:如果分配的边界在水平方向上太小,文本将换行,如果无法适应视图,可能会用"..."缩短。

输入组件

输入组件是为用户与UI交互而设计的。所有输入组件都支持enabled属性来控制交互是否启用,并定义各种回调属性,在交互时通知编辑器脚本。

如果您创建静态UI,只需定义简单修改局部变量的回调就足够了。对于动态UI和更高级的交互,请参阅响应式

例如,可以像这样创建一个简单的静态新建文件对话框:

-- 初始文件名,将被对话框替换
local file_name = ""
local create_file = editor.ui.show_dialog(editor.ui.dialog({
    title = "Create New File",
    content = editor.ui.horizontal({
        padding = editor.ui.PADDING.LARGE,
        spacing = editor.ui.SPACING.MEDIUM,
        children = {
            editor.ui.label({
                text = "New File Name",
                alignment = editor.ui.ALIGNMENT.CENTER
            }),
            editor.ui.string_field({
                grow = true,
                text = file_name,
                -- 输入回调:
                on_value_changed = function(new_text)
                    file_name = new_text
                end
            })
        }
    }),
    buttons = {
        editor.ui.dialog_button({ text = "Cancel", cancel = true, result = false }),
        editor.ui.dialog_button({ text = "Create File", default = true, result = true })
    }
}))
if create_file then
    print("create", file_name)
end

这是内置输入组件的列表:

  • string_fieldinteger_fieldnumber_field是单行文本字段的变体,允许编辑字符串、整数和数字。
  • select_box用于通过下拉控件从预定义的选项数组中选择一个选项。
  • check_box是一个带有on_value_changed回调的布尔输入字段
  • button带有在按钮按下时调用的on_press回调。
  • external_file_field是一个用于选择计算机上文件路径的组件。它由一个文本字段和一个打开文件选择对话框的按钮组成。
  • resource_field是一个用于选择项目中资源的组件。

除按钮外的所有组件都允许设置一个issue属性,显示与组件相关的问题(editor.ui.ISSUE_SEVERITY.ERROReditor.ui.ISSUE_SEVERITY.WARNING),例如:

issue = {severity = editor.ui.ISSUE_SEVERITY.WARNING, message = "This value is deprecated"}

当指定issue时,它会改变输入组件的外观,并添加带有问题消息的工具提示。

这是所有输入及其问题变体的演示:

Inputs

对话框相关组件

要显示对话框,您需要使用editor.ui.show_dialog函数。它需要一个dialog组件,该组件定义了Defold对话框的主要结构:titleheadercontentbuttons。对话框组件有点特殊:您不能将其用作另一个组件的子组件,因为它代表一个窗口,而不是UI元素。headercontent是常规组件。

对话框按钮也很特殊:它们是使用dialog_button组件创建的。与常规按钮不同,对话框按钮没有on_pressed回调。相反,它们定义了一个result属性,该属性将在对话框关闭时由editor.ui.show_dialog函数返回。对话框按钮还定义了canceldefault布尔属性:带有cancel属性的按钮在用户按Escape或使用OS关闭按钮关闭对话框时触发,而default按钮在用户按Enter时触发。对话框按钮可以同时将canceldefault属性设置为true

实用组件

此外,编辑器还定义了一些实用组件:

  • separator是一条细线,用于分隔内容块
  • scroll是一个包装组件,当包装的组件不适合分配的空间时显示滚动条

响应式

由于组件是不可变用户数据,因此在创建后无法更改它们。那么如何使UI随时间变化呢?答案是:响应式组件

编辑器脚本UI的灵感来自React库,因此了解响应式UI和React hooks将有所帮助。

在最简单的术语中,响应式组件是一个带有Lua函数的组件,该函数接收数据(props)并返回视图(另一个组件)。响应式组件函数可以使用hookseditor.ui模块中的特殊函数,为您的组件添加响应式功能。按照惯例,所有hooks的名称都以use_开头。

要创建响应式组件,请使用editor.ui.component()函数。

让我们看这个示例——一个新建文件对话框,只有当输入的文件名不为空时才允许创建文件:

-- 1. dialog是一个响应式组件
local dialog = editor.ui.component(function(props)
    -- 2. 组件定义了一个本地状态(文件名),默认为空字符串
    local name, set_name = editor.ui.use_state("")

    return editor.ui.dialog({ 
        title = props.title,
        content = editor.ui.vertical({
            padding = editor.ui.PADDING.LARGE,
            children = { 
                editor.ui.string_field({ 
                    value = name,
                    -- 3. 输入+Enter更新本地状态
                    on_value_changed = set_name 
                }) 
            }
        }),
        buttons = {
            editor.ui.dialog_button({ 
                text = "Cancel", 
                cancel = true 
            }),
            editor.ui.dialog_button({ 
                text = "Create File",
                -- 4. 当名称存在时启用创建
                enabled = name ~= "",
                default = true,
                -- 5. 结果是名称
                result = name
            })
        }
    })
end)

-- 6. show_dialog将返回非空文件名或在取消时返回nil
local file_name = editor.ui.show_dialog(dialog({ title = "New File Name" }))
if file_name then 
    print("create " .. file_name)
else
    print("cancelled")
end

当您执行运行此代码的菜单命令时,编辑器将显示一个在开始时禁用"Create File"对话框的对话框,但是当您输入名称并按Enter时,它将变为启用状态:

New File Dialog

那么,它是如何工作的呢?在第一次渲染时,use_state hook创建一个与组件关联的本地状态,并返回它以及状态的setter。当调用setter函数时,它会安排组件重新渲染。在随后的重新渲染中,组件函数再次被调用,use_state返回更新的状态。然后,组件函数返回的新视图组件与旧组件进行差异比较,并在检测到更改的地方更新UI。

这种响应式方法大大简化了构建交互式UI并使其保持同步:而不是在用户输入时显式更新所有受影响的UI组件,视图被定义为输入(props和本地状态)的纯函数,编辑器自己处理所有更新。

响应式规则

编辑器期望响应式函数组件表现良好才能正常工作:

  1. 组件函数必须是纯函数。不保证何时或多久调用一次组件函数。所有副作用都应该在渲染之外,例如在回调中
  2. Props和本地状态必须是不可变的。不要改变props。如果您的本地状态是一个表,不要原地修改它,而是在状态需要更改时创建一个新表并将其传递给setter。
  3. 组件函数必须在每次调用时以相同的顺序调用相同的hooks。不要在循环中、条件块中、提前返回后等调用hooks。最佳实践是在组件函数的开头调用hooks,在任何其他代码之前。
  4. 只从组件函数调用hooks。Hooks在响应式组件的上下文中工作,因此只允许在组件函数中(或由组件函数直接调用的另一个函数中)调用它们。

Hooks

如果您熟悉React,您会注意到编辑器中的hooks在hook依赖项方面具有稍微不同的语义。

编辑器定义了2个hooks:use_memouse_state

use_state

本地状态可以通过两种方式创建:使用默认值或使用初始化器函数:

-- 默认值
local enabled, set_enabled = editor.ui.use_state(true)
-- 初始化器函数+参数
local id, set_id = editor.ui.use_state(string.lower, props.name)

类似地,可以使用新值或更新器函数调用setter:

-- 更新器函数
local function increment_by(n, by)
    return n + by
end

local counter = editor.ui.component(function(props)
    local count, set_count = editor.ui.use_state(0)
    
    return editor.ui.horizontal({
        spacing = editor.ui.SPACING.SMALL,
        children = {
            editor.ui.label({
                text = tostring(count),
                alignment = editor.ui.ALIGNMENT.LEFT,
                grow = true
            }),
            editor.ui.text_button({
                text = "+1",
                on_pressed = function() set_count(increment_by, 1) end
            }),
            editor.ui.text_button({
                text = "+5",
                on_pressed = function() set_count(increment_by, 5) end
            })
        }
    })
end)

最后,状态可以被重置。当editor.ui.use_state()的任何参数更改时,状态会重置,使用==进行检查。因此,您不能使用字面量表或字面量初始化器函数作为use_state hook的参数:这会导致每次重新渲染时状态重置。举例说明:

-- ❌ 错误:字面量表初始化器在每次重新渲染时导致状态重置
local user, set_user = editor.ui.use_state({ first_name = props.first_name, last_name = props.last_name})

-- ✅ 正确:在组件函数外部使用初始化器函数创建表状态
local function create_user(first_name, last_name) 
    return { first_name = first_name, last_name = last_name}
end
-- ...稍后,在组件函数中:
local user, set_user = editor.ui.use_state(create_user, props.first_name, props.last_name)


-- ❌ 错误:字面量初始化器函数在每次重新渲染时导致状态重置
local id, set_id = editor.ui.use_state(function() return string.lower(props.name) end)

-- ✅ 正确:使用引用的初始化器函数创建状态
local id, set_id = editor.ui.use_state(string.lower, props.name)

use_memo

您可以使用use_memo hook来提高性能。通常在渲染函数中执行一些计算,例如检查用户输入是否有效。use_memo hook可用于检查计算函数的参数是否更改比调用计算函数更便宜的情况。hook将在第一次渲染时调用计算函数,如果use_memo的所有参数都未更改,则在随后的重新渲染中重用计算值:

-- 组件函数外的验证函数
local function validate_password(password)
    if #password < 8 then
        return false, "Password must be at least 8 characters long."
    elseif not password:match("%l") then
        return false, "Password must include at least one lowercase letter."
    elseif not password:match("%u") then
        return false, "Password must include at least one uppercase letter."
    elseif not password:match("%d") then
        return false, "Password must include at least one number."
    else
        return true, "Password is valid."
    end
end

-- ...稍后,在组件函数中
local username, set_username = editor.ui.use_state('')
local password, set_password = editor.ui.use_state('')
local valid, message = editor.ui.use_memo(validate_password, password)

在这个例子中,密码验证将在每次密码更改时运行(例如在密码字段中输入时),但在用户名更改时不会运行。

use_memo的另一个用例是创建然后在输入组件上使用的回调,或者当本地创建的函数用作另一个组件的prop值时——这可以防止不必要的重新渲染。