【Unity】【エディタ拡張】複数のカスタムPropertyAttributeを作るとPropertyDrawerが競合する問題とその対応策

Unityで複数のカスタムPropertyAttributeを作るとPropertyDrawerが競合する件とその対応策についてまとめます。

Unity2019.4.0f1

問題点

Unityには[Range]や[TextArea]などのアトリビュートが用意されいます。

using UnityEngine;

public class Example : MonoBehaviour
{
    [Range(0f, 5f)] public float testFloat;
    [TextArea] public string testString;
}

これらを使うとシリアライズされたフィールドのInspectorにおける見栄えを簡単に変えたりできます。

f:id:halya_11:20210326234011p:plain
Inspector

このようなアトリビュートはカスタムPropertyAttributeを作ることで独自に定義することができます。
方法の詳細については説明しませんが以下の記事のような感じです。

light11.hatenadiary.com

さてこのカスタムPropertyAttributeは一つだけ使う分には良いのですが、
一つのフィールドに対して二つ以上設定すると、どちらか一つのPropertyDrawerしか反映されない問題があります。

この問題については以下のスレッドで議論されていて、スマートな解決策はないもののいくつかの方法が提唱されています。

forum.unity.com

本記事ではこのスレッドで紹介されている手法を元にもう少し使いやすく変えたものを掲載します。

ソースコード

ソースコードは以下の通りです。

using System.Linq;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;

#endif

// GUI描画をコントロールしたいMultiPropertyAttributeにはこれを付ける
public interface IAttributePropertyDrawer
{
#if UNITY_EDITOR
    void OnGUI(Rect position, SerializedProperty property, GUIContent label);

    float GetPropertyHeight(SerializedProperty property, GUIContent label);
#endif
}

public abstract class MultiPropertyAttribute : PropertyAttribute
{
    public MultiPropertyAttribute[] Attributes;
    public IAttributePropertyDrawer[] PropertyDrawers;

#if UNITY_EDITOR
    public virtual void OnPreGUI(Rect position, SerializedProperty property)
    {
    }

    public virtual void OnPostGUI(Rect position, SerializedProperty property, bool changed)
    {
    }

    // アトリビュートのうち一つでもfalseだったらそのGUIは非表示になる
    public virtual bool IsVisible(SerializedProperty property)
    {
        return true;
    }
#endif
}

#if UNITY_EDITOR
[CustomPropertyDrawer(typeof(MultiPropertyAttribute), true)]
public class MultiPropertyAttributeDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        var attributes = GetAttributes();
        var propertyDrawers = GetPropertyDrawers();

        // 非表示の場合
        if (attributes.Any(attr => !attr.IsVisible(property)))
        {
            return;
        }

        // 前処理
        foreach (var attr in attributes)
        {
            attr.OnPreGUI(position, property);
        }

        // 描画
        using (var ccs = new EditorGUI.ChangeCheckScope())
        {
            if (propertyDrawers.Length == 0)
            {
                EditorGUI.PropertyField(position, property, label);
            }
            else
            {
                propertyDrawers.Last().OnGUI(position, property, label);
            }

            // 後処理
            foreach (var attr in attributes.Reverse())
            {
                attr.OnPostGUI(position, property, ccs.changed);
            }
        }
    }

    public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
    {
        var attributes = GetAttributes();
        var propertyDrawers = GetPropertyDrawers();

        // 非表示の場合
        if (attributes.Any(attr => !attr.IsVisible(property)))
        {
            return -EditorGUIUtility.standardVerticalSpacing;
        }

        var height = propertyDrawers.Length == 0
            ? base.GetPropertyHeight(property, label)
            : propertyDrawers.Last().GetPropertyHeight(property, label);
        return height;
    }

    private MultiPropertyAttribute[] GetAttributes()
    {
        var attr = (MultiPropertyAttribute) attribute;

        if (attr.Attributes == null)
        {
            attr.Attributes = fieldInfo
                .GetCustomAttributes(typeof(MultiPropertyAttribute), false)
                .Cast<MultiPropertyAttribute>()
                .OrderBy(x => x.order)
                .ToArray();
        }

        return attr.Attributes;
    }

    private IAttributePropertyDrawer[] GetPropertyDrawers()
    {
        var attr = (MultiPropertyAttribute) attribute;

        if (attr.PropertyDrawers == null)
        {
            attr.PropertyDrawers = fieldInfo
                .GetCustomAttributes(typeof(MultiPropertyAttribute), false)
                .OfType<IAttributePropertyDrawer>()
                .OrderBy(x => ((MultiPropertyAttribute) x).order)
                .ToArray();
        }

        return attr.PropertyDrawers;
    }
}
#endif

説明はコメントとして記述しています。

使い方としては、カスタムPropertyAttributeを作る際にPropertyAttributeではなくMultiPropertyAttributeを継承します。
このクラスにはプロパティ描画の前後処理(OnPreGUIとOnPostGUI)を定義できます。
この前後処理についてはプロパティ描画時にすべてのアトリビュートのものが走る仕組みになっています。

加えて、プロパティの描画方法自体を変更したい場合にはIAttributePropertyDrawerを実装します。
これにはプロパティ描画用のメソッドが定義されており、アトリビュートの中で一番orderが高いものが優先的に処理されます。

