ReorderableList으로 다양한 기능을 가진 List구현하기

Unity3D 2016. 2. 4. 23:58
반응형

유니티 4.5버전에서, 유니티 인스펙터창에 비주얼한 리스트를 만들수 있는 내장 기능이 추가 되었다.

이 기능을 가진 클래스의 이름은 ReorderableList이며, UnityEditorInternal 네임스페이스에 위치하고 있다.

하지만 문서화가 되지 않아 아직까지 홈페이지에서 공식자료를 찾아볼 순 없다.


ReorderableList가 어떤 기능을 가지는지는 다음의 그림을 보면 쉽게 알 수 있다.



그럼 이제 ReorderableList를 이용해서 어떻게 이런 아름다운 인터페이스를 구현할 수 있는지 알아보도록 하자.


Note : UnityEditorInternal 네임스페이스가 Public이지만, 이것은 Unity Team에서의 내부적인 사용을 위한 목적으로 만들어 진 것 같다. 그래서 이와관련해 아직 문서화된 부분이 없으며, 다음 버전에서 변경될 수도 있다.



로젝트 세팅하기


ReorderableList를 구현하기 위해, 일단 타워 디펜스 게임을 만든다고 가정해 보자. 그리고 게임 디자이너가 특정 레벨의 몬스터 웨이브를 설정하기 위한 인터페이스가 필요한 상황을 생각해보자.


우선, 새로운 Unity Project를 만들자.


그리고 구현을 위한 약간의 테스트 에셋이 필요하다. 프로젝트 뷰에 다음과 같은 경로를 만들자. 

(Prefabs/Mobs, Prefabs/Bosses)

그 다음, 밑에 그림처럼 해당 폴더안에 3개정도 임시 Prefab을 만들자.

Prefab들이 어떤 객체들을 담고있는지, 어떤 이름을 가져야하는지는 중요하지 않지만 다음의 튜토리얼을 위해 이름정도는 맞추도록 하자.




데이터 저장하기


스크립트를 저장할 Scripts폴더를 만들고, 폴더에 LevelData.cs와 MobWave.cs 파일을 만들자.


MobWave.cs에 다음의 코드를 작성하자. 이는 몬스터 웨이브에 필요한 데이터를 저장하는 값 객체(구조체)이다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using UnityEngine;  
using System;
 
[Serializable]
public struct MobWave {  
    public enum WaveType {
        Mobs,
        Boss
    }
 
    public WaveType Type;
    public GameObject Prefab;
    public int Count;
}
 
cs


위에서 보듯, 매 웨이브마다 Mobs또는 Bosses를 선택할 수 있다. 그리고 각 웨이브마다 프리팹 링크를 가지고 있어

많은 수를 복제할 수 있다.


Note : Unity 4.5버전부터 Custom Struct도 Serialize할 수 있게 되었다.


계속해서 LevelData.cs에서도 다음의 코드 작성하자. 이는 MobWave 객체를 담는 컨테이너 객체다.


1
2
3
4
5
6
7
using UnityEngine;  
using System.Collections.Generic;
 
public class LevelData : MonoBehaviour {  
    public List<MobWave> Waves = new List<MobWave>();
}
 
cs


다시 Unity로 돌아와서, Scene에서 Data라는 이름의 빈 GameObject를 추가하자.

이 Data GameObject에 방금 작성한 LevelData를 추가하자. 



이는 기본적으로 Unity IDE에서 어떻게 List가 보여지는지를 보여준다. 물론 지금도 나쁜건 아니지만, 리스트가 많거나, 특정 리스트를 중간으로 이동시킬때는 많은 고통을 가져다 줄 것이다.



커스텀 인스펙터 만들기


Unity IDE에서 만들 수 있는 모든 컴포넌트에 대해서, 인스펙터창에 자신이 원하는 형태로 보여지게 편집을 할 수 있다.

그래서 이번에는 LevelData.cs를 Unity에서 제공하는 기본형태에서, ReorderableList를 사용해서 더 기능적인 형태로 바꿔보도록 하자.


일단 Editor폴더를 만들고, 이 폴더에 새로운 스크립트인 LevelDataEditor.cs를 만들자.


Note : 모든 커스텀 인스펙터는 반드시 Editor 폴더에 있어야 하고, Editor 클래스를 상속받아야 한다.


LevelDataEditor.cs에 다음의 코드를 작성하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;  
using UnityEditor;  
using UnityEditorInternal;
 
