-
-
Save alexanderameye/c1f99c6b84162697beedc8606027ed9c to your computer and use it in GitHub Desktop.
using System.Collections.Generic; | |
using System.IO; | |
using UnityEditor; | |
using UnityEditor.Overlays; | |
using UnityEditor.SceneManagement; | |
using UnityEditor.Toolbars; | |
using UnityEngine; | |
using UnityEngine.SceneManagement; | |
using UnityEngine.UIElements; | |
public static class EditorSceneSwitcher | |
{ | |
public static bool AutoEnterPlaymode = false; | |
public static readonly List<string> ScenePaths = new(); | |
public static void OpenScene(string scenePath) | |
{ | |
if (EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) | |
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); | |
if (AutoEnterPlaymode) EditorApplication.EnterPlaymode(); | |
} | |
public static void LoadScenes() | |
{ | |
// clear scenes | |
ScenePaths.Clear(); | |
// find all scenes in the Assets folder | |
var sceneGuids = AssetDatabase.FindAssets("t:Scene", new[] {"Assets"}); | |
foreach (var sceneGuid in sceneGuids) | |
{ | |
var scenePath = AssetDatabase.GUIDToAssetPath(sceneGuid); | |
var sceneAsset = AssetDatabase.LoadAssetAtPath(scenePath, typeof(SceneAsset)); | |
ScenePaths.Add(scenePath); | |
} | |
} | |
} | |
[Icon("d_SceneAsset Icon")] | |
[Overlay(typeof(SceneView), OverlayID, "Scene Switcher Creator Overlay")] | |
public class SceneSwitcherToolbarOverlay : ToolbarOverlay | |
{ | |
public const string OverlayID = "scene-switcher-overlay"; | |
private SceneSwitcherToolbarOverlay() : base( | |
SceneDropdown.ID, | |
AutoEnterPlayModeToggle.ID | |
) | |
{ | |
} | |
public override void OnCreated() | |
{ | |
// load the scenes when the toolbar overlay is initially created | |
EditorSceneSwitcher.LoadScenes(); | |
// subscribe to the event where scene assets were potentially modified | |
EditorApplication.projectChanged += OnProjectChanged; | |
} | |
// Called when an Overlay is about to be destroyed. | |
// Usually this corresponds to the EditorWindow in which this Overlay resides closing. (Scene View in this case) | |
public override void OnWillBeDestroyed() | |
{ | |
// unsubscribe from the event where scene assets were potentially modified | |
EditorApplication.projectChanged -= OnProjectChanged; | |
} | |
private void OnProjectChanged() | |
{ | |
// reload the scenes whenever scene assets were potentially modified | |
EditorSceneSwitcher.LoadScenes(); | |
} | |
} | |
[EditorToolbarElement(ID, typeof(SceneView))] | |
public class SceneDropdown : EditorToolbarDropdown | |
{ | |
public const string ID = SceneSwitcherToolbarOverlay.OverlayID + "/scene-dropdown"; | |
private const string Tooltip = "Switch scene."; | |
public SceneDropdown() | |
{ | |
var content = | |
EditorGUIUtility.TrTextContentWithIcon(SceneManager.GetActiveScene().name, Tooltip, | |
"d_SceneAsset Icon"); | |
text = content.text; | |
tooltip = content.tooltip; | |
icon = content.image as Texture2D; | |
// hacky: the text element is the second one here so we can set the padding | |
// but this is not really robust I think | |
ElementAt(1).style.paddingLeft = 5; | |
ElementAt(1).style.paddingRight = 5; | |
clicked += ToggleDropdown; | |
// keep track of panel events | |
RegisterCallback<AttachToPanelEvent>(OnAttachToPanel); | |
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel); | |
} | |
protected virtual void OnAttachToPanel(AttachToPanelEvent evt) | |
{ | |
// subscribe to the event where the play mode has changed | |
EditorApplication.playModeStateChanged += OnPlayModeStateChanged; | |
// subscribe to the event where scene assets were potentially modified | |
EditorApplication.projectChanged += OnProjectChanged; | |
// subscribe to the event where a scene has been opened | |
EditorSceneManager.sceneOpened += OnSceneOpened; | |
} | |
protected virtual void OnDetachFromPanel(DetachFromPanelEvent evt) | |
{ | |
// unsubscribe from the event where the play mode has changed | |
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; | |
// unsubscribe from the event where scene assets were potentially modified | |
EditorApplication.projectChanged -= OnProjectChanged; | |
// unsubscribe from the event where a scene has been opened | |
EditorSceneManager.sceneOpened -= OnSceneOpened; | |
} | |
private void OnPlayModeStateChanged(PlayModeStateChange stateChange) | |
{ | |
switch (stateChange) | |
{ | |
case PlayModeStateChange.EnteredEditMode: | |
SetEnabled(true); | |
break; | |
case PlayModeStateChange.EnteredPlayMode: | |
// don't allow switching scenes while in play mode | |
SetEnabled(false); | |
break; | |
} | |
} | |
private void OnProjectChanged() | |
{ | |
// update the dropdown label whenever the active scene has potentially be renamed | |
text = SceneManager.GetActiveScene().name; | |
} | |
private void OnSceneOpened(Scene scene, OpenSceneMode mode) | |
{ | |
// update the dropdown label whenever a scene has been opened | |
text = scene.name; | |
} | |
private void ToggleDropdown() | |
{ | |
var menu = new GenericMenu(); | |
foreach (var scenePath in EditorSceneSwitcher.ScenePaths) | |
{ | |
var sceneName = Path.GetFileNameWithoutExtension(scenePath); | |
menu.AddItem(new GUIContent(sceneName), text == sceneName, | |
() => OnDropdownItemSelected(sceneName, scenePath)); | |
} | |
menu.DropDown(worldBound); | |
} | |
private void OnDropdownItemSelected(string sceneName, string scenePath) | |
{ | |
text = sceneName; | |
EditorSceneSwitcher.OpenScene(scenePath); | |
} | |
} | |
[EditorToolbarElement(ID, typeof(SceneView))] | |
public class AutoEnterPlayModeToggle : EditorToolbarToggle | |
{ | |
public const string ID = SceneSwitcherToolbarOverlay.OverlayID + "/auto-enter-playmode-toggle"; | |
private const string Tooltip = "Auto enter playmode."; | |
public AutoEnterPlayModeToggle() | |
{ | |
var content = EditorGUIUtility.TrTextContentWithIcon("", Tooltip, "d_preAudioAutoPlayOff"); | |
text = content.text; | |
tooltip = content.tooltip; | |
icon = content.image as Texture2D; | |
value = EditorSceneSwitcher.AutoEnterPlaymode; | |
this.RegisterValueChangedCallback(Toggle); | |
} | |
private void Toggle(ChangeEvent<bool> evt) | |
{ | |
EditorSceneSwitcher.AutoEnterPlaymode = evt.newValue; | |
} | |
} |
Hey @onefifth thanks for sharing, I really appreciate it! While I was adding the callbacks, I thought to myself that I should be unsubscribing to them but then just ignored it because I thought 'eh, it's UI Toolkit so not called every frame, shouldn't be too bad'.
So the first change I made is to make use of OnWillBeDestroyed in the ToolbarOverlay to unsubscribe from the events. Afaik OnCreated and OnWillBeDestroyed will only be called whenever the Scene View is created/destroyed, which is something I never do, but still good to handle this of course.
public override void OnCreated()
{
EditorSceneSwitcher.LoadScenes();
EditorApplication.projectChanged += OnProjectChanged;
}
// Called when an Overlay is about to be destroyed.
// Usually this corresponds to the EditorWindow in which this Overlay resides closing. (Scene View in this case)
public override void OnWillBeDestroyed()
{
EditorApplication.projectChanged -= OnProjectChanged;
}
Then for the ToolbarElements, using the panel events like you said looks good to me? From some testing there seem to be no leaks like you said. About your second point about the collapsed state, for this use case it does not seem to be an issue because the events don't need to be handled in collapsed state, and when you expand the overlay again or click on it to 'open' it, the scene changes have been propagated fine because the ToolbarOverlay itself still is handling the EditorApplication.projectChanged
events and updating the list of scenes. So I'm actually really happy with this solution that you proposed!
So the changes are like this (some code not shown here):
public SceneDropdown()
{
RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
protected virtual void OnAttachToPanel(AttachToPanelEvent evt)
{
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
EditorApplication.projectChanged += OnProjectChanged;
EditorSceneManager.sceneOpened += OnSceneOpened;
}
protected virtual void OnDetachFromPanel(DetachFromPanelEvent evt)
{
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
EditorApplication.projectChanged -= OnProjectChanged;
EditorSceneManager.sceneOpened -= OnSceneOpened;
}
One additional thing is this file from the AR Foundation package that showcases how Unity does things themselves (it's a fairly recent commit, May 2022 from what I can see). They seem to be using the panel events as well for handling the callbacks.
Do let me know if you have additional thoughts! If this looks good to you, I'll update the gist. Thanks again :)
Those changes look good to me! It's nice to know I wasn't way off base with the attach/detach handlers. Thanks for the com.unity.xr.arfoundation link.
OnCreated
and OnWillBeDestroyed
are almost certainly also called when enabling/disabling the overlay/toolbar completely. Using them here makes sense to me!
For the ToolbarElements
, in my case I was (perhaps foolishly) trying to use a SceneView.duringSceneGui +=
hook to have a simple ToolbarToggle
draw stuff in the scene view. My initial approach was nearly identical to the way the DropdownToggleExample
works on docs page for ToolbarOverlay. If you change that example to use the AttachToPanel/DetachFromPanel pattern, it does fix the leak issue, but the overlay collapsed state becomes a more obvious/real problem. The button's duringSceneGui
handler gets removed and the stuff being drawn stops getting drawn.
In that specific case, separating the draw logic from the toggle button is probably the way to go? Instead it'd notify some other object when the toggle state changed, and that other object would manage its own duringSceneGui
hook and draw stuff into the scene view.
But, my whole ramble is basically; it's real easy to accidentally cross the complexity threshold where jamming functionality directly into the button starts causing hard-to-spot problems. (the example in the docs leads you directly off that cliff imo) It'd be great to have better sample code to reference, or for the API to make it more clear how that kind of complexity should be dealt with.
Either way... you certainly don't need my permission to update your gist (haha), but for this specific case those changes look good to me! Thanks for responding!
I've been digging into Overlay stuff recently and wanted to point out a potential pitfall with:
Because of the way this whole API works, all the child
VisualElements
of anOverlay
are detached and recreated during many of the layout operations. These detached children are not explicitly cleaned up, so when these kinds of hooks are added in a constructor, duplicate orphanedVisualElements
end up getting leaked and keep on running their callbacks forever.To see this yourself, try docking and undocking the overlay toolbar a few times and notice the
PlayModeStateChanged
/OnProjectChanged
/OnSceneOpened
handlers being called multiple times per action.In this specific case, the extra calls aren't causing any real problems, but you can probably imagine cases where it would.
In terms of a solution... it's unfortunately really annoying? At the risk of rambling:
First thing I tried was
RegisterCallback
on theAttachToPanelEvent
/DetachFromPanelEvent
events and adding/removing callbacks there. This mostly works. It cleans up our handlers, but:ToolbarOverlay
will; detach children, reattach them, detach them again, then create new ones and attach those. I couldn't find out a way to know if aVisualElement
was going to be immediately reattached, or was being left in orphaned-child purgatory. (DetachFromPanelEvent.destinationPanel
looks useful, but is never set afaik)ToolbarOverlay
is put into a "collapsed" state, all its children are detached and orphaned. They're recreated when the overlay is expanded/uncollapsed, but by removing callbacks in detach, we're left with nothing handling events while the overlay remains collapsed.Next I went to add the callbacks in
ToolbarOverlay.OnCreated
, then pass those calls down to children. But again; the children aren't parented while the overlay is collapsed. Also, as far as I can tell the list of children for anOverlay
is internal, so iterating them is a one-way trip to reflection land :/So uh... what now?
I think the "correct" way to do this is by using some kind of context object owned by the parent overlay. The child
VisualElements
read/write to it, and the overlay/toolbar/whatever system reads changes and actually performs logic. Maybe leverage aSeralizedObject
and dig into theBind()
/bindingPath
bits ofVisualElement
? I dunno. I gave up and made my overlay uncollapsible with a hackycollapseChanged+=
.As a final note, I'm on Unity 2021.3, but glancing at 2022.x and beyond, there's now
ToolbarOverlay
andOverlayToolbar
classes. The naming choice there does not inspire confidence, but maybe this whole situation is improved? I'd love for Unity to change something here, even if it's just a better example in the docs. (at the time of writing, it also has this problem)tl;dr
The
EditorApplication +=
in theVisualElement
callbacks leak. I don't have a perfect example of how to not do that.The above wall of text will hopefully be useful for anyone curious. If you've read all this in the year 2022 and think I'm missing something, (or you've got a "good" example of the "correct" approach) feel free to @ me.