Last active
October 19, 2023 14:52
-
-
Save kraj0t/486de9d78ab79c06bc422bd91dffb1f1 to your computer and use it in GitHub Desktop.
AnimatedGUI - a Unity editor convenience class for drawing temporary GUI with animated fade-in & fade-out
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using UnityEditor; | |
using UnityEngine; | |
using kraj0tEditor.IMGUI; | |
namespace kraj0tEditor.Tests | |
{ | |
public class AnimatedIMGUIExampleWindow : EditorWindow | |
{ | |
private Editor _testEditor; | |
private Vector2 _scrollPos; | |
private bool _isUserInteractingWithPreview; | |
private bool _showPersistentHelpBox; | |
private long _debugDrawCount; | |
[MenuItem("kraj0t/AnimatedIMGUI example")] | |
static void Init() | |
{ | |
var window = GetWindow<AnimatedIMGUIExampleWindow>("AnimatedIMGUI example"); | |
window.minSize = new Vector2(340, 280); | |
window.Show(); | |
} | |
private void OnDisable() | |
{ | |
if (_testEditor != null) | |
{ | |
DestroyImmediate(_testEditor); | |
} | |
} | |
private void OnGUI() | |
{ | |
if (Event.current.type == EventType.Repaint) | |
{ | |
_debugDrawCount++; | |
} | |
_scrollPos = GUILayout.BeginScrollView(_scrollPos); | |
GUILayout.Space(20); | |
GUILayout.BeginHorizontal(); | |
GUILayout.Space(20); | |
GUILayout.BeginVertical(); | |
DrawTestGUIs(); | |
GUILayout.EndVertical(); | |
GUILayout.Space(20); | |
GUILayout.EndHorizontal(); | |
GUILayout.Space(20); | |
GUILayout.FlexibleSpace(); | |
DrawDebugAndSettings(); | |
GUILayout.EndScrollView(); | |
} | |
private void DrawTestGUIs() | |
{ | |
int id = 0; | |
// Show a label | |
if (GUILayout.Button("Click me!", GUILayout.Height(25))) | |
{ | |
AnimatedIMGUI.Restart(id, Repaint); | |
} | |
AnimatedIMGUI.Play(id++, () => GUILayout.Label("Hello there! Now click the other buttons too :-)", EditorStyles.centeredGreyMiniLabel)); | |
// Show help box | |
if (GUILayout.Button("What is AnimatedIMGUI?", GUILayout.Height(25))) | |
{ | |
AnimatedIMGUI.Restart(id, Repaint, 4); | |
} | |
AnimatedIMGUI.DrawHelpBox(id++, "It is a quick and easy way to make your Editor GUI a bit more lively adding just one method call to your code.", MessageType.Info); | |
// Show checkmark | |
EditorGUILayout.BeginHorizontal(); | |
if (GUILayout.Button("Use it for showing temporary information", GUILayout.Height(25))) | |
{ | |
AnimatedIMGUI.Restart(id, Repaint); | |
} | |
var checkmarkContent = EditorGUIUtility.IconContent("FilterSelectedOnly@2x"); | |
AnimatedIMGUI.Play(id++, | |
() => GUILayout.Label(checkmarkContent, GUILayout.MinWidth(0), GUILayout.MaxWidth(25), | |
GUILayout.MaxHeight(25)), 25); | |
EditorGUILayout.EndHorizontal(); | |
// Show an embedded preview panel. The fade-out will be posponed if the user is interacting with the preview. | |
var showPreview = GUILayout.Button("Remove clutter by hiding stuff if it is not used", GUILayout.Height(25)); | |
showPreview |= _isUserInteractingWithPreview; | |
if (showPreview) | |
{ | |
AnimatedIMGUI.Restart(id, Repaint, 3, 0.5f); | |
} | |
AnimatedIMGUI.Play(id++, DrawPreviewGUI); | |
// Draw a persistent helpbox. It will not invoke repaints once it has finished its fade-in transition. | |
_showPersistentHelpBox = EditorGUILayout.Toggle("Persist GUI after fade-in", _showPersistentHelpBox); | |
AnimatedIMGUI.PlayAndHold(id++, _showPersistentHelpBox, Repaint, | |
() => EditorGUILayout.HelpBox("This helpbox stays visible as long as the checkbox is checked. It does not trigger any repaint once it has faded in.", MessageType.Info), 0.1f); | |
// Fast sneaky nested buttons. | |
DrawFastNestedButtons(); | |
} | |
private void DrawFastNestedButtons() | |
{ | |
EditorGUILayout.BeginHorizontal(); | |
if (GUILayout.Button("And don't worry about performance!", GUILayout.Height(25))) | |
{ | |
AnimatedIMGUI.Restart(12345, Repaint, .5f, .1f); | |
} | |
AnimatedIMGUI.Play(12345, () => | |
{ | |
if (GUILayout.Button("So fast!")) | |
{ | |
AnimatedIMGUI.Restart(23456, Repaint, 0.25f, .05f); | |
} | |
AnimatedIMGUI.Play(23456, () => GUILayout.Label(";-)", EditorStyles.centeredGreyMiniLabel)); | |
}, 40); | |
EditorGUILayout.EndHorizontal(); | |
} | |
private void DrawPreviewGUI() | |
{ | |
if (_testEditor == null) | |
{ | |
var testObject = Resources.GetBuiltinResource<Mesh>("Cube.fbx"); | |
_testEditor = Editor.CreateEditor(testObject); | |
} | |
GUILayout.Label("This preview GUI will begin closing if the user has not interacted with it for a while.\nCome on, try rotating the cube, don't be shy.", EditorStyles.wordWrappedMiniLabel); | |
EditorGUI.BeginChangeCheck(); | |
var previewRect = EditorGUILayout.GetControlRect(true, 150); | |
_testEditor.DrawPreview(previewRect); | |
_isUserInteractingWithPreview = EditorGUI.EndChangeCheck(); | |
} | |
private void DrawDebugAndSettings() | |
{ | |
var guiColorBkp = GUI.color; | |
GUI.color = new Color(1, 0.8f, 0.8f); | |
GUILayout.BeginVertical("Debug", "window"); | |
var framesPerSecond = Mathf.RoundToInt(1f / AnimatedIMGUI.GlobalRepaintDelay); | |
framesPerSecond = EditorGUILayout.IntSlider("Frames per second: ", framesPerSecond, 15, 60); | |
AnimatedIMGUI.GlobalRepaintDelay = 1f / framesPerSecond; | |
EditorGUILayout.BeginHorizontal(); | |
GUILayout.Label($"Repaint count: {_debugDrawCount}", GUILayout.Width(125)); | |
if (GUILayout.Button("Clear")) | |
{ | |
_debugDrawCount = 0; | |
} | |
GUILayout.Label("<-- Includes external repaints", EditorStyles.centeredGreyMiniLabel); | |
GUILayout.FlexibleSpace(); | |
EditorGUILayout.EndHorizontal(); | |
GUILayout.EndVertical(); | |
GUI.color = guiColorBkp; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using JetBrains.Annotations; | |
using UnityEditor; | |
using UnityEngine; | |
// TODO: idea: avoid having two methods Set/Draw. Just send the boolean and the times in the same Draw method! | |
// TODO: sending in a high waitTime value in Set, and then calling Draw, is the same thing as calling DrawPersistent. Is there a need for this variety of methods? I think they should all be merged into one method. Well, maybe you should leave the Set and rename it to Reset, because it does have its uses. | |
// TODO: idea: pass in an Object instance as well as the id. Hash both things together! Sending null should be allowed too? | |
// TODO: refactor this to use a state machine instead of all the arcane -1s, waitTimes, targets... | |
// TODO: try animating GUI.color's alpha lol | |
// TODO: make this a package and begin using it somewhere! | |
// TODO: UPDATE THE COMMENTS AND ADD NOTES ABOUT USAGE !!!!!! | |
namespace kraj0tEditor.IMGUI | |
{ | |
/// <summary> | |
/// Convenience class for drawing temporary GUI with animated fade-in & fade-out. | |
/// | |
/// Instructions: | |
/// 1. Call Set() whenever the GUI should start showing up, e.g. if your GUILayout.Button() returns true. | |
/// 2. Call Draw() in the part of your code where the GUI should show up, using the same id you used for Set(). | |
/// | |
/// The code internally maintains a pool of instances of this class. You just need to call the static methods. | |
/// It is effectively free to call Draw() everywhere in your code. There is no performance hit. | |
/// </summary> | |
public class AnimatedIMGUI | |
{ | |
private static float MIN_REPAINT_DELAY = 1 / 60f; | |
private const float MIN_WAIT_TIME = 1 / 30f; | |
private const float DEFAULT_WAIT_TIME = 2f; | |
private const float DEFAULT_FADE_TIME = 0.5f; | |
private static float _globalRepaintDelay = 1 / 60f; | |
private static Dictionary<int, AnimatedIMGUI> _liveInstances; | |
private static Stack<AnimatedIMGUI> _pool; | |
private static List<GUILayoutOption> _tempLayoutOptions; | |
private static List<AnimatedIMGUI> _tempInstancesToRemove; | |
private float _waitTime; | |
private float _fadeTime; | |
private int _id; | |
private Action _repaintCallback; | |
private float _target; | |
private float _currentPosition; | |
private float _speed; | |
private double _lastUpdateTime; | |
private double _lastPaintTime; | |
private float _lastPaintPosition; | |
/// <summary>Set this value to affect the smoothness of the transitions made by this class. | |
/// You shouldn't normally need to bother about this, but you can try reducing the default value if your editor is suffering serious performance issues.</summary> | |
public static float GlobalRepaintDelay | |
{ | |
get => _globalRepaintDelay; | |
set => _globalRepaintDelay = Mathf.Max(value, MIN_REPAINT_DELAY); | |
} | |
/// <summary> | |
/// Call this before Draw(). | |
/// </summary> | |
/// <param name="id">Pass whatever value. Later, use that same value where you want the GUI to be drawn using the Draw() method.</param> | |
/// <param name="guiDrawer">The delegate that will do the drawing.</param> | |
/// <param name="repaintCallback">If you are drawing this GUI in an EditorWindow, then you should typically pass its Repaint method. Otherwise, if left to null, the animation will not update smoothly.</param> | |
/// <param name="waitTime">How long, in seconds, will the GUI remain fully expanded before fading out again.</param> | |
/// <param name="fadeTime">How long the fade will last, in seconds.</param> | |
public static void Restart(int id, Action repaintCallback, float waitTime = DEFAULT_WAIT_TIME, float fadeTime = DEFAULT_FADE_TIME) | |
{ | |
GetOrInstantiate(id).SetInstance(id, repaintCallback, waitTime, fadeTime); | |
} | |
/// <summary> | |
/// Will draw an animated GUI only if the following conditions are matched: | |
/// - The specified id matches the last one that was set in a call to Set() | |
/// - The animation has not yet finished since the last Set(). If you need to restart the animation, call Set again. | |
/// </summary> | |
/// <param name="id">Pass the same value here as where you called Set() for setting the values for this point of your code.</param> | |
/// <param name="drawGUICallback">The delegate that will actually draw your GUI.</param> | |
/// <param name="maxWidth">Set this value if you want the GUI to also animate horizontally. Important: you will probably need to | |
/// make sure that your GUI can scale down correctly (use GUILayout.MinWidth(0) in your GUI calls)</param> | |
/// <param name="layoutOptions">Same as with other GUI methods, use these layout options for the GUI container.</param> | |
/// <returns>True if the GUI was drawn.</returns> | |
public static bool Play(int id, [NotNull] Action drawGUICallback, float maxWidth = float.MaxValue, | |
params GUILayoutOption[] layoutOptions) | |
{ | |
var instance = Get(id); | |
if (instance != null) | |
{ | |
return instance.DrawInstance(id, drawGUICallback, maxWidth, layoutOptions); | |
} | |
return false; | |
} | |
// TODO: document! | |
public static bool PlayAndHold(int id, bool enabled, Action repaintCallback, [NotNull] Action drawGUICallback, | |
float fadeTime = DEFAULT_FADE_TIME, float maxWidth = float.MaxValue, params GUILayoutOption[] layoutOptions) | |
{ | |
if (enabled) | |
{ | |
var instance = GetOrInstantiate(id); | |
instance.SetInstance(id, repaintCallback, float.PositiveInfinity, fadeTime); | |
return instance.DrawInstance(id, drawGUICallback, maxWidth, layoutOptions); | |
} | |
else | |
{ | |
// If we are here, we should disable this instance, if it exists. | |
var instance = Get(id); | |
if (instance != null) | |
{ | |
// If the instance is becoming disabled just now, then begin fading it out immediately by giving it a waitTime of zero. | |
if (instance._waitTime > MIN_WAIT_TIME) | |
{ | |
instance.SetInstance(id, repaintCallback, 0, fadeTime); | |
} | |
return instance.DrawInstance(id, drawGUICallback, maxWidth, layoutOptions); | |
} | |
return false; | |
} | |
} | |
public static int GetUnusedId() | |
{ | |
if (_liveInstances == null) return 0; | |
int returnedId = 0; | |
while (_liveInstances.ContainsKey(returnedId)) | |
{ | |
returnedId++; | |
} | |
return returnedId; | |
} | |
/// <summary> | |
/// Convenience method that will call Draw() to draw an animated EditorGUILayout.HelpBox. | |
/// </summary> | |
/// <param name="id">Pass the same value here as where you called Set() for setting the values for this point of your code.</param> | |
/// <param name="text"></param> | |
/// <param name="messageType"></param> | |
/// <param name="maxWidth">Set this value if you want the GUI to also animate horizontally. Important: you will probably need to make sure that your GUI can scale down correctly (use GUILayout.MinWidth(0) in your GUI calls)</param> | |
/// <param name="layoutOptions">Same as with other GUI methods, use these layout options for the GUI container.</param> | |
/// <returns>True if the GUI was drawn.</returns> | |
public static bool DrawHelpBox(int id, string text, MessageType messageType, float maxWidth = float.MaxValue, | |
params GUILayoutOption[] layoutOptions) | |
{ | |
return Play(id, () => EditorGUILayout.HelpBox(text, messageType), maxWidth, layoutOptions); | |
} | |
private static AnimatedIMGUI Get(int id) | |
{ | |
if (_liveInstances != null && _liveInstances.TryGetValue(id, out var instance)) | |
{ | |
return instance; | |
} | |
return null; | |
} | |
private static AnimatedIMGUI GetOrInstantiate(int id) | |
{ | |
// Instantiate the collections if it's the first time calling this method. | |
if (_liveInstances == null) | |
{ | |
_liveInstances = new Dictionary<int, AnimatedIMGUI>(); | |
_pool = new Stack<AnimatedIMGUI>(); | |
_tempLayoutOptions = new List<GUILayoutOption>(); | |
_tempInstancesToRemove = new List<AnimatedIMGUI>(); | |
EditorApplication.update += UpdateAll; | |
} | |
else if (_liveInstances.Count == 0) | |
{ | |
EditorApplication.update += UpdateAll; | |
} | |
// Grab an instance currently assigned to that id, or get an inactive instance from the pool, or create it. | |
if (!_liveInstances.TryGetValue(id, out var instance)) | |
{ | |
if (_pool?.Count != 0) | |
{ | |
instance = _pool.Pop(); | |
} | |
else | |
{ | |
instance = new AnimatedIMGUI(); | |
} | |
_liveInstances[id] = instance; | |
} | |
return instance; | |
} | |
private static void UpdateAll() | |
{ | |
_tempInstancesToRemove.Clear(); | |
foreach (var instance in _liveInstances.Values) | |
{ | |
if (!instance.Update()) | |
{ | |
_tempInstancesToRemove.Add(instance); | |
} | |
} | |
foreach (var instance in _tempInstancesToRemove) | |
{ | |
instance._currentPosition = 0; | |
_liveInstances.Remove(instance._id); | |
_pool.Push(instance); | |
} | |
if (_liveInstances.Count == 0) | |
{ | |
EditorApplication.update -= UpdateAll; | |
} | |
} | |
private bool IsActive => _currentPosition * _speed < _target; | |
private bool IsAnimating => _currentPosition > 0 && _currentPosition < _fadeTime; | |
private bool ShouldRepaint => _lastPaintPosition != _currentPosition && (_currentPosition <= 0 || (IsAnimating && (EditorApplication.timeSinceStartup - _lastPaintTime >= (double)_globalRepaintDelay))); | |
private AnimatedIMGUI() | |
{ | |
} | |
private float GetSmoothedPosition() | |
{ | |
float t = Mathf.InverseLerp(0, _fadeTime, _currentPosition); | |
float num = 1 - t; | |
return 1 - num * num * num * num; | |
} | |
private void SetInstance(int id, Action repaintCallback, float waitTime, float fadeTime) | |
{ | |
_repaintCallback = repaintCallback; | |
_waitTime = Mathf.Max(waitTime, MIN_WAIT_TIME); | |
_fadeTime = fadeTime; | |
// If the id was already the same, and the animation is still going, we do not want to restart completely. | |
// Example: imagine quickly clicking a button a bunch of times; The GUI would reappear constantly. | |
if (_id == id) | |
{ | |
_currentPosition = Mathf.Min(_currentPosition, _fadeTime); | |
} | |
else | |
{ | |
_currentPosition = 0; | |
_id = id; | |
} | |
// Restart the animation. | |
_target = _fadeTime + _waitTime; | |
_speed = 1; | |
_lastUpdateTime = EditorApplication.timeSinceStartup; | |
} | |
private bool DrawInstance(int id, Action drawGUICallback, float maxWidth = float.MaxValue, | |
params GUILayoutOption[] layoutOptions) | |
{ | |
if (id != _id || !IsActive) | |
return false; | |
var smoothedPos = GetSmoothedPosition(); | |
if (maxWidth < float.MaxValue) | |
{ | |
// Unfortunately, I did not find a way of getting the width of the current rect for GUILayout. | |
// That is why I am asking the user to define the value explicitly as an argument. | |
var smoothedWidth = maxWidth * smoothedPos; | |
_tempLayoutOptions.Clear(); | |
_tempLayoutOptions.AddRange(layoutOptions); | |
_tempLayoutOptions.Add(GUILayout.MaxWidth(smoothedWidth)); | |
layoutOptions = _tempLayoutOptions.ToArray(); | |
} | |
EditorGUILayout.BeginVertical(layoutOptions); | |
EditorGUILayout.BeginFadeGroup(smoothedPos); | |
drawGUICallback.Invoke(); | |
EditorGUILayout.EndFadeGroup(); | |
EditorGUILayout.EndVertical(); | |
return true; | |
} | |
/// <summary> | |
/// Returns false if it did not update (animation finished) | |
/// </summary> | |
/// <returns></returns> | |
private bool Update() | |
{ | |
var oldShouldRepaint = ShouldRepaint; | |
UpdatePosition(); | |
if (oldShouldRepaint || ShouldRepaint) | |
{ | |
_lastPaintTime = EditorApplication.timeSinceStartup; | |
_lastPaintPosition = _currentPosition; | |
_repaintCallback?.Invoke(); | |
} | |
if (IsActive) | |
{ | |
return true; | |
} | |
if (_target > 0 && _currentPosition > 0) | |
{ | |
// Start the fade-out animation. We'll only enter this block once per full animation cycle. | |
_target = 0; | |
_currentPosition = _fadeTime; | |
_speed = -1; | |
return true; | |
} | |
return false; | |
} | |
private void UpdatePosition() | |
{ | |
double currentTime = EditorApplication.timeSinceStartup; | |
float step = _speed * (float)(currentTime - _lastUpdateTime); | |
_currentPosition += step; | |
_lastUpdateTime = currentTime; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment