How to handle [serializeReference] in a list? (Custom inspector)

I’m building a custom inspector for my Event System that looks like this:

Under the hood, it’s a list of type PlaybackEvent

[System.Serializable]
public class PlaybackEvent
{
    public float playbackTime; // Time in normalized percentage (0 - 1)

    [SerializeReference, UseGameEventEditor]
    public GameEvent associatedEvent; // Associated GameEvent
}

The problem is, that when changing a selection from the dropdown, sometimes it changes the proper index, sometimes not.

serializeReference

How can I fix this behavior? I’m out of ideas (I can share more code if needed)

Going to need to see your code. Mind you it’s fairly straightforward to make a property drawer that works for all SerializeReference fields.

Sure, it’s a little complicated but here are the parts:

UseGameEventEditor has this inspector:

[CustomPropertyDrawer(typeof(UseGameEventEditorAttribute))]
  public class GameEventPropertyDrawer : PropertyDrawer
  {
      private bool _isInitialized = false;

      // Caching the calculated height to avoid recalculating multiple times
      private float _cachedHeight = EditorGUIUtility.singleLineHeight;

      private Dictionary<string, bool> _foldouts = new Dictionary<string, bool>();

      private Dropdown _dropdown = new Dropdown("Events");

      public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
      {
          var gameEventAttribute = attribute as UseGameEventEditorAttribute;

          // Initialize dropdown only once
          if (!_isInitialized)
          {
              _dropdown.ClearEntries();
              InitializeDropdownOptions();
              _dropdown.Label = property.displayName;
              _dropdown.OnEntrySelected = null; // Clear all existing event handlers
              _dropdown.OnEntrySelected += e => OnGameEventSelected(e, property);
              _isInitialized = true;
          }

          EditorGUI.BeginProperty(position, label, property);

          // Synchronize dropdown with serialized property value
          UpdateDropdownSelection(property);

          // Reserve space for the dropdown above the fields
          Rect dropdownPosition = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);

          // Draw the dropdown 
          _dropdown.DrawDropdown(dropdownPosition);

          // Calculate the starting position for fields below the dropdown
          float fieldsStartY = dropdownPosition.yMax + EditorGUIUtility.standardVerticalSpacing;
          Rect fieldsPosition = new Rect(position.x, fieldsStartY, position.width, EditorGUIUtility.singleLineHeight);

          // Render the GameEvent fields dynamically if a value is selected
          if (gameEventAttribute != null && gameEventAttribute.ShowProperties)
          {
              if (property.managedReferenceValue != null)
              {
                  // Draw the fields below the dropdown
                  var gameEventInstance = property.managedReferenceValue;
                  DrawGameEventFields(ref fieldsPosition, gameEventInstance);

                  // Update cached height for proper layout updates
                  _cachedHeight = fieldsPosition.yMax - position.y;
              }
              else
              {
                  // Reset to default height if no value is selected
                  _cachedHeight = dropdownPosition.height;
              }
          }

          EditorGUI.EndProperty();
      }

      private void InitializeDropdownOptions()
      {
          var gameEventAttribute = attribute as UseGameEventEditorAttribute;

          _dropdown.AddEntryRange(
              GameEventService.GetGameEventsInProjectAsDropdownEntry(gameEventAttribute.IncludeSystemEvents));
      }

      private void OnGameEventSelected(DropdownEntry selectedEntry, SerializedProperty property)
      {
          // Cast the data back to GameEventHumanized
          var selectedEvent = (GameEventHumanized)selectedEntry.Value;

          if (selectedEvent != null && selectedEvent.defaultConstructor)
          {
              property.managedReferenceValue = Activator.CreateInstance(selectedEvent.type);
          }
          else
          {
              property.managedReferenceValue = null;
          }

          // Apply changes to the serialized object to notify Unity
          property.serializedObject.ApplyModifiedProperties();
          property.serializedObject.Update();
      }

      private void UpdateDropdownSelection(SerializedProperty property)
      {
          // If there is a serialized value, find its corresponding index in the dropdown
          if (property != null && property.managedReferenceValue != null && _dropdown.Entries.Count > 0)
          {
              var currentValue = property.managedReferenceValue.GetType();
              var selectedIndex =
                  _dropdown.Entries.FindIndex(entry => ((GameEventHumanized)entry.Value)?.type == currentValue);

              if (selectedIndex >= 0)
              {
                  _dropdown.selectedIndex = selectedIndex; // Synchronize dropdown with serialized value
              }
          }
          else
          {
              _dropdown.selectedIndex = 0; // Default to the first option if no value is set
          }
      }

      public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
      {
          // Return the cached height for this property
          return _cachedHeight;
      }

      private void DrawGameEventFields(ref Rect position, object gameEventInstance)
      {
          if (gameEventInstance == null) return;

          var type = gameEventInstance.GetType();
          foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
          {
              if (!field.IsPublic && field.GetCustomAttribute<SerializeField>() == null) continue;

              // Nicify the label and get the field value
              string fieldLabel = ObjectNames.NicifyVariableName(field.Name);
              object fieldValue = field.GetValue(gameEventInstance);

              // Render the field using the utility method
              position = RenderField(position, fieldLabel, gameEventInstance, field, fieldValue);
          }
      }

      private Rect DrawClassField(Rect position, object instance, FieldInfo field, string label, object value)
      {
          // Ensure foldout state exists for this field
          if (!_foldouts.TryGetValue(label, out bool isExpanded))
          {
              isExpanded = false;
              _foldouts[label] = isExpanded;
          }

          // Render the foldout and update its state
          _foldouts[label] =
              EditorGUI.Foldout(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight),
                  _foldouts[label], label);
          position.y += EditorGUIUtility.singleLineHeight;

          // If expanded, draw the class fields
          if (_foldouts[label])
          {
              if (value == null)
              {
                  // Allow creating an instance of the class if it's null
                  if (GUI.Button(new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight),
                          "Create Instance"))
                  {
                      value = Activator.CreateInstance(field.FieldType);
                      field.SetValue(instance, value);
                  }

                  position.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;
              }
              else
              {
                  // Get all nested fields
                  var nestedFields = field.FieldType
                      .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
                      .Where(f => f.IsPublic || f.IsDefined(typeof(SerializeField)));

                  foreach (var nestedField in nestedFields)
                  {
                      string nestedLabel = ObjectNames.NicifyVariableName(nestedField.Name);
                      object nestedValue = nestedField.GetValue(value);

                      // Render each nested field using the utility method
                      position = RenderField(position, nestedLabel, value, nestedField, nestedValue);
                  }
              }
          }

          return position;
      }

      private Rect RenderField(Rect position, string label, object instance, FieldInfo field, object fieldValue)
      {
          // Handle different field types
          if (field.FieldType == typeof(int))
          {
              fieldValue = EditorGUI.IntField(position, label, (int)fieldValue);
          }
          else if (field.FieldType == typeof(float))
          {
              fieldValue = EditorGUI.FloatField(position, label, (float)fieldValue);
          }
          else if (field.FieldType == typeof(bool))
          {
              fieldValue = EditorGUI.Toggle(position, label, (bool)fieldValue);
          }
          else if (field.FieldType == typeof(string))
          {
              fieldValue = EditorGUI.TextField(position, label, (string)fieldValue);
          }
          else if (field.FieldType == typeof(Vector3))
          {
              fieldValue = EditorGUI.Vector3Field(position, label, (Vector3)fieldValue);
          }
          else if (field.FieldType == typeof(Vector4))
          {
              fieldValue = EditorGUI.Vector4Field(position, label, (Vector4)fieldValue);
          }
          else if (field.FieldType == typeof(Color))
          {
              fieldValue = EditorGUI.ColorField(position, label, (Color)fieldValue);
          }
          else if (field.FieldType == typeof(Rect))
          {
              fieldValue = EditorGUI.RectField(position, label, (Rect)fieldValue);
          }
          else if (field.FieldType == typeof(Quaternion))
          {
              Quaternion quaternion = (Quaternion)fieldValue;
              Vector4 vector = new Vector4(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
              vector = EditorGUI.Vector4Field(position, label, vector);
              fieldValue = new Quaternion(vector.x, vector.y, vector.z, vector.w);
          }
          else if (typeof(Object).IsAssignableFrom(field.FieldType))
          {
              fieldValue = EditorGUI.ObjectField(position, label, fieldValue as Object, field.FieldType, true);
          }
          else if (field.FieldType.IsEnum)
          {
              fieldValue = EditorGUI.EnumPopup(position, label, (Enum)fieldValue);
          }
          else if (field.FieldType.IsClass && field.FieldType != typeof(object))
          {
              // Handle nested class fields recursively
              position = DrawClassField(position, instance, field, label, fieldValue);
              return position; // Return here because DrawClassField adjusts the position itself
          }
          else
          {
              EditorGUI.LabelField(position, label, $"Unsupported type: {field.FieldType.Name}");
          }

          // Apply the updated value back to the field
          field.SetValue(instance, fieldValue);

          // Move the position for the next field
          position.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing;

          return position;
      }
  }

