Search Unity

How can I tell Undo from Redo?

Discussion in 'Immediate Mode GUI (IMGUI)' started by Dreamteck, Feb 23, 2018.

  1. Dreamteck

    Dreamteck

    Joined:
    Feb 12, 2015
    Posts:
    336
    ModLunar likes this.
  2. CptKen

    CptKen

    Joined:
    May 30, 2017
    Posts:
    216
    I would also like to know this. Having the same issue.
     
  3. Xarbrough

    Xarbrough

    Joined:
    Dec 11, 2014
    Posts:
    1,188
    Currently, Unity doesn't distinguish those events. In their own editor code, they simply re-initialize or clean the state of their objects. E.g. when the animation window changes because of an undo or redo, they clear the view and reload all values from the serialized object representation, which is always in sync. If you are using SerializedObject and SerializedProperty and make sure all of your data is serialized like Unity wants it, you shouldn't need to check the cases separately. Or, what is your use-case? Maybe post some code and we can find a better approach to fit the Unity paradigm. So far, I've always had more success accepting Unity's way, than to roll my own. Just think about all the intricate issues beyond Undo/Redo...prefab overrides, duplicate-commands, the copy-component and paste-component-values commands, multi-object-editing in the inspector, and so on. Unity handles all of that if we stick to their serialization system and use SerializedObject for editing.
     
  4. CptKen

    CptKen

    Joined:
    May 30, 2017
    Posts:
    216
    This is exactly what I ended up doing. I was trying to implement Undo/Redo functionality to my node graph editor. At the end of the day I just had to re-initialise the entire graph after every Undo/Redo event. There's no way around it really even if you use SerializedPropery etc. because any non-serialised parameters won't be recognised by the system and won't be reverted.
     
  5. Xarbrough

    Xarbrough

    Joined:
    Dec 11, 2014
    Posts:
    1,188
    Right, I can add that at first I had the same problem for something like a non-serialized selectedIndex variable. After undo/redo I wanted to find out how to move my selection back and I actually had to look up the solution in Unity's code: Well, they serialize everything. ;) In OnEnable/OnDisable of my custom editor I create a ScriptableObject which contains an int, and is marked as HideAndDontSave. Now I can persist the selection and also have undo/redo working without having to know if a redo etc was performed. I must say it work really well. The same thing basically goes for all data used in EditorWindows. At first I tried to keep track of my editor state via different callbacks (OnEnable, Recompile, PlayModeChange, etc), but soon I realized that I had many small edge cases. Once I just create a single ScriptableObject and serialized the entire state, I only needed to set it up once and destroy it when I'm done. Works really well. This also provides a nice way of visually debugging editor code. Normally, editor state doesn't show anyway in Unity, but if you serialize your data you can e.g. make a memory snapshot via the profiler and highlight the hidden ScriptableObject or Editor in the inspector, showing all of its serializable fields.
     
  6. Dreamteck

    Dreamteck

    Joined:
    Feb 12, 2015
    Posts:
    336
    Thanks for the answer! I was afraid the answer would be something like that. The reason I need to intercept Unity's Undo/Redo commands is because I need to implement some custom Undo actions.

    I'm working on a custom audio mixer/editor which can create or remove game objects with AudioSources. These objects are also kept in an array inside a SerializedObject. The problem is that whenever I need to create a new object or remove one, Unity's Undo can only either record the action of creation/deletion or the change in the array - I can't group those two actions together and so If I am to undo a created object, it will either not get referenced in the list or not re-created at all.

    Basically I can't call RegisterCreatedObjectUndo and RecordObject and have them put in the same Undo group - this is the reason I need to implement my custom Undo actions just for object creation and deletion.
     
    ModLunar likes this.
  7. Dreamteck

    Dreamteck

    Joined:
    Feb 12, 2015
    Posts:
    336
    @Xarbrough Your ScriptableObject method of Undo recording gave me an idea:

    How about if I have two ScriptableObjects and I write to both of them for every action but record only one of them. Then, when I perform an Undo/Redo, I can compare both objects and evaluate the changes between them and decide if and Undo or a Redo was performed based on that. I imagine that the scriptable object would have a single list of ints and I would just add new elements with incremented value to help me compare at the end of the undo/redo cycle.

    How does that sound to you?
     
  8. Xarbrough

    Xarbrough

    Joined:
    Dec 11, 2014
    Posts:
    1,188
    It could work, but also a lot of duplicate steps. There might be other issues like recompile, manually deleting things, changing a scene, entering play mode and so on, which all would have to be handled.

    I would try to keep it simple and do something like this:

    Code (CSharp):
    1. using UnityEditor;
    2. using UnityEngine;
    3. using System.Collections.Generic;
    4.  
    5. public class MyAudioMixer : EditorWindow
    6. {
    7.     [MenuItem("Window/My Audio Mixer")]
    8.     public static void ShowWindow()
    9.     {
    10.         GetWindow<MyAudioMixer>().Show();
    11.     }
    12.  
    13.     // A temporary cache for all audio objects in the scene. Must be recreated at assembly reload etc.
    14.     private List<GameObject> currentAudioObjects;
    15.  
    16.     private void OnEnable()
    17.     {
    18.         RefreshCache();
    19.         Undo.undoRedoPerformed += OnUndoRedoPerformed;
    20.     }
    21.  
    22.     private void OnDisable()
    23.     {
    24.         ClearCache();
    25.         Undo.undoRedoPerformed -= OnUndoRedoPerformed;
    26.     }
    27.  
    28.     private void OnUndoRedoPerformed()
    29.     {
    30.         RefreshCache();
    31.     }
    32.  
    33.     private void RefreshCache()
    34.     {
    35.         if (currentAudioObjects == null)
    36.             currentAudioObjects = new List<GameObject>();
    37.         else
    38.             currentAudioObjects.Clear();
    39.  
    40.         // Find references to our custom objects.
    41.         foreach (var go in FindObjectsOfType<GameObject>())
    42.             if (go.GetComponent<AudioSource>() != null)
    43.                 currentAudioObjects.Add(go);
    44.     }
    45.  
    46.     private void ClearCache()
    47.     {
    48.         if (currentAudioObjects != null)
    49.             currentAudioObjects.Clear();
    50.     }
    51.  
    52.     private void OnGUI()
    53.     {
    54.         if (GUILayout.Button("Add AudioSource"))
    55.             AddAudioSource();
    56.  
    57.         GUI.enabled = SelectionCanBeRemoved();
    58.         if (GUILayout.Button("Remove AudioSource"))
    59.             RemoveAudioSource();
    60.         GUI.enabled = true;
    61.  
    62.         GUI.enabled = false;
    63.         EditorGUILayout.LabelField("Debug", EditorStyles.boldLabel);
    64.         for (int i = 0; i < currentAudioObjects.Count; i++)
    65.         {
    66.             // Debug display null. In production we would skip null-elements when using the data,
    67.             // in case the cache is invalid for a single frame or so.
    68.             string name = currentAudioObjects[i] ? currentAudioObjects[i].name : "Null";
    69.             EditorGUILayout.LabelField(i + " " + name);
    70.         }
    71.         if (currentAudioObjects.Count == 0)
    72.             EditorGUILayout.LabelField("-- no entries --");
    73.         GUI.enabled = true;
    74.     }
    75.  
    76.     private void AddAudioSource()
    77.     {
    78.         GameObject go = new GameObject("My Source");
    79.         go.AddComponent<AudioSource>();
    80.         Undo.RegisterCreatedObjectUndo(go, "Created Audio Object");
    81.         Selection.activeGameObject = go;
    82.     }
    83.  
    84.     private bool SelectionCanBeRemoved()
    85.     {
    86.         var selected = Selection.activeGameObject;
    87.         return selected != null && selected.GetComponent<AudioSource>() != null;
    88.     }
    89.  
    90.     private void RemoveAudioSource()
    91.     {
    92.         if (SelectionCanBeRemoved())
    93.             Undo.DestroyObjectImmediate(Selection.activeGameObject);
    94.     }
    95.  
    96.     private void OnHierarchyChange()
    97.     {
    98.         // Our custom object may have been deleted.
    99.         RefreshCache();
    100.         // If the hierarchy is changed we might need to validate our delete button.
    101.         Repaint();
    102.     }
    103.  
    104.     private void OnSelectionChange()
    105.     {
    106.         // Validate the delete button when selecting objects in the hierarchy.
    107.         Repaint();
    108.     }
    109. }
    110.  
    I hope this is close to what you're doing. I assume an editor window with references to scene objects. I simply find relevant objects (maybe you have to search the scene for inactive ones, too or use tags or whatever you like) every time the cache is invalidated. Now it's just a matter of getting all the callbacks which invalidate the cache, e.g. OnEnable because of recompile and play mode, OnHierarchyChange because of manually deleting objects and so on. Because the cache is always recreated, we don't need to track Undo/Redo for it, only for the Create and Destroy object functions. In most cases, it should be possible to build your system this way.

    Just for comparison, I tried to recreate the same thing with a ScriptableObject cache in memory. I assume you still want to reference scene objects, so the ScriptableObject isn't saved to the project folder (if it were only self-contained data we wouldn't have any issues, right?). The approach also works, if for some reason you want the additional object, but it's slightly more involved:

    Code (CSharp):
    1. using UnityEditor;
    2. using UnityEngine;
    3.  
    4. public class MyAudioMixer2 : EditorWindow
    5. {
    6.     [MenuItem("Window/My Audio Mixer 2")]
    7.     public static void ShowWindow()
    8.     {
    9.         GetWindow<MyAudioMixer2>().Show();
    10.     }
    11.  
    12.     [SerializeField]
    13.     private MyAudioData data;
    14.  
    15.     private void OnEnable()
    16.     {
    17.         RefreshCache();
    18.         Undo.undoRedoPerformed += OnUndoRedoPerformed;
    19.     }
    20.  
    21.     private void OnDisable()
    22.     {
    23.         ClearCache();
    24.         Undo.undoRedoPerformed -= OnUndoRedoPerformed;
    25.     }
    26.  
    27.     private void OnUndoRedoPerformed()
    28.     {
    29.         RefreshCache();
    30.     }
    31.  
    32.     private void RefreshCache()
    33.     {
    34.         if (data == null)
    35.         {
    36.             // Try to find an existing cache in memory.
    37.             var objects = Resources.FindObjectsOfTypeAll<MyAudioData>();
    38.  
    39.             if (objects.Length > 0)
    40.                 data = objects[0];
    41.             if (objects.Length > 1)
    42.                 Debug.LogError("Multiple instance of MyAudioData in memory.");
    43.  
    44.             if (data == null)
    45.             {
    46.                 // Create a new cache.
    47.                 data = CreateInstance<MyAudioData>();
    48.                 data.hideFlags = HideFlags.HideAndDontSave;
    49.             }
    50.         }
    51.  
    52.         // Now we still need to update our cache if:
    53.         // - play mode was entered
    54.         // - recompile happened
    55.         // - undoRedo was performed
    56.         // - potentially other gotchas
    57.         foreach (var go in FindObjectsOfType<GameObject>())
    58.             if (go.GetComponent<AudioSource>() != null)
    59.                 data.Add(go);
    60.     }
    61.  
    62.     private void ClearCache()
    63.     {
    64.         if (data != null)
    65.             DestroyImmediate(data);
    66.     }
    67.  
    68.     private void OnGUI()
    69.     {
    70.         if (GUILayout.Button("Add AudioSource"))
    71.             AddAudioSource();
    72.  
    73.         GUI.enabled = SelectionCanBeRemoved();
    74.         if (GUILayout.Button("Remove AudioSource"))
    75.             RemoveAudioSource();
    76.         GUI.enabled = true;
    77.  
    78.         EditorGUILayout.ObjectField("Data", data, typeof(MyAudioData), false);
    79.         data.DebugOnGUI();
    80.     }
    81.  
    82.     private void AddAudioSource()
    83.     {
    84.         GameObject go = new GameObject("My Source");
    85.         go.AddComponent<AudioSource>();
    86.         Undo.RegisterCreatedObjectUndo(go, "Created Audio Object");
    87.         Selection.activeGameObject = go;
    88.  
    89.         Undo.RecordObject(data, "Created Audio Object");
    90.         data.Add(go);
    91.     }
    92.  
    93.     private bool SelectionCanBeRemoved()
    94.     {
    95.         var selected = Selection.activeGameObject;
    96.         return selected != null && selected.GetComponent<AudioSource>() != null;
    97.     }
    98.  
    99.     private void RemoveAudioSource()
    100.     {
    101.         if (SelectionCanBeRemoved())
    102.         {
    103.             GameObject go = Selection.activeGameObject;
    104.             Undo.RecordObject(data, "Destroy Audio Object");
    105.             data.Remove(go);
    106.             Undo.DestroyObjectImmediate(go);
    107.         }
    108.     }
    109.  
    110.     private void OnHierarchyChange()
    111.     {
    112.         // Our custom object may have been deleted.
    113.         RefreshCache();
    114.         // If the hierarchy is changed we might need to validate our delete button.
    115.         Repaint();
    116.     }
    117.  
    118.     private void OnSelectionChange()
    119.     {
    120.         // Validate the delete button when selecting objects in the hierarchy.
    121.         Repaint();
    122.     }
    123. }
    124.  
    The data object is:

    Code (CSharp):
    1. using UnityEditor;
    2. using UnityEngine;
    3. using System.Collections.Generic;
    4.  
    5. public class MyAudioData : ScriptableObject
    6. {
    7.     [SerializeField]
    8.     private List<GameObject> activeObjects = new List<GameObject>(); // May not be automatically created by Unity.
    9.  
    10.     public void Add(GameObject go)
    11.     {
    12.         if (activeObjects == null)
    13.             activeObjects = new List<GameObject>();
    14.  
    15.         if (activeObjects.Contains(go) == false)
    16.             activeObjects.Add(go);
    17.     }
    18.  
    19.     public void Remove(GameObject go)
    20.     {
    21.         activeObjects.Remove(go);
    22.     }
    23.  
    24.     public void DebugOnGUI()
    25.     {
    26.         EditorGUILayout.LabelField("Debug", EditorStyles.boldLabel);
    27.         for (int i = 0; i < activeObjects.Count; i++)
    28.         {
    29.             string name = activeObjects[i] ? activeObjects[i].name : "Null";
    30.             EditorGUILayout.LabelField(i + " " + name);
    31.         }
    32.         if (activeObjects.Count == 0)
    33.             EditorGUILayout.LabelField("-- no entries --");
    34.     }
    35. }
    36.  
    I've tested this and found that Undo.RegisterCreatedObjectUndo and Undo.RecordObject when I "Created Audio Object" were grouped together in one undo action, therefore it worked fine to undo/redo them together. I'm not entirely sure if both must have the same name (I don't think so after trying it). When destroying, the same thing works fine as well. If I'm missing something, there's still the option to manually set the undo group via Undo.IncrementCurrentGroup, which should let you group multiple actions into one.

    In the RefreshCache method I find the data object in memory (a pattern I do use in development for certain tools because it makes handling state data easy for large tools. If I have a lot of properties and references which need to be kept up-to-date I wrap them up in the object, which can then be easily destroyed with one call, once the window is closed or it can even live in the background (e.g. a selection tracker object, which should keep tracking new object without the window being open).

    However, once play mode is entered, undoRedo is performed or potentially other things happen, we still need to manually update the entire list. Our list probably survives recompile because it's in the ScriptableObject which is marked as HideAndDontSave, but there's nothing to do against entering play mode. Once all scene objects are destroyed and recreated we must manually find them. So, basically, we don't gain anything from storing the cache in a more persistent ScriptableObject, unless there are other benefits for your use-case. To keep it simple, I would just refresh the cache every time something relevant happens.

    Edit: I've tested another thing and wanted to add: If we want to persist something like a selected index through play mode by using a ScriptableObject in memory, we are going to have a bad time. The object in memory is also destroyed along with everything else, once play mode starts. To save such data we need to serialize it to disk (as an asset or via EditorPrefs) or we need to find a way of recreating it every time.
     
    Last edited: Mar 3, 2018
    Dreamteck likes this.
  9. Dreamteck

    Dreamteck

    Joined:
    Feb 12, 2015
    Posts:
    336
    This was priceless! Actually your second method helped me a lot with my approach and I managed to get it working. Also, I found that it seems like if you call Undo.DestroyObjectImmediate and then Undo.RecordObject it would separate them in two groups but if you call RecordObject first, and then DestroyObjectImmediate, both will be grouped together. Weird.

    I owe you a beer!
     
    ModLunar and Xarbrough like this.
  10. PiotrK_AR

    PiotrK_AR

    Joined:
    Nov 26, 2021
    Posts:
    2
    for any1 that comes to this thread after 5 years. unity 2022.2 finally has a useable callback that tells you more info such as if it was a redo, the group name and id
     
    t2g4 likes this.