[CustomEditor(typeof(LevelData))]
public class LevelDataEditor : Editor {  
    private ReorderableList list;
 
    private void OnEnable() {
        list = new ReorderableList(serializedObject, 
                serializedObject.FindProperty("Waves"), 
                truetruetruetrue);
    }
 
    public override void OnInspectorGUI() {
        serializedObject.Update();
        list.DoLayoutList();
        serializedObject.ApplyModifiedProperties();
    }
}
cs


모든 커스텀 인스펙터 클래스는 다음과 같은 중요한 부분들을 포함하고 있다.


1. 모든 커스텀 인스펙터는 반드시 Editor 클래스를 상속받아야 한다.

2. Unity에게 LevelData 컴포넌트에 대해서 작업하고 있다고 알리기 위해, 

반드시[CustomEditor(typeof(LevelData))] 속성을 추가해야 한다.

3. private void OnEnable() 메소드는 초기화를 하는데 사용된다.

4. public override void OnIspectorGUI() 메소드는 인스펙터가 다시 그려질 때 호출된다.


위와 같은 경우에는, OnEnable에서 Waves 속성을 그리기 위해 ReorderableList 인스턴스를 생성했다.

그리고 반드시 UnityEditorInternal 네임스페이스를 추가하는 것을 잊지말아야 한다.


1
using UnityEditorInternal;
cs

ReorderableList는 SerializedProperty뿐만 아니라 일반적인 C# List와도 작동한다.


다음의 두가지 형태의 생성자와, 여기에 여러가지 변형들


1. public ReorderableList(IList elements, Type elementType),

2. public ReorderableList(SerializedObject serializedObject, SerializedProperty elements).


이 예제에서는 커스텀 인스펙터에서 속성과 관련된 작업을 하는데 권장되는 방법인 SerializedProperty를 사용하도록 하겠다. 이는 코드를 더 작게 만들고, Unity와 되돌리기 시스템에서 훌륭하게 작동되게 해줄 것이다.


1
2
3
4
5
6
7
public ReorderableList(  
    SerializedObject serializedObject, 
    SerializedProperty elements, 
    bool draggable, 
    bool displayHeader, 
    bool displayAddButton, 
    bool displayRemoveButton);
cs


위에서 보듯, 매개변수를 활용해서 리스트에 있는 아이템을 추가, 삭제, 순서변경을 하는데 제한을 걸 수 있다.


list가 생성된 이후 list.DoLayoutList() 메소드가 호출되는데 다음과 같은 인터페이스를 나타낸다.



아마 이 모습을 보고 당신은 다음과 같이 말했을 것이다 : "뭐야, 더 보기 불편하잖아!".

잠시만 진정하고, 위에 처럼 보이는 이유는, 우리의 Data 구조가 복잡하기 때문에 

Unity는 어떻게 Data 구조의 속성을 그려야 하는지 알 수가 없기 때문이다.

자, 이제 이를 고쳐보도록 하자.



아이템 리스트 그리기


ReorderableList는 list를 편집하기 위해 여러가지 델리게이트(Delegate)를 가지고 있다.

첫번째로는 drawElementCallback인데, 이는 List 아이템이 그려질 때 호출된다.


계속해서 LevelData.cs에 있는 OnEnable에 다음의 코드를 list뒤에 추가하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
list.drawElementCallback =  
    (Rect rect, int index, bool isActive, bool isFocused) => {
    var element = list.serializedProperty.GetArrayElementAtIndex(index);
    rect.y += 2;
    EditorGUI.PropertyField(
        new Rect(rect.x, rect.y, 60, EditorGUIUtility.singleLineHeight),
        element.FindPropertyRelative("Type"), GUIContent.none);
    EditorGUI.PropertyField(
        new Rect(rect.x + 60, rect.y, rect.width - 60 - 30, EditorGUIUtility.singleLineHeight),
        element.FindPropertyRelative("Prefab"), GUIContent.none);
    EditorGUI.PropertyField(
        new Rect(rect.x + rect.width - 30, rect.y, 30, EditorGUIUtility.singleLineHeight),
        element.FindPropertyRelative("Count"), GUIContent.none);
};
cs


다시 Unity로 돌아가자. 이제 모든 리스트 아이템들이 예쁘고, 기능적으로 변하였다.

새로운 아이템을 추가해보고, 이를 드래깅해서 이리저리 움직여 보자. 훨씬 낫지 않는가?




만약, 위에 코드에 포함된 문법과 API에 익숙하지 않다면 잘 이해가 되지 않을 수도 있는데,

