19
Unity Tips: Properties and the Inspector
Unity is a powerful game engine, but many of its quirks make it difficult to write clean code. In this article we are going to address one of the most fundamental issues: replacing public member variables with properly scopeable properties.
Look at nearly any Unity documentation, tutorial, or project, even much of the built-in code, and you will find public member variables used everywhere.
Defining data as public member variables is the standard convention in Unity because such variables are automatically detected and serialized by the Unity editor, allowing them to be displayed in the Inspector for reading and writing. This is great for the experience of using the editor, but from a coding perspective it is horrible design. Public member variables give up control of who can change the state of the object and that introduces an opportunity for bugs.
NOTE: All example code uses standard C# naming conventions, not Unity's incorrect style. Also, this
is used religiously.
Here we have a simple example script with one public member variable, Health
, that can be raised and lowered using the HealHealth
and TakeDamage
methods, both of which cause side effects based on the new Health
value. The Health
value can be changed directly by the Inspector, but it can also be changed directly by any code in the project, even though that would bypass the side effects of the HealHealth
and TakeDamage
methods.
Instead of a public member variable, Health
should be a public property with a private setter that is displayed by and can be changed in the Inspector.
public class HealthScript : MonoBehaviour
{
public float Health;
public void HealHealth(float healAmount)
{
this.Health += healAmount;
if (this.Health > 100)
{
// Do something based on excessive health.
}
}
public void TakeDamage(float damageAmount)
{
this.Health -= damageAmount;
if (this.Health <= 0)
{
// Do something like destroy the owning object.
}
}
}
The first step is to convert the member variable into an auto-implemented property. After changing Health
to a property, the code will behave the same, but the property won't be shown in the Inspector.
//public float Health;
public float Health { get; set; }
The Inspector can only display values that are serialized by Unity and Unity doesn't auto-detect properties like it does public member variables. This means we have to tell it which properties to serialize so they will be displayed in the Inspector.
Unfortunately, Unity won't serialize properties directly, but we can serialize the property's backing field. In fact, any private member variable can be serialized by Unity if we annotate it with the SerializeField
attribute. Since the attribute has to go on a member variable, not a property, we'll rewrite the auto-implemented property to use a manually declared backing field.
//public float Health { get; set; }
public float Health { get => this._health; set => this._health = value; }
private float _health = 0;
That done, annotate the _health
backing field with the SerializeField
attribute so Unity will detect it.
public float Health { get => this._health; set => this._health = value; }
[SerializeField]
private float _health = 0;
Now the Health
value can be read and changed in the Inspector. While properties can now be used instead of public member variables, it is a shame to give up auto-implemented properties and return to the stone age of manually declaring backing fields. Thankfully, there is a solution.
Attributes can be applied to the backing fields of auto-implemented properties by prefixing the term field:
to the annotation.
Rewrite the Health
property back to an auto-implemented form, annotate it with the SerializeField
attribute, and add the field
prefix to the annotation.
//public float Health { get => this._health; set => this._health = value; }
//[SerializeField]
//private float _health = 0;
[field: SerializeField]
public float Health { get; set; }
Again, the Health
value can be read and changed in the Inspector, but, unlike the manually declared backing field, the InspectorName
attribute is no longer needed to display the correct label in the Inspector.
The one downside to this strategy is that exception messages will display the compiler mangled name of the backing field, something similar to <Health>k__BackingField
, instead of a more readable name of Health
.
It is important to note that C# only generates a usable backing field if the property has a getter and a setter. An auto-implemented property with only a getter will not be serialized by Unity, even if it is annotated with the SerializeField
attribute.
// This will not be serialized by Unity.
[field: SerializeField]
public float Health { get; }
Applying serialization to the property's backing field means that the Inspector can access the value regardless of the access scope of the property. In other words, the property and its getter and setter can be public, protected, private, or any valid combination without affecting the Inspector's ability to read and change the value.
So now we can change the property setter to be private, allowing any code to read the value, but only code in the same class can change it. This only affects use of the value in code, the Inspector can still read and change the value.
[field: SerializeField]
public float Health { get; private set; }
The code access can be controlled through the scoping of the property and its getter and setter, but in some cases one may want to limit the Inspector's ability to change a value. Unfortunately, there is no built-in way to accomplish this, but it can be accomplished with a relatively simple custom property drawer.
A property drawer is a class that tells Unity how a type should be displayed in the inspector. There is a property drawer that displays arrays with their length and a dropdown of their items, another drawer that displays float
values as a number that can be dragged left and right, and a property drawer that displays Vector2
values as a combination of X and Y values.
In this case, we want a custom property drawer that displays values as they would normally be shown, but disabled so that they can not be changed. We also need a custom attribute that will invoke our drawer when it is applied to a field.
The attribute is fairly straight forward. It should inherit from PropertyAttribute
, the Unity base class for all attributes. The attribute should be restricted to fields only, as Unity only deals with fields. Lastly, the attribute should be inherited and should not allow multiple declarations on the same field. The class itself has no functionality and will only serve as a marker to invoke our custom drawer.
[System.AttributeUsage(System.AttributeTargets.Field, Inherited = true, AllowMultiple = false)]
public class ReadOnlyFieldAttribute : PropertyAttribute { }
The custom property drawer takes a little more code than the attribute, but not much. The class should inherit from PropertyDrawer
, the Unity base class for all property drawers. The class should be annotated with two attributes; JetBrains.Annotations.UsedImplicitly
, which will keep the compiler from complaining about the class not being directly used in the project; and CustomPropertyDrawer
with the type of our attribute, which tells Unity to use the class to draw any fields annotated with our attribute.
[UsedImplicitly, CustomPropertyDrawer(typeof(ReadOnlyFieldAttribute))]
public class ReadOnlyFieldAttributeDrawer : PropertyDrawer
{
}
The first method that needs to be overridden is GetPropertyHeight
and it simply wraps a call to EditorGUI.GetPropertyHeight
so that we preserve the desired height of the property as displayed by the normal drawer.
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
=> EditorGUI.GetPropertyHeight(property, label, true);
The other method that has to be overridden is OnGUI
to do the actual drawing. Since we don't want to change the display of the value, delegate the drawing down to the built-in EditorGUI.PropertyField
method. To accomplish our goal, disabling the drawn fields, we can use the GUI.enabled
field, setting it to false
(disabled) before the drawing and back to true
(enabled) after the drawing.
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
GUI.enabled = false;
EditorGUI.PropertyField(position, property, label, true);
GUI.enabled = true;
}
Now that we have the attribute and custom property drawer, the latter can be invoked by adding the former to a field, the same as was done with the built-in SerializeField
attribute. Note that the field:
expression doesn't have to be repeated before each attribute as it applies to all attributes that follow it. That done, the Health
value will now be displayed in the Inspector, but it will be displayed as disabled and won't be editable.
public class HealthScript : MonoBehaviour
{
[field: SerializeField, ReadOnlyField]
public float Health { get; private set; }
}
Here are four examples of exposing properties to the Unity Inspector with various combinations of the value being writable from code and/or the Inspector.
- Inspector: Read and Write
- Code: Read and Write
This example is a plugin replacement for the standard public member variable. It will be readable and writable by both the Inspector and any code in the project.
public class HealthScript : MonoBehaviour
{
[field: SerializeField]
public float Health { get; set; }
}
- Inspector: Read and Write
- Code: Read Only
In this example, the Inspector can still access the value as normal, both reading and writing it, but the value is now restricted to only being writable from inside the same class. One could instead mark the setter as protected
if the value can be modified by derived classes.
public class HealthScript : MonoBehaviour
{
[field: SerializeField]
public float Health { get; private set; }
}
- Inspector: Read Only
- Code: Read and Write
Here the Inspector can show the value, but will show it disabled so that it can not be changed in the Inspector. However, any code in the project is allowed to change the value.
public class HealthScript : MonoBehaviour
{
[field: SerializeField, ReadOnlyField]
public float Health { get; set; }
}
- Inspector: Read Only
- Code: Read Only
This example is only readable in the Inspector and can only be changed by code in the same class. One could instead mark the setter as protected
if the value can be modified by derived classes.
public class HealthScript : MonoBehaviour
{
[field: SerializeField, ReadOnlyField]
public float Health { get; private set; }
}
Controlling the access scope of values is compartmentalization 101 and rampant use of public member variables throws that out the window. With the use of the SerializeField
attribute on backing fields, it is trivial to separate code access from Inspector access and return some sanity to the code.
With the addition of the custom ReadOnlyField
attribute, it is also possible to display values in the Inspector and restrict them from being changed. More importantly, that restriction can be imposed regardless of property's declared access scope.
- Copyright (c) 2021 Jay Jeckel
- Article License: CC BY
- Code License: CC0
19