Checking Which Field Changes in OnValidate

As the title suggests, is there a way to check which field is changed in the OnValidate call? I’d like to update certain values in the Inspector when a specific value is changed. For example, I’d like my CurrentHealth stat to update to match my MaxHealth when the max is manually changed in the Inspector, but at the same time, I would like the CurrentHealth field to not update when I change it by itself. For example:

I set MaxHealth to 100
CurrentHealth gets automatically set to 100
I manually set CurrentHealth to 50

I tried to use OnValidate for these purposes, but there did not seem to be a way for me to distinguish which fields I was changing, so it would not let me update the fields at all.

You can use a secondary (hidden) serialized field for this:

[SerializeField]
float health;
[SerializeField]
[HideInInspector]
float _healthChangeCheck;

[SerializeField]
float someOtherField;

void OnValidate() {
  if (_healthChangeCheck != health) {
    print($"Health changed from {_healthChangeCheck} to {health}");
    _healthChangeCheck = health;
  }
}
2 Likes

Ah, this seems like a cheeky, yet efficient way to do it. Thanks for the help, I’ll be using this in my code unless someone happens to mention a more official way.

I’ve just come up with another way, since I didn’t want to use extra serialized fields. It comes straight from the oven, so it’s not battle-tested and I haven’t thought about any possible edge cases and whatnot (plus I don’t like too much using JSON strings and comparing them to “{}” at some point, though for now it’s a quick way to have it working), but for simple cases it’s working perfectly so far.

Basically, you add this file to the project:

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

public class FieldChangesTracker
{
    Dictionary<string, string> lastValuesByFieldPath = new Dictionary<string, string>();


    public bool TrackFieldChanges<TOwner, TMember>(TOwner rootOwnerInstance, Expression<Func<TOwner, TMember>> fieldSelector)
    {
        // Get the field info path:
        var fieldInfoPath = GetMemberInfoPath(rootOwnerInstance, fieldSelector);
        if (fieldInfoPath.Count == 0)
        {
            Debug.LogError("No member info path could be retrieved");
            return false;
        }


        // Get the current field value, and its path as a string to use as key:
        FieldInfo fieldInfo = null;
        object targetObject = rootOwnerInstance;
        string fieldPath = null;

        for (int i = 0; i < fieldInfoPath.Count; i++)
        {
            if (fieldInfo != null)
                targetObject = fieldInfo.GetValue(targetObject);

            fieldInfo = fieldInfoPath[i] as FieldInfo;
            if (fieldInfo == null)
            {
                Debug.LogError("One of the members in the field path is not a field");
                return false;
            }

            if (i > 0)
                fieldPath += ".";
            fieldPath += fieldInfo.Name;
        }

        object currentValueObject = fieldInfo.GetValue(targetObject);
          

        // Get the current value as a string:
        string currentValueString = null;

        if (currentValueObject != null)
        {
            if (currentValueObject is UnityEngine.Object)
            {
                currentValueString = currentValueObject.ToString();
            }
            else
            {
                try
                {
                    currentValueString = JsonUtility.ToJson(currentValueObject);
                }
                catch (Exception)
                {
                    Debug.LogError("Couldn't get the current value with \"JsonUtility.ToJson\"");
                    return false;
                }

                if (string.IsNullOrEmpty(currentValueString)  ||  currentValueString == "{}")
                    currentValueString = currentValueObject.ToString();
            }
        }


        // Check if the value was changed, and store the current value:
        bool changed = lastValuesByFieldPath.TryGetValue(fieldPath, out string lastValue)  &&  lastValue != currentValueString;

        lastValuesByFieldPath[fieldPath] = currentValueString;

        return changed;
    }


    public static List<MemberInfo> GetMemberInfoPath<TOwner, TMember>(TOwner ownerInstance, Expression<Func<TOwner, TMember>> memberSelector)
    {
        Expression body = memberSelector;
        if (body is LambdaExpression lambdaExpression)
        {
            body = lambdaExpression.Body;
        }

        List<MemberInfo> membersInfo = new List<MemberInfo>();
        while (body is MemberExpression memberExpression)
        {
            membersInfo.Add(memberExpression.Member);
            body = memberExpression.Expression;
        }

        membersInfo.Reverse();
        return membersInfo;
    }
}

And in MonoBehaviours and whatnot, you can just do this:

public class Test : MonoBehaviour
{
    [SerializeField] int someField = 0;

    FieldChangesTracker changesTracker = new FieldChangesTracker();

    void OnValidate()
    {
        if (changesTracker.TrackFieldChanges(this, x => x.someField))
            Debug.Log($"\"{nameof(someField)}\" changed");
    }
}

