バージョン: 2023.2+
ドラッグアンドドロップは、UI 設計の一般的な機能です。UI Toolkit を使用して、カスタムエディターウィンドウ内または Unity でビルドされたアプリケーション内に、ドラッグアンドドロップ UI を作成できます。この例では、カスタムエディターウィンドウ内に、 ListView と TreeView を使ってドラッグアンドドロップ UI を作成する方法を示します。
この例では、カスタムエディターウィンドウ内に 1 つのロビーと 2 つのチームを持つ分割ウィンドウを作成します。ロビーは ListView で作成します。デモの目的で、MultiColumnListView を使用して 1 つのチームを作成し、TreeView を使用してもう 1 つのチームを作成します。例では、Toggle を使用してドラッグアンドドロップ操作を有効にしたり無効にしたりします。有効にすると、以下のように、プレイヤーをドラッグして並べ替えたり、ロビーリストからチームリストにドラッグしたりできます。
この例で作成する完成したファイルは、こちらの GitHub リポジトリ にあります。
このガイドは、Unity エディター、UI Toolkit、および C# スクリプトに精通している開発者を対象としています。始める前に、以下の点を理解しておいてください。
まず、ロビー内のプレイヤーのリストを管理するアセットを作成します。プレイヤーのデータを表す PlayerData の構造体を定義するスクリプトを作成します。この構造体には、文字列名、整数、Texture2D オブジェクトアイコンの 3 つのフィールドがあります。フィールドを [SerializeField] 属性でマークし、その値をシリアライズして Unity のデータ形式で保存できるようにします。ドラッグアンドドロップ UI のプレイヤーデータを管理するコレクションデータベースアセットを作成します。コレクションデータベースアセットには、Unity エディターで設定できる PlayerData オブジェクトのシリアライズされたリストを持たせます。
Unity で任意のテンプレートを使用してプロジェクトを作成します。
Project ウィンドウの Assets フォルダーに、スクリプトファイルを保存する Scripts という名前のフォルダーを作成します。
Scripts フォルダーに、Data という名前のフォルダーを作成します。
Data フォルダーに、以下の内容の C# スクリプトを作成し PlayerData.cs と命名します。
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()}";
}
}
}
Data フォルダーに、以下の内容の C# スクリプトを作成し CollectionDatabase.cs と命名します。
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;
}
}
Assets フォルダーに、Resources という名前のフォルダーを作成します。
Resources フォルダーを右クリックし、Create > Collection Database を選択します。これにより、新しいコレクションデータベースアセットが作成されます。
コレクションデータベースアセットの Inspector ウィンドウで、Lobby リストにプレイヤーを何人か追加します。プレイヤーは何人でも追加できます。
プレイヤーのデータを表示する PlayerDataElement と PlayerItemView という名前のカスタムコントロールを作成します。PlayerItemView コントロールは、データコンテキストとして PlayerData オブジェクトにバインドします。
Scripts フォルダーに、UI という名前のフォルダーを作成します。
UI フォルダーに、以下の内容の C# スクリプトを作成し PlayerDataElement.cs と命名します。
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;
}
}
}
UI フォルダーに、以下の内容の C# スクリプトを作成し PlayerItemView.cs と命名します。
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 ファイルを作成します。2 つの UXML ドキュメントを作成し、プレイヤーアイテムビューとメインビューの UI レイアウトを定義します。メインビューで、リストアイテムをドラッグして並べ替えられるようにするには、ListView、MultiColumnListView、TreeView の reorderable 属性を true に設定します。
Assets フォルダーに、UXML ファイルと USS ファイルを格納する UI という名前のフォルダーを作成します。
UI フォルダーに、以下の内容の USS ファイルを作成し main.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;
}
UI フォルダーに、以下の内容の UXML ファイルを作成し PlayerItemView.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>
UI フォルダーに、以下の内容の UXML ファイルを作成し ListDragAndDropTestWindow.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>
ロビーとチームのリストを設定して先に作成したプレイヤーデータにバインドする、スクリプトを作成します。このスクリプトで、ロビーとチームのリスト間のドラッグアンドドロップ操作も実装します。
Scripts フォルダーに、Controllers という名前のフォルダーを作成します。
Controllers フォルダーに、以下の内容の C# スクリプトを作成し LobbyController.cs と命名します。
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 を表示するカスタムエディターウィンドウを作成します。
Assets フォルダーに、Editor という名前のフォルダーを作成します。
Editor フォルダーに、以下の内容の C# スクリプトを作成し ListDragAndDropTestWindow.cs と命名します。
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);
}
}
}
テストするには、Lobby リスト内のプレイヤーの順序を変更し、Lobby Owner チェックボックスを選択しているときに Lobby リストからチームリストにプレイヤーを移動します。Red チームリストのプレイヤーの階層を変更することもできます。LobbyController.cs スクリプトで設定された条件に基づいて、各チームに最大 3 人のプレイヤーを追加できます。