版本:Unity 6 (6000.0)
语言:英语
为自定义控件创建自定义样式
创建纵横比自定义控件

在窗口之间创建拖放列表和树形视图

版本: 2023.2+

拖放是UI(用户界面) 允许用户与您的应用程序交互。Unity 目前支持三种 UI 系统。 更多信息
术语表 中查看
设计中的常见功能。您可以使用 UI 工具包在自定义编辑器窗口或 Unity 构建的应用程序中创建拖放 UI。此示例演示如何在自定义编辑器窗口中使用 ListView 和 TreeView 创建拖放 UI。

示例概述

该示例在一个自定义编辑器窗口中创建了一个拆分窗口,其中包含一个大厅和两个团队。大厅使用 ListView 创建。为了演示目的,一个团队使用 MultiColumnListView 创建,另一个团队使用 TreeView 创建。该示例使用一个切换按钮来启用和禁用拖放操作。启用后,您可以拖动玩家以重新排列他们的顺序,并将他们从大厅列表拖动到团队列表,如下所示

A preview of a drag-and-drop UI
拖放 UI 的预览

您可以在此GitHub 存储库中找到此示例创建的完整文件。

先决条件

本指南适用于熟悉 Unity 编辑器、UI 工具包和 C# 脚本的开发人员。在开始之前,请熟悉以下内容

创建玩家数据

首先,创建一个资产来管理大厅中的一系列玩家。创建一个脚本来定义表示玩家数据的 PlayerData 结构体。该结构体包含三个字段:一个字符串名称、一个整数编号和一个 Texture2D 对象图标。用[SerializeField]属性标记这些字段,以便它们的数值可以序列化并存储在 Unity 的数据格式中。创建一个集合数据库资产来管理拖放 UI 的玩家数据。集合数据库资产包含一个已序列化的 PlayerData 对象列表,您可以在 Unity 编辑器中设置它们。

  1. 在 Unity 中使用任何模板创建一个项目。

  2. 在您的项目窗口一个显示您的Assets文件夹内容的窗口(项目选项卡)更多信息
    术语表 中查看
    Assets 文件夹中,创建一个名为 Scripts 的文件夹来存储您的脚本文件。

  3. 脚本一段代码,允许您创建自己的组件、触发游戏事件、随时间推移修改组件属性,以及以任何您喜欢的方式响应用户输入。更多信息
    术语表 中查看
    文件夹中,创建一个名为 Data 的文件夹。

  4. Data 文件夹中,创建一个名为 PlayerData.cs 的 C# 脚本,内容如下

    using System;
    using UnityEngine;
        
    namespace CollectionTests
    {
        // Make the struct serializable, so its values can be stored in Unity's data format
        [Serializable]
        public struct PlayerData
        {
            // Declare private fields for the player's name, number, and icon, with the SerializeField attribute
            [SerializeField]
            string name;
            [SerializeField]
            int number;
            [SerializeField]
            Texture2D icon;
        
            // Calculate a unique identifier for the player based on their name and number
            public int id => name.GetHashCode() + 27 * number;
        
            // Define read-only properties for accessing the private fields
            public string Name => name;
            public int Number => number;
            public Texture2D Icon => icon;
        
            // Override the ToString() method to return a formatted string representation of the player data
            public override string ToString()
            {
                return $"{Name} #{Number.ToString()}";
            }
        }
    }
        
    
  5. Data 文件夹中,创建一个名为 CollectionDatabase.cs 的 C# 脚本,内容如下

    using System.Collections.Generic;
    using UnityEngine;
        
    namespace CollectionTests
    {
        // Create a CollectionDatabase object that you can create as an asset via the Asset menu.
        [CreateAssetMenu]
        public class CollectionDatabase : ScriptableObject
        {
            // Declare a private list of PlayerData that can set in the Unity Editor.
            [SerializeField]
            List<PlayerData> m_InitialLobbyList;
        
            public IEnumerable<PlayerData> initialLobbyList => m_InitialLobbyList;
        }
    }
        
    
  6. Assets 文件夹中,创建一个名为 Resources 的文件夹。

  7. Resources 文件夹中右键单击,然后选择 创建 > 集合数据库。这将创建一个新的集合数据库资产。

  8. 在集合数据库资产的 检视器一个 Unity 窗口,显示当前选定游戏对象、资产或项目设置的信息,允许您检查和编辑数值。更多信息
    术语表 中查看
    窗口中,将一些玩家添加到 大厅 列表中。您可以根据需要添加任意数量的玩家。

创建自定义控件以显示数据

创建名为 PlayerDataElement 和 PlayerItemView 的自定义控件以显示玩家数据。PlayerItemView 控件将其数据上下文绑定到一个 PlayerData 对象。

  1. Scripts 文件夹中,创建一个名为 UI 的文件夹。

  2. UI 文件夹中,创建一个名为 PlayerDataElement.cs 的 C# 脚本,内容如下

    using System;
    using UnityEngine.UIElements;
        
    namespace CollectionTests
    {
        [UxmlElement]
        public partial class PlayerDataElement : VisualElement
        {
            public PlayerData data { get; private set; }
            public int id { get; set; }
        
            public virtual void Bind(PlayerData player)
            {
                data = player;
            }
        
            public virtual void Reset()
            {
                data = default;
                id = -1;
            }
        }
    }
    
  3. UI 文件夹中,创建一个名为 PlayerItemView.cs 的 C# 脚本,内容如下

    using System;
    using UnityEngine.UIElements;
        
    namespace CollectionTests
    {
        [UxmlElement]
        public partial class PlayerItemView : PlayerDataElement
        {
            VisualElement m_Icon;
            Label m_Name;
        
            // Bind the player data to the UI.
            public override void Bind(PlayerData player)
            {
                base.Bind(player);
                        
                m_Icon ??= this.Q("Icon");
                m_Name ??= this.Q<Label>();
        
                m_Icon.style.backgroundImage = player.Icon;
                m_Name.text = player.Name;
            }
        }
    }
    

定义 UI 的布局和样式

