Editor

[Unity, Editor] SOManagementWindow 제작 일기 Part.1

HK1206 2025. 4. 25. 10:49

SO를 가져와서 정리해주는 EditorWindow를 만들어 봤습니다.

 

기능 소개

그림을 보기 전에 글로 간단하게 소개하자면 TypeTable에 중복 되는 SO가 아닌 것을 하나씩 넣어주고 Path를 지정해준 뒤 Window를 열면 하나씩 넣어준 SO들이 전부 가져와서 정리되어 있는 것을 볼 수 있다. 그리고 그 가져온 SO들을 지워주거나 이름을 바꾸거나 값들을 수정할 수 있고 지정해준 Path로 생성을 할 수 도 있다.

구현부

구현 같은 경우에는 중요한 함수들을 하나씩 설명해드리겠습니다.

 

SOTypeTable

먼저 Scriptable Object 들의 Type들을 담아 주는 Table SO를 구현하였습니다.

왜냐하면 Type들을 컨테이너로 담아 관리 하는 것이 편하다고 판단했기 때문입니다.

using System;
using System.Collections.Generic;
using UnityEngine;

namespace Editors.SO
{
    [Serializable]
    public struct SOinfo
    {
        public ScriptableObject so;
        public string filePath;
    }
    
    [CreateAssetMenu(fileName = "SOTypeTable", menuName = "SO/LJS/Editor/SOTypeTable")]
    public class SOTypeTable : ScriptableObject
    {
        [field:SerializeField] public List<SOinfo> SOTypeList = new();
        [HideInInspector] public readonly List<Type> _typeList = new();

        public void OnValidate()
        {
            _typeList.Clear();
            foreach (var type in SOTypeList)
            {
                if (!_typeList.Contains(type.so.GetType()))
                {
                    _typeList.Add(type.so.GetType());
                }
                else
                    Debug.LogError($"{type.so.GetType().Name} is already registered please not insert this type Scriptable Object");
            }
        }

        public string ReturnPath(Type type)
        {
            foreach (var item in SOTypeList)
            {
                if(item.so.GetType() == type) return item.filePath;
            }

            return "Unknown";
        }
    }
}

 

먼저 ScriptableObject와 String 변수를 가지고 있는 구조체를 선언합니다. 각각 설명을 하자면 SO는 Type 불러오는 용도 이고 Path는 생성할 SO 위치입니다.

그리고 그 구조체(SOInfo)를 담는 List를 만들어주고 Type를 담는 List를 만들어서 구조체 리스트가 변경될 때마다 받아온 구조체에 SO에 Type를 List에 넣어줍니다. 그리고 Type를 넣어주면 경로를 반환하는 함수를 만들어두었습니다.

 

SOManagement : CreateGUI

/// <summary>
/// 기본 설정
/// </summary>
public void CreateGUI()
{
    VisualElement root = rootVisualElement;
    root.style.flexGrow = 1;

    VisualElement labelFromUXML = m_VisualTreeAsset.Instantiate();
    root.Add(labelFromUXML);

    _tabView = root.Q<TabView>("SOTypeToolbar");
    _tabView.activeTabChanged += ChangeCurrentTabEvent;

    Tab tab = null;
    for (int i = 0; i < _soTypeTable._typeList.Count; ++i)
    {
        if(_soTypeTable._typeList[i] == null) continue;
        
        var template = _tabSplitView.Instantiate().Q<VisualElement>();
        string tabName = _soTypeTable._typeList[i].Name;

        tab = new Tab();
        tab.Add(template);
        tab.label = tabName;
        tab.name = tabName;

        tab.Q<Button>("MakeBtn").clicked += HandleMakeBtnClickEvent;
        tab.Q<Button>("DeleteBtn").clicked += HandleDeleteBtnClickEvent;
        tab.Q<Button>("RenameBtn").clicked += HandleRenameBtnClickEvent;

        if (_currentTab == null) _currentTab = tab;
        _tabView.Add(tab);
        _typeDict.Add(tab, _soTypeTable._typeList[i]);
        _createdTabList.Add(tab);
        
        ViewTabSetting(tab, _soTypeTable._typeList[i].Name);
    }

    _isEndStartSetting = true;
}

 

먼저 기본 EditorWindow의 기본 설정을 한 뒤 Tab들을 담는 TabView를 먼저 Q를 사용하여 탐색하여 넣어줍니다. 그 다음 Tab이 변경 될 때 발생한 이벤트를 구독합니다. (이벤트 함수는 후술함)

