// Copyright (c) 2015 - 2023 Doozy Entertainment. All Rights Reserved. // This code can only be used under the standard Unity Asset Store End User License Agreement // A Copy of the EULA APPENDIX 1 is available at http://unity3d.com/company/legal/as_terms using System; using System.Collections.Generic; using System.Linq; using Doozy.Runtime.Common.Attributes; using Doozy.Runtime.Common.Extensions; using Doozy.Runtime.Common.Utils; using Doozy.Runtime.Global; using Doozy.Runtime.Signals; using Doozy.Runtime.UIManager.Components; using Doozy.Runtime.UIManager.Containers.Internal; using Doozy.Runtime.UIManager.Input; using Doozy.Runtime.UIManager.ScriptableObjects; using Doozy.Runtime.UIManager.Triggers; using TMPro; using UnityEngine; using UnityEngine.Events; using UnityEngine.EventSystems; using UnityEngine.UI; // ReSharper disable PartialTypeWithSinglePart // ReSharper disable MemberCanBePrivate.Global namespace Doozy.Runtime.UIManager.Containers { /// /// Specialized container that behaves like a popup (modal window), /// with parenting and positioning options. /// [RequireComponent(typeof(RectTransform))] [RequireComponent(typeof(Canvas))] [RequireComponent(typeof(GraphicRaycaster))] [AddComponentMenu("Doozy/UI/Containers/UIPopup")] [DisallowMultipleComponent] public partial class UIPopup : UIContainerComponent { #if UNITY_EDITOR [UnityEditor.MenuItem("GameObject/Doozy/UI/Containers/UIPopup", false, 8)] private static void CreateComponent(UnityEditor.MenuCommand menuCommand) { GameObjectUtils.AddToScene("UIPopup", false, true); } #endif /// Describes where a popup can be instantiated under public enum Parenting { /// The parent of the popup will be the PopupCanvas PopupsCanvas = 0, /// The parent of the popup will be the RectTransform of the GameObject that has the given UITagId UITag = 1 } /// Maximum sorting order value for popups (it's 1 level lower than popups) public const int k_MaxSortingOrder = 32766; /// Default popup name public const string k_DefaultPopupName = "None"; /// Default category used by the UITagId to identify the default Popup Canvas public const string k_DefaultPopupCanvasUITagCategory = "UIPopup"; /// Default name used by the UITagId to identify the default Popup Canvas public const string k_DefaultPopupCanvasUITagName = "Canvas"; /// Default name for the popups queue. public const string k_DefaultQueueName = "Default"; /// Get all the visible popups (returns all popups that are either in the isVisible or isShowing state) public static IEnumerable visiblePopups => database.Where(item => item.isVisible || item.isShowing); /// Internal static reference to the default canvas used to display popups [ClearOnReload] private static Canvas s_popupsCanvas; /// /// Static reference to the default canvas used to display popups (popups get parented to this canvas). /// You can override the default canvas by referencing another canvas (at runtime) to be used as the default canvas. /// public static Canvas popupsCanvas { get { if (s_popupsCanvas != null) return s_popupsCanvas; //look for UITag UITag uiTag = UITag.GetTags(k_DefaultPopupCanvasUITagCategory, k_DefaultPopupCanvasUITagName).FirstOrDefault(); if (uiTag != null) { s_popupsCanvas = uiTag.GetComponent(); if (s_popupsCanvas != null) return s_popupsCanvas; } //create Popup Canvas s_popupsCanvas = new GameObject("Popups Canvas").AddComponent(); s_popupsCanvas.renderMode = RenderMode.ScreenSpaceOverlay; s_popupsCanvas.overrideSorting = true; s_popupsCanvas.sortingOrder = k_MaxSortingOrder; //add the default tag uiTag = s_popupsCanvas.gameObject.AddComponent(); uiTag.Id.Category = k_DefaultPopupCanvasUITagCategory; uiTag.Id.Name = k_DefaultPopupCanvasUITagName; return s_popupsCanvas; } set => s_popupsCanvas = value; } #region Popup Queue [ClearOnReload] private static Dictionary> s_queues; /// Popup queues public static Dictionary> queues => s_queues ?? (s_queues = new Dictionary>()); /// Get the queue with the given name /// Queue name /// The queue with the given name public static List GetQueue(string queueName = k_DefaultQueueName) { if (string.IsNullOrEmpty(queueName)) return null; return queues.TryGetValue(queueName, out List queue) ? queue.RemoveNulls() : null; } /// Get the first popup in the queue with the given name /// Queue name /// The first popup in the queue with the given name public static UIPopup GetFirstPopupInQueue(string queueName = k_DefaultQueueName) => GetQueue(queueName)?.FirstOrDefault(); /// Show the first popup in the queue with the given name /// Queue name /// /// The first popup in the queue with the given name. /// Returns null if the queue is empty. /// public static UIPopup ShowNextPopupInQueue(string queueName = k_DefaultQueueName) { List queue = GetQueue(queueName); if (queue == null) return null; if (queue.Count == 0) { queues.Remove(queueName); return null; } UIPopup popup = queue.FirstOrDefault(); if (popup == null) return null; popup.OnHiddenCallback.Event.AddListener(() => { RemovePopupFromQueue(popup); ShowNextPopupInQueue(queueName); }); popup.Show(); return popup; } /// /// Add a popup to the queue with the given name. /// If the queue doesn't exist, it will be created and the popup will be shown. /// /// Popup to add to the queue /// Queue name public static void AddPopupToQueue(UIPopup popup, string queueName = k_DefaultQueueName) { if (popup == null) return; List queue = GetQueue(queueName); //check if the queue already exists and if it's empty bool showPopup = queue == null || queue.Count == 0; //create queue if it doesn't exist if (queue == null) { queue = new List(); queues.Add(queueName, queue); } //don't add the popup if it's already in the queue if (!showPopup && queue.Contains(popup)) return; //add the popup to the queue queue.Add(popup); //instantly hide the popup if it's not hidden if (!popup.isHidden) popup.InstantHide(false); //show the popup if it's the first one in the queue if (showPopup) ShowNextPopupInQueue(queueName); } /// /// Remove the popup from the queue with the given name. /// If the popup is currently showing, it will be hidden and removed from the queue. /// /// Popup to remove from the queue /// Queue name public static void RemovePopupFromQueue(UIPopup popup, string queueName = k_DefaultQueueName) { if (popup == null) return; List queue = GetQueue(queueName); queue?.Remove(popup); if (popup.isVisible || popup.isShowing) popup.Hide(); } /// /// Clear the queue with the given name by hiding all the popups in the queue and removing them from the queue. /// /// Queue name public static void ClearQueue(string queueName = k_DefaultQueueName) { List queue = GetQueue(queueName); if (queue == null) return; foreach (UIPopup popup in queue) popup.Hide(); queue.Clear(); queues.Remove(queueName); } #endregion /// Set where a popup should be instantiated under public Parenting ParentMode = Parenting.PopupsCanvas; /// /// Id used to identify the designated parent where the popup should be parented under, /// after is has been instantiated /// public UITagId ParentTag; /// /// Enable override sorting and set the sorting order to the maximum value, /// for the Canvas component attached to this popup /// public bool OverrideSorting = true; /// Block the 'Back' button when the popup is visible public bool BlockBackButton = true; /// /// Reselect the previously selected GameObject when the popup is hidden. /// This is useful when the popup is hidden from a button that was selected before the popup was shown. /// EventSystem.current is used to determine the currently selected GameObject. /// public bool RestoreSelectedAfterHide = true; /// /// Hide (close) the popup when the user clicks on any of the UIButton references. /// At runtime, a 'hide popup' event will be added to all the referenced UIButtons (if any). /// public bool HideOnAnyButton = true; /// Set the next 'Back' button event to hide (close) this UIPopup public bool HideOnBackButton; /// Set the next click (on the Container) to hide (close) this UIPopup public bool HideOnClickContainer = true; /// Set the next click (on the Overlay) to hide (close) this UIPopup public bool HideOnClickOverlay = true; /// Reference to the popup's Overlay RectTransform public RectTransform Overlay; /// TRUE if the popup has an Overlay RectTransform reference public bool hasOverlay => Overlay != null; /// Reference to the popup's Container RectTransform public RectTransform Container; /// TRUE if the popup has a Content RectTransform reference public bool hasContainer => Container != null; /// List of all the labels this UIPopup has public List Labels = new List(); /// TRUE if the popup has at least one TextMeshProUGUI label reference public bool hasLabels => Labels.RemoveNulls().Count > 0; /// List of all the images this UIPopup has public List Images = new List(); /// TRUE if the popup has at least one Image reference public bool hasImages => Images.RemoveNulls().Count > 0; /// List of all the buttons this UIPopup has public List Buttons = new List(); /// TRUE if the popup has at least one UIButton reference public bool hasButtons => Buttons.RemoveNulls().Count > 0; /// /// Reference to the RectTransform of the popup's parent. /// This value is updated after SetParent() is called. /// // ReSharper disable once UnusedAutoPropertyAccessor.Global public RectTransform parentRectTransform { get; internal set; } /// 'Back' button signal receiver used to trigger the hiding of this UIPopup if HideOnBackButton is TRUE public SignalReceiver backButtonReceiver { get; set; } /// Internal flag used to keep track if this popup disabled the 'Back' button, used to restore the previous state private bool unblockBackButton { get; set; } /// Internal flag used to keep track if this popup added a PointerClickTrigger to the Overlay RectTransform private bool addedHideOnClickOverlay { get; set; } /// Internal flag used to keep track if this popup added a PointerClickTrigger to the Container RectTransform private bool addedHideOnClickContainer { get; set; } /// /// Internal flag used to keep track if the hide event was added to the buttons. /// This is needed to avoid adding the event multiple times to the same button. /// private bool addedHideEventToButtons { get; set; } /// Internal reference to the previously selected GameObject. private GameObject previouslySelectedGameObject { get; set; } /// Internal coroutine used to update the popup's sorting order. private Coroutine sortingCoroutine { get; set; } /// Validate the popup's settings public virtual void Validate() { Labels.RemoveNulls(); Images.RemoveNulls(); Buttons.RemoveNulls(); } protected override void Awake() { base.Awake(); //initialize the 'Back' button signal receiver backButtonReceiver = new SignalReceiver().SetOnSignalCallback(signal => { if (!HideOnBackButton) return; if (isHidden || isHiding) return; Hide(); }); } protected override void OnEnable() { base.OnEnable(); //connect to the 'Back' button secondary signal stream //(this stream ignores the disabled state for the 'Back' button) BackButton.streamIgnoreDisabled.ConnectReceiver(backButtonReceiver); } protected override void OnDisable() { base.OnDisable(); //disconnect from the 'Back' button secondary signal stream //(this stream ignores the disabled state for the 'Back' button) BackButton.streamIgnoreDisabled.DisconnectReceiver(backButtonReceiver); StopSortingCoroutine(); } protected override void OnDestroy() { base.OnDestroy(); //sanity check to make sure the 'Back' button is enabled back again EnableBackButton(); } public override void Show() { SavePreviouslySelectedGameObject(); DisableBackButton(); AddOnClickToOverlay(); AddOnClickToContainer(); StartSortingCoroutine(); base.Show(); } public override void InstantShow() { SavePreviouslySelectedGameObject(); DisableBackButton(); AddOnClickToOverlay(); AddOnClickToContainer(); StartSortingCoroutine(); base.InstantShow(); } public override void Hide() { StopSortingCoroutine(); EnableBackButton(); base.Hide(); } public override void InstantHide() { StopSortingCoroutine(); EnableBackButton(); base.InstantHide(); } #region Public Methods /// /// Get a parent reference for the popup according to the popup's current ParentMode setting. /// This is not the same as the popup's transform.parent, which is the parent of the popup's GameObject. /// public RectTransform GetParent() { RectTransform parent; switch (ParentMode) { case Parenting.PopupsCanvas: parent = popupsCanvas.GetComponent(); break; case Parenting.UITag: if (ParentTag == null) { Debug.Log ( "[Popup] Parenting mode set to 'UITag' but no UITag is set." + "Used the PopupsCanvas as parent instead." ); parent = popupsCanvas.GetComponent(); break; } var uiTag = UITag.GetFirstTag(ParentTag.Category, ParentTag.Name); if (uiTag == null) { Debug.Log ( "[Popup] Parenting mode set to 'UITag' but the UITag is not found." + "Used the PopupsCanvas as parent instead." ); parent = popupsCanvas.GetComponent(); break; } parent = uiTag.GetComponent(); if (parent == null) { Debug.Log ( "[Popup] Parenting mode set to 'UITag' but the UITag has no RectTransform component." + "Used the PopupsCanvas as parent instead." ); parent = popupsCanvas.GetComponent(); } break; default: throw new ArgumentOutOfRangeException(); } return parent; } #endregion #region Private Methods private void StartSortingCoroutine() { StopSortingCoroutine(); sortingCoroutine = StartCoroutine ( Coroutiner.DelayExecution ( () => { if (this == null) return; this.ApplyOverrideSorting(); }, 3 //number of frames to wait ) ); } private void StopSortingCoroutine() { if (sortingCoroutine == null) return; StopCoroutine(sortingCoroutine); sortingCoroutine = null; } /// Disable the 'Back' button if it is enabled and the popup is visible private void DisableBackButton() { //sanity check to make sure the 'Back' button was not already disabled by this UIPopup if (unblockBackButton) return; //check that block 'Back' button option is enabled if (!BlockBackButton) return; BackButton.Disable(); unblockBackButton = true; } private void EnableBackButton() { //sanity check to make sure the 'Back' button was not already enabled by this UIPopup if (!unblockBackButton) return; //check that block 'Back' button option is enabled if (!BlockBackButton) return; BackButton.Enable(); unblockBackButton = false; } /// /// Add on pointer click trigger on the Overlay RectTransform to hide (close) this UIPopup. /// If the Overlay RectTransform is not assigned, nothing will happen. /// Calling this method multiple time will not add multiple triggers (nor events) to the Overlay RectTransform. /// private void AddOnClickToOverlay() { if (!hasOverlay) return; if (!HideOnClickOverlay) return; if (addedHideOnClickOverlay) return; PointerClickTrigger clickTrigger = Overlay.GetComponent() ?? Overlay.gameObject.AddComponent(); clickTrigger.OnTrigger.AddListener(evt => Hide()); addedHideOnClickOverlay = true; } /// /// Add on pointer click trigger on the Container RectTransform to hide (close) this UIPopup. /// If the Container RectTransform is not assigned, nothing will happen. /// Calling this method multiple time will not add multiple triggers (nor events) to the Container RectTransform. /// private void AddOnClickToContainer() { if (!hasContainer) return; if (!HideOnClickContainer) return; if (addedHideOnClickContainer) return; PointerClickTrigger clickTrigger = Container.GetComponent() ?? Container.gameObject.AddComponent(); clickTrigger.OnTrigger.AddListener(evt => Hide()); addedHideOnClickContainer = true; } /// Set all the referenced buttons to hide the tooltip is HideOnAnyButton is enabled private void ApplyHideOnAnyButton() { if (addedHideEventToButtons) return; if (!hasButtons) return; if (!HideOnAnyButton) return; foreach (UIButton button in Buttons) { button.onClickBehaviour.Event.AddListener(Hide); button.onSubmitBehaviour.Event.AddListener(Hide); } addedHideEventToButtons = true; //only add the event once } /// /// Save the currently selected GameObject in the EventSystem.current. /// private void SavePreviouslySelectedGameObject() { if (EventSystem.current == null) return; previouslySelectedGameObject = EventSystem.current.currentSelectedGameObject; } /// /// Restore the previously selected GameObject when the popup is hidden. /// private void RestorePreviouslySelectedGameObject() { if (!RestoreSelectedAfterHide) return; if (EventSystem.current == null) return; if (previouslySelectedGameObject == null) return; EventSystem.current.SetSelectedGameObject(previouslySelectedGameObject); } #endregion #region Static Methods /// /// Instantiate a new popup from the prefab that is registered in the database. /// If a prefab with the given popup name is not found, null will be returned. /// /// The name of the popup prefab in the database. /// The popup instance. Null if the prefab is not found. public static UIPopup Get(string popupName) { if (string.IsNullOrEmpty(popupName)) return null; GameObject prefab = UIPopupDatabase.instance.GetPrefab(popupName); if (prefab == null) { Debug.LogWarning($"UIPopup.Get({popupName}) - prefab not found in the database"); return null; } UIPopup popup = Instantiate(prefab) .GetComponent() .Reset(); popup.Validate(); popup.ApplyHideOnAnyButton(); popup.SetParent(popup.GetParent()); popup.InstantHide(false); //destroy the popup when it is hidden popup.OnHiddenCallback.Event.AddListener(() => { //sanity check to make sure the popup is not already destroyed if (popup == null) return; popup.RestorePreviouslySelectedGameObject(); Destroy(popup.gameObject); popup = null; }); return popup; } #endregion } public static class UIPopupExtensions { /// Reset the popup to its initial state /// Target popup /// The popup instance public static T Reset(this T popup) where T : UIPopup { popup.parentRectTransform = null; return popup; } /// Reparent the popup to a new parent /// Target popup /// The new parent /// The popup instance public static T SetParent(this T popup, RectTransform parent) where T : UIPopup { popup.parentRectTransform = parent; if (parent == null) return popup; popup.rectTransform.SetParent(parent, true); popup.rectTransform.CenterPivot().ExpandToParentSize(true); return popup; } /// Update the override sorting order setting /// Target popup /// New override sorting order value /// TRUE to apply the new value, FALSE to only update the setting /// The popup instance public static T SetOverrideSorting(this T popup, bool overrideSortingOrder, bool apply = false) where T : UIPopup { popup.OverrideSorting = overrideSortingOrder; if (apply) popup.ApplyOverrideSorting(); return popup; } /// Apply the override sorting order setting (if enabled) /// Target popup /// The popup instance public static T ApplyOverrideSorting(this T popup) where T : UIPopup { if (!popup.OverrideSorting) return popup; popup.canvas.overrideSorting = true; popup.canvas.sortingOrder = UIPopup.k_MaxSortingOrder; if (!popup.canvas.gameObject.activeInHierarchy) Debug.Log($"Cannot apply override sorting order to popup {popup.name} because it is not active in the scene"); if (!popup.canvas.enabled) Debug.Log($"Cannot apply override sorting order to popup {popup.name} because its canvas is not enabled"); return popup; } /// /// Set the text values for all the text mesh pro labels this popup has references to. /// Each string value will be set to the TextMeshProUI label with the same index in the Labels list. /// /// Target popup /// Text values to set /// The popup instance public static T SetTexts(this T popup, params string[] texts) where T : UIPopup { int textsCount = texts.Length; if (textsCount == 0) return popup; if (popup.Labels == null) { Debug.LogWarning($"Cannot set texts for popup {popup.name} because it has no labels references"); return popup; } popup.Labels = popup.Labels.RemoveNulls(); if (popup.Labels.Count == 0) { Debug.LogWarning($"Cannot set texts for popup {popup.name} because it has no labels references"); return popup; } for (int i = 0; i < popup.Labels.Count; i++) { TextMeshProUGUI label = popup.Labels[i]; if (label == null) continue; label.SetText(i < textsCount ? texts[i] : string.Empty); label.ForceMeshUpdate(); } return popup; } /// /// Set the sprite references for all the Images this popup has references to. /// Each Sprite will be referenced to the Image with the same index in the Images list. /// /// Target popup /// Sprite references to set /// The popup instance public static T SetSprites(this T popup, params Sprite[] sprites) where T : UIPopup { int spritesCount = sprites.Length; if (spritesCount == 0) return popup; if (popup.Images == null) { Debug.LogWarning($"Cannot set sprites for popup {popup.name} because it has no image references"); return popup; } popup.Images = popup.Images.RemoveNulls(); if (popup.Images.Count == 0) { Debug.LogWarning($"Cannot set sprites for popup {popup.name} because it has no image references"); return popup; } for (int i = 0; i < popup.Images.Count; i++) { Image image = popup.Images[i]; if (image == null) continue; image.sprite = i < spritesCount ? sprites[i] : null; } return popup; } /// /// Set the UnityEvents to invoke for all the UIButtons this popup has references to /// Each UnityEvent will be assigned to the UIButton with the same index in the Buttons list. /// The UnityEvent will be invoked when the UIButton's either on click or submit behaviour is invoked. /// /// Target popup /// UnityEvents to invoke /// The popup instance public static T SetEvents(this T popup, params UnityEvent[] events) where T : UIPopup { int eventsCount = events.Length; if (eventsCount == 0) return popup; bool hasGraphicRaycaster = popup.GetComponentInChildren(); if (popup.Buttons == null) { Debug.LogWarning($"Cannot set events for popup {popup.name} because it has no button references"); return popup; } popup.Buttons = popup.Buttons.RemoveNulls(); if (popup.Buttons.Count == 0) { Debug.LogWarning($"Cannot set events for popup {popup.name} because it has no button references"); return popup; } for (int i = 0; i < popup.Buttons.Count; i++) { UIButton button = popup.Buttons[i]; //get the button if (button == null) continue; //if the button is null, continue if (!hasGraphicRaycaster) //if the popup doesn't have a graphic raycaster, check if the button has one if (!button.GetComponent()) //if the button doesn't have a graphic raycaster button.gameObject.AddComponent(); //add a graphic raycaster to the button if (eventsCount <= i) continue; //if the event count is less than the index, continue UnityEvent evt = events[i]; //get the event if (evt == null) continue; //if the event is null, continue button.onClickBehaviour.Event.AddListener(evt.Invoke); //add the event to the button on click behaviour button.onSubmitBehaviour.Event.AddListener(evt.Invoke); //add the event to the button on submit behaviour } return popup; } /// /// Set the UnityActions to invoke for all the UIButtons this popup has references to. /// Each UnityAction will be assigned to the UIButton with the same index in the Buttons list. /// The UnityAction will be invoked when the UIButton's either on click or submit behaviour is invoked. /// /// Target popup /// UnityActions to invoke /// The popup instance public static T SetEvents(this T popup, params UnityAction[] actions) where T : UIPopup { int actionsCount = actions.Length; if (actionsCount == 0) return popup; bool hasGraphicRaycaster = popup.GetComponentInChildren(); if (popup.Buttons == null) { Debug.LogWarning($"Cannot set events for popup {popup.name} because it has no button references"); return popup; } popup.Buttons = popup.Buttons.RemoveNulls(); if (popup.Buttons.Count == 0) { Debug.LogWarning($"Cannot set events for popup {popup.name} because it has no button references"); return popup; } for (int i = 0; i < popup.Buttons.Count; i++) { UIButton button = popup.Buttons[i]; //get the button if (button == null) continue; //if the button is null, continue if (!hasGraphicRaycaster) //if the popup doesn't have a graphic raycaster, check if the button has one if (!button.GetComponent()) //if the button doesn't have a graphic raycaster button.gameObject.AddComponent(); //add a graphic raycaster to the button if (actionsCount <= i) continue; //if the event count is less than the index, continue UnityAction action = actions[i]; //get the action if (action == null) continue; //if the action is null, continue button.onClickBehaviour.Event.AddListener(action.Invoke); //add the action to the button on click behaviour button.onSubmitBehaviour.Event.AddListener(action.Invoke); //add the action to the button on submit behaviour } return popup; } #region Popup Queue /// Add a popup to the popup queue and show it (if the queue is not already showing a popup). /// Target popup /// Name of the popup queue to add the popup to /// The popup instance public static T ShowFromQueue(this T popup, string queueName = UIPopup.k_DefaultQueueName) where T : UIPopup { if (string.IsNullOrEmpty(queueName)) { Debug.LogError($"Cannot show popup {popup.name} from queue because the queue name is null or empty"); return popup; } UIPopup.AddPopupToQueue(popup); return popup; } #endregion } }