创建一个 USS 文件来定义 UI 的样式。创建两个 UXML 文档来定义玩家项目视图和主视图的 UI 布局。在主视图中,要通过拖动启用列表项目的重新排序,请将 ListView、MultiColumnListView 和 TreeView 的reorderable属性设置为true

  1. Assets 文件夹中,创建一个名为 UI 的文件夹来存储您的 UXML 和 USS 文件。

  2. UI 文件夹中,创建一个名为 main.uss 的 USS 文件,内容如下

        .team-list {
            border-color: rgb(164, 164, 164);
            border-width: 2px;
            border-top-left-radius: 5px;
            border-bottom-left-radius: 5px;
            border-top-right-radius: 5px;
            border-bottom-right-radius: 5px;
            flex-grow: 1;
        }
        
        .section-container {
            padding: 5px;
            flex-grow: 1; 
            background-color: rgba(0, 0, 0, 0);
        }
        
        .unity-list-view__empty-label {
            display: none;
        }
        
        #Container {
            flex-direction: row; 
            align-items: center; 
            padding-left: 6px;
        }
        
        #Icon {
             width: 24px; 
             height: 24px;
        }
        
        #PlayerName {
            flex-grow: 1; 
            -unity-text-align: middle-left; 
            font-size: 14px; 
            padding-left: 6px;
        }
        
        .split-window{
            min-width: 250px;
        }
        
        .main-view{
            flex-grow: 1; 
            background-color: rgba(0, 0, 0, 0); 
            flex-direction: column;
        }
            
    
  3. UI 文件夹中,创建一个名为 PlayerItemView.uxml 的 UXML 文件,内容如下

    <ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">
        <Style src="main.uss" />
        <CollectionTests.PlayerItemView name="container">
            <ui:VisualElement name="Icon" />
            <ui:Label name="PlayerName"/>
        </CollectionTests.PlayerItemView>
    </ui:UXML>
        
        
    
  4. UI 文件夹中,创建一个名为 ListDragAndDropTestWindow.uxml 的 UXML 文件,内容如下

    <ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xsi="http://www.w3.org/2001/XMLSchema-instance" engine="UnityEngine.UIElements" editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../../UIElementsSchema/UIElements.xsd" editor-extension-mode="False">
        <Style src="main.uss" />
        <ui:VisualElement class="main-view">
            <ui:Toggle name="Toggle-LobbyOwner" text="Lobby Owner" />
            <ui:VisualElement class="section-container" >
                <ui:TwoPaneSplitView fixed-pane-initial-dimension="300">
                    <ui:VisualElement class="split-window" >
                        <ui:VisualElement name="LobbyContainer" class="section-container" >
                            <ui:Label tabindex="-1" text="Lobby" display-tooltip-when-elided="true" name="Name-Lobby" />
                            <ui:ListView name="ListView-Lobby" reorderable="true" selection-type="Multiple" class="team-list" />
                        </ui:VisualElement>
                    </ui:VisualElement>
                    <ui:VisualElement class="split-window" >
                        <ui:VisualElement name="TeamContainer" class="section-container" >
                            <ui:VisualElement name="BlueTeam" class="section-container" >
                                <ui:Label tabindex="-1" text="Blue Team" display-tooltip-when-elided="true" name="Name-BlueTeam" />
                                <ui:MultiColumnListView name="ListView-BlueTeam" reorderable="true" selection-type="Multiple" class="team-list" >
                                    <ui:Columns>
                                        <ui:Column name="icon" title="Icon" width="50" resizable="false" />
                                        <ui:Column name="number" title="#" width="40" resizable="false" />
                                        <ui:Column name="name" stretchable="true" title="Name" />
                                    </ui:Columns>
                                </ui:MultiColumnListView>
                            </ui:VisualElement>
                            <ui:VisualElement name="RedTeam" class="section-container" >
                                <ui:Label tabindex="-1" text="Red Team" display-tooltip-when-elided="true" name="Name-RedTeam" />
                                <ui:TreeView name="TreeView-RedTeam" reorderable="true" selection-type="Multiple" class="team-list" />
                            </ui:VisualElement>
                        </ui:VisualElement>
                    </ui:VisualElement>
                </ui:TwoPaneSplitView>
            </ui:VisualElement>
        </ui:VisualElement>
    </ui:UXML>
    

实现拖放操作