이는 EditorGUI 메소드를 표준 C# 람다-표현식으로 표현한 것이다.


Note : 람다는 세미콜론(;)로 끝나야 한다.


밑에는 화면에 그려지는 아이템 리스트를 얻는 부분이다.


1
var element = list.serializedProperty.GetArrayElementAtIndex(index);
cs


그리고 FindPropertyRelative 메소드를 사용해서 웨이브의 속성을 찾을 수 있다.


1
element.FindPropertyRelative("Type")
cs


다음, 우리는 한 줄에 3가지 속성을 그릴 것이다 : Type, Prefab, Count


1
2
3
EditorGUI.PropertyField(  
    new Rect(rect.x, rect.y, 60, EditorGUIUtility.singleLineHeight),
    element.FindPropertyRelative("Type"), GUIContent.none);
cs


지금의 List의 헤더에는 "Serialized Property"라고 적혀 있다. 이를 좀 더 의미적으로 바꿔보자.

이를 위해 drawHeaderCallback을 사용하면 된다.


다음의 코드를 OnEnable 메소드에 붙여넣어 보자.


1
2
3
list.drawHeaderCallback = (Rect rect) => {  
    EditorGUI.LabelField(rect, "Monster Waves");
};
cs


자, 이제 뭔가 올바르게 된 느낌이 들 것이다.




지금 단계에서도 충분히 기능적인 형태를 보여주지만, 나는 얼마나 더 확장가능한지를 보여주고 싶다.

ReorderableList가 제공하는 다른 Callback함수들을 한번 보도록 하자.


ReorderableList의 Callbacks

여기에는 ReorderableList의 인스턴스가 가지고 있는 Callback의 리스트가 있다.

  • drawElementCallback
  • drawHeaderCallback
  • onReorderCallback
  • onSelectCallback
  • onAddCallback
  • onAddDropdownCallback
  • onRemoveCallback
  • onCanRemoveCallback
  • onChangedCallback

drawElementCallback

Signature: (Rect rect, int index, bool isActive, bool isFocused)


여기서는 List 요소들이 어떻게 그려져야하는지 나타낼 수 있다.

만약 이 Callback을 사용하지 않는다면, 다음의 코드가 기본적으로 삽입될 것이다.


1
2
EditorGUI.LabelField(rect,  
    EditorGUIUtility.TempContent((element == null) ? listItem.ToString() : element.displayName));
cs


drawHeaderCallback
Signature: (Rect rect)

List의 헤더를 그리는데 사용된다.

onReorderCallback
Signature: (ReorderableList list)

List의 요소가 움직일 때 호출된다.

onSelectCallback
Signature: (ReorderableList list)

List의 요소가 선택될 때 호출된다.

onAddCallback
Signature: (ReorderableList list)

+버튼이 눌렸을 때 호출된다. 만약 이 Callback을 작성하면, 반드시 직접 해당 item을 만들어야하고, 기본적으로 제공된 로직은 실행되지 않는다.

onAddDropdownCallback
Signature: (Rect buttonRect, ReorderableList list)

+버튼이 눌렸을 때 호출된다. 만약 이 Callback을 작성하면, +버튼이 추가하기 버튼으로 변경되고, onAddCallback은 작동하지 않는다. onAddCallback처럼 반드시 직접 list의 요소를 작성해야 한다.

onRemoveCallback
Signature: (ReorderableList list)

리스트에서 선택된 요소를 삭제할 때 호출된다. 만약 이 Callback을 작성하면, 기본 로직은 실행되지 않는다.

onCanRemoveCallback
Signature: bool (ReorderableList list)

-버튼이 활성화또는 비활성화로 그려져야 되는지 결정되야 할 때 이 Callback이 호출 된다.

onChangedCallback
Signature: (ReorderableList list)

List가 변경될때 호출된다. 예를 들어, 추가, 삭제 혹은 재배열될 때 호출된다. 만약 item안에 있는 data가 변경될 경우에는 이 callback이 호출되지 않는다.



선택시 하이라이트 기능 추가하기


위에 보여진 모든 콜백들에 Debug.log()를 추가하면, 해당 Callback이 언제 호출되는지 쉽게 알 수 있다.

하지만 우리는 그들을 활용해서 좀 더 유용한 기능들을 추가해보도록 하자.


첫 번째로는 onSelectCallback이다. 우리는 해당 요소를 클릭했을 때, 프로젝트 뷰에서 요소와 관련된 Mob 프리팹을 하이라이트되게 할 것이다.


