DEV Community

Jay Jeckel
Jay Jeckel

Posted on

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.

Example Script

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.
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Enabled

Using Properties

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; }
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Not Shown

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;
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Not Shown

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;
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Enabled

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; }
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Enabled

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; }
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Not Shown

Controlling Code Access

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; }
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Enabled

Controlling Inspector Access

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.

ReadOnlyField Attribute

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 { }
Enter fullscreen mode Exit fullscreen mode

ReadOnlyField Drawer

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
{
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Using ReadOnlyField

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; }
}
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Disabled

Usage Examples

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.

Example 1

  • 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; }
}
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Enabled

Example 2

  • 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; }
}
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Enabled

Example 3

  • 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; }
}
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Disabled

Example 4

  • 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; }
}
Enter fullscreen mode Exit fullscreen mode

Inspector Health Value Disabled

Conclusion

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.

Discussion (1)

Collapse
jayjeckel profile image
Jay Jeckel Author

Been stuck all morning waiting on a client, but at least I finally got around to writing my first article. :)