【Unity】【エディタ拡張】非MonoBehaviourのインスペクタ表示をカスタムする

f:id:halya_11:20180802214202p:plain

MonoBeahaviourやScriptableObjectではないクラス・構造体のインスペクタ表示をカスタムする方法です。
PropertyDrawerを用いて実装します。

PropertyDrawer?

まず、MonoBehaviourやScriptableObjectのインスペクタ表示をカスタムするにはCustomEditorを使います。

light11.hatenadiary.com

これに対して、MonoBehaviourやScriptableObjectを継承していないクラスや構造体のインスペクタ表示をカスタムするにはPropertyDrawerを使います。

EditorGUILayoutが使えるCustomEditorに対してPropertyDrawerはEditorGUIしか使えなかったり、
使い方に色々癖があったりしますが、使いこなせれば便利そうです。

ちなみにPropertyDrawerはカスタムアトリビュートを作るときにも使いますが、それは別の記事で紹介しています。

light11.hatenadiary.com

折り畳みなしの場合

折り畳み表示を有効にするか無効にするかで実装が異なるので分けて説明します。
たとえばVector3のような一行で表示できるようなものは折り畳みなしで実装します。

まず、検証用に適当な構造体を定義します。

[System.Serializable]
public struct PostalCode
{
    [SerializeField]
    private int _first;
    [SerializeField]
    private int _second;
}

次にPropertyDrawerを継承したクラスを定義し、インスペクタ表示をカスタムします。

[CustomPropertyDrawer(typeof(PostalCode))]
public class PostalCodeDrawer : PropertyDrawer
{
    private class PropertyData
    {
        public SerializedProperty firstProperty;
        public SerializedProperty secondProperty;
    }
    
    private Dictionary<string, PropertyData> _propertyDataPerPropertyPath = new Dictionary<string, PropertyData>();
    private PropertyData _property;

    private void Init(SerializedProperty property)
    {
        if (_propertyDataPerPropertyPath.TryGetValue(property.propertyPath, out _property)){
                return;
        }
        
        _property = new PropertyData();
        _property.firstProperty = property.FindPropertyRelative("_first");
        _property.secondProperty = property.FindPropertyRelative("_second");
        _propertyDataPerPropertyPath.Add(property.propertyPath, _property);
    }

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        Init(property);
        var fieldRect = position;
        // インデントされた位置のRectが欲しければこっちを使う
        var indentedFieldRect   = EditorGUI.IndentedRect(fieldRect);
        fieldRect.height = EditorGUIUtility.singleLineHeight;

        // Prefab化した後プロパティに変更を加えた際に太字にしたりする機能を加えるためPropertyScopeを使う
        using (new EditorGUI.PropertyScope(fieldRect, label, property)) 
        {
            // ラベルを表示し、ラベルの右側のプロパティを描画すべき領域のpositionを得る
            fieldRect = EditorGUI.PrefixLabel(fieldRect, GUIUtility.GetControlID(FocusType.Passive), label);
            
            // ここでIndentを0に
            var preIndent = EditorGUI.indentLevel;
            EditorGUI.indentLevel = 0;

            // プロパティを描画
            var firstRect = fieldRect;
            firstRect.width /= 3;
            EditorGUI.PropertyField(firstRect, _property.firstProperty, GUIContent.none);

            var dashRect = fieldRect;
            dashRect.xMin += firstRect.width;
            dashRect.width = 10;
            EditorGUI.LabelField(dashRect, "-");

            var secondRect= fieldRect;
            secondRect.xMin += firstRect.width + dashRect.width;
            secondRect.width = fieldRect.width - (firstRect.width + dashRect.width);
            EditorGUI.PropertyField(secondRect, _property.secondProperty, GUIContent.none);

            EditorGUI.indentLevel = preIndent;
        }
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        Init(property);

        return EditorGUIUtility.singleLineHeight;
    }
}

この構造体を適当なコンポーネントシリアライズすると、結果は下図のようになります。

f:id:halya_11:20180802214422p:plain

折り畳みありの場合

次に折り畳みありの場合のコードです。
表示が2行以上になるものは基本的には折りたたんだほうが見やすいかなと思います。

前節と同様に、検証用に適当な構造体を定義します。

public enum AnimalKind {
    Dog,
    Cat,
    Hedgehog,
}

