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)
UXML(結構) ←→ USS(樣式) ↑ C# 腳本(邏輯)

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")
注意:USS 的 pointer-events 注意事項:
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 可以同時存在畫面上
Sort Order 100:DragManager(Ghost 拖曳層,永遠在最上面) Sort Order 0:HotbarUI(快捷列) Sort Order 0:InventoryUI(背包)
注意: Sort Order 不同的 UIDocument 之間,事件不會自動共享。 滑鼠游標離開某個 UIDocument 的範圍,那個面板就收不到 PointerMove 事件了。 這正是為什麼我們要用 Mouse.current 直接讀取滑鼠位置。
第二章

拖曳系統架構

2.1 為什麼拖曳系統比較複雜?

一般的點擊事件很簡單:使用者點哪裡,那個元素就收到事件。但拖曳涉及到:

  1. 按下滑鼠(在來源格子上)
  2. 移動滑鼠(可能移出當前 UIDocument 範圍)
  3. 放開滑鼠(可能在完全不同的 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; // 不在任何格子上
}
優點: 這個方法不需要 UI 事件,座標系與 Mouse.current 轉換後一致,可靠性高。

2.4 DragManager:全域 Ghost 層

拖曳時跟隨游標的圖示(Ghost)不能加在 InventoryUI 或 HotbarUI 裡, 因為它們的 Sort Order 不保證是最高的,Ghost 可能被其他視窗遮住。

解決方案:建立一個獨立的 DragManager,掛在 Sort Order = 100 的 UIDocument 上,專門負責渲染 Ghost:

拖曳開始 → HotbarUI 呼叫 DragManager.Instance.StartDrag(icon, name, count) 拖曳中 → DragManager.Update() 每幀移動 Ghost 位置 拖曳結束 → HotbarUI 呼叫 DragManager.Instance.EndDrag()
注意: DragManager 的根元素必須設為 PickingMode.Ignore, 否則它的全螢幕透明背景會攔截所有滑鼠事件。

2.5 跨面板拖曳(InventoryUI → HotbarUI)

這是最複雜的情況:使用者把背包裡的物品拖到快捷列上,涉及兩個不同的 UIDocument。

1. InventoryUI:使用者按下格子,開始拖曳 2. InventoryUI.Update():每幀讀取 Mouse.current 3. InventoryUI.Update():詢問 HotbarUI「游標在你哪個格子上?」 → _externalDragTarget.NotifyHover(foundIdx) 4. HotbarUI:收到通知,在對應格子顯示高亮 5. 使用者放開滑鼠 6. InventoryUI:詢問 HotbarUI「你現在高亮的是哪格?」 → int dropIdx = _externalDragTarget.GetHoverIndex() 7. InventoryUI:把物品放到 HotbarUI 的那個格子 → _externalDragTarget.TryDrop(dropIdx, srcSlot) 8. InventoryUI:清除高亮 → _externalDragTarget.ClearHover()

IDragTarget 介面(低耦合設計):

public interface IDragTarget
{
    int  GetDropIndexAtPanelPos(Vector2 panelPos); // 游標在哪格?
    void NotifyHover(int index);                   // 通知顯示高亮
    void ClearHover();                              // 清除高亮
    int  GetHoverIndex();                           // 目前高亮哪格?
    bool TryDrop(int dropIndex, InventorySlot src); // 執行放下
}
設計優點: InventoryUI 只認識 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(樣式)

注意: 樣式已直接內嵌在上方 UXML 的 <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 介面溝通,彼此不直接依賴。

場景結構 ├── GameObject: InventoryUI (UIDocument Sort Order = 0) │ └── InventoryUI.cs ├── GameObject: HotbarUI (UIDocument Sort Order = 0) │ └── HotbarUI.cs └── GameObject: DragManager (UIDocument Sort Order = 100) └── DragManager.cs (渲染跟隨游標的 Ghost 圖示)

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;
    }
}
設計重點: InventoryUI 透過 [SerializeField] 拿到 HotbarUI 的參考, 但實際操作時只透過 IDragTarget 介面呼叫。 日後新增技能欄、裝備欄等,只要讓它們實作 IDragTarget,InventoryUI 完全不用改。

4.4 跨面板拖曳流程

1. InventoryUI:使用者按下格子,_isDragging = true,記錄 _dragItemId 2. InventoryUI.Update():每幀讀取 Mouse.current,轉換為 panelPos 3. UpdateHover():游標不在自己格子上時,詢問 _hotbarUI.GetDropIndexAtPanelPos(panelPos) 4. 找到目標格:呼叫 _hotbarUI.NotifyHover(idx),HotbarUI 顯示高亮 5. 使用者放開滑鼠 6. FinishDrop():呼叫 _dragTarget.GetHoverIndex() 取得目標格 7. 呼叫 _dragTarget.TryDrop(dropIdx, _dragItemId),HotbarUI 寫入資料 8. 呼叫 _dragTarget.ClearHover(),清除高亮 9. 重置所有拖曳狀態
第五章

常見問題與解法

問題原因解法
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

此網誌的熱門文章

哥利亞遙控炸彈 (Leichter Ladungsträger Goliath)

O-I(試製120t超重戰車「オイ」)