Skip to content

Instantly share code, notes, and snippets.

@kraj0t
Last active October 19, 2023 14:52
Show Gist options
  • Save kraj0t/486de9d78ab79c06bc422bd91dffb1f1 to your computer and use it in GitHub Desktop.
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
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;
}
}
}
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