创建一个脚本来设置大厅和团队列表,并将它们绑定到您之前创建的玩家数据。该脚本还实现了大厅和团队列表之间的拖放操作。

  1. Scripts 文件夹中,创建一个名为 Controllers 的文件夹。

  2. Controllers 文件夹中,创建一个名为 LobbyController.cs 的 C# 脚本,内容如下

    using System;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.UIElements;
        
    namespace CollectionTests
    {
        public class LobbyController
        {
            const string k_DraggedItemsKey = "DraggedIndices";
            const string k_SourceKey = "SourceCollection";
        
            ListView m_LobbyListView;
            MultiColumnListView m_BlueTeamListView;
            TreeView m_RedTeamTreeView;
            Toggle m_IsOwnerToggle;
        
            List<PlayerData> m_LobbyItemsSource;
            List<PlayerData> m_BlueTeamItemsSource = new();
            List<TreeViewItemData<PlayerData>> m_RedTeamItemsSource = new();
        
            public LobbyController(VisualElement rootVisualElement, VisualTreeAsset playerItemAsset, CollectionDatabase collectionDatabase)
            {
                // Grab references
                m_IsOwnerToggle = rootVisualElement.Q<Toggle>("Toggle-LobbyOwner");
                m_LobbyListView = rootVisualElement.Q<ListView>("ListView-Lobby");
                m_BlueTeamListView = rootVisualElement.Q<MultiColumnListView>("ListView-BlueTeam");
                m_RedTeamTreeView = rootVisualElement.Q<TreeView>("TreeView-RedTeam");
        
                m_LobbyItemsSource = new List<PlayerData>(); 
        
                foreach (var item in collectionDatabase.initialLobbyList)
                {
                    m_LobbyItemsSource.Add(item);
                }
        
                m_LobbyListView.makeItem = MakeItem;
                m_LobbyListView.bindItem = (e, i) => BindItem(e, i, m_LobbyItemsSource[i]);
                m_LobbyListView.destroyItem = DestroyItem;
                m_LobbyListView.fixedItemHeight = 38;
                m_LobbyListView.itemsSource = m_LobbyItemsSource;
                m_LobbyListView.canStartDrag += OnCanStartDrag;
                m_LobbyListView.setupDragAndDrop += args => OnSetupDragAndDrop(args, m_LobbyListView);
                m_LobbyListView.dragAndDropUpdate += args => OnDragAndDropUpdate(args, m_LobbyListView, true);
                m_LobbyListView.handleDrop += args => OnHandleDrop(args, m_LobbyListView, true);
        
                var scrollView = m_LobbyListView.Q<ScrollView>();
                scrollView.touchScrollBehavior = ScrollView.TouchScrollBehavior.Elastic;
                scrollView.verticalScrollerVisibility = ScrollerVisibility.AlwaysVisible;
        
                m_BlueTeamListView.columns["icon"].makeCell = () => new PlayerDataElement { style = { width = 24, height = 24, alignSelf = Align.Center } };
                m_BlueTeamListView.columns["icon"].bindCell = (element, i) =>
                {
                    BindItem(element, i, m_BlueTeamItemsSource[i]);
                    element.style.backgroundImage = m_BlueTeamItemsSource[i].Icon;
                };
                m_BlueTeamListView.columns["number"].makeCell = () => new Label { style = { alignSelf = Align.Center } };
                m_BlueTeamListView.columns["number"].bindCell = (element, i) => ((Label)element).text = $"#{m_BlueTeamItemsSource[i].Number}";
                m_BlueTeamListView.columns["name"].makeCell = () => new Label { style = { paddingLeft = 10 } };
                m_BlueTeamListView.columns["name"].bindCell = (element, i) => ((Label)element).text = m_BlueTeamItemsSource[i].Name;
                m_BlueTeamListView.fixedItemHeight = 38;
                m_BlueTeamListView.reorderable = false;
                m_BlueTeamListView.itemsSource = m_BlueTeamItemsSource;
                m_BlueTeamListView.canStartDrag += OnCanStartDrag;
                m_BlueTeamListView.setupDragAndDrop += args => OnSetupDragAndDrop(args, m_BlueTeamListView);
                m_BlueTeamListView.dragAndDropUpdate += args => OnDragAndDropUpdate(args, m_BlueTeamListView);
                m_BlueTeamListView.handleDrop += args => OnHandleDrop(args, m_BlueTeamListView);
        
                m_RedTeamTreeView.makeItem = MakeItem;
                m_RedTeamTreeView.bindItem = (e, i) => BindItem(e, m_RedTeamTreeView.GetIdForIndex(i), (PlayerData)m_RedTeamTreeView.viewController.GetItemForIndex(i));
                m_RedTeamTreeView.destroyItem = DestroyItem;
                m_RedTeamTreeView.fixedItemHeight = 38;
                m_RedTeamTreeView.SetRootItems(m_RedTeamItemsSource);
                m_RedTeamTreeView.canStartDrag += OnCanStartDrag;
                m_RedTeamTreeView.setupDragAndDrop += args => OnSetupDragAndDrop(args, m_RedTeamTreeView);
                m_RedTeamTreeView.dragAndDropUpdate += args => OnDragAndDropUpdate(args, m_RedTeamTreeView);
                m_RedTeamTreeView.handleDrop += args => OnHandleDrop(args, m_RedTeamTreeView);
        
                VisualElement MakeItem()
                {
                    return playerItemAsset.Instantiate();
                }
        
                static void BindItem(VisualElement element, int index, PlayerData data)
                {
                    var playerView = element.Q<PlayerDataElement>();
                    playerView.Bind(data);
                    playerView.id = index;
                }
        
                static void DestroyItem(VisualElement element)
                {
                    var playerView = element.Q<PlayerDataElement>();
                    playerView.Reset();
                }
        
                bool OnCanStartDrag(CanStartDragArgs _) => m_IsOwnerToggle.value;
        
                StartDragArgs OnSetupDragAndDrop(SetupDragAndDropArgs args, BaseVerticalCollectionView source)
                {
                    var playerView = args.draggedElement.Q<PlayerDataElement>();
                    if (playerView == null)
                        return args.startDragArgs;
        
                    var startDragArgs = new StartDragArgs(args.startDragArgs.title, DragVisualMode.Move);
                    startDragArgs.SetGenericData(k_SourceKey, source);
                    var hasSelection = false;
                    foreach (var id in args.selectedIds)
                    {
                        hasSelection = true;
                        break;
                    }
        
                    startDragArgs.SetGenericData(k_DraggedItemsKey, hasSelection ? args.selectedIds : new List<int> { playerView.id });
                    return startDragArgs;
                }
        
                DragVisualMode OnDragAndDropUpdate(HandleDragAndDropArgs args, BaseVerticalCollectionView destination, bool isLobby = false)
                {
                    var source = args.dragAndDropData.GetGenericData(k_SourceKey);
                    if (source == destination)
                        return DragVisualMode.None;
        
                    return !isLobby && destination.itemsSource.Count >= 3 ? DragVisualMode.Rejected : DragVisualMode.Move;
                }
        
                DragVisualMode OnHandleDrop(HandleDragAndDropArgs args, BaseVerticalCollectionView destination, bool isLobby = false)
                {
                    if (args.dragAndDropData.unityObjectReferences != null)
                    {
                        var objectsToString = string.Empty;
                        foreach (var obj in args.dragAndDropData.unityObjectReferences)
                        {
                            objectsToString += $"{obj.name}, ";
                        }
        
                        if (!string.IsNullOrEmpty(objectsToString))
                        {
                            Debug.Log($"That was {objectsToString}");
                            return DragVisualMode.Move;
                        }
                    }
        
                    if (args.dragAndDropData.GetGenericData(k_DraggedItemsKey) is not List<int> draggedIds)
                        throw new ArgumentNullException($"Indices are null.");
                    if (args.dragAndDropData.GetGenericData(k_SourceKey) is not BaseVerticalCollectionView source)
                        throw new ArgumentNullException($"Source is null.");
        
                    // Let default reordering happen.
                    if (source == destination)
                        return DragVisualMode.None;
        
                    // Be coherent with the dragAndDropUpdate condition.
                    if (!isLobby && destination.itemsSource.Count >= 3)
                        return DragVisualMode.Rejected;
        
                    var treeViewSource = source as BaseTreeView;
        
                    // ********************************************************
                    // Add items first, from item indices in the source.
                    // ********************************************************
        
                    // Gather ids from dragged indices
                    var ids = new List<int>();
        
                    foreach (var id in draggedIds)
                    {
                        ids.Add(id);
                    }
        
                    // Special TreeView case, we need to gather children or selected indices.
                    if (treeViewSource != null)
                    {
                        GatherChildrenIds(ids, treeViewSource);
                    }
        
                    if (destination is BaseTreeView treeView)
                    {
                        foreach (var id in ids)
                        {
                            var data = (PlayerData)source.viewController.GetItemForId(id);
                            treeView.AddItem(new TreeViewItemData<PlayerData>(data.id, data), args.parentId, args.childIndex, false);
                        }
        
                        treeView.viewController.RebuildTree();
                    }
                    else if (destination.viewController is BaseListViewController destinationListViewController)
                    {
                        for (var i = ids.Count - 1; i >= 0; i--)
                        {
                            var id = ids[i];
                            var data = (PlayerData)source.viewController.GetItemForId(id);
                            destinationListViewController.itemsSource.Insert(args.insertAtIndex, data);
                        }
                    }
                    else
                    {
                        throw new ArgumentException("Unhandled destination.");
                    }
        
                    // Then remove from the source.
                    if (source is BaseTreeView sourceTreeView)
                    {
                        foreach (var id in draggedIds)
                        {
                            var data = (PlayerData)source.viewController.GetItemForId(id);
                            sourceTreeView.viewController.TryRemoveItem(data.id, false);
                        }
        
                        sourceTreeView.viewController.RebuildTree();
                        sourceTreeView.RefreshItems();
                    }
                    else if (source.viewController is BaseListViewController sourceListViewController)
                    {
                        sourceListViewController.RemoveItems(draggedIds);
                    }
                    else
                    {
                        throw new ArgumentException("Unhandled source.");
                    }
        
                    foreach (var id in ids)
                    {
                        var index = destination.viewController.GetIndexForId(id);
                        destination.AddToSelection(index);
                    }
                    source.ClearSelection();
                    destination.RefreshItems();
                    LogTeamSizes();
                    return DragVisualMode.Move;
                }
            }
        
            void LogTeamSizes()
            {
                Debug.Log($"Blue: {m_BlueTeamListView.itemsSource.Count} / 3\tRed: {m_RedTeamTreeView.viewController.GetItemsCount()} / 3");
            }
        
            static void GatherChildrenIds(List<int> ids, BaseTreeView treeView)
            {
                for (var i = 0; i < ids.Count; i++)
                {
                    var id = ids[i];
                    var childrenIds = treeView.viewController.GetChildrenIds(id);
                    foreach (var childId in childrenIds)
                    {
                        ids.Insert(i + 1, childId);
                        i++;
                    }
                }
            }
        }
    }
    

