読者です 読者をやめる 読者になる 読者になる

チリペヂィア

リンクフリー。サンプルコードなどは関連記事内でライセンスについて明示されない限り商用利用なども自由に行って構いませんが、自己責任でお願いします。またこれら日記内容の著作権自体は放棄していません。引用部分については引用元の権利に従ってください。

PropertyDrawerで配列表示カスタム

エンターするまで入力を反映しない

PropertyDrawerの使用に直接関係するわけじゃないんですが、標準の配列入力が当たり前のようにやっている「配列数をテキストフィールドで入力する」仕様。ところがこれそのまま真似しようと思っても、テキストフィールドって「1文字でも書き換えると最新の入力値が反映される」んです。そのまま配列数入力として使用すると、バックスペースやデリートキーがまともに使用できません。(例えば25個から26個に書き換えるとき、5を消した瞬間に配列数が2個に切り詰められてしまう)

こういう場合、「コントロールからフォーカス(カーソル)が去る」or「フォーカス中のEnter」をハンドリングするわけですが、UnityのGUIにはイベント構造は無く、ものすげーややこしい方法を使用します。

1. フォーカスを監視したいコントロールを描画する際は、常に一定かつユニークなコントロール名を設定

GUI.SetNextControlName()を使用します。

2. コントロールのレンダリングが、「フォーカスされた状態でのEnter入力」or「非フォーカス状態でのRepaint」なのか確認

GUI.GetNameOfFocusedControl()を使用し、Event.current.typeおよびEvent.current.keyCodeの確認をあわせて行います。

また、入力状態と実際の値が違っている状態を許容するには、これらを別々に管理しないとダメなので、PropertyDrawerのメンバとしてエイリアスな入力値を管理しておいて、オリジナルの値(配列数)と入力値に違いがあるかも検出します。

ちなみに、SetNextControlName()で設定する名前を作業中に仕様変更すると、キャッシュされた状態が書き換わらなくなることがあります(GetNameOfFocusedControl()で取得されるコントロール名が、修正前のソースでSetNextControlName()していた名前のままになってしまい、今のソースで割り当てている名前と別な名前を返す)。名前の比較処理がおかしくなる場合はDebug.Log()でコントロール名を出力してみて、もしおかしかったらUnity再起動すると直ります。プロジェクト再読込でもいいかも。

サンプル

とりあえずこんな属性を定義。

/// <summary>配列の要素数をプロパティ名と同じ列に表示してしまう属性</summary>
public class BetterArrayAttribute : PropertyAttribute
{
    /// <summary>個数入力フィールドの表示サイズをサンプリングする文字列</summary>
    public readonly System.String countFieldSample;
    /// <summary>デフォルトコンストラクタ。countFieldSampleには"#####"が使用されます。</summary>
    public BetterArrayAttribute() : this(null) { }
    /// <summary>コンストラクタ</summary>
    /// <param name="countFieldSampleString">countFieldSampleを指定します。
    /// 配列数入力フィールドはこの文字列を表示するのに必要なサイズで描画されます。
    /// 省略するとcountFieldSampleには"#####"が使用されます。</param>
    public BetterArrayAttribute(System.String countFieldSampleString)
    {
        this.countFieldSample = System.String.IsNullOrEmpty(countFieldSampleString) ? "#####" : countFieldSampleString;
    }
}

まとめなおすのが面倒だったので実際に使ってる配列要素数Drawerを下記にさらします。このDrawerは、折りたたみタイトルのところに要素数フィールドを一緒に表示してしまいます。また、要素数入力フィールドの右隣に、任意で別プロパティフィールドを一つ追加できます。長いので肝心なところのロジックをまとめると、

  • 入力値のエイリアスとしてのarraySizeをnullableで持っておき、加えてコンポーネント(MonoBehavior)のインスタンスIDを保持しています。入力値のエイリアスが初期化されていないnull状態、あるいはインスタンスIDが不一致する時は、入力値をクリアして再同期を取ります。
  • 素数フィールドのユニークなコントロール名は次のように作ります。
    > "ゲームオブジェクト名"."コンポーネント型名"(id:"コンポーネントインスタンスID値")."配列プロパティへのフルパス"
  • 入力変更を受け付けるのは入力が変化した時で、かつ下記2パターンのどちらか一方を満たす時だけ
    • コントロールがフォーカス状態にあって、イベントタイプがKeyUpで、keyCodeがエンターの時
    • コントロールがフォーカス状態ではなく、イベントタイプがRepaint
/// <summary>要素数の行を、折りたたみ表示の右端に持ってくる配列表示。右端にひとつプロパティフィールドを追加可能。プロパティフィールドを追加する場合は別のPropertyDrawerのメンバに持つ必要があります。</summary>
[CustomPropertyDrawer(typeof(BetterArrayAttribute))]
public class AppendableArrayPropertyField : UnityEditor.PropertyDrawer
{
    /// <summary>要素数の入力フィールドサイズを決定するサンプリング文字列</summary>
    public System.String numberFieldSample;
    private int? arraySize;
    private int lastInstanceID;