It also works for nested classes/structures and their fields:

public class Test : MonoBehaviour
{
    [Serializable]
    public class Something
    {
        public int meow;
        public GameObject obj;
        public string text;
    }

    [SerializeField] Something something = default;
   
    FieldChangesTracker changesTracker = new FieldChangesTracker();
   
    void OnValidate()
    {
        if (changesTracker.TrackFieldChanges(this, x => x.something))
            Debug.Log($"\"{nameof(something)}\" changed");

        if (changesTracker.TrackFieldChanges(this, x => x.something.meow))
            Debug.Log($"\"{nameof(something)}.{nameof(something.meow)}\" changed");
    }
}

There were indeed edge cases… There were issues when the component/ScriptableObject was just created and its fields were initialized to default values, but this version should solve them:

#if UNITY_EDITOR

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
using UnityEngine;
using System.Runtime.Serialization;

/// <summary>
/// (Editor only)
/// Helper class to track changes of serialized fields from "OnValidate" calls in <see cref="MonoBehaviour"/>, <see cref="ScriptableObject"/>, etc.
/// Create an instance of this class in the object, then call <see cref="TrackFieldChanges"/> with the desired field to track every time "OnValidate" is executed,
/// and its return value will be true if a change has happened.
/// <example>
/// <code>
/// FieldChangesTracker changesTracker = new FieldChangesTracker();
/// void OnValidate()
/// {
///     if (changesTracker.TrackFieldChanges(this, x => x.field.subfield))
///         Debug.Log(Changed");
/// }
/// </code>
/// </example>
/// </summary>

public class FieldChangesTracker
{
    Dictionary<string, string> lastValuesByFieldPath = new Dictionary<string, string>();


    /// <summary>
    /// Tracks the current value of a field, and returns whether it has changed from the previously known one.
    /// <example>
    /// <code>
    /// FieldChangesTracker changesTracker = new FieldChangesTracker();
    /// void OnValidate()
    /// {
    ///     if (changesTracker.TrackFieldChanges(this, x => x.field.subfield))
    ///         Debug.Log(Changed");
    /// }
    /// </code>
    /// </example>
    /// </summary>
    /// <typeparam name="TOwner">The type of the root owner instance.</typeparam>
    /// <typeparam name="TField">The type of the field.</typeparam>
    /// <param name="rootOwnerInstance">The root owner instance, the <see cref="MonoBehaviour"/>, <see cref="ScriptableObject"/>, etc., that owns the field.</param>
    /// <param name="fieldSelector">Expression to specify the field.</param>
    /// <returns>Whether the field value changed from the last known one. Always false the first time is called for that field after compilation.</returns>

    public bool TrackFieldChanges<TOwner, TField>(TOwner rootOwnerInstance, Expression<Func<TOwner, TField>> fieldSelector)
        where TOwner : UnityEngine.Object
    {
        // Get the field info path:
        var fieldInfoPath = GetMemberInfoPath(rootOwnerInstance, fieldSelector);
        if (fieldInfoPath.Count == 0)
        {
            Debug.LogError("No member info path could be retrieved");
            return false;
        }


        // Get the current field value, and its path as a string to use as key:
        FieldInfo fieldInfo = null;
        object targetObject = rootOwnerInstance;
        string fieldPath = null;

        for (int i = 0; i < fieldInfoPath.Count; i++)
        {
            if (fieldInfo != null)
                targetObject = targetObject != null  ?  fieldInfo.GetValue(targetObject)  :  null;

            fieldInfo = fieldInfoPath[i] as FieldInfo;
            if (fieldInfo == null)
            {
                Debug.LogError("One of the members in the field path is not a field");
                return false;
            }

            if (fieldInfo.GetCustomAttribute<SerializeReference>(true) != null)
            {
                Debug.LogError($"Fields with the {nameof(SerializeReference)} attribute are not supported for now");
                return false;
            }

            if (i > 0)
                fieldPath += ".";
            fieldPath += fieldInfo.Name;
        }

        object currentValueObject = targetObject != null  ?  fieldInfo.GetValue(targetObject)  :  null;


        // If the current value object is null, the owner instance may not completely be initialized.
        // We'll set a dummy value, otherwise in the next call the value will always be considered changed for several field types:
        if (currentValueObject == null)
        {
            Type fieldType = typeof(TField);
            if (fieldType == typeof(string))
            {
                currentValueObject = string.Empty;
            }
            else
            {
                currentValueObject = FormatterServices.GetUninitializedObject(fieldType);
            }
        }
          

        // Get the current value as a string:
        string currentValueString = null;

        if (currentValueObject != null)
        {
            if (currentValueObject is UnityEngine.Object)
            {
                currentValueString = currentValueObject.ToString();
            }
            else
            {
                try
                {
                    currentValueString = JsonUtility.ToJson(currentValueObject);
                }
                catch (Exception)
                {
                    Debug.LogError("Couldn't get the current value with \"JsonUtility.ToJson\"");
                    return false;
                }

                if (string.IsNullOrEmpty(currentValueString)  ||  currentValueString == "{}")
                    currentValueString = currentValueObject.ToString();
            }
        }


        // Check if the value was changed, and store the current value:
        bool changed = lastValuesByFieldPath.TryGetValue(fieldPath, out string lastValue)  &&  lastValue != currentValueString;

        lastValuesByFieldPath[fieldPath] = currentValueString;

        return changed;
    }




