1
\$\begingroup\$

I'm trying to implement an observable pattern on variables, so that I can hook up UI components, etc. to change displayed values or other actions when their value changes.

I wanted three features that the built-in Delegate system doesn't satisfy:

  • (optional) try/catch calls so that some not important UI element doesn't break the whole game loop
  • having the variables in ScriptableObjects for easy storage and inspection
  • having a list of listeners in the inspector for debugging and transparency

Here is the code I finally came up with, and it does what I need. But I'm relatively new to C# so I'm looking forward to improvements and advise:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEditor;
using Sirenix.OdinInspector;


[CreateAssetMenu(fileName = "New Observable Float", menuName = "Observables/Float")]
public class ObservableFloat : ScriptableObject {

    [System.Serializable]
    public class Listener {
        [DisplayAsString, HideInInspector] public int ID;
        [DisplayAsString, HideLabel, HorizontalGroup] public string Observer;
        [DisplayAsString, HideLabel, HorizontalGroup] public string Method;
        [HideInInspector] public UnityAction<float> Callback;
        
        public Listener(UnityAction<float> cb) {
            ID = cb.GetHashCode();
            Observer = cb.Target.ToString();
            Method = cb.Method.ToString();
            Callback = cb;
        }
    }

    [Delayed]
    [OnValueChanged("NotifyListeners")]
    [SerializeField] private float m_Value;
    public float Value {
        get {
            return m_Value;
        }
        set {
            m_Value = value;
            NotifyListeners();
        }
    }

    [Tooltip("Log Invoke() calls")]
    [SerializeField] bool Trace;

    [Tooltip("Use try/catch around Invoke() calls so events continue to other listeners even if one fails")]
    [SerializeField] bool CatchExceptions;

    [ListDrawerSettings(DraggableItems = false, Expanded = true, ShowIndexLabels = false, ShowPaging = false, ShowItemCount = true)]
    [SerializeField] List<Listener> listeners = new List<Listener>();


    void Awake() {
        // clear out whenever we start - just in case some observer doesn't properly remove himself
        // maybe later I'll also add persistent listeners, but for now I don't see the use case
        listeners = new List<Listener>();
    }

    void NotifyListeners() {
        foreach (Listener listener in listeners) {
            if (Trace) {
                Debug.Log("invoking "+listener.Observer+" / "+listener.Method+ " / value = "+m_Value);
            }
            if (CatchExceptions) {
                try {
                    listener.Callback.Invoke(m_Value);
                } catch (System.Exception exception) {
                    Debug.LogException(exception, this);
                }
            } else {
                listener.Callback.Invoke(m_Value);              
            }
        }
    }

    public void AddListener(UnityAction<float> func) { listeners.Add(new Listener(func)); }
    public void RemoveListener(UnityAction<float> func) { listeners.RemoveAll(l => l.ID == func.GetHashCode()); }
}

Note that I'm using a few OdinInspector tags to make the inspector display a bit more beautiful, but it's not required for functionality.

Note 2: Yes, I plan to turn this into a generic eventually.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.