VisualElement root = rootVisualElement;
root.style.flexGrow = 1;

VisualElement labelFromUXML = m_VisualTreeAsset.Instantiate();
root.Add(labelFromUXML);

_tabView = root.Q<TabView>("SOTypeToolbar");
_tabView.activeTabChanged += ChangeCurrentTabEvent;

 

그 다음 TypeTable에서 Type를 하나씩 돌면서 Tab를 생성해주고 TabView에 넣어줍니다. 그리고 여러가지 기능에 쓰이는 Dict에 넣어줍니다.

그리고 Button들도 미리 구독을 해둡니다.

Tab tab = null;
for (int i = 0; i < _soTypeTable._typeList.Count; ++i)
{
    if(_soTypeTable._typeList[i] == null) continue;

    var template = _tabSplitView.Instantiate().Q<VisualElement>();
    string tabName = _soTypeTable._typeList[i].Name;

    tab = new Tab();
    tab.Add(template);
    tab.label = tabName;
    tab.name = tabName;

    tab.Q<Button>("MakeBtn").clicked += HandleMakeBtnClickEvent;
    tab.Q<Button>("DeleteBtn").clicked += HandleDeleteBtnClickEvent;
    tab.Q<Button>("RenameBtn").clicked += HandleRenameBtnClickEvent;

    if (_currentTab == null) _currentTab = tab;
    _tabView.Add(tab);
    _typeDict.Add(tab, _soTypeTable._typeList[i]);
    _createdTabList.Add(tab);

    ViewTabSetting(tab, _soTypeTable._typeList[i].Name);
}

 

SOManagement : ViewTabSetting

/// <summary>
/// Tab의 그릴 것을 모두 그려주는 함수 FindAssets 함수를 활용하여 특정 SO를 전부 가져온다.
/// 그리고 가져온 GUID를 경로로 변환하여 불러온뒤 List와 Dict에 저장시켜준다.
/// 그리고 가져온 SO을 통해서 ViewItem 함수를 호출함
/// </summary>
/// <param name="tab">그릴 Tab</param>
/// <param name="type">Scriptable Object Type to String</param>
private void ViewTabSetting(Tab tab, string type)
{
    if(_createdChildTabList.Contains(tab)) return;

    _createdChildTabList.Add(tab);
    List<ScriptableObject> spawnSoList = new();
    _itemView = tab.Q<VisualElement>("ItemVisualList");

    StringBuilder strBuilder = new StringBuilder();
    strBuilder.Append("t:");
    strBuilder.Append(type);
    string[] soPathArray = AssetDatabase.FindAssets(strBuilder.ToString());
    string path = "";
    for (int i = 0; i < soPathArray.Length; ++i)
    {
        path = AssetDatabase.GUIDToAssetPath(soPathArray[i]);
        ScriptableObject so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
        spawnSoList.Add(so);
        _soPathDict.Add(so, path);
        if(so != null)
            ViewItem(so.name, so);
    }
    _soDataDictionary.Add(type, spawnSoList);
}

먼저 이미 만들어진 Tab이라면 걸러줍니다.

if(_createdChildTabList.Contains(tab)) return;

 

아니라면 자식을 만든 Tab List에 넣어주고 자식 항목들을 만들 준비를 합니다.

그 다음 StringBuilder를 이용해서 두 문자열을 이어주고 AssetDatabase.FindAssets 함수를 사용해서 받아온 Type의 SO를 Project 폴더에서 전부 찾아서 가져옵니다. 이 함수는 GUID를 사용해서 가져오므로 GUIDToAssetPath를 사용해서 Path로 변경해준 뒤 LoadAsset를 사용해서 결과적으로 List에 넣어준다. ViewItem를 Visual을 만들어 줍니다.

_createdChildTabList.Add(tab);
List<ScriptableObject> spawnSoList = new();
_itemView = tab.Q<VisualElement>("ItemVisualList");

StringBuilder strBuilder = new StringBuilder();
strBuilder.Append("t:");
strBuilder.Append(type);
string[] soPathArray = AssetDatabase.FindAssets(strBuilder.ToString());
string path = "";
for (int i = 0; i < soPathArray.Length; ++i)
{
    path = AssetDatabase.GUIDToAssetPath(soPathArray[i]);
    ScriptableObject so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
    spawnSoList.Add(so);
    _soPathDict.Add(so, path);
    if(so != null)
        ViewItem(so.name, so);
}

 