OnEnable 메소드에 다음의 코드를 추가하자.


1
2
3
4
5
list.onSelectCallback = (ReorderableList l) => {  
    var prefab = l.serializedProperty.GetArrayElementAtIndex(l.index).FindPropertyRelative("Prefab").objectReferenceValue as GameObject;
    if (prefab)
        EditorGUIUtility.PingObject(prefab.gameObject);
};
cs


이 코드는 꽤 간단하다. 선택된 Wave의 Prefab 속성을 찾고, 만약 Prefab이 정의되어있다면, EditorGUIUtility.PingObject 메소드를 호출해서 프로젝트 뷰에있는 Prefab이 하이라이팅되게 한다.



남은 List수 확인하기


이번에는, List에서 적어도 하나의 요소는 남아있기를 원한다고 해보자. 다시 말하면, List에서 하나의 요소가 남았을 경우에는 -버튼이 비활성화 되기를 바라는 것이다.

이를 위해 onCanRemoveCallback을 사용하면 된다.


OnEnable 메소드에 다음의 코드를 추가해보자.


1
2
3
list.onCanRemoveCallback = (ReorderableList l) => {  
    return l.count > 1;
};
cs


Unity로 돌아가, List에 있는 모든 요소들을 삭제해보자. 1개의 요소가 남아 있을 때 -버튼이 비활성화가 되는 것을 볼 수 있을 것이다.



경고문 추가하기


우리는 실수로 List에 있는 요소들을 지우는 것을 원하지 않을 것이다. onRemoveCallback을 사용하면 화면에 경고창을 표시할 수 있고, 정말 삭제를 원한다면 Yes버튼을 눌러 삭제가 되게 할 수 있다.


1
2
3
4
5
6
list.onRemoveCallback = (ReorderableList l) => {  
    if (EditorUtility.DisplayDialog("Warning!"
        "Are you sure you want to delete the wave?""Yes""No")) {
        ReorderableList.defaultBehaviours.DoRemoveButton(l);
    }
};
cs


여기서 우리는 ReorderableList.defaultBehaviours.DoRemoveButton 메소드를 사용했다. ReorderableList.defaultBehaviours는 우리가 편하게 사용할 수 있는 다양한 함수들의 구현을 담고 있다.





새로 만들어진 요소들을 초기화하기


+버튼을 눌러 새로운 요소들을 추가 했을 때, 손으로 직접 값을 복사하는 대신 미리 설정된 값들이 정의되어있다면 편하지 않을까? 우리는 onAddCallback을 사용해서 요소들을 추가하는 기본 로직을 재작성할 수 있다.


OnEnable 메소드에 다음의 코드를 추가하자.


1
2
3
4
5
6
7
8
9
10
11
list.onAddCallback = (ReorderableList l) => {  
    var index = l.serializedProperty.arraySize;
    l.serializedProperty.arraySize++;
    l.index = index;
    var element = l.serializedProperty.GetArrayElementAtIndex(index);
    element.FindPropertyRelative("Type").enumValueIndex = 0;
    element.FindPropertyRelative("Count").intValue = 20;
    element.FindPropertyRelative("Prefab").objectReferenceValue = 
            AssetDatabase.LoadAssetAtPath("Assets/Prefabs/Mobs/Cube.prefab"
            typeof(GameObject)) as GameObject;
};
cs


여기서는 List의 맨 마지막에 빈 Wave를 추가 했다. 그리고 element.FindPropertyRelatvie 메소드를 사용해서 각 속성에 미리 정의된 값들을 대입하였다. Prefab 속성의 경우엔, Prefab이 있는 특정 경로를 활용해서 해당 Prefab을 가져오고 있다.

만약 잘 작동하지 않는다면, Unity에서 폴더구조가 제대로 되어있는지 확인해보도록 하자.

정상적으로 잘 작동한다면, 새로운 요소가 추가될 때, 미리 정의된 값들로 세팅된 요소들을 볼 수 있을 것이다.



드랍-다운 메뉴 추가하기


다음은 마지막 예제로, 가장 흥미로울 것이다. 우리는 onAddDropdownCallback을 사용하여 +버튼을 눌렀을 때 동적인 드랍-다운 메뉴를 만들 것이다.


Prefab 폴더에는 3개의 Mobs과 3개의 bosses가 존재 하는데, List에 Prefab을 수동적으로 드래깅해서 넣는 방법보다

추가할 때 직접 특정한 형태 하나를 선택해서 넣는 것이 훨씬 더 자연스러워 보인다.