    /// <summary>
    /// Retrieves the list of <see cref="MemberInfo"/> of the member returned by the body of the specified expression. For example:
    /// <para><c>GetMembersInfo(instance, x => x.field.subfield)</c></para>
    /// </summary>
    /// <typeparam name="TOwner">The type of the member root owner.</typeparam>
    /// <typeparam name="TMember">The type of the member.</typeparam>
    /// <param name="ownerInstance">The owner instance.</param>
    /// <param name="memberSelector">The expression to select the member.</param>
    /// <returns>The list of members info, from parents to childs; an empty but not null list if they couldn't be retrieved.</returns>

    public static List<MemberInfo> GetMemberInfoPath<TOwner, TMember>(TOwner ownerInstance, Expression<Func<TOwner, TMember>> memberSelector)
    {
        Expression body = memberSelector;
        if (body is LambdaExpression lambdaExpression)
        {
            body = lambdaExpression.Body;
        }

        List<MemberInfo> membersInfo = new List<MemberInfo>();
        while (body is MemberExpression memberExpression)
        {
            membersInfo.Add(memberExpression.Member);
            body = memberExpression.Expression;
        }

        membersInfo.Reverse();
        return membersInfo;
    }
}

#endif

More fixes, I have tested it in all situations I could imagine, and haven’t found any more issues. The only thing to remember, is that “OnValidate” is not called for ScriptableObjects right when they are created, so the call to “TrackFieldChanges” should also be done in “Reset” for them.

#if UNITY_EDITOR

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

/// <summary>
/// (Editor only)
/// Helper class to track changes of serialized fields from "OnValidate" or "Reset" calls in <see cref="MonoBehaviour"/>, <see cref="ScriptableObject"/>, etc.
/// Create an instance of this class in the object, then call <see cref="TrackFieldChanges"/> with the desired field to track every time "OnValidate" or "Reset" are executed,
/// and its return value will be true if a change has happened.
/// <para>
/// Note that for <see cref="ScriptableObject"/> assets, it's recommended to call <see cref="TrackFieldChanges"/> from "Reset" as well as "OnValidate", since "OnValidate" won't be called when the object is created.
/// </para>
/// <example>
/// <code>
/// FieldChangesTracker changesTracker = new FieldChangesTracker();
/// void OnValidate()
/// {
///     if (changesTracker.TrackFieldChanges(this, x => x.field.subfield))
///         Debug.Log(Changed");
/// }
/// </code>
/// </example>
/// </summary>

public class FieldChangesTracker
{
    Dictionary<string, string> lastValuesByFieldPath = new Dictionary<string, string>();


    /// <summary>
    /// Tracks the current value of a field, and returns whether it has changed from the previously known one.
    /// <para>
    /// Note that for <see cref="ScriptableObject"/> assets, it's recommended to call <see cref="TrackFieldChanges"/> from "Reset" as well as "OnValidate", since "OnValidate" won't be called when the object is created.
    /// </para>
    /// <example>
    /// <code>
    /// FieldChangesTracker changesTracker = new FieldChangesTracker();
    /// void OnValidate()
    /// {
    ///     if (changesTracker.TrackFieldChanges(this, x => x.field.subfield))
    ///         Debug.Log(Changed");
    /// }
    /// </code>
    /// </example>
    /// </summary>
    /// <typeparam name="TOwner">The type of the root owner instance.</typeparam>
    /// <typeparam name="TField">The type of the field.</typeparam>
    /// <param name="rootOwnerInstance">The root owner instance, the <see cref="MonoBehaviour"/>, <see cref="ScriptableObject"/>, etc., that owns the field.</param>
    /// <param name="fieldSelector">Expression to specify the field.</param>
    /// <returns>Whether the field value changed from the last known one. Always false the first time is called for that field after compilation.</returns>