    /// <summary>折りたたみ配列を描画し、描画に必要だった高さを計算して返します(実際の描画を省略して高さだけ計算することも出来ます)。</summary>
    /// <param name="position">描画領域を指定します。preDrawがtrueの時はゼロを渡して構いません。</param>
    /// <param name="root">描画する配列プロパティを渡します。</param>
    /// <param name="label">折りたたみ配列の表示名を指定します。nullで省略するとrootのnameプロパティから生成されます。空のラベルを指定する時はGUIContent.noneを指定します。</param>
    /// <param name="preDraw">trueを指定すると、描画しないで描画領域の高さだけ計算して返します。</param>
    /// <param name="appendix">要素数ボックスの隣にひとつだけプロパティフィールドを追加して表示します。表示しない場合はnullで省略可能。</param>
    /// <param name="appendixSize">appendixフィールドの表示サイズを指定します。表示しない場合は無視されます。</param>
    /// <param name="appendixLabel">appendixフィールドのラベル表示を指定します。appendixがnullの時は無視されます。ラベルの描画を省略するにはGUIContent.noneを指定します。nullを指定するとappendixのnameプロパティを表示します。</param>
    /// <returns>描画領域の高さを計算して返します。</returns>
    public float OnGUI(Rect position, SerializedProperty root, GUIContent label, bool preDraw, SerializedProperty appendix, Vector2 appendixSize, GUIContent appendixLabel)
    {
        if (numberFieldSample == null)
        {
            numberFieldSample = ((attribute as BetterArrayAttribute) ?? new BetterArrayAttribute()).countFieldSample;
        }

        int indent = EditorGUI.indentLevel;

        if (label == null)
        {
            System.String name = null;
            if (root != null) name = root.name;
            if (System.String.IsNullOrEmpty(name)) label = GUIContent.none;
            else label = new GUIContent(System.Char.ToUpper(root.name[0]) + root.name.Substring(1));
        }

        if (appendix == null) appendixSize = Vector2.zero;

        Vector2 titleSize = EditorStyles.foldout.CalcSize(label);
        titleSize.x += EditorGUI.IndentedRect(position).x - position.x;

        Vector2 numberSampleSize = EditorStyles.numberField.CalcSize(new GUIContent(numberFieldSample));

        float titleLineHeight = Mathf.Max(titleSize.y, numberSampleSize.y, appendixSize.y);
        titleSize.y = numberSampleSize.y = appendixSize.y = titleLineHeight;

        float ret = 0f;

        ret += EditorStyles.foldout.margin.top + EditorStyles.foldout.padding.top;
        if (preDraw == false)
        {
            Rect titleFullRect = position;
            titleFullRect.x += EditorStyles.foldout.margin.left;
            titleFullRect.y += ret;
            titleFullRect.width = position.width - EditorStyles.foldout.margin.horizontal;
            titleFullRect.height = titleLineHeight;

            Rect titleAppendixRect = titleFullRect;
            titleAppendixRect.width = appendixSize.x;
            titleAppendixRect.x = (titleFullRect.x + titleFullRect.width) - titleAppendixRect.width;

            Rect numberFieldRect = titleFullRect;
            numberFieldRect.width = numberSampleSize.x; //checkSize.x * 3;
            numberFieldRect.x = titleAppendixRect.x - numberFieldRect.width - ((appendix != null) ? EditorStyles.numberField.margin.right : 0f);

            Rect expandFieldRect = titleFullRect;
            expandFieldRect.width = numberFieldRect.x - expandFieldRect.x - EditorStyles.numberField.margin.left;

            EditorGUIUtility.LookLikeControls(expandFieldRect.width, 0f);
            root.isExpanded = EditorGUI.Foldout(expandFieldRect, root.isExpanded, label);

            EditorGUI.indentLevel = 0;

            if (appendix != null)
            {
                Vector2 appendixLabelSize = Vector2.zero;
                if (appendixLabel == null) appendixLabel = new GUIContent(appendix.name);
                if ((System.String.IsNullOrEmpty(appendixLabel.text) == false) ||
                    ((appendixLabel.image != null) && (0f < appendixLabel.image.width)))
                {
                    appendixLabelSize = EditorStyles.label.CalcSize(appendixLabel);
                }
                EditorGUIUtility.LookLikeControls(appendixLabelSize.x, titleAppendixRect.width - appendixLabelSize.x);
                EditorGUI.PropertyField(titleAppendixRect, appendix, appendixLabel);
            }

            EditorGUI.BeginChangeCheck();
            int hash = root.serializedObject.targetObject.GetInstanceID();
            if ((arraySize.HasValue == false) || (lastInstanceID != hash))
            {
                arraySize = root.arraySize;
                lastInstanceID = hash;
            }
            System.String ctrlName = System.String.Format("{0}.{1}(id:{2}).{3}",
                root.serializedObject.targetObject.name,
                root.serializedObject.targetObject.GetType().Name,
                lastInstanceID, root.propertyPath);
            //Debug.Log(ctrlName);
            GUI.SetNextControlName(ctrlName);
            EditorGUIUtility.LookLikeControls(0f, numberFieldRect.width);
            arraySize = EditorGUI.IntField(numberFieldRect, GUIContent.none, arraySize.Value);
            GUI.SetNextControlName(null);
            System.String focused = GUI.GetNameOfFocusedControl();
            bool enterApply = EditorGUI.EndChangeCheck() && (focused == ctrlName) && (Event.current.type == EventType.KeyUp) &&
                    ((Event.current.keyCode == KeyCode.Return) || (Event.current.keyCode == KeyCode.KeypadEnter));
            bool anotherFocusApply = (focused != ctrlName) && (Event.current.type == EventType.Repaint);
            if ((arraySize.Value != root.arraySize) && (enterApply || anotherFocusApply))
            {
                root.arraySize = arraySize.Value;
                EditorUtility.SetDirty(root.serializedObject.targetObject);
                if (enterApply) Event.current.Use();
            }
        }

        ret += titleLineHeight + EditorStyles.foldout.margin.bottom;

        if (root.isExpanded)
        {
            EditorGUI.indentLevel = indent + 1;
            int count = root.arraySize;
            Rect listRect = EditorGUI.IndentedRect(position);
            listRect.y += ret;
            EditorGUI.indentLevel = 0;
            for (int index = 0; index < count; index++)
            {
                SerializedProperty item = root.GetArrayElementAtIndex(index);
                float height = EditorGUI.GetPropertyHeight(item);
                if (preDraw == false)
                {
                    listRect.height = height;
                    EditorGUI.PropertyField(listRect, item);
                    listRect.y += height;
                }
                ret += height;
            }
        }

        EditorGUI.indentLevel = indent;
        return ret + EditorStyles.foldout.padding.bottom;
    }

