Bindable Values

Let’s say I want Script B to perform some logic whenever a variable in Script A changes. It’s a pretty basic problem, but unfortunately, one that is rarely given the attention it deserves. I’d like to discuss some of the common ways of approaching the task, and share one of the tools I use to make my implementations easier and more consistent.

A common first approach is to track the value in parallel, polling the source for changes and responding when the value no longer matches the local copy:

    private int lastScore;

    internal void Update()
    {
        if (this.stateManager.score != this.lastScore)
        {
            this.lastScore = this.stateManager.score;
            // do something
        }
    }

The more advanced option is to raise an event when a value changes. In this example, the state manager exposes its score value through a property, which raises an event when the backing field changes:

    private int score;

    public Action<int> OnScoreChanged; 

    public int Score
    {
        get
        {
            return this.score;
        }

        set
        {
            if (this.score != value)
            {
                this.score = value;
                if (this.OnScoreChanged != null)
                {
                    this.OnScoreChanged(this.score);
                }
            }
        }
    }

Consumers can then check the Score property directly, or subscribe to the OnScoreChanged event to be notified of a change in value. However, this can quickly produce a large amount of repetitive code, which complicates the score manager class. It would be nice to associate this logic with the value itself, so any variable could be subscribed to simply and easily.

That’s where BindableValue comes in. BindableValue is a generic class that handles the responsibility for tracking changes to, and raising events for, a backing type. It’s similar in concept to the INotifyPropertyChanged interface. But INotifyPropertyChanged leaves implementation up to the derived class, resulting again in repetitive code. BindableValue wraps the affected type, minimizing code repetition, and raises an event with the updated value as its argument, enabling a more natural usage pattern:

// --------------------------------
// <copyright file="BindableValue.cs" company="Rumor Games">
//     Copyright (C) Rumor Games, LLC.  All rights reserved.
// </copyright>
// --------------------------------

using System;
using System.Collections.Generic;

/// <summary>
/// BindableValue class.
/// </summary>
/// <typeparam name="T">Backing value type.</typeparam>
public class BindableValue<T> where T : IEquatable<T>
{
    /// <summary>
    /// Backing value, serialize to show in Inspector.
    /// </summary>
    [UnityEngine.SerializeField]
    private T backingValue;

    /// <summary>
    /// Event raised when bindable value changes.
    /// </summary>
    public Action<T> OnValueChanged;

    /// <summary>
    /// Gets or sets the bindable value.
    /// </summary>
    public T Value
    {
        get
        {
            return this.backingValue;
        }
        set
        {
            if (!this.Equals(value))
            {
                this.backingValue = value;
                if (this.OnValueChanged != null)
                {
                    this.OnValueChanged(this.backingValue);
                }
            }
        }
    }

    /// <summary>
    /// Initializes a new instance of the BindableValue class.
    /// </summary>
    /// <param name="value">Initial value.</param>
    public BindableValue(T value)
    {
        this.backingValue = value;
    }
  
    /// <summary>
    /// Implicitly convert BindableValue to backing value type.
    /// </summary>
    /// <param name="bindableValue">BindableValue to convert.</param>
    /// <returns>Backing value.</returns>
    public static implicit operator T(BindableValue<T> bindableValue)
    {
        return bindableValue.Value;
    }

    /// <summary>
    /// Determines whether one BindableValue is equal to another.
    /// </summary>
    /// <param name="a">First BindableValue with value of type T to compare.</param>
    /// <param name="b">Second BindableValue with value of type T to compare.</param>
    /// <returns>True if the BindableValues are equal.</returns>
    public static bool operator ==(BindableValue<T> a, BindableValue<T> b)
    {
        if (ReferenceEquals(a, null))
        {
            return ReferenceEquals(b, null);
        }

        return a.Equals(b);
    }

    /// <summary>
    /// Determines whether one BindableValue type is not equal to another.
    /// </summary>
    /// <param name="x">First BindableValue with value of type T to compare.</param>
    /// <param name="y">Second BindableValue with value of type T to compare.</param>
    /// <returns>True if the BindableValues are not equal.</returns>
    public static bool operator !=(BindableValue<T> x, BindableValue<T> y)
    {
        return !(x == y); 
    }

    /// <summary>
    /// Determines whether the specified object is equal to the current object.
    /// </summary>
    /// <returns>True if the specified object is equal to the current object; otherwise, false.</returns>
    /// <param name="obj">The object to compare with the current object.</param>
    public override bool Equals(object obj)
    {
        // is the object null?
        if (ReferenceEquals(obj, null))
        {
            return false;
        }

        // are they the same instance?
        if (ReferenceEquals(this, obj))
        {
            return true;
        }

        // are the values equal?
        if (obj is T)
        {
            return this.Equals((T)obj);
        }

        // are they the same type?
        if (obj.GetType() != this.GetType())
        {
            return false;
        }

        // are they otherwise equal?
        return this.Equals((BindableValue<T>)obj);
    }

    /// <summary>
    /// Serves as a hash function for a particular type. 
    /// </summary>
    /// <returns>A hash code for the current object.</returns>
    public override int GetHashCode()
    {
        unchecked
        {
            return (EqualityComparer<T>.Default.GetHashCode(this.backingValue) * 397) ^ (this.OnValueChanged != null ? this.OnValueChanged.GetHashCode() : 0);
        }
    }