SOManagement : ViewItem

/// <summary>
/// 실제 표기될 SO 항목들을 그려주는 함수 실제로 Element과 Label를 생성하고 USS Class를 넣어준다.
/// 그 뒤 마우스 다운 이벤트를 등록해준다.
/// </summary>
/// <param name="vName">이름</param>
/// <param name="so">데이터</param>
public void ViewItem(string vName, ScriptableObject so){
    VisualElement element = new VisualElement();
    element.AddToClassList(_itemVisual);
    element.name = vName;
    element.tooltip = GUID.Generate().ToString();
    _elementDataDictionary.Add(element.tooltip, so);

    // Mouse Down Event
    element.RegisterCallback<PointerDownEvent>(OnPointerDownInElementEvent);

    Label label = new Label();
    label.name = "label";
    label.AddToClassList(_itemLabel);
    label.text = vName;

    element.Add(label);
    _itemView.Add(element);
}

간단합니다. 동적으로 생성을 해주고 USSClass를 넣어줍니다.

그리고 이벤트만 등록해주면 됩니다.

 

SOManagement : Event Methods

ChangeCurrentTabEvent

/// <summary>
/// Tab이 변경 되었을 때 호출되는 함수
/// 현재 탭을 변경해준다.
/// </summary>
/// <param name="arg1">변경 되기 전 Tab</param>
/// <param name="arg2">변경 후 Tab</param>
private void ChangeCurrentTabEvent(Tab arg1, Tab arg2)
{
    if (!_isEndStartSetting) return;
    _currentTab = arg2;
    _selectedLabel = _currentTab.Q<Label>("NameLabel");

    // ViewTabSetting(_tabView.activeTab, 
    //     _soDataDictionary[_tabView.activeTab.label][0].GetType().Name);
}

 

현재 Tab를 변경해주고 NameLabel(Tab마다 NameLabel 다르기 때문에)를 다시 Q로 받아옵니다.

 

OnPointerDownInElementEvent

/// <summary>
/// 선택 되었을 때 USS Class를 선택 전용 USS Class로 바꿔준다.
/// 그 뒤 CachedEditor와 연결시켜줌
/// </summary>
/// <param name="evt">이벤트에서 반환된 정보들</param>
private void OnPointerDownInElementEvent(PointerDownEvent evt)
{
    if (_selectedLabel == null)
        _selectedLabel = _currentTab.Q<Label>("NameLabel");

    foreach(var item in _soDataDictionary[_currentTab.label]){
        VisualElement element = _currentTab.Q<VisualElement>(item.name);

        _cachedGUI = _currentTab.Q<IMGUIContainer>();
        List<string> classNames = element.GetClasses().ToList();

        // 모든 SelectEffect가 적용된 VisualElement에 Effect를 제거한다.
        foreach(string str in classNames){
            if(str == _itemVisualSelect){
                element.AddToClassList(_itemVisual);
                element.RemoveFromClassList(_itemVisualSelect);
            }
        }

        // 클릭한 위치의 VisualElement에 Effect를 부여하고
        // Toolbar Label의 Value를 바꾸어 준다.
        if (evt.currentTarget.GetHashCode() == element.GetHashCode())
        {
            element.RemoveFromClassList(_itemVisual);
            element.AddToClassList(_itemVisualSelect);
            _selectedLabel.text = item.name;
            _selected = element;

            ValueListBinding(item);
        }
    }
}

 

항목을 클릭했을 때 발행 되는 이벤트 입니다.

먼저 만일 SelectLabel이 null이라면 Q로 받아와 줍니다. 그리고 IMGUIContainer를 Q로 들고 옵니다. (CachedEditor 표기할려고 하는 거임)

그런 뒤 현재 Tab이 가지고 있는 SOList를 전부 돌면서 현재 선택된 항목을 찾습니다.

만일 이 항목이 아니라면 다른 항목에 있는 SelectUSSClass를 지워주고 다시 기존 상태로 변경합니다. 항목이 맞다면 SelectUSSClass를 넣어주고 기존 USSClass를 지워줍니다. 그리고 SelectLabel의 Text를 선택된 항목의 이름으로 바꿔주고 현재 선택된 VisualElement를 항목으로 선택해줍니다. 그 다음에 Binding 작업을 진행합니다.(Binding은 후술함)

 