EnabledIfAttributeをMultiPropertyAttributeで実装する

実際の使用例として、以前以下の記事で紹介したEnabledIfAttributeをMultiPropertyAttributeを使って実装してみます。

light11.hatenadiary.com

実装は以下の通りです。

using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;

#endif

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public class EnabledIfAttribute : MultiPropertyAttribute
{
    public enum HideMode
    {
        Invisible,
        Disabled
    }

    private readonly int _enableIfValueIs;
    private readonly HideMode _hideMode;
    private readonly string _switcherFieldName;

    public EnabledIfAttribute(string switcherFieldName, bool enableIfValueIs, HideMode hideMode = HideMode.Disabled)
        : this(switcherFieldName, enableIfValueIs ? 1 : 0, hideMode)
    {
    }

    public EnabledIfAttribute(string switcherFieldName, int enableIfValueIs, HideMode hideMode = HideMode.Disabled)
    {
        _hideMode = hideMode;
        _switcherFieldName = switcherFieldName;
        _enableIfValueIs = enableIfValueIs;
    }
    
#if UNITY_EDITOR
    public override void OnPreGUI(Rect position, SerializedProperty property)
    {
        var isEnabled = GetIsEnabled(property);

        if (_hideMode == HideMode.Disabled)
        {
            GUI.enabled &= isEnabled;
        }
    }

    public override void OnPostGUI(Rect position, SerializedProperty property, bool changed)
    {
        if (_hideMode == HideMode.Disabled)
        {
            GUI.enabled = true;
        }
    }

    public override bool IsVisible(SerializedProperty property)
    {
        return _hideMode != HideMode.Invisible || GetIsEnabled(property);
    }

    private bool GetIsEnabled(SerializedProperty property)
    {
        return _enableIfValueIs == GetSwitcherPropertyValue(property);
    }

    private int GetSwitcherPropertyValue(SerializedProperty property)
    {
        var propertyNameIndex = property.propertyPath.LastIndexOf(property.name, StringComparison.Ordinal);
        var switcherPropertyName = property.propertyPath.Substring(0, propertyNameIndex) + _switcherFieldName;
        var switcherProperty = property.serializedObject.FindProperty(switcherPropertyName);
        switch (switcherProperty.propertyType)
        {
            case SerializedPropertyType.Boolean:
                return switcherProperty.boolValue ? 1 : 0;
            case SerializedPropertyType.Enum:
                return switcherProperty.intValue;
            default:
                throw new Exception("unsupported type.");
        }
    }
#endif
}

NormalizedAnimationCurveAttributeをMultiPropertyAttributeで実装する

さらにもう一つの例として、以下の記事のNormalizedAnimationCurveAttributeもMultiPropertyAttributeで実装してみます。

light11.hatenadiary.com

実装は以下の通りです。

using System;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;

#endif

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public class NormalizedAnimationCurveAttribute : MultiPropertyAttribute
{
    private readonly bool _normalizeTime;
    private readonly bool _normalizeValue;

    public NormalizedAnimationCurveAttribute(bool normalizeValue = true, bool normalizeTime = true)
    {
        _normalizeValue = normalizeValue;
        _normalizeTime = normalizeTime;
    }

#if UNITY_EDITOR
    public override void OnPostGUI(Rect position, SerializedProperty property, bool changed)
    {
        if (changed)
        {
            if (_normalizeValue)
            {
                property.animationCurveValue = NormalizeValue(property.animationCurveValue);
            }

            if (_normalizeTime)
            {
                property.animationCurveValue = NormalizeTime(property.animationCurveValue);
            }
        }
    }

    private static AnimationCurve NormalizeValue(AnimationCurve curve)
    {
        var keys = curve.keys;
        if (keys.Length <= 0)
        {
            return curve;
        }

        var minVal = keys[0].value;
        var maxVal = minVal;
        foreach (var t in keys)
        {
            minVal = Mathf.Min(minVal, t.value);
            maxVal = Mathf.Max(maxVal, t.value);
        }

        var range = maxVal - minVal;
        var valScale = range < 1 ? 1 : 1 / range;
        var valOffset = 0f;
        if (range < 1)
        {
            if (minVal > 0 && minVal + range <= 1)
            {
                valOffset = minVal;
            }
            else
            {
                valOffset = 1 - range;
            }
        }

        for (var i = 0; i < keys.Length; ++i)
        {
            keys[i].value = (keys[i].value - minVal) * valScale + valOffset;
        }

        curve.keys = keys;
        return curve;
    }

    private static AnimationCurve NormalizeTime(AnimationCurve curve)
    {
        var keys = curve.keys;
        if (keys.Length <= 0)
        {
            return curve;
        }

        var minTime = keys[0].time;
        var maxTime = minTime;
        foreach (var t in keys)
        {
            minTime = Mathf.Min(minTime, t.time);
            maxTime = Mathf.Max(maxTime, t.time);
        }

        var range = maxTime - minTime;
        var timeScale = range < 0.0001f ? 1 : 1 / range;
        for (var i = 0; i < keys.Length; ++i)
        {
            keys[i].time = (keys[i].time - minTime) * timeScale;
        }

        curve.keys = keys;
        return curve;
    }
#endif
}

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com

参考

forum.unity.com