[System.Serializable]
public struct Pet
{
    [SerializeField]
    private string _name;
    [SerializeField]
    private int _age;
    [SerializeField]
    public AnimalKind _type;
}

PropertyDrawerを継承したクラスを定義し、インスペクタ表示をカスタムします。

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

[CustomPropertyDrawer(typeof(Pet))]
public class PetDrawer : PropertyDrawer
{
    private class PropertyData
    {
        public SerializedProperty nameProperty;
        public SerializedProperty typeProperty;
        public SerializedProperty ageProperty;
    }

    private Dictionary<string, PropertyData> _propertyDataPerPropertyPath = new Dictionary<string, PropertyData>();
    private PropertyData _property;

    private float LineHeight { get { return EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; } }

    private void Init(SerializedProperty property)
    {
        if (_propertyDataPerPropertyPath.TryGetValue(property.propertyPath, out _property)){
                return;
        }

        _property = new PropertyData();
        _property.nameProperty = property.FindPropertyRelative("_name");
        _property.typeProperty = property.FindPropertyRelative("_type");
        _property.ageProperty = property.FindPropertyRelative("_age");
        _propertyDataPerPropertyPath.Add(property.propertyPath, _property);
    }

    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        Init(property);
        var fieldRect = position;
        // インデントされた位置のRectが欲しければこっちを使う
        var indentedFieldRect   = EditorGUI.IndentedRect(fieldRect);
        fieldRect.height = LineHeight;
        

        // Prefab化した後プロパティに変更を加えた際に太字にしたりする機能を加えるためPropertyScopeを使う
        using ( new EditorGUI.PropertyScope(fieldRect, label, property)) 
        {
            // プロパティ名を表示して折り畳み状態を得る
            property.isExpanded = EditorGUI.Foldout (new Rect(fieldRect), property.isExpanded, label);
            if (property.isExpanded) {
            
                using (new EditorGUI.IndentLevelScope()) 
                {
                    // Nameを描画
                    fieldRect.y += LineHeight;
                    EditorGUI.PropertyField(new Rect(fieldRect), _property.nameProperty);
                    
                    // Typeを描画
                    fieldRect.y += LineHeight;
                    EditorGUI.PropertyField(new Rect(fieldRect), _property.typeProperty);
                    
                    // Ageを描画
                    fieldRect.y += LineHeight;
                    EditorGUI.PropertyField(new Rect(fieldRect), _property.ageProperty);
                }
            }
        }
        
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        Init(property);
        // (EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing) x 行数 で描画領域の高さを求める
        return LineHeight * (property.isExpanded ? 4 : 1);
    }
}

シリアライズした結果は下図のようになりました。
今回は構造体をリストの要素にしています。

f:id:halya_11:20180802214247p:plain

カスタムした構造体にカスタムした構造体を持たせてみる

ここまでうまく表示できたので、試しに上記で表示をカスタムした構造体の中に、他方の構造体を定義してみます。

[System.Serializable]
public struct Pet
{
    [SerializeField]
    private string _name;
    [SerializeField]
    private int _age;
    [SerializeField]
    public AnimalKind _type;
    [SerializeField]
    private PostalCode _postalCode;
}

Pet構造体のPropertyDrawerも適宜書き換えておきます。
この結果、インスペクタ表示は以下のようになりました。

f:id:halya_11:20180802214202p:plain

正常に表示されていることがわかります。

PropoertyHeightが可変のPropertyを入れ子にする

今回は使いませんでしたが、あるプロパティのPropertyDrawerから他のプロパティのPropertyDrawer.GetPropertyHeightで指定した高さを取得したい場合には、
「他のプロパティ」のSerializedPropertyをEditorGUI.GetPropertyHeight()に渡します。

高さが可変のプロパティを入れ子にする場合はこれを使用することになるはずです。

方針

実装するうえで癖があって苦しんだので、自分なりの使い方をまとめます。

  • 実装方法のテンプレートは折りたたみあり/なしで分ける
  • 折り畳みなしの場合、インデントの処理を書かないとリスト表示したときに崩れるので注意
  • なるだけ小さい粒度でPropertyDrawerを定義する
  • メンバ変数を使うとリスト表示で不具合が起こるので原則使わない
  • どうしてもメンバ変数を使う場合はプロパティパスをキーにする辞書を作る

参考

github.com