diff --git a/Assets/Editor/ToastNotificationSetup.cs b/Assets/Editor/ToastNotificationSetup.cs index 3477212e8a..ce0d46e1b0 100644 --- a/Assets/Editor/ToastNotificationSetup.cs +++ b/Assets/Editor/ToastNotificationSetup.cs @@ -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() @@ -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(path) != null) + if (AssetDatabase.LoadAssetAtPath(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(); - 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(path) != null) + if (AssetDatabase.LoadAssetAtPath(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(); - 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(path) != null) + if (AssetDatabase.LoadAssetAtPath(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"); @@ -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(); - - // 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.)"); } @@ -134,39 +130,14 @@ public static void CreateManagerInScene() var go = new GameObject("ToastNotificationManager"); var mgr = go.AddComponent(); - // Wire settings - var settings = AssetDatabase.LoadAssetAtPath( - "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( - 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(SettingsPath)); + SetObjectReference(mgr, "channel", + AssetDatabase.LoadAssetAtPath(ChannelPath)); - // Wire prefab - var prefab = AssetDatabase.LoadAssetAtPath( - PrefabFolder + "/ToastNotificationItem.prefab"); + var prefab = AssetDatabase.LoadAssetAtPath(PrefabPath); if (prefab != null) - { - var item = prefab.GetComponent(); - 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()); Undo.RegisterCreatedObjectUndo(go, "Create ToastNotificationManager"); Selection.activeGameObject = go; @@ -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)); diff --git a/Assets/_Prefabs/UI Elements/ToastNotificationItem.prefab b/Assets/Resources/ToastNotificationItem.prefab similarity index 100% rename from Assets/_Prefabs/UI Elements/ToastNotificationItem.prefab rename to Assets/Resources/ToastNotificationItem.prefab diff --git a/Assets/_Prefabs/UI Elements/ToastNotificationItem.prefab.meta b/Assets/Resources/ToastNotificationItem.prefab.meta similarity index 100% rename from Assets/_Prefabs/UI Elements/ToastNotificationItem.prefab.meta rename to Assets/Resources/ToastNotificationItem.prefab.meta diff --git a/Assets/_Scripts/UI/ToastNotification/ToastNotificationAPI.cs b/Assets/_Scripts/UI/ToastNotification/ToastNotificationAPI.cs index a1efe57ba9..c8e3102bd2 100644 --- a/Assets/_Scripts/UI/ToastNotification/ToastNotificationAPI.cs +++ b/Assets/_Scripts/UI/ToastNotification/ToastNotificationAPI.cs @@ -5,7 +5,8 @@ namespace CosmicShore.UI { /// /// 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". /// public static class ToastNotificationAPI @@ -52,26 +53,9 @@ private static void EnsureManagerExists() var go = new GameObject("ToastNotificationManager"); var mgr = go.AddComponent(); - // Wire settings from Resources - var settings = Resources.Load(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(SettingsPath), + Channel); TryAssignContainer(mgr); diff --git a/Assets/_Scripts/UI/ToastNotification/ToastNotificationItem.cs b/Assets/_Scripts/UI/ToastNotification/ToastNotificationItem.cs index a91e311c31..7ceb609d64 100644 --- a/Assets/_Scripts/UI/ToastNotification/ToastNotificationItem.cs +++ b/Assets/_Scripts/UI/ToastNotification/ToastNotificationItem.cs @@ -3,12 +3,14 @@ using TMPro; using UnityEngine; using UnityEngine.EventSystems; +using UnityEngine.UI; namespace CosmicShore.UI { /// - /// 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. /// [RequireComponent(typeof(CanvasGroup))] public sealed class ToastNotificationItem : MonoBehaviour, @@ -35,48 +37,69 @@ private void Awake() } /// - /// Set the message text, activate, and fade in. Does not touch layout. + /// Set the message text, activate, and play the intro (slide + fade + typewriter). /// 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; @@ -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(() => @@ -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; } @@ -140,16 +168,17 @@ 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); } @@ -157,6 +186,12 @@ public void OnEndDrag(PointerEventData eventData) #endregion + /// + /// Runtime binding hook for the code-built fallback item. Authored prefabs + /// wire in the inspector instead. + /// + internal void BindMessageText(TMP_Text text) => messageText = text; + private void KillSequence() { if (_activeSeq != null && _activeSeq.IsActive()) diff --git a/Assets/_Scripts/UI/ToastNotification/ToastNotificationManager.cs b/Assets/_Scripts/UI/ToastNotification/ToastNotificationManager.cs index 9a75fa932f..07c3652406 100644 --- a/Assets/_Scripts/UI/ToastNotification/ToastNotificationManager.cs +++ b/Assets/_Scripts/UI/ToastNotification/ToastNotificationManager.cs @@ -12,9 +12,16 @@ namespace CosmicShore.UI /// (VerticalLayoutGroup, ContentSizeFitter, RectMask2D, etc.) controls /// positioning and clipping - this script never touches anchors, size, or position. /// New toasts are added as the last sibling; older toasts shift upward via layout. + /// + /// Toast visuals come from the authored prefab: the serialized reference wins, + /// then Resources/ToastNotificationItem (so the runtime-auto-created manager + /// still uses the authored prefab), and only as a last resort a plain + /// code-built item. /// public sealed class ToastNotificationManager : SingletonPersistent { + private const string PrefabResourcePath = "ToastNotificationItem"; + [Header("Configuration")] [SerializeField] private ToastNotificationSettingsSO settings; @@ -23,7 +30,8 @@ public sealed class ToastNotificationManager : SingletonPersistent + /// Runtime wiring for the auto-created manager (see ToastNotificationAPI). + /// Rebinds the channel subscription when the channel changes. + /// + public void Configure(ToastNotificationSettingsSO newSettings, ToastNotificationChannel newChannel) + { + if (newSettings != null) + settings = newSettings; + + if (newChannel != null && newChannel != channel) + { + if (channel && isActiveAndEnabled) channel.OnRaised -= Show; + channel = newChannel; + if (isActiveAndEnabled) channel.OnRaised += Show; + } + } + public void Show(string message) { if (settings == null) @@ -77,15 +102,15 @@ public void Show(string message) if (string.IsNullOrWhiteSpace(message)) return; + // Scene transitions can destroy the previous container along with any + // toasts parented under it - drop the dead references before counting. + _activeToasts.RemoveAll(item => item == null); + if (_activeToasts.Count >= settings.maxVisible) { - if (_pendingQueue.Count < settings.maxQueue) - { - _pendingQueue.Enqueue(message); - return; - } + if (_pendingQueue.Count >= settings.maxQueue) + _pendingQueue.Dequeue(); - _pendingQueue.Dequeue(); _pendingQueue.Enqueue(message); return; } @@ -107,19 +132,20 @@ private void SpawnToast(string message) private ToastNotificationItem GetOrCreateItem() { - ToastNotificationItem item; - - if (_pool.Count > 0) - { - item = _pool.Pop(); - item.transform.SetParent(container, false); - } - else + // Pooled items live under the container, so a destroyed container + // leaves destroyed entries behind - skip them. + while (_pool.Count > 0) { - item = Instantiate(toastPrefab, container); - item.OnDismissed += HandleDismissed; + var pooled = _pool.Pop(); + if (pooled == null) continue; + + pooled.transform.SetParent(container, false); + return pooled; } + var item = Instantiate(toastPrefab, container); + item.gameObject.SetActive(false); + item.OnDismissed += HandleDismissed; return item; } @@ -134,9 +160,20 @@ private void HandleDismissed(ToastNotificationItem item) #endregion - #region Default Prefab (Runtime Fallback) + #region Prefab Resolution + + private ToastNotificationItem ResolvePrefab() + { + var loaded = Resources.Load(PrefabResourcePath); + if (loaded != null) return loaded; + + CSDebug.LogWarning( + "[ToastNotificationManager] No toast prefab assigned and none found at " + + $"Resources/{PrefabResourcePath}. Falling back to a code-built default."); + return CreateDefaultTemplate(); + } - private ToastNotificationItem CreateDefaultPrefab() + private ToastNotificationItem CreateDefaultTemplate() { var go = new GameObject("ToastItem_Default", typeof(RectTransform)); go.SetActive(false); @@ -164,11 +201,10 @@ private ToastNotificationItem CreateDefaultPrefab() tmp.alignment = TextAlignmentOptions.MidlineLeft; tmp.enableWordWrapping = true; tmp.overflowMode = TextOverflowModes.Ellipsis; + tmp.raycastTarget = false; var item = go.AddComponent(); - var field = typeof(ToastNotificationItem).GetField("messageText", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - field?.SetValue(item, tmp); + item.BindMessageText(tmp); go.transform.SetParent(transform, false); return item; diff --git a/Assets/_Scripts/UI/ToastNotification/ToastNotificationSettingsSO.cs b/Assets/_Scripts/UI/ToastNotification/ToastNotificationSettingsSO.cs index 60bed70ec8..4d0328f1c0 100644 --- a/Assets/_Scripts/UI/ToastNotification/ToastNotificationSettingsSO.cs +++ b/Assets/_Scripts/UI/ToastNotification/ToastNotificationSettingsSO.cs @@ -21,6 +21,9 @@ public class ToastNotificationSettingsSO : ScriptableObject [Tooltip("Easing curve for slide-out.")] public Ease slideOutEase = Ease.InCubic; + [Tooltip("Extra pixels beyond the toast's own width for the off-screen slide start/end position.")] + public float offscreenPadding = 24f; + [Header("Fade")] [Tooltip("Duration of the fade-in (overlaps with slide-in).")] public float fadeInDuration = 0.25f; @@ -28,29 +31,26 @@ public class ToastNotificationSettingsSO : ScriptableObject [Tooltip("Duration of the fade-out (overlaps with slide-out).")] public float fadeOutDuration = 0.2f; + [Header("Text Animation")] + [Tooltip("Reveal the message with a typewriter effect while the toast slides in.")] + public bool useTypewriterText = true; + + [Tooltip("Characters revealed per second during the typewriter effect.")] + public float typewriterCharactersPerSecond = 45f; + + [Tooltip("Upper bound on the typewriter reveal so long messages don't crawl.")] + public float typewriterMaxDuration = 1.5f; + [Header("Lifetime")] - [Tooltip("Seconds the toast stays visible before auto-dismissing.")] + [Tooltip("Seconds the toast stays visible (after the intro finishes) before auto-dismissing.")] public float autoRemoveDelay = 5f; [Header("Swipe Dismiss")] [Tooltip("Minimum horizontal drag distance (in pixels) to trigger a swipe dismiss.")] public float swipeDismissThreshold = 60f; - [Header("Layout")] - [Tooltip("Extra pixels of padding beyond the screen edge for the off-screen start position.")] - public float offscreenPadding = 24f; - - [Tooltip("Vertical offset from the top of the screen (in canvas units) for the toast anchor.")] - public float topMargin = 120f; - - [Tooltip("Horizontal margin from the left edge when fully visible (in canvas units).")] - public float leftMargin = 24f; - - [Tooltip("Spacing between stacked toasts (in canvas units).")] - public float stackSpacing = 10f; - [Header("Capacity")] - [Tooltip("Maximum number of toasts visible at the same time. Oldest is dismissed when exceeded.")] + [Tooltip("Maximum number of toasts visible at the same time. Additional toasts wait in the queue.")] public int maxVisible = 3; [Tooltip("Maximum number of queued toasts. Oldest queued toast is dropped when exceeded.")]