Btn Events

private void HandleRenameBtnClickEvent()
{
    if (_currentTab == null)
    {
        Debug.LogError("Tab is not select");
        return;
    }

    if(_selected == null) return;

    string path = _soPathDict[_currentData];
    ScriptableObject changeTarget = AssetDatabase.LoadAssetAtPath<ScriptableObject>(
        path);

    AssetDatabase.RenameAsset($"path",
        _fileNameField.text);

    _itemView.Remove(_selected);
    ViewItem(changeTarget.name, changeTarget);
}

private void HandleDeleteBtnClickEvent()
{
    if (_currentTab == null)
    {
        Debug.LogError("Tab is not select");
        return;
    }

    if (_selected == null)
    {
        Debug.LogError("Select None");
        return;
    }

    // 선택된 Item 삭제
    string path = _soPathDict[_currentData];
    Debug.Log(path);
    ScriptableObject changeTarget = AssetDatabase.LoadAssetAtPath<ScriptableObject>(
        path);

    VisualElement deleteElement = _itemView.Q<VisualElement>(_selected.name);
    _itemView.Remove(deleteElement);

    _elementDataDictionary.Remove(_selected.name);
    _soDataDictionary[_currentTab.label].Remove(changeTarget);

    AssetDatabase.DeleteAsset(path);
    EditorUtility.SetDirty(changeTarget);
    AssetDatabase.SaveAssets();

    Editor cachedEditor = _cachedEditorDict[changeTarget];
    if (cachedEditor != null)
    {
        _cachedEditorDict.Remove(changeTarget);
        DestroyImmediate(cachedEditor);
    }
}

private void HandleMakeBtnClickEvent()
{
    if (_currentTab == null)
    {
        Debug.LogError("Tab is not select");
        return;
    }

    Type soType = _typeDict[_currentTab];
    string path = _soTypeTable.ReturnPath(soType);

    _soCreator = GetWindow<SOCreator>();
    _soCreator.titleContent = new GUIContent("SOCreator");
    _soCreator.minSize = new Vector2(300f, 75f);
    _soCreator.maxSize = new Vector2(300f, 75f);
    _soCreator.SettingInfo(soType, path, HandleCreateSOEvent);
}

 

버튼 이벤트들입니다. 

Rename은 경로를 들고와서 이름을 바꿔주고 다시 Visual을 Refresh하는 작업을 하고

Delete 경로를 들고와서 지워주고 Visual을 없애주는 작업을 합니다.

Make는 따로 다루도록 하겠습니다. (새로운 EditorWindow를 열어 하기 때문에)

 

ValueListBinding

/// <summary>
/// SO를 가져와 CachedEditor를 사용해서 IMGUIConationer에 표기 시켜줌
/// </summary>
/// <param name="item"> 현재 표기 할 SO Data</param>
private void ValueListBinding(ScriptableObject item)
{
    Editor cachedEditor = null;
    _cachedGUI.onGUIHandler = () =>
    {
        if (item != null)
        {
            Editor.CreateCachedEditor(item, null, ref cachedEditor);
            if (cachedEditor != null)
            {
                _currentData = item;
                cachedEditor.OnInspectorGUI();
                _cachedEditorDict.TryAdd(item, cachedEditor);
            }
        }
    };
}

CachedEditor 생성을 하고 IMGUIConatiner에 연결해줍니다.

IMGUIConatiner는 다양한 GUI들을 띄울 수 있는 컨테이너입니다. 자세한 내용은 유니티 공식문서를 참고 해주세요

https://docs.unity3d.com/kr/2022.3/Manual/UIE-uxml-element-IMGUIContainer.html

 

UXML 요소 IMGUIContainer - Unity 매뉴얼

C# 클래스:IMGUIContainer 네임스페이스:UnityEngine.UIElements 기본 클래스:VisualElement

docs.unity3d.com

 

SOManagement : Disable

private void OnDisable()
{
    for (int i = 0; i < _createdTabList.Count; i++)
    {
        _createdTabList[i].Q<Button>("MakeBtn").clicked -= HandleMakeBtnClickEvent;
        _createdTabList[i].Q<Button>("DeleteBtn").clicked -= HandleDeleteBtnClickEvent;
        _createdTabList[i].Q<Button>("RenameBtn").clicked -= HandleRenameBtnClickEvent;
    }
}

버튼 이벤트 구독 취소를 해줍니다.

 

Make 하는 과정

