Unity UI Toolkit 與拖曳系統說明
UI Toolkit 基本概念
1.1 什麼是 UI Toolkit?
UI Toolkit 是 Unity 提供的新一代 UI 系統,用來取代舊有的 UGUI(GameObject + Canvas 架構)。 它的運作方式更接近網頁開發:
- UXML:定義 UI 的結構(像 HTML)
- USS:定義 UI 的外觀(像 CSS)
- C#:控制 UI 的行為(像 JavaScript)
1.2 VisualElement:UI 的基本單位
UI Toolkit 裡的每一個元素都是 VisualElement,包括:
| 元素類型 | 說明 |
|---|---|
VisualElement | 最基本的容器,相當於 HTML 的 <div> |
Label | 顯示文字 |
Button | 可點擊的按鈕 |
TextField | 可輸入的文字框 |
ScrollView | 可捲動的容器 |
這些元素可以巢狀組合,形成一個樹狀結構,稱為 Visual Tree。
1.3 PickingMode:決定元素能不能被滑鼠偵測到
這個屬性決定元素是否會攔截滑鼠事件,是 UI Toolkit 最容易踩坑的地方之一。
| PickingMode | 行為 |
|---|---|
Position(預設) | 在元素的視覺範圍內,滑鼠事件會被此元素接收 |
Ignore | 此元素完全忽略滑鼠,事件會穿透到下方的元素 |
// 錯誤:把背景根元素設為 Ignore,導致所有子元素也收不到 Hover 事件
_root.pickingMode = PickingMode.Ignore;
// 正確:只讓純背景透明層設為 Ignore,格子本身保持 Position
// (在 UXML 的 root 元素上設定 picking-mode="Ignore")
Unity USS 的
pointer-events: none 與網頁 CSS 行為不同。網頁 CSS 中,子元素可以用
pointer-events: auto 覆蓋父元素的 none;但 Unity USS 子元素無法覆蓋父元素的 none,一旦父元素設為 none,子元素也全部失效。
建議直接在 UXML 元素屬性上設定
picking-mode="Ignore",比 USS 更可靠。
1.4 UIDocument 與 Sort Order
每個 UIDocument 是一個獨立的 UI 面板,有自己的 Sort Order(排列順序):
- Sort Order 數字越大,渲染在越上層
- 多個 UIDocument 可以同時存在畫面上
Mouse.current 直接讀取滑鼠位置。
拖曳系統架構
2.1 為什麼拖曳系統比較複雜?
一般的點擊事件很簡單:使用者點哪裡,那個元素就收到事件。但拖曳涉及到:
- 按下滑鼠(在來源格子上)
- 移動滑鼠(可能移出當前 UIDocument 範圍)
- 放開滑鼠(可能在完全不同的 UIDocument 上)
步驟 2 和 3 是問題所在。當滑鼠移出 InventoryUI 的面板範圍,InventoryUI 就收不到 PointerMove 了,拖曳追蹤就中斷了。
2.2 解決方案:Mouse.current + Update()
本專案的解法是完全不依賴 UI Toolkit 的事件系統來追蹤拖曳位置,改用 Unity 的 MonoBehaviour.Update() 每幀直接讀取實體滑鼠位置:
private void Update()
{
if (!_isDragging) return;
var mouse = Mouse.current; // New Input System 直接讀取滑鼠
if (mouse == null) return;
// 座標轉換:Unity Screen Space → UI Toolkit Panel Space
// Screen Space:左下角為原點,Y 軸朝上
// Panel Space: 左上角為原點,Y 軸朝下
var screenPos = mouse.position.ReadValue();
var panelPos = new Vector2(screenPos.x, Screen.height - screenPos.y);
// 無論游標在哪個 UIDocument 上,都能正確更新
UpdateHover(panelPos);
// 備援放下機制:OnPointerUp 可能因 pickingMode 無法觸發
if (mouse.leftButton.wasReleasedThisFrame)
FinishDrop(panelPos);
}
2.3 Hit Test:判斷游標在哪個格子上
worldBound 是元素在 Panel Space 中的實際位置和大小(Rect),用 Contains() 判斷點是否在範圍內:
private int GetSlotUnderCursor(Vector2 panelPos)
{
for (int i = 0; i < SLOT_COUNT; i++)
{
// worldBound 是元素在畫面上的實際範圍
if (_slotEls[i] != null && _slotEls[i].worldBound.Contains(panelPos))
return i;
}
return -1; // 不在任何格子上
}
Mouse.current 轉換後一致,可靠性高。
2.4 DragManager:全域 Ghost 層
拖曳時跟隨游標的圖示(Ghost)不能加在 InventoryUI 或 HotbarUI 裡, 因為它們的 Sort Order 不保證是最高的,Ghost 可能被其他視窗遮住。
解決方案:建立一個獨立的 DragManager,掛在 Sort Order = 100 的 UIDocument 上,專門負責渲染 Ghost:
PickingMode.Ignore,
否則它的全螢幕透明背景會攔截所有滑鼠事件。
2.5 跨面板拖曳(InventoryUI → HotbarUI)
這是最複雜的情況:使用者把背包裡的物品拖到快捷列上,涉及兩個不同的 UIDocument。
IDragTarget 介面(低耦合設計):
public interface IDragTarget
{
int GetDropIndexAtPanelPos(Vector2 panelPos); // 游標在哪格?
void NotifyHover(int index); // 通知顯示高亮
void ClearHover(); // 清除高亮
int GetHoverIndex(); // 目前高亮哪格?
bool TryDrop(int dropIndex, InventorySlot src); // 執行放下
}
IDragTarget,不認識 HotbarUI。
未來若新增技能欄,只要讓它實作 IDragTarget,InventoryUI 完全不用改。
完整拖曳範例
3.1 UXML(結構)
<ui:UXML xmlns:ui="UnityEngine.UIElements">
<!-- 根容器:全螢幕,picking-mode="Ignore" 讓背景不攔截事件 -->
<ui:VisualElement name="root" picking-mode="Ignore"
style="position: absolute; left:0; right:0; top:0; bottom:0;">
<!-- 格子容器 -->
<ui:VisualElement name="slot-container"
style="flex-direction: row; position: absolute; bottom: 40px; left: 100px;">
<ui:VisualElement name="slot-0" class="slot" />
<ui:VisualElement name="slot-1" class="slot" />
<ui:VisualElement name="slot-2" class="slot" />
</ui:VisualElement>
</ui:VisualElement>
</ui:UXML>
3.2 USS(樣式)
<Style> 區塊中,不需要另外建立 .uss 檔案。
這樣可以避免 UIDocument 忘記掛載 USS 導致格子沒有外觀的問題。
3.3 C#(邏輯)
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
// 掛載需求:此腳本和 UIDocument 必須在同一個 GameObject 上
[RequireComponent(typeof(UIDocument))]
public class SimpleDragExample : MonoBehaviour
{
private const int SLOT_COUNT = 3;
private UIDocument _uiDocument;
private VisualElement[] _slotEls = new VisualElement[SLOT_COUNT];
// 拖曳狀態
private bool _isDragging = false;
private int _dragSrcIdx = -1;
private VisualElement _dragSrcEl;
private int _dragDstIdx = -1;
private VisualElement _dragDstEl;
private void Awake()
{
// 在 Awake 取好元件,確保 OnEnable 時一定有
_uiDocument = GetComponent<UIDocument>();
}
private void OnEnable()
{
if (_uiDocument == null)
{
Debug.LogError("[SimpleDragExample] 找不到 UIDocument,請確認掛在同一個 GameObject");
return;
}
var root = _uiDocument.rootVisualElement;
if (root == null)
{
Debug.LogError("[SimpleDragExample] rootVisualElement 是 null,UXML 可能尚未載入");
return;
}
// 等 UI 幾何計算完成再初始化,避免 worldBound 全是 0
root.RegisterCallback<GeometryChangedEvent>(OnReady);
}
private void OnDisable()
{
if (_uiDocument == null) return;
var root = _uiDocument.rootVisualElement;
root?.UnregisterCallback<GeometryChangedEvent>(OnReady);
}
private void OnReady(GeometryChangedEvent evt)
{
var root = _uiDocument.rootVisualElement;
root.UnregisterCallback<GeometryChangedEvent>(OnReady);
// 取格子並綁定 PointerDown
for (int i = 0; i < SLOT_COUNT; i++)
{
var el = root.Q<VisualElement>($"slot-{i}");
if (el == null)
{
Debug.LogError($"[SimpleDragExample] 找不到 slot-{i},請確認 UXML 裡有對應的 name");
continue;
}
_slotEls[i] = el;
int idx = i; // 閉包捕獲
el.RegisterCallback<PointerDownEvent>(e =>
{
if (e.button != 0) return;
_isDragging = true;
_dragSrcIdx = idx;
_dragSrcEl = el;
el.AddToClassList("slot--dragging");
e.StopPropagation();
});
}
// 全域 PointerUp:備援放下機制
root.RegisterCallback<PointerUpEvent>(e =>
{
if (!_isDragging) return;
FinishDrop(e.position);
});
Debug.Log("[SimpleDragExample] 初始化完成");
}
// Update:主要拖曳追蹤迴圈(不依賴 UI 事件)
private void Update()
{
if (!_isDragging) return;
var mouse = Mouse.current;
if (mouse == null) return;
var screenPos = mouse.position.ReadValue();
var panelPos = new Vector2(screenPos.x, Screen.height - screenPos.y);
UpdateHover(panelPos);
if (mouse.leftButton.wasReleasedThisFrame)
FinishDrop(panelPos);
}
private void UpdateHover(Vector2 panelPos)
{
int hovered = -1;
for (int i = 0; i < SLOT_COUNT; i++)
{
if (_slotEls[i] != null && _slotEls[i].worldBound.Contains(panelPos))
{
hovered = i;
break;
}
}
if (hovered >= 0 && hovered != _dragSrcIdx)
{
if (hovered != _dragDstIdx)
{
_dragDstEl?.RemoveFromClassList("slot--drag-over");
_dragDstEl = _slotEls[hovered];
_dragDstIdx = hovered;
_dragDstEl.AddToClassList("slot--drag-over");
}
}
else
{
_dragDstEl?.RemoveFromClassList("slot--drag-over");
_dragDstEl = null;
_dragDstIdx = -1;
}
}
private void FinishDrop(Vector2 panelPos)
{
if (!_isDragging) return;
if (_dragDstIdx >= 0 && _dragDstIdx != _dragSrcIdx)
Debug.Log($"交換格子 {_dragSrcIdx} 和格子 {_dragDstIdx}");
else if (_dragDstIdx < 0 && _dragSrcEl != null)
{
bool overSrc = _dragSrcEl.worldBound.Contains(panelPos);
if (!overSrc) Debug.Log($"格子 {_dragSrcIdx} 被拖出格外,應清空");
}
_dragSrcEl?.RemoveFromClassList("slot--dragging");
_dragDstEl?.RemoveFromClassList("slot--drag-over");
_isDragging = false;
_dragSrcIdx = -1;
_dragSrcEl = null;
_dragDstEl = null;
_dragDstIdx = -1;
}
}
第四章
跨面板拖曳範例(InventoryUI 到 HotbarUI)
這個範例示範如何把背包(InventoryUI)裡的物品拖曳到快捷列(HotbarUI)。
兩者是各自獨立的 UIDocument,透過 IDragTarget 介面溝通,彼此不直接依賴。
4.1 IDragTarget 介面
InventoryUI 只認識這個介面,不直接引用 HotbarUI,方便日後新增其他目標面板。
public interface IDragTarget
{
// 游標目前在哪一格?找不到回傳 -1
int GetDropIndexAtPanelPos(Vector2 panelPos);
// 通知顯示高亮
void NotifyHover(int index);
// 清除高亮
void ClearHover();
// 目前高亮的是哪一格?
int GetHoverIndex();
// 執行放下,回傳是否成功
bool TryDrop(int dropIndex, string itemId);
}
4.2 HotbarUI.cs
實作 IDragTarget,負責管理自己的格子高亮狀態與放下邏輯。
using UnityEngine;
using UnityEngine.UIElements;
[RequireComponent(typeof(UIDocument))]
public class HotbarUI : MonoBehaviour, IDragTarget
{
private const int SLOT_COUNT = 5;
private UIDocument _uiDocument;
private VisualElement[] _slotEls = new VisualElement[SLOT_COUNT];
private int _hoverIdx = -1;
private void Awake()
{
_uiDocument = GetComponent<UIDocument>();
}
private void OnEnable()
{
var root = _uiDocument.rootVisualElement;
root.RegisterCallback<GeometryChangedEvent>(OnReady);
}
private void OnReady(GeometryChangedEvent evt)
{
var root = _uiDocument.rootVisualElement;
root.UnregisterCallback<GeometryChangedEvent>(OnReady);
for (int i = 0; i < SLOT_COUNT; i++)
{
var el = root.Q<VisualElement>($"hotbar-slot-{i}");
if (el == null)
{
Debug.LogError($"[HotbarUI] 找不到 hotbar-slot-{i}");
continue;
}
_slotEls[i] = el;
}
}
// IDragTarget 實作
public int GetDropIndexAtPanelPos(Vector2 panelPos)
{
for (int i = 0; i < SLOT_COUNT; i++)
{
if (_slotEls[i] != null && _slotEls[i].worldBound.Contains(panelPos))
return i;
}
return -1;
}
public void NotifyHover(int index)
{
if (index == _hoverIdx) return;
ClearHover();
if (index >= 0 && index < SLOT_COUNT && _slotEls[index] != null)
{
_slotEls[index].AddToClassList("slot--drag-over");
_hoverIdx = index;
}
}
public void ClearHover()
{
if (_hoverIdx >= 0 && _slotEls[_hoverIdx] != null)
_slotEls[_hoverIdx].RemoveFromClassList("slot--drag-over");
_hoverIdx = -1;
}
public int GetHoverIndex() => _hoverIdx;
public bool TryDrop(int dropIndex, string itemId)
{
if (dropIndex < 0 || dropIndex >= SLOT_COUNT) return false;
Debug.Log($"[HotbarUI] 格子 {dropIndex} 收到物品:{itemId}");
// 實際遊戲中:寫入資料、更新圖示等
return true;
}
}
4.3 InventoryUI.cs
拖曳的發起方。Update() 每幀透過 IDragTarget 詢問 HotbarUI 游標位置,
放下時呼叫 TryDrop()。
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
[RequireComponent(typeof(UIDocument))]
public class InventoryUI : MonoBehaviour
{
private const int SLOT_COUNT = 9;
[SerializeField] private HotbarUI _hotbarUI; // Inspector 拖入
private UIDocument _uiDocument;
private VisualElement[] _slotEls = new VisualElement[SLOT_COUNT];
private IDragTarget _dragTarget; // 目前拖曳指向的外部面板
// 拖曳狀態
private bool _isDragging = false;
private int _dragSrcIdx = -1;
private VisualElement _dragSrcEl;
private VisualElement _dragDstEl;
private int _dragDstIdx = -1;
private string _dragItemId;
private void Awake()
{
_uiDocument = GetComponent<UIDocument>();
}
private void OnEnable()
{
var root = _uiDocument.rootVisualElement;
root.RegisterCallback<GeometryChangedEvent>(OnReady);
}
private void OnDisable()
{
var root = _uiDocument?.rootVisualElement;
root?.UnregisterCallback<GeometryChangedEvent>(OnReady);
}
private void OnReady(GeometryChangedEvent evt)
{
var root = _uiDocument.rootVisualElement;
root.UnregisterCallback<GeometryChangedEvent>(OnReady);
for (int i = 0; i < SLOT_COUNT; i++)
{
var el = root.Q<VisualElement>($"inv-slot-{i}");
if (el == null) { Debug.LogError($"[InventoryUI] 找不到 inv-slot-{i}"); continue; }
_slotEls[i] = el;
int idx = i;
el.RegisterCallback<PointerDownEvent>(e =>
{
if (e.button != 0) return;
_isDragging = true;
_dragSrcIdx = idx;
_dragSrcEl = el;
_dragItemId = $"item_{idx}"; // 實際遊戲中從資料層取得
el.AddToClassList("slot--dragging");
e.StopPropagation();
});
}
root.RegisterCallback<PointerUpEvent>(e =>
{
if (_isDragging) FinishDrop(e.position);
});
}
private void Update()
{
if (!_isDragging) return;
var mouse = Mouse.current;
if (mouse == null) return;
var screenPos = mouse.position.ReadValue();
var panelPos = new Vector2(screenPos.x, Screen.height - screenPos.y);
UpdateHover(panelPos);
if (mouse.leftButton.wasReleasedThisFrame)
FinishDrop(panelPos);
}
private void UpdateHover(Vector2 panelPos)
{
// 先檢查同面板內的格子
int localHover = -1;
for (int i = 0; i < SLOT_COUNT; i++)
{
if (_slotEls[i] != null && _slotEls[i].worldBound.Contains(panelPos))
{
localHover = i;
break;
}
}
if (localHover >= 0 && localHover != _dragSrcIdx)
{
// 游標在自己的格子上:清除外部高亮,更新內部高亮
_hotbarUI?.ClearHover();
_dragTarget = null;
if (localHover != _dragDstIdx)
{
_dragDstEl?.RemoveFromClassList("slot--drag-over");
_dragDstEl = _slotEls[localHover];
_dragDstIdx = localHover;
_dragDstEl.AddToClassList("slot--drag-over");
}
}
else
{
// 游標不在自己的格子上:清除內部高亮,詢問外部面板
_dragDstEl?.RemoveFromClassList("slot--drag-over");
_dragDstEl = null;
_dragDstIdx = -1;
int externalIdx = _hotbarUI != null
? _hotbarUI.GetDropIndexAtPanelPos(panelPos)
: -1;
if (externalIdx >= 0)
{
_dragTarget = _hotbarUI;
_hotbarUI.NotifyHover(externalIdx);
}
else
{
_hotbarUI?.ClearHover();
_dragTarget = null;
}
}
}
private void FinishDrop(Vector2 panelPos)
{
if (!_isDragging) return;
if (_dragTarget != null)
{
// 放到外部面板
int dropIdx = _dragTarget.GetHoverIndex();
if (dropIdx >= 0)
_dragTarget.TryDrop(dropIdx, _dragItemId);
_dragTarget.ClearHover();
}
else if (_dragDstIdx >= 0 && _dragDstIdx != _dragSrcIdx)
{
// 放到自己面板內的另一格
Debug.Log($"[InventoryUI] 交換格子 {_dragSrcIdx} 和格子 {_dragDstIdx}");
}
// 清除所有狀態
_dragSrcEl?.RemoveFromClassList("slot--dragging");
_dragDstEl?.RemoveFromClassList("slot--drag-over");
_isDragging = false;
_dragSrcIdx = -1;
_dragSrcEl = null;
_dragDstEl = null;
_dragDstIdx = -1;
_dragItemId = null;
_dragTarget = null;
}
}
[SerializeField] 拿到 HotbarUI 的參考,
但實際操作時只透過 IDragTarget 介面呼叫。
日後新增技能欄、裝備欄等,只要讓它們實作 IDragTarget,InventoryUI 完全不用改。
4.4 跨面板拖曳流程
常見問題與解法
| 問題 | 原因 | 解法 |
|---|---|---|
| Hover 效果不顯示 | 根元素設了 PickingMode.Ignore |
移除該設定,或只在 UXML 的透明背景層設定 |
| 拖到面板外就追蹤不到 | 依賴 PointerMove 事件,移出面板就停止 |
改用 Update() + Mouse.current |
| 放下時沒有觸發 | OnPointerUp 因 pickingMode 問題未觸發 |
在 Update() 加入 wasReleasedThisFrame 備援 |
| 跨面板 Hover 偵測不到 | 兩個 UIDocument 事件不共享 | InventoryUI 的 Update 呼叫 HotbarUI 的 API |
| Ghost 被其他視窗蓋住 | Ghost 加在低 Sort Order 的 UIDocument | 建立獨立的 DragManager,Sort Order = 100 |
| Ghost 攔截了下方的點擊 | Ghost 根元素預設 PickingMode.Position |
設定 _root.pickingMode = PickingMode.Ignore |