Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 45 additions & 58 deletions Assets/Editor/ToastNotificationSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ namespace CosmicShore.Editor
{
public static class ToastNotificationSetup
{
private const string PrefabFolder = "Assets/_Prefabs/UI Elements";
private const string SOFolder = "Assets/_SO_Assets";
// Everything lives in Resources so the runtime-auto-created manager
// (ToastNotificationAPI) can resolve settings, channel, and prefab.
private const string ResourcesFolder = "Assets/Resources";
private const string ChannelFolder = "Assets/Resources/Channels";
private const string PrefabPath = ResourcesFolder + "/ToastNotificationItem.prefab";
private const string SettingsPath = ResourcesFolder + "/ToastNotificationSettings.asset";
private const string ChannelPath = ChannelFolder + "/ToastNotificationChannel.asset";

[MenuItem("Cosmic Shore/Toast Notification/Create All Assets", priority = 0)]
public static void CreateAllAssets()
Expand All @@ -20,55 +24,51 @@ public static void CreateAllAssets()
CreatePrefab();
CreateManagerInScene();

Debug.Log("[ToastNotification] All assets created. Customize the prefab at " +
PrefabFolder + "/ToastNotificationItem.prefab");
Debug.Log("[ToastNotification] All assets created. Customize the prefab at " + PrefabPath);
}

[MenuItem("Cosmic Shore/Toast Notification/Create Settings Asset")]
public static void CreateSettingsAsset()
{
var path = SOFolder + "/ToastNotificationSettings.asset";
if (AssetDatabase.LoadAssetAtPath<ToastNotificationSettingsSO>(path) != null)
if (AssetDatabase.LoadAssetAtPath<ToastNotificationSettingsSO>(SettingsPath) != null)
{
Debug.Log("[ToastNotification] Settings asset already exists at " + path);
Debug.Log("[ToastNotification] Settings asset already exists at " + SettingsPath);
return;
}

EnsureFolder(SOFolder);
EnsureFolder(ResourcesFolder);
var settings = ScriptableObject.CreateInstance<ToastNotificationSettingsSO>();
AssetDatabase.CreateAsset(settings, path);
AssetDatabase.CreateAsset(settings, SettingsPath);
AssetDatabase.SaveAssets();
Debug.Log("[ToastNotification] Created settings at " + path);
Debug.Log("[ToastNotification] Created settings at " + SettingsPath);
}

[MenuItem("Cosmic Shore/Toast Notification/Create Channel Asset")]
public static void CreateChannelAsset()
{
var path = ChannelFolder + "/ToastNotificationChannel.asset";
if (AssetDatabase.LoadAssetAtPath<ToastNotificationChannel>(path) != null)
if (AssetDatabase.LoadAssetAtPath<ToastNotificationChannel>(ChannelPath) != null)
{
Debug.Log("[ToastNotification] Channel asset already exists at " + path);
Debug.Log("[ToastNotification] Channel asset already exists at " + ChannelPath);
return;
}

EnsureFolder(ChannelFolder);
var channel = ScriptableObject.CreateInstance<ToastNotificationChannel>();
AssetDatabase.CreateAsset(channel, path);
AssetDatabase.CreateAsset(channel, ChannelPath);
AssetDatabase.SaveAssets();
Debug.Log("[ToastNotification] Created channel at " + path);
Debug.Log("[ToastNotification] Created channel at " + ChannelPath);
}

[MenuItem("Cosmic Shore/Toast Notification/Create Prefab")]
public static void CreatePrefab()
{
var path = PrefabFolder + "/ToastNotificationItem.prefab";
if (AssetDatabase.LoadAssetAtPath<GameObject>(path) != null)
if (AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath) != null)
{
Debug.Log("[ToastNotification] Prefab already exists at " + path);
Debug.Log("[ToastNotification] Prefab already exists at " + PrefabPath);
return;
}

EnsureFolder(PrefabFolder);
EnsureFolder(ResourcesFolder);

// Root object
var root = new GameObject("ToastNotificationItem");
Expand Down Expand Up @@ -105,20 +105,16 @@ public static void CreatePrefab()
tmp.overflowMode = TextOverflowModes.Ellipsis;
tmp.raycastTarget = false;

// Add the toast item component
// Add the toast item component and wire the messageText field
var item = root.AddComponent<ToastNotificationItem>();

// Wire the messageText field
var field = typeof(ToastNotificationItem).GetField("messageText",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(item, tmp);
SetObjectReference(item, "messageText", tmp);

// Save as prefab
var prefab = PrefabUtility.SaveAsPrefabAsset(root, path);
var prefab = PrefabUtility.SaveAsPrefabAsset(root, PrefabPath);
Object.DestroyImmediate(root);

EditorGUIUtility.PingObject(prefab);
Debug.Log("[ToastNotification] Created prefab at " + path +
Debug.Log("[ToastNotification] Created prefab at " + PrefabPath +
" - customize visuals here (background, font, size, etc.)");
}

Expand All @@ -134,39 +130,14 @@ public static void CreateManagerInScene()
var go = new GameObject("ToastNotificationManager");
var mgr = go.AddComponent<ToastNotificationManager>();

// Wire settings
var settings = AssetDatabase.LoadAssetAtPath<ToastNotificationSettingsSO>(
"Assets/Resources/ToastNotificationSettings.asset");
if (settings != null)
{
var settingsField = typeof(ToastNotificationManager).GetField("settings",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
settingsField?.SetValue(mgr, settings);
}

// Wire channel
var channel = AssetDatabase.LoadAssetAtPath<ToastNotificationChannel>(
ChannelFolder + "/ToastNotificationChannel.asset");
if (channel != null)
{
var channelField = typeof(ToastNotificationManager).GetField("channel",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
channelField?.SetValue(mgr, channel);
}
SetObjectReference(mgr, "settings",
AssetDatabase.LoadAssetAtPath<ToastNotificationSettingsSO>(SettingsPath));
SetObjectReference(mgr, "channel",
AssetDatabase.LoadAssetAtPath<ToastNotificationChannel>(ChannelPath));

// Wire prefab
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(
PrefabFolder + "/ToastNotificationItem.prefab");
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
if (prefab != null)
{
var item = prefab.GetComponent<ToastNotificationItem>();
if (item != null)
{
var prefabField = typeof(ToastNotificationManager).GetField("toastPrefab",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
prefabField?.SetValue(mgr, item);
}
}
SetObjectReference(mgr, "toastPrefab", prefab.GetComponent<ToastNotificationItem>());

Undo.RegisterCreatedObjectUndo(go, "Create ToastNotificationManager");
Selection.activeGameObject = go;
Expand All @@ -175,6 +146,22 @@ public static void CreateManagerInScene()

#region Helpers

private static void SetObjectReference(Object target, string propertyName, Object value)
{
if (value == null) return;

var so = new SerializedObject(target);
var prop = so.FindProperty(propertyName);
if (prop == null)
{
Debug.LogWarning($"[ToastNotification] Property '{propertyName}' not found on {target.GetType().Name}.");
return;
}

prop.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}

private static GameObject CreateChild(string name, Transform parent)
{
var go = new GameObject(name, typeof(RectTransform));
Expand Down
26 changes: 5 additions & 21 deletions Assets/_Scripts/UI/ToastNotification/ToastNotificationAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace CosmicShore.UI
{
/// <summary>
/// Static convenience API for showing toast notifications from anywhere in the codebase.
/// Auto-creates the ToastNotificationManager singleton if it doesn't exist in the scene.
/// Auto-creates the ToastNotificationManager singleton if it doesn't exist in the scene,
/// wiring settings, channel, and the authored toast prefab from Resources.
/// Finds the container by searching for a GameObject named "ToastNotificationContainer".
/// </summary>
public static class ToastNotificationAPI
Expand Down Expand Up @@ -52,26 +53,9 @@ private static void EnsureManagerExists()
var go = new GameObject("ToastNotificationManager");
var mgr = go.AddComponent<ToastNotificationManager>();

// Wire settings from Resources
var settings = Resources.Load<ToastNotificationSettingsSO>(SettingsPath);
if (settings != null)
{
var field = typeof(ToastNotificationManager).GetField("settings",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(mgr, settings);
}

// Wire channel
var channel = Channel;
if (channel != null)
{
var field = typeof(ToastNotificationManager).GetField("channel",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(mgr, channel);

mgr.enabled = false;
mgr.enabled = true;
}
mgr.Configure(
Resources.Load<ToastNotificationSettingsSO>(SettingsPath),
Channel);

TryAssignContainer(mgr);

Expand Down
75 changes: 55 additions & 20 deletions Assets/_Scripts/UI/ToastNotification/ToastNotificationItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace CosmicShore.UI
{
/// <summary>
/// Individual toast item view. Sets text, fades in/out, supports swipe-to-dismiss.
/// Never modifies its own anchors, pivot, size, or position - layout is owned by the container.
/// Individual toast item view. Slides/fades in, reveals the message with a
/// typewriter effect, and supports swipe-to-dismiss. Vertical layout is owned
/// by the container; this component only animates its own horizontal offset.
/// </summary>
[RequireComponent(typeof(CanvasGroup))]
public sealed class ToastNotificationItem : MonoBehaviour,
Expand All @@ -35,48 +37,69 @@ private void Awake()
}

/// <summary>
/// Set the message text, activate, and fade in. Does not touch layout.
/// Set the message text, activate, and play the intro (slide + fade + typewriter).
/// </summary>
public void Show(string message, ToastNotificationSettingsSO settings)
{
_settings = settings;
_isDismissing = false;

if (messageText) messageText.text = message;
if (messageText)
{
messageText.text = message;
messageText.maxVisibleCharacters = int.MaxValue;
}

_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = true;
gameObject.SetActive(true);

// Cache resting X so swipe-dismiss can restore it
// Let the container layout place us before caching the resting position —
// it is also what recovers pooled items left off-screen by a slide-out.
if (transform.parent is RectTransform parentRect)
LayoutRebuilder.ForceRebuildLayoutImmediate(parentRect);
_restX = _rect.anchoredPosition.x;

KillSequence();

_activeSeq = DOTween.Sequence();
if (settings.useUnscaledTime) _activeSeq.SetUpdate(true);

float startX = _restX - (_rect.rect.width + settings.offscreenPadding);
_rect.anchoredPosition = new Vector2(startX, _rect.anchoredPosition.y);
_activeSeq.Append(_rect.DOAnchorPosX(_restX, settings.slideInDuration).SetEase(settings.slideInEase));
_activeSeq.Join(_canvasGroup.DOFade(1f, settings.fadeInDuration));

if (settings.useTypewriterText && messageText)
{
messageText.ForceMeshUpdate();
int charCount = messageText.textInfo.characterCount;
if (charCount > 0)
{
messageText.maxVisibleCharacters = 0;
float revealDuration = Mathf.Min(
charCount / Mathf.Max(1f, settings.typewriterCharactersPerSecond),
settings.typewriterMaxDuration);
_activeSeq.Join(DOTween.To(
() => messageText.maxVisibleCharacters,
visible => messageText.maxVisibleCharacters = visible,
charCount, revealDuration)
.SetEase(Ease.Linear));
}
}

_activeSeq.AppendInterval(settings.autoRemoveDelay);
_activeSeq.AppendCallback(AutoDismiss);
}

// Legacy overload kept for compatibility - ignores position parameter.
public void Show(string message, Vector2 _, ToastNotificationSettingsSO settings)
=> Show(message, settings);

// Legacy - no-op, layout handles positioning.
public void AnimateToY(float _) { }

#region Dismiss

private void AutoDismiss()
{
if (_isDismissing) return;
FadeOutAndDismiss();
// Auto-remove exits back out the way it came in (left).
FadeOutAndDismiss(-1f);
}

private void FadeOutAndDismiss()
private void FadeOutAndDismiss(float directionX)
{
if (_isDismissing) return;
_isDismissing = true;
Expand All @@ -86,6 +109,9 @@ private void FadeOutAndDismiss()
_activeSeq = DOTween.Sequence();
if (_settings.useUnscaledTime) _activeSeq.SetUpdate(true);

float exitX = _rect.anchoredPosition.x
+ directionX * (_rect.rect.width + _settings.offscreenPadding);
_activeSeq.Append(_rect.DOAnchorPosX(exitX, _settings.slideOutDuration).SetEase(_settings.slideOutEase));
_activeSeq.Join(_canvasGroup.DOFade(0f, _settings.fadeOutDuration));

_activeSeq.OnComplete(() =>
Expand Down Expand Up @@ -116,6 +142,8 @@ public void OnBeginDrag(PointerEventData eventData)
{
if (_isDismissing) return;
KillSequence();
if (messageText) messageText.maxVisibleCharacters = int.MaxValue;
_canvasGroup.alpha = 1f;
_dragStartX = eventData.position.x;
}

Expand All @@ -140,23 +168,30 @@ public void OnEndDrag(PointerEventData eventData)

if (deltaX >= _settings.swipeDismissThreshold)
{
FadeOutAndDismiss();
// Swipe continues out to the right.
FadeOutAndDismiss(1f);
}
else
{
_canvasGroup.alpha = 1f;
_rect.anchoredPosition = new Vector2(_restX, _rect.anchoredPosition.y);

KillSequence();
_activeSeq = DOTween.Sequence();
if (_settings.useUnscaledTime) _activeSeq.SetUpdate(true);

_activeSeq.Append(_rect.DOAnchorPosX(_restX, _settings.slideInDuration).SetEase(_settings.slideInEase));
_activeSeq.Join(_canvasGroup.DOFade(1f, _settings.fadeInDuration));
_activeSeq.AppendInterval(_settings.autoRemoveDelay);
_activeSeq.AppendCallback(AutoDismiss);
}
}

#endregion

/// <summary>
/// Runtime binding hook for the code-built fallback item. Authored prefabs
/// wire <see cref="messageText"/> in the inspector instead.
/// </summary>
internal void BindMessageText(TMP_Text text) => messageText = text;

private void KillSequence()
{
if (_activeSeq != null && _activeSeq.IsActive())
Expand Down
Loading