Help us understand the problem. What is going on with this article?

[Unity] CustomPropertyDrawerを使ってインスペクタ上から、実行するメソッドを指定する仕組みを作る

More than 3 years have passed since last update.

前回の記事で書いた「カスタムエディタ」は特定のクラス全体のインスペクタGUIを変更する方法でした。

今回書くのは、それのプロパティ版です。
特定クラス全体ではなく、プロパティとして定義されたクラスのインスペクタ上の表現を変えるものです。

目的

今回これを作った経緯は、今作っているコンテンツで、エンジニアとプランナーで分担して作業をしたかったからです。
そのため、Prefab単位でオブジェクトを作ったり、あるいは汎用的なクラスを作ってそれをアタッチしてもらい、さらにそれを今回のプロパティに設定してもらうことで、各イベントとメソッドを橋渡ししたい、と思ったからです。

具体的な実装例としては、2D UIのButton要素にある、イベントリスナを設定するやつを思い浮かべてもらうといいと思います。まさにあれを実現したい感じでした。

↓これ
サンプル

実現したもの

そして実際に実装したやつをアタッチした状態がこんな感じです↓
実装サンプル

今回はあまり凝ったことはせず、該当オブジェクトにアタッチされているコンポーネントリストから、 public でかつ引数がないメソッドだけをリストするようにしています。

使い方

具体的な使い方は、↑の画像で説明すると、

Target となっているプロパティに、実行したいメソッドを持っている GameObject を設定します。
すると、対象となったオブジェクトに設定されているコンポーネントリストから、上記の条件にマッチしたメソッドを抜き出し、リスト化します。

そしてそれを表示しているのがサンプル画像の選択されている部分です。
例では Door クラスの OpenClose がリストされている、というわけです。

もちろん、もし仮にこれ以外のコンポーネントがアタッチされていて、かつそのクラスが引数なしの public メソッドを持っている場合は同じようにリストされます。

ポイント

実装についてはコードを全文載せておくのでそれを見てもらうといいと思いますが、今回実装した中でポイントとなる部分を。

最初、Reflectionの機能を使ってメソッドを抜き出すまではすぐできたんですが、ポップアップ部分の階層構造(ネスト構造)を作る部分が分からず悩みました。
が、分かってみたらなんのことはなくて、階層構造を作りたい場合は / で区切ればいいみたいです。
(よくよく考えてみると、Unityはその他の部分でも / を使うことで階層構造を作れることが多いですよね)

なので、↑の例では単純に Door/OpenDoor/Close というふたつの要素を指定しているに過ぎません。
あとは勝手にUnityエンジン側でこれを階層構造と見なして制御してくれます。

コード

さて、最後に、今回作成したクラスとそのカスタムプロパティの実装コードをそのまま載せておきます。

EventHandlerクラス

さて、まずはメインとなるクラスから。
これは、他の MonoBehaviour なクラスのプロパティとして利用されることを想定しているクラスです。(なので MonoBehaviour は継承していない)

using UnityEngine;
using System.Collections;

[System.Serializable]
public class EventHandler {

    [SerializeField]
    public GameObject Target;

    [SerializeField]
    public string HandleMethod;

    /// <summary>
    /// ターゲットにメッセージを送信
    /// </summary>
    public void Invoke()
    {
        if (HandleMethod == "None") {
            return;
        }

        string[] methodParam = HandleMethod.Split('/');

        string typeString = methodParam[0];
        string methodName = methodParam[1];

        System.Type type = System.Type.GetType(typeString);

        Object comp = Target.GetComponent(typeString);

        System.Reflection.MethodInfo info = type.GetMethod(methodName);
        info.Invoke(comp, null);
    }
}

実装はそんなに複雑ではありません。
前述したように、ターゲットとなる GameObject を指定できるように publicTarget メンバを定義します。
ここにアタッチされたオブジェクトからメソッドを抜き出すわけですね。

メソッドの実行

さて、設定されたメソッドを実行するのは Invoke メソッドです。
ここではReflectionを使って該当コンポーネントを見つけ、さらにそのメソッドを実行しています。

あまり行数もないので読んでもらうのが早いかなと思います。

カスタムプロパティ

さて、今回の記事のメインとなる部分です。
上で書いた EventHandler クラスをプロパティに持つ、 MonoBehaviour なコンポーネントがインスペクタに表示された際に、それをどう表示するかを決める部分です。

using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Reflection;
using System.Linq;

[CustomPropertyDrawer(typeof(EventHandler))]
internal sealed class EventHandlerDrawer : PropertyDrawer {

    // 現状、折りたたまれているかのフラグ
    bool m_Unfoled = false;

    // 定義されている条件にマッチしたメソッドリスト
    string[] m_Methods = new string[]{};