private void HandleMakeBtnClickEvent()
{
    if (_currentTab == null)
    {
        Debug.LogError("Tab is not select");
        return;
    }

    Type soType = _typeDict[_currentTab];
    string path = _soTypeTable.ReturnPath(soType);

    _soCreator = GetWindow<SOCreator>();
    _soCreator.titleContent = new GUIContent("SOCreator");
    _soCreator.minSize = new Vector2(300f, 75f);
    _soCreator.maxSize = new Vector2(300f, 75f);
    _soCreator.SettingInfo(soType, path, HandleCreateSOEvent);
}

여기서 새로운 SOCreator라는 EditorWindow를 새로 열고 생성할 Type, Path, 생성이 끝난 뒤 실행할 함수를 넘겨줍니다.

using System;
using System.ComponentModel;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;

public class SOCreator : EditorWindow
{
    [SerializeField]
    private VisualTreeAsset m_VisualTreeAsset = default;

    private Action<ScriptableObject> _endCallback;

    #region MyElements
    
        private TextField _nameField;
        private Button _createBtn;
        private Button _cancelBtn;
        
    #endregion

    /// <summary>
    /// 생성할 SO의 Type
    /// </summary>
    private Type _createSOType;
    /// <summary>
    /// 생성할 위치
    /// </summary>
    private string _createPath;

    public void CreateGUI()
    {
        VisualElement root = rootVisualElement;
        root.style.flexGrow = 1;
        
        VisualElement labelFromUXML = m_VisualTreeAsset.Instantiate();
        root.Add(labelFromUXML);
        
        _nameField = root.Q<TextField>("NameField");
        _createBtn = root.Q<Button>("CreateBtn");
        _cancelBtn = root.Q<Button>("CancelBtn");

        _createBtn.clicked += HandleCreateBtnClickEvent;
        _cancelBtn.clicked += HandleCancelBtnClickEvent;
    }

    /// <summary>
    /// 취소 버튼 클릭 이벤트
    /// </summary>
    private void HandleCancelBtnClickEvent()
    {
        Close();
    }

    /// <summary>
    /// 생성 버튼 클릭 이벤트
    /// </summary>
    private void HandleCreateBtnClickEvent()
    {
        ScriptableObject newItem = CreateInstance(_createSOType);
        Guid typeGuid = Guid.NewGuid();
        if (_nameField.text.Length == 0)
        {
            newItem.name = typeGuid.ToString();
            Debug.Log($"a random GUID was assigned due to the absence of input.");
            Debug.Log($"random GUID : {typeGuid}");
        }
        else
        {
            newItem.name = _nameField.text;
        }
        
        AssetDatabase.CreateAsset(newItem,
            $"{_createPath}/{newItem.name}.asset");
        
        Debug.
            Log($"Scucess Create Item, Name : {newItem.name} Path : {_createPath}/{newItem.name}.asset");
        
        AssetDatabase.SaveAssets();
        
        _endCallback?.Invoke(newItem);
        Close();
    }

    public void SettingInfo(Type createType, string path, Action<ScriptableObject> endCallback)
    {
        _createSOType = createType;
        _createPath = path;
        _endCallback = endCallback;
    }

    private void OnDisable()
    {
        _createBtn.clicked -= HandleCreateBtnClickEvent;
        _cancelBtn.clicked -= HandleCancelBtnClickEvent;
    }
}

읽어보면 이해가 되겠지만 생성 로직 빼곤 뭐가 없는 Script입니다.

이렇게 생겼음

전체 코드

전체 코드입니다.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Animancer.Editor;
using Editors.SO;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;

public class SOManagementWindow : EditorWindow
{

    #region Path OR UssClassName
    
    private readonly string _itemVisual = "item-visual";
    private readonly string _itemLabel = "item-label";
    private readonly string _itemVisualSelect = "item-visual-select";
    private readonly string _itemFilePath = "Assets/00Work/LJS/05_SO/Item";

    #endregion
    
    #region UxmlTemplate

    [SerializeField] private VisualTreeAsset m_VisualTreeAsset = default;
    [SerializeField] private VisualTreeAsset _tabSplitView;

    #endregion
    
    /// <summary>
    /// 생성할 SO의 Type들을 모은 Table
    /// </summary>
    [SerializeField] private SOTypeTable _soTypeTable;
    /// <summary>
    /// ScriptableObject를 생성하는 Window
    /// </summary>
    private SOCreator _soCreator;
    