    public bool TrackFieldChanges<TOwner, TField>(TOwner rootOwnerInstance, Expression<Func<TOwner, TField>> fieldSelector)
        where TOwner : UnityEngine.Object
    {
        // Get the field info path:
        var fieldInfoPath = GetMemberInfoPath(rootOwnerInstance, fieldSelector);
        if (fieldInfoPath.Count == 0)
        {
            Debug.LogError("No member info path could be retrieved");
            return false;
        }


        // Get the current field value, and its path as a string to use as key:
        FieldInfo fieldInfo = null;
        object targetObject = rootOwnerInstance;
        string fieldPath = null;

        for (int i = 0; i < fieldInfoPath.Count; i++)
        {
            if (fieldInfo != null)
                targetObject = targetObject != null  ?  fieldInfo.GetValue(targetObject)  :  null;

            fieldInfo = fieldInfoPath[i] as FieldInfo;
            if (fieldInfo == null)
            {
                Debug.LogError("One of the members in the field path is not a field");
                return false;
            }

            if (fieldInfo.GetCustomAttribute<SerializeReference>(true) != null)
            {
                Debug.LogError($"Fields with the {nameof(SerializeReference)} attribute are not supported for now");
                return false;
            }

            if (i > 0)
                fieldPath += ".";
            fieldPath += fieldInfo.Name;
        }

        if (targetObject == null)
        {
            // If the final target object is null, the owner instance may not have been initialized,
            // we call the method again after a delay to see if it's initialized then:
            UnityEditor.EditorApplication.delayCall += () => TrackFieldChanges(rootOwnerInstance, fieldSelector);
            return false;
        }

        object currentValueObject = fieldInfo.GetValue(targetObject);


        // If the current value object is null, the owner instance may not have been initialized.
        // We'll set a dummy value for UnityEngine.Object types, or will call the method again after a delay for other types,
        // otherwise in the next call the value will always be considered changed for several field types:
        if (currentValueObject == null)
        {
            Type fieldType = typeof(TField);

            if (fieldType == typeof(string))
            {
                currentValueObject = string.Empty;
            }
            else if (typeof(UnityEngine.Object).IsAssignableFrom(fieldType))
            {
                currentValueObject = "null";
            }
            else
            {
                UnityEditor.EditorApplication.delayCall += () => TrackFieldChanges(rootOwnerInstance, fieldSelector);
                return false;
            }
        }
          

        // Get the current value as a string:
        string currentValueString = null;

        if (currentValueObject != null)
        {
            if (currentValueObject is UnityEngine.Object)
            {
                currentValueString = currentValueObject.ToString();
            }
            else
            {
                try
                {
                    currentValueString = JsonUtility.ToJson(currentValueObject);
                }
                catch (Exception)
                {
                    Debug.LogError("Couldn't get the current value with \"JsonUtility.ToJson\"");
                    return false;
                }

                if (string.IsNullOrEmpty(currentValueString)  ||  currentValueString == "{}")
                    currentValueString = currentValueObject.ToString();
            }
        }


        // Check if the value was changed, and store the current value:
        bool changed = lastValuesByFieldPath.TryGetValue(fieldPath, out string lastValue)  &&  lastValue != currentValueString;

        lastValuesByFieldPath[fieldPath] = currentValueString;

        return changed;
    }




    /// <summary>
    /// Retrieves the list of <see cref="MemberInfo"/> of the member returned by the body of the specified expression. For example:
    /// <para><c>GetMembersInfo(instance, x => x.field.subfield)</c></para>
    /// </summary>
    /// <typeparam name="TOwner">The type of the member root owner.</typeparam>
    /// <typeparam name="TMember">The type of the member.</typeparam>
    /// <param name="ownerInstance">The owner instance.</param>
    /// <param name="memberSelector">The expression to select the member.</param>
    /// <returns>The list of members info, from parents to childs; an empty but not null list if they couldn't be retrieved.</returns>

    public static List<MemberInfo> GetMemberInfoPath<TOwner, TMember>(TOwner ownerInstance, Expression<Func<TOwner, TMember>> memberSelector)
    {
        Expression body = memberSelector;
        if (body is LambdaExpression lambdaExpression)
        {
            body = lambdaExpression.Body;
        }

        List<MemberInfo> membersInfo = new List<MemberInfo>();
        while (body is MemberExpression memberExpression)
        {
            membersInfo.Add(memberExpression.Member);
            body = memberExpression.Expression;
        }

        membersInfo.Reverse();
        return membersInfo;
    }
}

#endif
13 Likes