이렇게 엄청나게 많은 양의 변수들을 보고있으면 단순히 Header 하나로 정리하기가 힘듭니다.
이것들을 정리하기 위해서 Custom Attribute
를 구현하고, 이것들을 정리해보도록 하겠습니다.
public class StartFoldoutAttribute : PropertyAttribute
{
public string header;
public StartFoldoutAttribute(string header)
{
this.header = header;
}
}
public class FoldableAttribute : PropertyAttribute { }
우선 Attribute를 정의합니다.
StartFoldout
은 Foldout 을 시작할 때 사용할 어트리뷰트이고, Foldable
은 해당 Header에서 Fold 시 사라지거나 나타날 변수들의 어트리뷰트 입니다.
해당 어트리뷰트들이 Inspector 창에서 어떻게 보일지는 PropertyDrawer
를 상속받아서 구현합니다.
[CustomPropertyDrawer(typeof(StartFoldoutAttribute))]
[CustomPropertyDrawer(typeof(FoldableAttribute))]
public class FoldoutGroupDrawer : PropertyDrawer
{
private static Dictionary<string, bool> foldoutStates = new Dictionary<string, bool>();
private static Dictionary<string, string> headerDict = new Dictionary<string, string>();
private static string currentHeader = string.Empty;
}
위의 CustomPropertyDrawer
를 통해 StartFoldout
, Foldable
어트리뷰트 사용 시 해당 스크립트가 실행되도로 합니다.
foldoutStates
는 여러 Foldout 들의 상태를 기록하는 딕셔너리입니다.
headerDict
는 특정 프로퍼티의 헤더를 기록해두는 딕셔너리입니다.
currentHeader
는 OnGUI
에서 사용되며, 프로퍼티를 그릴지 판단하거나 headerDict
에 기록할 때 사용됩니다.
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
headerDict[property.name] = currentHeader;
if (attribute is StartFoldoutAttribute startFoldout)
{
currentHeader = startFoldout.header;
if (!foldoutStates.ContainsKey(currentHeader))
foldoutStates[currentHeader] = false;
Rect headerRect = position;
headerRect.height = EditorGUIUtility.singleLineHeight;
Rect propertyRect = position;
propertyRect.y += EditorGUIUtility.singleLineHeight;
propertyRect.height -= EditorGUIUtility.singleLineHeight;
foldoutStates[currentHeader] = EditorGUI.Foldout(headerRect, foldoutStates[currentHeader], currentHeader, true);
if (foldoutStates[currentHeader])
{
EditorGUI.PropertyField(propertyRect, property, label, true);
}
}
else if (attribute is FoldableAttribute)
{
if (currentHeader != string.Empty)
{
bool foldoutState = foldoutStates.ContainsKey(currentHeader) && foldoutStates[currentHeader];
if (foldoutState)
{
EditorGUI.PropertyField(position, property, label, true);
}
}
}
}
OnGUI
는 해당 프로퍼티를 그리는 함수입니다.
해당 함수를 어떻게 그릴지에 대한 구현을 작성하면 됩니다.
StartFoldout
어트리뷰트가 있으면 Foldout 을 그리기 위한 headerRect
와 변수를 나타낼 propertyRect
를 나누고, 각 Rect에 필요한 GUI를 그립니다.Foldable
어트리뷰트가 있으면 foldout 상태를 확인한 후에, 펼쳐져있으면 그리도록 합니다. public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
string curHeader = headerDict.ContainsKey(property.name) ? headerDict[property.name] : string.Empty;
if (attribute is StartFoldoutAttribute)
{
if (curHeader != string.Empty)
{
bool foldoutState = foldoutStates.ContainsKey(curHeader) && foldoutStates[curHeader];
if (foldoutState)
{
return EditorGUIUtility.singleLineHeight + EditorGUI.GetPropertyHeight(property, label, true);
}
}
return EditorGUIUtility.singleLineHeight;
}
else if (attribute is FoldableAttribute)
{
if (curHeader != string.Empty)
{
bool foldoutState = foldoutStates.ContainsKey(curHeader) && foldoutStates[curHeader];
if (!foldoutState)
{
return EditorGUI.GetPropertyHeight(property, label, true);
}
}
}
return 0;
}
GetPropertyHeight
는 OnGUI의 Input에 있는 Rect position
의 높이 및 다른 프로퍼티들의 시작지점에 영향을 줍니다.
따라서 headerDict
를 통해 프로퍼티의 헤더를 찾고, 헤더의 foldout 여부에 따라 높이를 반환합니다.
근데 한가지 문제가 있습니다.
잘 보시면 Foldout 헤더 간에 빈 공간의 차이가 있습니다.
더 정확히 보기위해 Foldout 에만 Rect에 색을 칠해보면 다음과 같습니다.
아마도 GetPropertyHeight
나 OnGUI
가 호출될 때마다 자동으로 2px 만큼 자동으로 띄워줍니다.
이 현상을 해결할 수 없으므로 다른 방식으로 Foldout을 구현해보겠습니다.
실제로 Editor를 수정하게 되면 각 변수에 꼭 어트리뷰트를 지정할 필요는 없습니다.
[FoldoutGroup("Test Integerssssss", 15, ColorType.White)]
[SerializeField]
private int m_int1;
[SerializeField]
private int m_int2;
이런식으로 맨 위에 FoldoutGroup
어트리뷰트 하나만 적어서 Foldout을 구현해보도록 하겠습니다.
public class FoldoutGroupAttribute : PropertyAttribute
{
public string Name { get; }
public int FontSize { get; }
public ColorType ColorType { get; }
public FoldoutGroupAttribute(string name) : this(name, 14, ColorType.White) { }
public FoldoutGroupAttribute(string name, int fontSize) : this(name, fontSize, ColorType.White) { }
public FoldoutGroupAttribute(string name, int fontSize, ColorType type)
{
Name = name;
FontSize = fontSize;
ColorType = type;
}
}
Attribute 클래스에서는 이름, Header 폰트 사이즈, Header 글자 색상을 인자로 받습니다.
[CustomEditor(typeof(MonoBehaviour), true)]
public class FoldoutGroupEditor : Editor
{
private static Dictionary<string, bool> foldoutStates = new Dictionary<string, bool>();
private List<PropertyData> propertyDataList = new List<PropertyData>();
private string currentGroup = null;
private class PropertyData
{
public SerializedProperty Property;
public FoldoutGroupAttribute Attribute;
}
CustomEditor
어트리뷰트를 통해 MonoBehaviour
의 인스펙터창을 수정하여 Foldout을 구현하겠습니다.
foldoutStates
는 위와 동일하게 각 Foldout 이 펼쳐져있는지 여부를 저장하는 Dictionary이고,
propertyDataList
는 property-Attribute를 묶어서 List로 저장하였습니다.
public override void OnInspectorGUI()
{
currentGroup = null;
serializedObject.Update();
CollectProperties();
DrawProperties();
serializedObject.ApplyModifiedProperties();
}
Editor를 그리는 함수인 OnInspectorGUI
함수입니다.
Update
와 ApplyModifiedProperties
함수는 Editor와 엔진 스크립트 간의 동기화를 위한 코드입니다.
그 사이에 있는 함수들에 대해서 더 자세히 알아보겠습니다.
private void CollectProperties()
{
propertyDataList.Clear();
SerializedProperty prop = serializedObject.GetIterator();
HashSet<string> processedPaths = new HashSet<string>();
while (prop.NextVisible(true))
{
if (processedPaths.Contains(prop.propertyPath))
continue;
FoldoutGroupAttribute attr = GetFoldoutGroupAttribute(prop);
if (attr == null && currentGroup != null)
{
attr = new FoldoutGroupAttribute(currentGroup);
}
else if (attr != null)
{
currentGroup = attr.Name;
}
propertyDataList.Add(new PropertyData
{
Property = prop.Copy(),
Attribute = attr
});
// HACK - Exception with list needed.
if (prop.propertyType == SerializedPropertyType.Vector2 ||
prop.propertyType == SerializedPropertyType.Vector3 ||
prop.propertyType == SerializedPropertyType.Quaternion ||
prop.propertyType == SerializedPropertyType.Vector2Int ||
prop.propertyType == SerializedPropertyType.Vector3Int)
{
string basePath = prop.propertyPath + ".";
processedPaths.Add(basePath + "x");
processedPaths.Add(basePath + "y");
if (prop.propertyType == SerializedPropertyType.Vector3 ||
prop.propertyType == SerializedPropertyType.Vector3Int ||
prop.propertyType == SerializedPropertyType.Quaternion)
{
processedPaths.Add(basePath + "z");
}
if (prop.propertyType == SerializedPropertyType.Quaternion)
{
processedPaths.Add(basePath + "w");
}
}
}
}
CollectProperties
함수는 간단하게 List에 Propety-Attribute 쌍을 저장하여 그룹단위로 그릴때 사용합니다.
아래쪽의 예외처리 부분은 Vector3 와 Vector3.x, y, z 를 다른 프로퍼티로 인식하기 때문에, 중복되는 프로퍼티를 지우기 위해 적어둔 예외처리 코드입니다.
private void DrawProperties()
{
string activeGroup = null;
foreach (var data in propertyDataList)
{
string groupName = data.Attribute?.Name;
if (activeGroup != groupName)
{
activeGroup = groupName;
if (!string.IsNullOrEmpty(activeGroup))
DrawFoldoutGroup(data.Attribute);
}
if (string.IsNullOrEmpty(activeGroup) || foldoutStates[activeGroup])
{
EditorGUILayout.PropertyField(data.Property, true);
}
}
}
private void DrawFoldoutGroup(FoldoutGroupAttribute attr)
{
if (!foldoutStates.ContainsKey(attr.Name))
foldoutStates[attr.Name] = false;
GUIStyle foldoutStyle = new GUIStyle(EditorStyles.foldout)
{
fontSize = attr.FontSize,
fontStyle = FontStyle.Bold,
normal = { textColor = InspectorUtils.Utils.Constants.PALLETE[(int)attr.ColorType] },
alignment = TextAnchor.MiddleLeft,
onFocused = new GUIStyleState
{
textColor = InspectorUtils.Utils.Constants.PALLETE[(int)attr.ColorType]
}
};
Rect rect = GUILayoutUtility.GetRect(EditorGUIUtility.currentViewWidth, attr.FontSize * 1.5f);
foldoutStates[attr.Name] = EditorGUI.Foldout(rect, foldoutStates[attr.Name], attr.Name, true, foldoutStyle);
}
DrawProperties
가 실제로 프로퍼티를 그리는 함수입니다.
List에 저장되어있는 정보를 토대로 그룹에 포함되어야 하는지, 그냥 그려야하는지 판단하여 그립니다.
여기까지 하고 테스트를 해보면, 아래와 같습니다.
public class DemoScript : MonoBehaviour
{
[SerializeField]
private int test_int;
[FoldoutGroup("Test Integerssssss", 15, ColorType.White)]
[SerializeField]
private int m_int1;
[SerializeField]
private int m_int2;
[FoldoutGroup("Test Structs", 15, ColorType.Red)]
[SerializeField]
private Vector3 m_vector3;
}
InsepctorGUI 를 커스텀하여 Foldout 을 구현하였습니다.
Property 단위로 다루지 못해서 다른 Attribute 들을 추가할 때 신경쓸게 많아진다는게 단점이지만 해당 문제를 해결하기 위해 예외처리나 추가하기 쉬운 구조를 만들어 보도록 하겠습니다.