    /// <summary>
    /// 현재 SO Data
    /// </summary>
    private ScriptableObject _currentData;

    #region MyElements

    /// <summary>
    /// CachedEditor 그리는 용도
    /// </summary>
    private IMGUIContainer _cachedGUI;
    private TabView _tabView;
    private Tab _currentTab;
    private VisualElement _itemView;
    private Label _selectedLabel;
    private TextField _fileNameField;
    private VisualElement _selected;
    #endregion
    
    /// <summary>
    /// 생성된 element가 들고 있는 SOData Dict
    /// </summary>
    private Dictionary<string, ScriptableObject> _elementDataDictionary = new();
    /// <summary>
    /// 각각의 Tab이 들고 SOData List Dict
    /// </summary>
    private Dictionary<string, List<ScriptableObject>> _soDataDictionary = new();
    /// <summary>
    /// SO의 실제 경로를 담고 있는 Dict
    /// </summary>
    private Dictionary<ScriptableObject, string> _soPathDict = new();
    /// <summary>
    /// 생성된 Cached Editor을 담고 있는 Dict
    /// </summary>
    private Dictionary<ScriptableObject, Editor> _cachedEditorDict = new();
    /// <summary>
    /// Tab에 따른 Type을 들고 올 수 있는 Dict
    /// </summary>
    private Dictionary<Tab, Type> _typeDict = new();

    /// <summary>
    /// 현재 생성된 Tab이 들어오는 List
    /// </summary>
    private List<Tab> _createdTabList = new();
    /// <summary>
    /// Child가 전부 생성된 TabList 새롭게 자식이 추가 된다면 Remove 됨
    /// 따라서 두번 Child 생성하는 일을 방지함
    /// </summary>
    private List<Tab> _createdChildTabList = new();

    /// <summary>
    /// 기초 설정이 종료 됬는가?
    /// </summary>
    private bool _isEndStartSetting = false;

    public SOManagementWindow()
    {
        if (_soTypeTable == null)
        {
            string[] soGuidArray = AssetDatabase.FindAssets("t:SOTypeTable");
            string path = "s";
            if (soGuidArray.Length > 2)
            {
                Debug.LogError("SOTypeTable More than one has been created. Please reduce it to one.");
                Close();
                return;
            }
        }
    }
    
    public static void ShowExample()
    {
        SOManagementWindow wnd = GetWindow<SOManagementWindow>();
        wnd.titleContent = new GUIContent("SOManagementWindow");
        wnd.maxSize = new Vector2(900, 600);
        wnd.minSize = new Vector2(900, 600);
    }

    /// <summary>
    /// 기본 설정
    /// </summary>
    public void CreateGUI()
    {
        VisualElement root = rootVisualElement;
        root.style.flexGrow = 1;

        VisualElement labelFromUXML = m_VisualTreeAsset.Instantiate();
        root.Add(labelFromUXML);

        _tabView = root.Q<TabView>("SOTypeToolbar");
        _tabView.activeTabChanged += ChangeCurrentTabEvent;

        Tab tab = null;
        for (int i = 0; i < _soTypeTable._typeList.Count; ++i)
        {
            if(_soTypeTable._typeList[i] == null) continue;
            
            var template = _tabSplitView.Instantiate().Q<VisualElement>();
            string tabName = _soTypeTable._typeList[i].Name;

            tab = new Tab();
            tab.Add(template);
            tab.label = tabName;
            tab.name = tabName;

            tab.Q<Button>("MakeBtn").clicked += HandleMakeBtnClickEvent;
            tab.Q<Button>("DeleteBtn").clicked += HandleDeleteBtnClickEvent;
            tab.Q<Button>("RenameBtn").clicked += HandleRenameBtnClickEvent;

            if (_currentTab == null) _currentTab = tab;
            _tabView.Add(tab);
            _typeDict.Add(tab, _soTypeTable._typeList[i]);
            _createdTabList.Add(tab);
            
            ViewTabSetting(tab, _soTypeTable._typeList[i].Name);
        }

        _isEndStartSetting = true;
    }