Reflection is needed, because GameEvent is a non unity object and also it’s abstract

Here is the dropdown used in that drawer:

public class Dropdown
    {
        public string Label;
        public List<DropdownEntry> Entries;
        public Action<DropdownEntry> OnEntrySelected;

        public int selectedIndex = -1; // Default to -1 to indicate no selection
        private DropdownEntry selectedEntry; // Cache the selected entry for efficient comparisons

        public Dropdown()
        {
            ClearEntries();
            Label = string.Empty;
        }

        public Dropdown(string label)
        {
            ClearEntries();
            Label = label;
        }

        public void AddEntryRange(List<DropdownEntry> entries)
        {
            Entries.AddRange(entries);
        }

        public void AddEntry(DropdownEntry entry)
        {
            Entries.Add(entry);
        }

        public void RemoveEntry(DropdownEntry entry)
        {
            Entries.Remove(entry);
            CorrectInvalidSelection();
        }

        public void RemoveEntry(int index)
        {
            if (index >= 0 && index < Entries.Count)
            {
                Entries.RemoveAt(index);
                CorrectInvalidSelection();
            }
        }

        public void ClearEntries()
        {
            Entries = new List<DropdownEntry>();
            selectedEntry = null;
            selectedIndex = -1; // Reset selection
        }

        private void CorrectInvalidSelection()
        {
            // Ensure a valid selection index and entry
            if (selectedIndex >= Entries.Count)
            {
                selectedIndex = Entries.Count - 1;
            }

            if (selectedIndex >= 0 && selectedIndex < Entries.Count)
            {
                selectedEntry = Entries[selectedIndex];
            }
            else
            {
                selectedEntry = null;
                selectedIndex = -1;
            }
        }

        public void DrawDropdown()
        {
            UpdateSelectionFromEntries();

            // Draw the dropdown
            int newSelection =
                EditorGUILayout.Popup(Label, selectedIndex, Entries.ConvertAll(entry => entry.Name).ToArray());

            ProcessNewSelection(newSelection);
        }

        public void DrawDropdown(Rect position)
        {
            UpdateSelectionFromEntries();

            // Draw the dropdown using explicit Rect-based positioning
            int newSelection = EditorGUI.Popup(position, Label, selectedIndex, Entries.ConvertAll(entry => entry.Name).ToArray());

            ProcessNewSelection(newSelection);
        }

        private void UpdateSelectionFromEntries()
        {
            // Ensure the selectedIndex is valid, even after entries have changed
            if (selectedIndex < 0 || selectedIndex >= Entries.Count)
            {
                selectedIndex = -1;
                selectedEntry = null;
            }
        }

        private void ProcessNewSelection(int newSelection)
        {
            if (newSelection != selectedIndex)
            {
                selectedIndex = newSelection;

                if (selectedIndex >= 0 && selectedIndex < Entries.Count)
                {
                    selectedEntry = Entries[selectedIndex];
                    OnEntrySelected?.Invoke(selectedEntry);
                }
                else
                {
                    selectedEntry = null;
                }
            }
        }
    }

