前言
在 Unity Editor 开发中,我们经常需要写自定义窗口(EditorWindow)来做工具面板。比如调试 Dify 接口时,需要一个窗口同时显示几段 JSON:发送的 query、标准流程standardProcess、以及 Dify 返回的结果。每段文本都很长,自然就想到用 ScrollView 包起来,鼠标停在区域内滚轮滚动。
看起来非常简单的需求,实际做的时候却出现了问题:把 SelectableLabel 放进父级 ScrollView 里,滚轮没反应;而换成 TextArea 或 LabelField,滚轮正常。
为什么三个控件对滚轮有这样的表现?要弄清楚这件事,需要先理解 IMGUI 的整体机制,和理解控件对鼠标滚轮事件的处理方式。
本文结合一个真实项目中的 Dify 调试窗口,对 GUILayout、EditorGUILayout、三个文本控件进行说明、以及讨论 ScrollView 滚轮失效的可能原因。
本文涉及 Unity 内部实现的部分(如控件对
ScrollWheel的处理逻辑)是基于实测行为做的推断,并非 Unity 公开源码的逐行引用。凡不确定的部分都会明确标注。
一、GUILayout 与 EditorGUILayout 是什么关系
1. 两者的定位
GUILayout 和 EditorGUILayout 都是 Unity Immediate Mode GUI(即时模式 GUI,简称 IMGUI)提供的绘制 API。它们的核心思想是:每一帧重新绘制整个界面。
GUILayout:通用 IMGUI 绘制 API,运行时(Runtime,游戏内)和编辑器都能用。提供最基础的按钮、文本、滑动条、滚动视图等。EditorGUILayout:编辑器专用 ,只能在 Editor 程序集里用,且主要作用于 Editor 窗口。它在GUILayout之上扩展了一批"编辑器特有"的控件,比如PropertyField、ObjectField、TagField、HelpBox、InspectorTitlebar等。
简单说,EditorGUILayout 是 GUILayout 的"编辑器增强版"。
2. 为什么会有两套
因为运行时 UI 不应该依赖编辑器类型(UnityEditor 命名空间在打包后不存在),所以运行时只能用 GUILayout。而编辑器工具为了方便,可以用更高级的 EditorGUILayout 。
两者共享同一套布局引擎和事件系统。GUILayout.BeginHorizontal / GUILayout.EndHorizontal 和 EditorGUILayout.BeginHorizontal / EditorGUILayout.EndHorizontal 在底层是同一套 Group 机制,可以混用(但建议同一个代码块里保持一致,避免混乱)。
3. 共同的布局机制:自动布局栈
IMGUI 的布局是"栈"式的。每调用一次 Begin... 就把一个新的布局组压栈,End... 弹出。控件绘制时会被自动归入当前栈顶的组里。
csharp
GUILayout.BeginHorizontal(); // 进入横向组
GUILayout.Button("A");
GUILayout.BeginVertical(); // 进入纵向组(栈顶)
GUILayout.Button("B");
GUILayout.Button("C");
GUILayout.EndVertical(); // 弹出纵向组
GUILayout.EndHorizontal(); // 弹出横向组
Begin 和 End 必须严格配对,少一个 End 会导致整帧布局错乱甚至报错。这是 IMGUI 最容易出错的地方之一。
二、IMGUI 的事件模型:绘制与事件是同一件事
这是理解滚轮失效可能原因的前提,也是 IMGUI 和 Retained Mode UI(如 uGUI、UI Toolkit)最大的区别。
1. 每帧 OnGUI 会被调用多次
OnGUI 在一帧内会被 Unity 调用多次 ,每次对应一个不同的 Event:
Layout事件:先跑一遍,只为了计算布局尺寸,不真正绘制。Repaint事件:真正把界面画到屏幕上。- 鼠标/键盘事件:
MouseDown、MouseUp、MouseDrag、MouseMove、ScrollWheel、KeyDown、KeyUp等。
每次 OnGUI 进来,Event.current 就是当前要处理的事件。所有控件(GUILayout.Button、EditorGUILayout.LabelField 等)本质上都是一个函数:它拿到当前事件,决定要不要处理它。
2. 事件被"用掉"就没了:Use() 与 EventType.Used
IMGUI 事件系统里有一个关键概念:一个事件在一帧里只能被"消费"一次。
控件通过调用 Event.current.Use() 来声明"这个事件我处理了"。一旦 Use() 被调用,Event.current.type 就会从原来的类型(比如 ScrollWheel)变成 EventType.Used。后续所有控件看到 EventType.Used 时,通常会直接跳过,不做任何处理。
这就是整个滚轮问题的核心:滚轮事件最终被谁 Use() 掉,决定了 ScrollView 能不能滚动。
关于
Use()的语义是 Unity 公开 API 行为(Event.Use有公开文档),这一点是确定的。下面"具体哪个控件在哪一步调用了 Use"属于推断。
三、ScrollView 的滚动逻辑在哪里执行
这是容易被误解的一点,但很关键。
EditorGUILayout.BeginScrollView 和 EndScrollView 是一对。滚动条的绘制在 BeginScrollView 里,但真正的"是否滚动、滚动多少"的判断和位移,通常发生在 EndScrollView 阶段 (EndScrollView 负责收尾并应用滚动偏移)。
为什么这个顺序重要?因为在 BeginScrollView 和 EndScrollView 之间写的所有控件,都会先于 EndScrollView 的滚动逻辑执行。
也就是说:
csharp
scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
// ------ 这里面的控件,先于下面的滚动判断执行 ------
EditorGUILayout.SelectableLabel(longText); // 先执行
EditorGUILayout.EndScrollView(); // 滚动判断后执行
如果中间的 SelectableLabel 已经把 ScrollWheel 事件 Use() 掉了,那么当执行到 EndScrollView 时,Event.current.type 已经变成了 Used,ScrollView 的滚动逻辑看到 Used 就跳过 → 不滚动。
这一段"滚动判断在 EndScrollView"是基于 IMGUI 布局-事件分离模型和实测行为的推断。Unity 的 ScrollView 内部 C++ 源码并未完整公开,无法 100% 逐行确认,但从行为上完全自洽。
四、三个文本控件:LabelField / TextArea / SelectableLabel
1. LabelField
LabelField 是最纯粹的"显示文本"控件。它只负责把文字画出来,不接收任何鼠标/键盘输入事件。鼠标点它、滚轮滚它,事件原样穿过,交给后面的控件。
常用形式:
csharp
// 普通标签
EditorGUILayout.LabelField("标题");
// 自动换行风格(按宽度折行,适合长文本)
EditorGUILayout.LabelField(longText, EditorStyles.wordWrappedLabel);
// 带自定义样式(颜色、字号、对齐等)
EditorGUILayout.LabelField("红色", new GUIStyle(GUI.skin.label) { normal = { textColor = Color.red } });
常用参数:
text:要显示的字符串。- 第二个参数常传一个
GUIStyle,控制字体、颜色、换行、对齐。EditorStyles.wordWrappedLabel是常用的"自动换行只读文本"样式。 GUILayout布局参数(GUILayout.Width、GUILayout.Height、GUILayout.ExpandWidth等):控制占用尺寸。
特点:绝对只读、不碰事件、不能选中复制。最常用,但功能最少。
2. TextArea
TextArea 是一个可编辑的多行文本框。它内部维护一个 TextEditor(文本编辑器状态),支持输入、删除、光标移动、选中文本、复制粘贴。
常用形式:
csharp
// 可编辑文本区
string s = EditorGUILayout.TextArea(text);
// 只读展示风格:用 wordWrappedLabel 样式,看起来像标签,但仍可选中复制
EditorGUILayout.TextArea(text, EditorStyles.wordWrappedLabel, GUILayout.ExpandHeight(true));
常用参数:
text:当前文本。GUILayout.MinHeight/GUILayout.Height/GUILayout.ExpandHeight:控制高度。ExpandHeight(true)让它尽量撑满父容器剩余空间。- 第二个参数可传
GUIStyle,控制外观。
关键行为(实测):TextArea 不会主动消费鼠标滚轮事件,即使它获得了键盘焦点,滚轮事件仍然原样传给父级 ScrollView,父级正常滚动。这一点和很多人直觉相反------直觉会觉得"聚焦的输入框会吞滚轮",实际不会。
3. SelectableLabel
SelectableLabel 的设计目的是"可选可复制、但不能编辑"的只读文本。它内部同样用 TextEditor,但处于一种特殊的"只读可选"模式。
常用形式:
csharp
// 默认:自动换行、可选中复制
EditorGUILayout.SelectableLabel(longText);
// 固定高度
EditorGUILayout.SelectableLabel(longText, GUILayout.MinHeight(120));
关键行为(实测,这是本文的核心结论):把 SelectableLabel 放进父级 ScrollView,滚轮完全失效。 无论有没有焦点都不行。
为什么?合理推断是:SelectableLabel 为了支持"鼠标拖选文本""选区在滚动时保持可视""选区超出可视区自动定位"这些能力,它内部的文本处理逻辑无条件消费了鼠标所在区域的 ScrollWheel 事件 (一进入控件就 Use() 掉)。这样 EndScrollView 看到的已经是 Used,滚动逻辑被跳过。
这个"无条件 Use ScrollWheel"的判断属于基于行为的推断,不是从 Unity 源码确认的。我没有读 Unity 内部 C++ 的条件。但实测结果非常稳定:只要内层是 SelectableLabel,父级 ScrollView 就滚不动;一换成 TextArea/LabelField,立刻能滚。从"事件只能被 Use 一次"的公开机制反推,最合理的解释就是 SelectableLabel 把事件吞了。
五、为什么 SelectableLabel 失败、TextArea 和 LabelField 成功
把前面几点合起来,结论很清晰:
| 控件 | 是否主动消费 ScrollWheel | 配父级 ScrollView | 能否选中复制 | 是否可编辑 |
|---|---|---|---|---|
LabelField |
否(完全不碰事件) | 成功 | 不能 | 否 |
TextArea |
否(聚焦与否都不吞) | 成功 | 能 | 是 |
SelectableLabel |
是(推断为无条件吞) | 失败 | 能 | 否 |
核心链条:
ScrollWheel事件一帧只能被消费一次。- ScrollView 的滚动应用发生在
EndScrollView,排在内部控件之后。 SelectableLabel先把ScrollWheelUse 掉 →EndScrollView看到Used→ 不滚动。TextArea、LabelField不碰ScrollWheel→ 事件传到EndScrollView→ 正常滚动。
正确选型
在一个"长文本放进固定高度区域、需要区域内滚轮"的场景下:
- 要绝对只读 :用
LabelField+wordWrappedLabel样式,最干净,但没法复制。 - 要能复制、又要滚轮可用 :用
TextArea+wordWrappedLabel样式(推荐)。它继承了 TextArea 的事件特性(不抢滚轮),又靠样式看起来像只读标签,还保留了选中复制能力。唯一理论缺陷是用户可能误删显示内容,对 Editor 测试工具来说可接受。 - 别用
SelectableLabel:在"放进父级 ScrollView"的场景下它是无法实现滚动的功能。
实测可用写法(本项目最终采用):
csharp
GUILayout.Label("Query(发送给 Dify 的提问数据)", EditorStyles.boldLabel);
EditorGUILayout.BeginVertical("HelpBox");
_queryScroll = EditorGUILayout.BeginScrollView(_queryScroll, GUILayout.MinHeight(120));
// 用 wordWrappedLabel 让它显示成长文本,ExpandHeight 撑满高度
EditorGUILayout.TextArea(record.query, EditorStyles.wordWrappedLabel, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
GUILayout.Space(5);
六、常用 EditorWindow 绘制函数速查
下面列出写 Editor 窗口最常用的函数,给出用途、常用参数和效果。
1. 布局控制
csharp
// 横向组:内部控件水平排列
GUILayout.BeginHorizontal();
GUILayout.EndHorizontal();
// 纵向组:内部控件垂直排列
GUILayout.BeginVertical();
GUILayout.EndVertical();
// 区块样式:传入 GUIStyle,如 "box"、"HelpBox"
GUILayout.BeginVertical("box");
GUILayout.EndVertical();
布局组必须严格配对。
2. 间距与弹性
csharp
GUILayout.Space(10); // 固定像素间距
GUILayout.FlexibleSpace(); // 弹性空白,撑满剩余空间(常用于两端对齐)
3. 文本显示
csharp
EditorGUILayout.LabelField(text); // 只读标签
EditorGUILayout.LabelField(text, EditorStyles.wordWrappedLabel); // 自动换行只读
EditorGUILayout.TextArea(text); // 可编辑多行
EditorGUILayout.SelectableLabel(text); // 只读可选(注意滚轮是否存在问题)
GUILayout.Label(text, EditorStyles.boldLabel); // 加粗标题
4. 按钮
csharp
if (GUILayout.Button("点击")) { } // 普通按钮
if (GUILayout.Button("点击", GUILayout.Height(30))) { } // 固定高度
if (GUILayout.Button("刷新", EditorStyles.toolbarButton)) { } // 工具栏风格按钮
GUILayout.Button 在被点击的当帧返回 true,否则 false。
5. 滚动视图
csharp
// 固定高度的滚动区域
scrollPos = EditorGUILayout.BeginScrollView(scrollPos, GUILayout.Height(150));
// 内部控件
EditorGUILayout.EndScrollView();
常用参数:
- 第一参数
Vector2 scrollPosition:当前滚动位置(用成员变量保存)。 GUILayout.Height/MinHeight:控制视图高度,超出部分靠滚动条。- 注意
Begin/End配对。
6. 输入控件
csharp
string s = EditorGUILayout.TextField("名字", value); // 单行文本
int n = EditorGUILayout.IntField("数量", value); // 整数
float f = EditorGUILayout.FloatField("速度", value); // 浮点
bool b = EditorGUILayout.Toggle("开关", value); // 勾选框
int i = EditorGUILayout.Popup("选项", index, options); // 下拉选择
Vector3 v = EditorGUILayout.Vector3Field("位置", value); // 三维向量
Color c = EditorGUILayout.ColorField("颜色", value); // 颜色
这些都返回用户输入后的新值,用返回值回写即可。
7. 编辑器特有控件
csharp
// 引用一个对象(拖拽赋值)
Object obj = EditorGUILayout.ObjectField("资源", value, typeof(GameObject), true);
// 自动绘制一个序列化属性(最常用在 Inspector)
EditorGUILayout.PropertyField(serializedProperty);
// 带图标的提示框
EditorGUILayout.HelpBox("这是一段提示", MessageType.Info); // Info / Warning / Error
ObjectField 第四个参数 allowSceneObjects 表示是否允许拖入场景对象。
8. 尺寸控制(GUILayoutOptions)
这些可以附加到几乎任何绘制函数末尾:
csharp
GUILayout.Width(200) // 固定宽度
GUILayout.Height(30) // 固定高度
GUILayout.MinWidth(100) // 最小宽度
GUILayout.MaxWidth(300) // 最大宽度
GUILayout.MinHeight(120) // 最小高度
GUILayout.ExpandWidth(true) // 是否横向撑满
GUILayout.ExpandHeight(true) // 是否纵向撑满
例:GUILayout.Button("OK", GUILayout.Width(80), GUILayout.Height(30))。
9. 窗口与重绘
csharp
Repaint(); // 手动触发窗口重绘(异步操作完成后更新 UI 常用)
异步任务(如网络请求)完成后,OnGUI 不会自动重绘,需要主动调用 Repaint() 刷新显示。
七、结语
这个滚轮问题看似很小,但它正好暴露了 IMGUI 最容易被忽视的一点:在即时模式里,"绘制"和"事件处理"是同一件事,控件之间通过 Use() 隐式协作。 谁先把事件消费掉,谁就决定了后面的行为。
记住几个要点:
GUILayout是通用 IMGUI,EditorGUILayout是编辑器增强版,共享同一套布局和事件机制。- 一个事件一帧只能被
Use()一次。 - ScrollView 的滚动应用排在内部控件之后,所以内部控件一旦吞掉滚轮,ScrollView 就无法使用滚轮。
SelectableLabel会吞滚轮(基于行为推断),TextArea和LabelField不吞。- "长文本 + 父级 ScrollView + 滚轮可用 + 可复制"这个组合,最优解是
TextArea + wordWrappedLabel。
写 Editor 工具时,理解了这套事件流,再遇到"滚轮不滚"问题,就能从事件被谁消费的角度去定位,尝试解决问题。
参考资料
- Unity Scripting API ---
EditorGUILayout(官方文档,编辑器绘制 API 总览):https://docs.unity3d.com/ScriptReference/EditorGUILayout.html - Unity Scripting API ---
GUILayout(官方文档,通用 IMGUI API):https://docs.unity3d.com/ScriptReference/GUILayout.html - Unity Scripting API ---
GUI(官方文档,底层 GUI 类):https://docs.unity3d.com/ScriptReference/GUI.html - Unity Scripting API ---
EditorGUI(官方文档,非自动布局的编辑器绘制):https://docs.unity3d.com/ScriptReference/EditorGUI.html - Unity Scripting API ---
Event(官方文档,事件类型与Use()语义):https://docs.unity3d.com/ScriptReference/Event.html - Unity Scripting API ---
EventType(官方文档,包含ScrollWheel/Used等事件类型):https://docs.unity3d.com/ScriptReference/EventType.html - Unity Manual --- "Editor Windows"(官方手册,自定义窗口基础):https://docs.unity3d.com/Manual/editor-EditorWindows.html
说明:以上 Unity 官方文档确认了 IMGUI 的基本 API 行为(
GUILayout/EditorGUILayout的用法、Event.Use的语义、EventType的取值)。但本文关于SelectableLabel内部为何吞掉 ScrollWheel 的解释(TextEditor无条件Use滚轮),是基于反复实测的行为反推,Unity 并未公开这部分内部 C++ 源码,属于推断而非确认 。如果未来 Unity 版本调整了SelectableLabel的事件处理逻辑,本文的结论可能需要重新验证。