    /// <summary>
    /// Tab의 그릴 것을 모두 그려주는 함수 FindAssets 함수를 활용하여 특정 SO를 전부 가져온다.
    /// 그리고 가져온 GUID를 경로로 변환하여 불러온뒤 List와 Dict에 저장시켜준다.
    /// 그리고 가져온 SO을 통해서 ViewItem 함수를 호출함
    /// </summary>
    /// <param name="tab">그릴 Tab</param>
    /// <param name="type">Scriptable Object Type to String</param>
    private void ViewTabSetting(Tab tab, string type)
    {
        if(_createdChildTabList.Contains(tab)) return;
        
        _createdChildTabList.Add(tab);
        List<ScriptableObject> spawnSoList = new();
        _itemView = tab.Q<VisualElement>("ItemVisualList");
        
        StringBuilder strBuilder = new StringBuilder();
        strBuilder.Append("t:");
        strBuilder.Append(type);
        string[] soPathArray = AssetDatabase.FindAssets(strBuilder.ToString());
        string path = "";
        for (int i = 0; i < soPathArray.Length; ++i)
        {
            path = AssetDatabase.GUIDToAssetPath(soPathArray[i]);
            ScriptableObject so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
            spawnSoList.Add(so);
            _soPathDict.Add(so, path);
            if(so != null)
                ViewItem(so.name, so);
        }
        _soDataDictionary.Add(type, spawnSoList);
    }
    
    /// <summary>
    /// 실제 표기될 SO 항목들을 그려주는 함수 실제로 Element과 Label를 생성하고 USS Class를 넣어준다.
    /// 그 뒤 마우스 다운 이벤트를 등록해준다.
    /// </summary>
    /// <param name="vName">이름</param>
    /// <param name="so">데이터</param>
    public void ViewItem(string vName, ScriptableObject so){
        VisualElement element = new VisualElement();
        element.AddToClassList(_itemVisual);
        element.name = vName;
        element.tooltip = GUID.Generate().ToString();
        _elementDataDictionary.Add(element.tooltip, so);
        
        // Mouse Down Event
        element.RegisterCallback<PointerDownEvent>(OnPointerDownInElementEvent);
        
        Label label = new Label();
        label.name = "label";
        label.AddToClassList(_itemLabel);
        label.text = vName;
        
        element.Add(label);
        _itemView.Add(element);
    }

    #region EventMethod
    
    /// <summary>
    /// Tab이 변경 되었을 때 호출되는 함수
    /// 현재 탭을 변경해준다.
    /// </summary>
    /// <param name="arg1">변경 되기 전 Tab</param>
    /// <param name="arg2">변경 후 Tab</param>
    private void ChangeCurrentTabEvent(Tab arg1, Tab arg2)
    {
        if (!_isEndStartSetting) return;
        _currentTab = arg2;
        _selectedLabel = _currentTab.Q<Label>("NameLabel");
        
        // ViewTabSetting(_tabView.activeTab, 
        //     _soDataDictionary[_tabView.activeTab.label][0].GetType().Name);
    }
    
    /// <summary>
    /// 선택 되었을 때 USS Class를 선택 전용 USS Class로 바꿔준다.
    /// 그 뒤 CachedEditor와 연결시켜줌
    /// </summary>
    /// <param name="evt">이벤트에서 반환된 정보들</param>
    private void OnPointerDownInElementEvent(PointerDownEvent evt)
    {
        if (_selectedLabel == null)
            _selectedLabel = _currentTab.Q<Label>("NameLabel");
        
        foreach(var item in _soDataDictionary[_currentTab.label]){
            VisualElement element = _currentTab.Q<VisualElement>(item.name);
            
            _cachedGUI = _currentTab.Q<IMGUIContainer>();
            List<string> classNames = element.GetClasses().ToList();
            
            // 모든 SelectEffect가 적용된 VisualElement에 Effect를 제거한다.
            foreach(string str in classNames){
                if(str == _itemVisualSelect){
                    element.AddToClassList(_itemVisual);
                    element.RemoveFromClassList(_itemVisualSelect);
                }
            }

            // 클릭한 위치의 VisualElement에 Effect를 부여하고
            // Toolbar Label의 Value를 바꾸어 준다.
            if (evt.currentTarget.GetHashCode() == element.GetHashCode())
            {
                element.RemoveFromClassList(_itemVisual);
                element.AddToClassList(_itemVisualSelect);
                _selectedLabel.text = item.name;
                _selected = element;
                
                ValueListBinding(item);
            }
        }
    }
    
    private void HandleRenameBtnClickEvent()
    {
        if (_currentTab == null)
        {
            Debug.LogError("Tab is not select");
            return;
        }
        
        if(_selected == null) return;
        
        string path = _soPathDict[_currentData];
        ScriptableObject changeTarget = AssetDatabase.LoadAssetAtPath<ScriptableObject>(
            path);

        AssetDatabase.RenameAsset($"path",
            _fileNameField.text);
        
        _itemView.Remove(_selected);
        ViewItem(changeTarget.name, changeTarget);
    }

    private void HandleDeleteBtnClickEvent()
    {
        if (_currentTab == null)
        {
            Debug.LogError("Tab is not select");
            return;
        }
        
        if (_selected == null)
        {
            Debug.LogError("Select None");
            return;
        }
        
        // 선택된 Item 삭제
        string path = _soPathDict[_currentData];
        Debug.Log(path);
        ScriptableObject changeTarget = AssetDatabase.LoadAssetAtPath<ScriptableObject>(
            path);

        VisualElement deleteElement = _itemView.Q<VisualElement>(_selected.name);
        _itemView.Remove(deleteElement);

        _elementDataDictionary.Remove(_selected.name);
        _soDataDictionary[_currentTab.label].Remove(changeTarget);
        
        AssetDatabase.DeleteAsset(path);
        EditorUtility.SetDirty(changeTarget);
        AssetDatabase.SaveAssets();

        Editor cachedEditor = _cachedEditorDict[changeTarget];
        if (cachedEditor != null)
        {
            _cachedEditorDict.Remove(changeTarget);
            DestroyImmediate(cachedEditor);
        }
    }

    private void HandleMakeBtnClickEvent()
    {
        if (_currentTab == null)
        {
            Debug.LogError("Tab is not select");
            return;
        }
        
        Type soType = _typeDict[_currentTab];
        string path = _soTypeTable.ReturnPath(soType);

        _soCreator = GetWindow<SOCreator>();
        _soCreator.titleContent = new GUIContent("SOCreator");
        _soCreator.minSize = new Vector2(300f, 75f);
        _soCreator.maxSize = new Vector2(300f, 75f);
        _soCreator.SettingInfo(soType, path, HandleCreateSOEvent);
    }
    
    /// <summary>
    /// 새로운 SO를 생성할 때 호출되는 Method
    /// SO Data Dict에 넣어주고 비주얼을 그려줌
    /// </summary>
    /// <param name="item"></param>
    private void HandleCreateSOEvent(ScriptableObject item)
    {
        _soDataDictionary[_currentTab.label].Add(item);
        _soPathDict.Add(item, AssetDatabase.GetAssetPath(item));
        ViewItem(item.name, item);
    }
    
    #endregion
    
    /// <summary>
    /// SO를 가져와 CachedEditor를 사용해서 IMGUIConationer에 표기 시켜줌
    /// </summary>
    /// <param name="item"> 현재 표기 할 SO Data</param>
    private void ValueListBinding(ScriptableObject item)
    {
        Editor cachedEditor = null;
        _cachedGUI.onGUIHandler = () =>
        {
            if (item != null)
            {
                Editor.CreateCachedEditor(item, null, ref cachedEditor);
                if (cachedEditor != null)
                {
                    _currentData = item;
                    cachedEditor.OnInspectorGUI();
                    _cachedEditorDict.TryAdd(item, cachedEditor);
                }
            }
        };
    }

    public void OnDisable()
    {
        for (int i = 0; i < _createdTabList.Count; i++)
        {
            _createdTabList[i].Q<Button>("MakeBtn").clicked -= HandleMakeBtnClickEvent;
            _createdTabList[i].Q<Button>("DeleteBtn").clicked -= HandleDeleteBtnClickEvent;
            _createdTabList[i].Q<Button>("RenameBtn").clicked -= HandleRenameBtnClickEvent;
        }
    }
}

USS 파일과 UXML 파일은 GitHub에 올라가 있습니다.

https://github.com/ljs1206/GraduationWork_Script/tree/main/Scripts/Editor

 

문제점

기존에는 이 상단 부분을 UIToolkit의 TabView 기능을 활용하여 구현하였습니다.

하지만 TabView는 Tab이 Window 바깥으로 나가거나 겹치는 문제가 발생했고 이는 Scroller를 활용해서 해결해야 했습니다. 하지만 TabView내에서 Tab을 Scroll로 관리할 수 없었고 이에 저는 다른 방식으로 해결을 해야 합니다.

 

이 문제점을 해결하는 방법은 다음 파트에서 다루도록 하겠습니다.