创建自定义编辑器窗口

创建一个自定义编辑器窗口来显示拖放 UI。

  1. Assets 文件夹中,创建一个名为 Editor 的文件夹。

  2. Editor 文件夹中,创建一个名为 ListDragAndDropTestWindow.cs 的 C# 脚本,内容如下

    using System;
    using UnityEditor;
    using UnityEngine;
    using UnityEngine.UIElements;
        
    namespace CollectionTests
    {
        public class ListDragAndDropTestWindow : EditorWindow
        {
            [MenuItem("Collection Tests/List DragAndDrop Window")]
            public static void ShowExample()
            {
                var wnd = GetWindow<ListDragAndDropTestWindow>();
                wnd.titleContent = new GUIContent("List DragAndDrop Test");
            }
        
            public void CreateGUI()
            {
                // Each editor window contains a root VisualElement object
                var root = rootVisualElement;
        
                // Import UXML
                var visualTreeAsset = EditorGUIUtility.Load("Assets/create-drag-and-drop-list-treeview/UI/ListDragAndDropTestWindow.uxml") as VisualTreeAsset;
                visualTreeAsset.CloneTree(root);
        
                // Load the PlayerItemView.uxml file
                var playerItemAsset = EditorGUIUtility.Load("Assets/create-drag-and-drop-list-treeview/UI/PlayerItemView.uxml") as VisualTreeAsset;
        
                //Load the CollectionDatabase from the Resources folder
                var collectionDatabase = Resources.Load<CollectionDatabase>("CollectionDatabaseAsset");
        
                // Create the LobbyController
                var lobbyController = new LobbyController(root, playerItemAsset, collectionDatabase);
            }
        }
    }
    

测试 UI

要进行测试,请在 大厅 列表中更改玩家的顺序,并在选中 大厅所有者 复选框时将玩家从 大厅 列表移动到团队列表。您还可以更改红色团队列表中玩家的层级结构。根据 LobbyController.cs 脚本中设置的条件,您可以向每个团队添加最多三个玩家。

  1. 从主菜单中选择 集合测试 > 列表拖放窗口
  2. 列表拖放测试 窗口中,选中 大厅所有者 复选框。
  3. 拖动 大厅 列表中的玩家以更改它们的顺序。
  4. 将玩家从 大厅 列表拖动到团队列表。
  5. 拖动红色团队列表中的玩家以更改它们的层级结构。

其他资源

为自定义控件创建自定义样式
创建纵横比自定义控件