    /// <summary>
    /// Determines whether the specified object is equal to the current object.
    /// </summary>
    /// <returns>True if the specified object is equal to the current object; otherwise, false.</returns>
    /// <param name="other">The object to compare with the current object.</param>
    protected bool Equals(BindableValue<T> other)
    {
        return this.Equals(other.backingValue) && Equals(this.OnValueChanged, other.OnValueChanged);
    }

    /// <summary>
    /// Determines whether the specified object is equal to the current object.
    /// </summary>
    /// <returns>True if the specified object is equal to the current object; otherwise, false.</returns>
    /// <param name="other">The object to compare with the current object.</param>
    protected bool Equals(T other)
    {
        return EqualityComparer<T>.Default.Equals(this.backingValue, other);
    }
}

And a sample implementation:

    /// <summary>
    /// This function is called when the object becomes enabled and active.
    /// </summary>
    internal void OnEnable()
    {
        this.stateManager.score.OnValueChanged += this.OnValueChanged;
    }

    /// <summary>
    /// This function is called when the behaviour becomes disabled or inactive.
    /// </summary>
    internal void OnDisable()
    {
        this.stateManager.score.OnValueChanged -= this.OnValueChanged;
    }

    /// <summary>
    /// Handles a change in value.
    /// </summary>
    /// <param name="value">New value after change.</param>
    private void OnValueChanged(int value)
    {
        // do something
    }

There are a couple of things to make note of:

  • The backing type must implement IEquatable<T>. This is so that we can accurately detect changes (inequality) between an old and new value.
  • The backing field is private, so it cannot be accessed directly by consumers, but is marked with [UnityEngine.SerializeField] so it will be visible in the Inspector.
  • The event delegate is of type Action<T> and transmits the updated value as its event argument.
  • The class includes an implicit conversion operator so that it can masquerade as the backing type (in some situations).

Because the BindableValue is implicitly convertable to its backing type, it can be used where the value would normally be read:

        if (this.score > 5)
        {
            // do something
        }

The value can also be retrieved using the Value property. Editing requires the Value property to be explicitly addressed:

        this.score.Value += scoreDelta;

With BindableValue, it is easy to add the ability to monitor updates to any type, but this approach is most suited for simple value types. I keep a collection of commonly-needed implementations in my Framework directory:

// --------------------------------
// <copyright file="BindableValueTypes.cs" company="Rumor Games">
//     Copyright (C) Rumor Games, LLC.  All rights reserved.
// </copyright>
// --------------------------------

using System;

/// <summary>
/// Float class, bindable float value.
/// </summary>
[Serializable]
public class Float : BindableValue<float>
{
    /// <summary>
    /// Initializes a new instance of the Float class.
    /// </summary>
    /// <param name="value">Initial value.</param>
    public Float(float value)
        : base(value)
    {
    }
}

/// <summary>
/// Int class, bindable int value.
/// </summary>
[Serializable]
public class Int : BindableValue<int>
{
    /// <summary>
    /// Initializes a new instance of the Int class.
    /// </summary>
    /// <param name="value">Initial value.</param>
    public Int(int value)
        : base(value)
    {
    }
}

/// <summary>
/// Bool class, bindable bool value.
/// </summary>
[Serializable]
public class Bool : BindableValue<bool>
{
    /// <summary>
    /// Initializes a new instance of the Bool class.
    /// </summary>
    /// <param name="value">Initial value.</param>
    public Bool(bool value)
        : base(value)
    {
    }
}

In addition to value types, BindableValue will accept as a backing type any class that implements IEquatable<T>. I find these make sense to implement on a per-project basis. Here’s an example of a BindableValue wrapping a complex type:

/// <summary>
/// BindablePoint class, bindable Point value.
/// </summary>
[Serializable]
public class BindablePoint : BindableValue<Point>
{
    /// <summary>
    /// Initializes a new instance of the BindablePoint class.
    /// </summary>
    /// <param name="value">Initial value.</param>
    public BindablePoint(Point value)
        : base(value)
    {
    }

    /// <summary>
    /// Gets the X value of the backing point.
    /// </summary>
    public int x
    {
        get
        {
            return this.Value.x;
        }
    }

    /// <summary>
    /// Gets the Y value of the backing point.
    /// </summary>
    public int y
    {
        get
        {
            return this.Value.y;
        }
    }
}

Unfortunately, there are a few situations where the compiler cannot infer type from usage, and so implicit conversion is not enough. The compiler needs a little more guidance, which can be handled in one of three ways: explicitly cast the BindableValue to its backing type, explicitly access the Value property, or add pass-through properties for the fields and properties of the backing type you want exposed (as in the BindablePoint example).

Looks great, I’m going to use this everywhere!

Please don’t! This is not a golden hammer. Like most tools, it is meant to solve a specific problem, and mis-applying it can result in frustration and bruised thumbs. Let’s look at a few scenarios and whether BindableValue is the best tool for the job:

  • I want to notify my camera to update when the player’s position changes.

NO. In this case, polling in the Update or FixedUpdate loop makes sense. The player’s position is changing frequently, and the camera is going to need to perform its own update logic anyway.

  • I want to notify my UI to update when the score changes.

YES! This is the perfect example of when to use a BindableValue. It enables the UI script to be extremely lightweight, simply listening to and responding to changes in state without a lot of overhead.

  • I want to notify several systems to update when the game is over.

MAYBE. Given the infrequency of this event, and the fact that there may be a large number of consumers that care about it (but are decoupled from whatever class is making the “game over” determination), this might be a job for a global event dispatcher instead.

When regular events make sense, BindableValue is a time- and energy-saving way of implementing them.

Bookmark the permalink.

Comments are closed