And then, in a custom editor I had this to draw the list of the playback object that is in the post:

private void DrawPlaybackEventsList()
    {
        for (int i = 0; i < playbackEventsProperty.arraySize; i++)
        {
            SerializedProperty element = playbackEventsProperty.GetArrayElementAtIndex(i);

            EditorGUILayout.BeginVertical(EditorStyles.helpBox);

            // Display playback time
            SerializedProperty timeProp = element.FindPropertyRelative("playbackTime");
            timeProp.floatValue = EditorGUILayout.Slider("Time", timeProp.floatValue, 0f, 1f);

            // Display associated GameEvent dropdown with unique PropertyField
            SerializedProperty eventProp = element.FindPropertyRelative("associatedEvent");
            if (eventProp != null)
            {
                EditorGUILayout.PropertyField(eventProp, new GUIContent("Event"), true);
            }
            else
            {
                Debug.LogError($"associatedEvent not found in PlaybackEvent at index {i}.");
            }

            // Remove button
            if (GUILayout.Button("Remove"))
            {
                playbackEventsProperty.DeleteArrayElementAtIndex(i);
            }

            EditorGUILayout.EndVertical();
        }
    }

I have an updated code, where I made it work only one form for editing any of the options in the list (the user clicks on an object of the list, and then gets to edit that element individually).

But I would love to know why this behavior is happening when I have multiple in a list

This is way too much code.

For one PropertyDrawers need to be stateless, as they are reused for multiple fields. That’s probably where the issue is.

Secondly, you can auto-draw a serialised property with a PropertyField so there’s no need to handle that yourself.

Here’s my basic general solution using UI Toolkit: abstract SkillList that can be edited in Inspector - #15 by spiney199

1 Like

Ah, I see.

I never worked with custom editors before (fan of Odin), but it makes completly sense that they need to be stateless.

I was unsure about the PropertyField, because I thought that Unity only manages Unity.Object derived classes. Is there a way to detect if I can use PropertyField for certain property?

A PropertyField can handle any SerializedProperty.

1 Like

Ahh, I see.

So you are first drawing a Dropdown to select the class, and then after knowing the type, you just draw a Property Field and that takes care of the properties inside. Right?

Sounds powerful! Thanks for the insight.

One last question: In my custom inspector, I’m also handling plain C# classes. If they are null, I draw a button to create the instance and then continue the Property Field thing. How would you implement this?

By using reflection, I’m manually checking these but by migrating to the Property Field, I’m not sure if I should add the attribute to the class property as well, or which would be the best approach.

Thank you!

I mean I’d just use the same attribute/property drawer for that, as that already does the work of creating an instance based on the type. You’re already serialising by-reference, so even if you may not have any derived types now, you might later.