Unity: Build a better ReadOnlyAttribute

(Skip the read, here’s the repo. Otherwise, read on.)

I tend to use the Header attribute to break up my component values in the inspector. I’ll write code like this:

public class TestScript : MonoBehaviour {
	[Header("Inspector Values")]
	public bool someBool = true;
	public float someFloat = 1f;
	public int someInt = 30;

	[Header("Debug")]
	public bool debugBool = false;
	public float debugFloat = 0f;
	public int debugInt = 0;

	private void Update() {
		debugInt++;
		if(debugInt == someInt) {
			debugBool = !debugBool;
			debugInt = 0;
		}

		debugFloat += someFloat * Time.deltaTime;

		if(someBool && Mathf.Sin(debugFloat) > .95) {
			debugInt = 0;
		}
	}
}

That code will get you results like this:

This is a perfectly reasonable approach; it’s easy enough for someone else (designer, etc) to figure out that the values under Debug aren’t for editing. And you could always change the value to DO NOT TOUCH or something to be even more explicit. Sometimes, though, I can’t leave good enough alone, and I wonder to myself, “How do we make sure?”

We could use an attribute to make the values read-only. Examples of this exist already (see this), and they’re pretty straight forward. But I think we can do a bit better. I’d like to clean up the debug info under a foldout, but I don’t want to put all my debug values into a Serializable struct. So I came up with this:

using UnityEngine;

public class ReadOnlyAttribute : PropertyAttribute {
    public readonly string grouping;

    public ReadOnlyAttribute (string grouping) {
		this.grouping = grouping;
    }
}

and here is the property drawer:

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

[CustomPropertyDrawer (typeof (ReadOnlyAttribute))]
public class ReadOnlyPropertyDrawer : PropertyDrawer {
	const int spacer = 8;
    const int textHeight = 16;

	Color fontColor = new Color(.25f, .25f, .25f);
    ReadOnlyAttribute readOnlyAttribute { get { return ((ReadOnlyAttribute)attribute); } }

	public override float GetPropertyHeight (SerializedProperty prop, GUIContent label) {
		var allReadOnlyFields = GetAllReadOnlyAttributeFields(prop);
		return AmIFirstReadOnly(prop, allReadOnlyFields) ? 
			(prop.isExpanded ? (allReadOnlyFields.Length + 1) * textHeight : textHeight) + spacer :
			0;
    }

	public override void OnGUI (Rect position, SerializedProperty prop, GUIContent label) {
		position.y += spacer;
		var allReadOnlyFields = GetAllReadOnlyAttributeFields(prop);
		if(AmIFirstReadOnly(prop, allReadOnlyFields)) {
			int currentIndent = EditorGUI.indentLevel;

			EditorGUI.indentLevel = 0;
			var style = new GUIStyle(EditorStyles.foldout);
			SetStyleTextColor(style);

			prop.isExpanded = EditorGUI.Foldout(position, prop.isExpanded, readOnlyAttribute.grouping, style);
			
			if(prop.isExpanded) {
				position.y += textHeight;
				DrawAllReadOnlyProperties(position, allReadOnlyFields, prop.serializedObject.targetObject);
			}

			EditorGUI.indentLevel = currentIndent;
		}
	}

	void DrawAllReadOnlyProperties(Rect position, FieldInfo[] readOnlyFields, Object targetObject) {
		int indent = EditorGUI.indentLevel;
		GUIStyle style = new GUIStyle(EditorStyles.label);
		EditorStyles.label.normal.textColor = fontColor;
		SetStyleTextColor(style);

		EditorGUI.indentLevel = indent + 1;
		EditorGUI.BeginDisabledGroup(true);
		for(int i=0; i<readOnlyFields.Length; i++) {
			RenderAppropriateEditorElement(readOnlyFields[i], targetObject, position, style); position.y += textHeight;
		}
		EditorGUI.EndDisabledGroup();
		EditorGUI.indentLevel = indent;
		EditorStyles.label.normal.textColor = Color.black;
	}

	private bool AmIFirstReadOnly(SerializedProperty prop, FieldInfo[] allReadOnlyFields) {
		string propName = prop.name;
		return allReadOnlyFields.Length > 0 && allReadOnlyFields[0].Name == propName;
	}

	private FieldInfo[] GetAllReadOnlyAttributeFields(SerializedProperty prop) {
		var otherFields = prop.serializedObject.targetObject.GetType().GetFields();
		return otherFields.Where(fi =>
			fi.GetCustomAttributes(true).Any(attr =>
				attr is ReadOnlyAttribute && ((ReadOnlyAttribute)attr).grouping == this.readOnlyAttribute.grouping))
				.ToArray();
	}

	private void RenderAppropriateEditorElement(FieldInfo fieldInfo, object targetObject, Rect position, GUIStyle style) {
		var value = fieldInfo.GetValue(targetObject);
		var label1 = new GUIContent() { text = fieldInfo.Name };
		var label2 = new GUIContent() { text = fieldInfo.GetValue(targetObject).ToString() };
		
		if(value is int || value is float || value is string) {
			EditorGUI.LabelField(position, label1, label2, style);
		}
		else if(value is bool) {
			EditorGUI.Toggle(position, label1, (bool)value);
		}
		else if(value is Vector2) {
			EditorGUI.Vector2Field(position, label1, (Vector2)value);
		}
		else if(value is Vector3) {
			EditorGUI.Vector3Field(position, label1, (Vector3)value);
		}
	}

	private void SetStyleTextColor(GUIStyle style) {
		style.onNormal.textColor = fontColor;
		style.normal.textColor = fontColor;
		style.onActive.textColor = fontColor;
		style.active.textColor = fontColor;
		style.onFocused.textColor = fontColor;
		style.focused.textColor = fontColor;
	}
}

It’s a bit of a mess, but what we’re doing is taking advantage of the consistent ordering of fields returned via reflection. If this drawer gets called for a property that turns out to be the first property with the ReadOnlyAttribute in the fields for whatever object we’re on, we do all the rendering. Otherwise, we do nothing. And if we are the first, we need to get the values of all the others to render under the foldout. The grouping property of the attribute allows us to have multiple foldouts, and we could probably expand that concept to allow for nested foldouts if we wanted to.

Here’s our newly attributed class file:

public class TestScript : MonoBehaviour {
	[Header("Inspector Values")]
	public bool someBool = true;
	public float someFloat = 1f;
	public int someInt = 30;

	[ReadOnly("debug")]
	public bool debugBool = false;
	[ReadOnly("debug")]
	public float debugFloat = 0f;
	[ReadOnly("debug")]
	public int debugInt = 0;

	private void Update() {
		debugInt++;
		if(debugInt == someInt) {
			debugBool = !debugBool;
			debugInt = 0;
		}

		debugFloat += someFloat * Time.deltaTime;

		if(someBool && Mathf.Sin(debugFloat) > .95) {
			debugInt = 0;
		}
	}
}

and the final result looks like this:

Anyway, hope that helps someone other than just me.

Leave a Reply

Your email address will not be published.