    // 設定されたオブジェクトからメソッドリストを生成
    string[] CollectMethods(GameObject target)
    {
        if (target == null) {
            return new string[] { };
        }

        // MonoBehaviourを継承したものをすべて取得
        MonoBehaviour[] components = target.GetComponents<MonoBehaviour>();

        ArrayList result = new ArrayList();
        result.Add("None"); // 常に「指定しない」の意味で `None` を追加
        foreach (var component in components) {
            // 該当コンポーネントの名前を取得(階層構造のいわゆるディレクトリ的な扱いで使う)
            string componentName = component.GetType().Name;

            // 該当コンポーネントから「public」でかつ「引数 0」のメソッドを抜き出す
            string[] methodNames = component.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public)
                                    .Where(x => x.DeclaringType == component.GetType())
                                    .Where(x => x.GetParameters().Length == 0)
                                    .Select(x => componentName + "/" + x.Name)
                                    .ToArray();
            result.AddRange(methodNames);
        }

        // string[] に変換して返す
        return (string[])result.ToArray(typeof(string));
    }

    // GUIのレンダリング
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        var rect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);

        // 折りたたまれているかの状態を得る
        m_Unfoled = EditorGUI.Foldout(rect, m_Unfoled, label);

        // 折りたたまれている場合はGUIの更新をせずに終了
        if (!m_Unfoled) {
            return;
        }

        var backupIndent = EditorGUI.indentLevel;
        EditorGUI.indentLevel = 0;

        label = EditorGUI.BeginProperty(position, label, property);

        // 表示に使うメソッドリストへの参照
        string[] methods;

        // レイアウトのスタート位置
        float y = position.y;
        {
            // `Target` プロパティを取得
            SerializedProperty targetProperty = property.FindPropertyRelative("Target");

            // 指定したプロパティから、実際に設定されている値を取り出し
            GameObject target = targetProperty.objectReferenceValue as GameObject;

            // 設定されているターゲットを対象に、コンポーネントの条件に合うメソッドリストを収集
            methods = CollectMethods(target);

            // Yの位置を更新
            y += EditorGUIUtility.singleLineHeight + 5f; ;

            // インデントレベルを修正
            EditorGUI.indentLevel++;

            // ターゲットのラベルをレンダリング
            var targetRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight);
            EditorGUI.PropertyField(targetRect, targetProperty, new GUIContent("Target"));
        }

        {
            if (methods.Length > 0) {
                // `HandleMethod` プロパティから値を取得
                SerializedProperty handleMethodProperty = property.FindPropertyRelative("HandleMethod");
                y += EditorGUIUtility.singleLineHeight + 5f; ;

                // メソッドのラベルをレンダリング
                var methodRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight);
                EditorGUI.LabelField(methodRect, new GUIContent("Method"));

                if (!methods.Contains(handleMethodProperty.stringValue)) {
                    handleMethodProperty.stringValue = "";
                }

                string selected = handleMethodProperty.stringValue == "" ? "None" : handleMethodProperty.stringValue;
                int index = methods
                                .Select((Name, Index) => new { Name, Index })
                                .First(x => x.Name == selected)
                                .Index;
                var padding = 105f;
                methodRect.x += padding;
                methodRect.width -= padding;
                index = EditorGUI.Popup(methodRect, index, methods);

                handleMethodProperty.stringValue = methods[index];
            }
        }

        EditorGUI.EndProperty();

        EditorGUI.indentLevel = backupIndent;
    }

    // プロパティのインスペクタ上で占める範囲の高さ
    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        // 折りたたまれている場合は1行分だけの高さ
        if (!m_Unfoled) {
            return EditorGUIUtility.singleLineHeight;
        }

        // それ以外の場合は3行(プロパティの数)+マージン
        var height = EditorGUIUtility.singleLineHeight * 3 + 5f + 5f;
        return height;
    }
}

ちょっとハマった点

最初、形だけは思い通りの形になったんですが、左側の▼をクリックしても展開したり閉じたり、という状況が作れませんでした。
それを実現するには以下のような感じにするといいようです。
(ドキュメントとかからではなく、試行錯誤の結果なので正規のやり方ではないかも)

// 折りたたみ状態かどうかを保持するプロパティ
bool m_Unfoled = false;

// 中略

// 該当プロパティが閉じている状態かを取得
m_Unfoled = EditorGUI.Foldout(rect, m_Unfoled, label);

// 以下、閉じていた場合と開いている場合で処理を分岐

// GetPropertyHeightメソッド内で、展開の状態に応じて高さの値を変えることで、
// 双方の状態を適切な高さで表現することができる
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
    if (!m_Unfoled) {
        return EditorGUIUtility.singleLineHeight;
    }

    var height = EditorGUIUtility.singleLineHeight * 3 + 5f + 5f;
    return height;
}
edo_m18
現在はUnity ARエンジニア。 主にARのコンテンツ制作をしています。 最近は機械学習にも興味が出て勉強中です。 Unityに関するブログは別で書いています↓ https://edom18.hateblo.jp/
http://edom18.hateblo.jp/
unity-game-dev-guild
趣味・仕事問わずUnityでゲームを作っている開発者のみで構成されるオンラインコミュニティです。Unityでゲームを開発・運用するにあたって必要なあらゆる知見を共有することを目的とします。
https://unity-game-dev-guild.github.io/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away