    public override void OnGUI(Rect position, SerializedProperty prop, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, prop);
        OnGUI(position, prop, label, false, null, Vector2.zero, null);
        EditorGUI.EndProperty();
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return OnGUI(new Rect(), property, label, true, null, Vector2.zero, null);
    }
}

通常の使用と、トグルを追加してみた例が左。通常の使用ならフィールドにBetterArray属性を与えるだけでス。一方、トグル追加は下記のサンプルのようにします。

// 属性修飾する型
[System.Serializable]
public class ToggledList
{
    /// <summary>有効無効フラグ</summary>
    public bool enabled = false;

    /// <summary>配列メンバ</summary>
    public System.Collections.Generic.List<アイテム型> list;
}

// Drawer定義
[CustomPropertyDrawer(typeof(ToggledList))]
public class ToggledListDrawer : UnityEditor.PropertyDrawer
{
    // ローカルで持っておいて
    private AppendableArrayPropertyField arrayField = null;
    public float OnGUI(Rect position, SerializedProperty prop, GUIContent label, bool preDraw)
    {
        float ret = 0;
        SerializedProperty useConnectableActionFilters = prop.FindPropertyRelative("enabled"); // トグル用プロパティ
        SerializedProperty connectableActionFilters = prop.FindPropertyRelative("list"); // 配列プロパティ
        if (arrayField == null) arrayField = new AppendableArrayPropertyField();
        Vector2 toggleSize = EditorStyles.toggle.CalcSize(GUIContent.none); // トグルサイズ
        toggleSize.y = 0; // トグルの高さがデカイので無視させる
        // OnGUIを直接駆動する。
        ret += arrayField.OnGUI(position, connectableActionFilters, label, preDraw, useConnectableActionFilters, toggleSize, GUIContent.none);
        return ret;
    }

    public override void OnGUI(Rect position, SerializedProperty prop, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, prop);
        OnGUI(position, prop, label, false);
        EditorGUI.EndProperty();
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        return OnGUI(new Rect(), property, label, true);
    }
}

属性プログラミングを使えばエディタカスタマイズの再利用率はだいぶあげられるんですが、そもそも「アセット単位で切り売りできると便利だよね」という設計思想が入っているので、あまり多層化して下層に行くほど抽象度と再利用率が上がっていくような設計には向かないところもあります(このアセットを使うには別のライブラリアセットのバージョンx.x.xを使用してね!みたいな事になってしまいます。また他のアセットからは別バージョンの同じライブラリが要求されて版管理が競合するシナリオとかも発生します。常にスパゲティと紙一重である逐次スクリプティング思想と、製品企画の根本的な先見と安定を一番に好む構造化設計は基本的に相反しやすいものです。ゲームデザインそのものはスクリプト的な瞬発力が好ましく、しかしその一方で大規模で複雑なコーディングにはJavaScriptよりはJavaC#のように構造化を好む言語が向きます)。PropertyDrawerともうまいこと付き合っていきたいものです。