우선, LevelData.cs 파일의 맨 뒷부분에 ("}"앞에) 다음의 코드를 추가하자.


1
2
3
4
private struct WaveCreationParams {  
    public MobWave.WaveType Type;
    public string Path;
}
cs

다음으로, Unity 메뉴 시스템에에 의해 호출될 callback함수를 정의하자. 지금은 텅 비었지만,

잠시후 우리는 수정할 것이다. OnInspectorGUI() 메소드뒤에 다음 코드를 추가하자.


1
private void clickHandler(object target) {} 
cs


이제 onAddDropdownCallback을 정의할 차례다. OnEnable 메소드에 다음의 코드를 추가하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
list.onAddDropdownCallback = (Rect buttonRect, ReorderableList l) => {  
    var menu = new GenericMenu();
    var guids = AssetDatabase.FindAssets(""new[]{"Assets/Prefabs/Mobs"});
    foreach (var guid in guids) {
        var path = AssetDatabase.GUIDToAssetPath(guid);
        menu.AddItem(new GUIContent("Mobs/" + Path.GetFileNameWithoutExtension(path)), 
        false, clickHandler, 
        new WaveCreationParams() {Type = MobWave.WaveType.Mobs, Path = path});
    }
    guids = AssetDatabase.FindAssets(""new[]{"Assets/Prefabs/Bosses"});
    foreach (var guid in guids) {
        var path = AssetDatabase.GUIDToAssetPath(guid);
        menu.AddItem(new GUIContent("Bosses/" + Path.GetFileNameWithoutExtension(path)), 
        false, clickHandler, 
        new WaveCreationParams() {Type = MobWave.WaveType.Boss, Path = path});
    }
    menu.ShowAsContext();
};
cs


여기서는 Mobs와 bosses폴더로 부터 동적 드랍-다운 메뉴를 생성하고 있는데, 어떤 메뉴가 선택되었는지 추후에 clickHandler에서 찾을 수 있게 WaveCreationParams에 해당 데이터들을 추가하고 있다.


그리고 Path.GetFileNameWithoutExtension를 사용하기 위해서는 using System.IO;를 추가하는 것도 잊지 말아야 한다. 유니티로 돌아가 +버튼을 누르면 다음과 같은 화면을 볼 수 있을 것이다.



이제 마지막으로 clickHandler에 실제 Wave 생성 로직을 추가해 보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
private void clickHandler(object target) {  
    var data = (WaveCreationParams)target;
    var index = list.serializedProperty.arraySize;
    list.serializedProperty.arraySize++;
    list.index = index;
    var element = list.serializedProperty.GetArrayElementAtIndex(index);
    element.FindPropertyRelative("Type").enumValueIndex = (int)data.Type;
    element.FindPropertyRelative("Count").intValue = 
        data.Type == MobWave.WaveType.Boss ? 1 : 20;
    element.FindPropertyRelative("Prefab").objectReferenceValue = 
        AssetDatabase.LoadAssetAtPath(data.Path, typeof(GameObject)) as GameObject;
    serializedObject.ApplyModifiedProperties();
}
cs


메뉴 아이템이 클릭되었을 때, Value 객체를 매개변수로한 clickHandler가 호출된다. 이 Value객체는  새롭게 생성된 Wave의 속성을 설정하는데 사용되는 Data를 포함하고 있다. 

또 Wave에 올바른 Prefab을 할당하기 위해서, AssetDatabase.LoadAssetAtPath(data.path, typeof(GameObject)) 메소드를 사용하고 있다.


결론


여기까지 오면서 우리가 건들지 않았던 Callback은 onChangedCallback과 onReorderCallback뿐이다. 이들의 Callback은 그렇게 흥미롭지 않다. 그러나 반드시 이 Callback의 존재는 알고 있어야 한다.


만약 당신이 오랫동안 유니티를 이용해서 작업을 해왔다면, Unity IDE에서 Collection을 표현하기 위한 적절한 인터페이스를 만드는 것이 얼마나 어려운지 알고 있을 것이다. 

나는 한동안 rotorz에서 제공하는 다른ReorderableList를 사용해 왔다. 하지만 이제 우리는 Unity에서 제공하는 이 기능을 가졌고, 이것을 사용하지 않을 이유가 없다.


만약 어떻게 이 List가 구현되는지를 알고 싶다면, ILSpy를 이용해서 UnityEditor.dll을 디컴파일할 수 있을 것이다.

반응형
: