// 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.Globalization; using System.Linq; using Doozy.Runtime.Common.Attributes; using Doozy.Runtime.Common.Events; using Doozy.Runtime.Common.Extensions; using Doozy.Runtime.Common.Utils; using Doozy.Runtime.Mody; using Doozy.Runtime.Reactor; using Doozy.Runtime.Signals; using Doozy.Runtime.UIManager.Utils; using TMPro; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; using MoveDirection = UnityEngine.EventSystems.MoveDirection; // ReSharper disable MemberCanBePrivate.Global // ReSharper disable UnusedMember.Global namespace Doozy.Runtime.UIManager.Components { /// /// Slider component based on UISelectable with category/name id identifier. /// [RequireComponent(typeof(RectTransform))] [AddComponentMenu("Doozy/UI/Components/UISlider")] [SelectionBase] public partial class UISlider : UISelectable, IDragHandler, IInitializePotentialDragHandler { private const float TOLERANCE = 0.0001f; #if UNITY_EDITOR [UnityEditor.MenuItem("GameObject/Doozy/UI/Components/UISlider", false, 8)] private static void CreateComponent(UnityEditor.MenuCommand menuCommand) { GameObjectUtils.AddToScene("UISlider", false, true); } #endif /// UISliders database public static HashSet database { get; private set; } = new HashSet(); [ExecuteOnReload] // ReSharper disable once UnusedMember.Local private static void OnReload() { database = new HashSet(); } [ClearOnReload] private static SignalStream s_stream; /// UISlider signal stream public static SignalStream stream => s_stream ?? (s_stream = SignalsService.GetStream(k_StreamCategory, nameof(UISlider))); /// All sliders that are active and enabled public static IEnumerable availableSliders => database.Where(item => item.isActiveAndEnabled); /// TRUE is this selectable is selected by EventSystem.current, FALSE otherwise public bool isSelected => EventSystem.current.currentSelectedGameObject == gameObject; /// Type of selectable public override SelectableType selectableType => SelectableType.Button; /// UISlider identifier public UISliderId Id; /// Slider changed its value - executed when the slider changes its value [Obsolete("Use OnValueChanged instead")] public FloatEvent OnValueChangedCallback; /// /// Fired when the value changed. /// Returns the new value. /// public FloatEvent OnValueChanged = new FloatEvent(); /// /// Fired when the value increases. /// Returns the difference between the new value and the previous value. /// Example: if the previous value was 0.5 and the new value is 0.7, the difference is 0.2 /// public FloatEvent OnValueIncremented = new FloatEvent(); /// /// Fired when the value decreases. /// Returns the difference between the new value and the previous value. /// Example: if the previous value was 10 and the new value is 5, the returned value will be -5 /// public FloatEvent OnValueDecremented = new FloatEvent(); /// Fired when the value was reset public ModyEvent OnValueReset = new ModyEvent(); /// Fired when the value has reached the minimum value public ModyEvent OnValueReachedMin = new ModyEvent(); /// Fired when the value has reached the maximum value public ModyEvent OnValueReachedMax = new ModyEvent(); [SerializeField] private RectTransform FillRect; /// Optional RectTransform to use as fill for the slider public RectTransform fillRect { get => FillRect; set { if (value == FillRect) return; FillRect = value; UpdateCachedReferences(); UpdateVisuals(); } } [SerializeField] private RectTransform HandleRect; /// Optional RectTransform to use as a handle for the slider public RectTransform handleRect { get => HandleRect; set { if (value == HandleRect) return; HandleRect = value; UpdateCachedReferences(); UpdateVisuals(); } } [SerializeField] private SlideDirection Direction = SlideDirection.LeftToRight; /// The direction of the slider, from minimum to maximum value public SlideDirection direction { get => Direction; set { Direction = value; UpdateVisuals(); } } [SerializeField] private float MinValue; /// The minimum allowed value of the slider public float minValue { get => MinValue; set { MinValue = value; Value.Clamp(MinValue, MaxValue); UpdateLabel(minValueLabel, MinValue); UpdateVisuals(); } } [SerializeField] private float MaxValue = 1f; /// The maximum allowed value of the slider public float maxValue { get => MaxValue; set { MaxValue = value; Value.Clamp(MinValue, MaxValue); UpdateLabel(maxValueLabel, MaxValue); UpdateVisuals(); } } [SerializeField] private bool WholeNumbers; /// Should the value only be allowed to be whole numbers? public bool wholeNumbers { get => WholeNumbers; set { WholeNumbers = value; if (!value) return; MinValue = Mathf.Round(MinValue); MaxValue = Mathf.Round(MaxValue); Value.Clamp(MinValue, MaxValue); UpdateVisuals(); UpdateLabel(minValueLabel, MinValue); UpdateLabel(maxValueLabel, MaxValue); UpdateLabel(valueLabel, Value); } } [SerializeField] protected float Value; /// The current value of the slider public virtual float value { get => wholeNumbers ? Mathf.Round(Value) : Value; set => SetValue(value); } [SerializeField] private float DefaultValue; /// Reset value for the slider public float defaultValue { get => DefaultValue; set => DefaultValue = Mathf.Clamp(value, minValue, maxValue); } [SerializeField] private TMP_Text ValueLabel; /// Reference to the value label that displays the current value of the slider public TMP_Text valueLabel { get => ValueLabel; private set { ValueLabel = value; UpdateLabel(ValueLabel, Value); } } [SerializeField] private TMP_Text MinValueLabel; /// Reference to the value label that displays the min value of the slider public TMP_Text minValueLabel { get => MinValueLabel; private set { MinValueLabel = value; UpdateLabel(MinValueLabel, minValue); } } [SerializeField] private TMP_Text MaxValueLabel; /// Reference to the value label that displays the max value of the slider public TMP_Text maxValueLabel { get => MaxValueLabel; private set { MaxValueLabel = value; UpdateLabel(MaxValueLabel, maxValue); } } [SerializeField] private Progressor TargetProgressor; /// Reference to a Progressor that will be updated when the slider value changes public Progressor targetProgressor { get => TargetProgressor; private set { TargetProgressor = value; if (value == null) return; UpdateTargetProgressorMinMax(); UpdateTargetProgressorValue(); } } /// /// When true, the slider will update the target progressor value with SetValueAt instead of PlayToValue. /// Basically, if true, the progressor will not animate when the slider value changes. /// public bool InstantProgressorUpdate = true; /// Reset the slider value to the default value OnEnable public bool ResetValueOnEnable = false; /// The current value of the slider normalized into a value between 0 and 1 public float normalizedValue { get => Mathf.Approximately(minValue, maxValue) ? 0 : Mathf.InverseLerp(minValue, maxValue, value); set => this.value = Mathf.Lerp(minValue, maxValue, value); } private Axis axis => GetAxis(direction); private static Axis GetAxis(SlideDirection slideDirection) { switch (slideDirection) { case SlideDirection.LeftToRight: case SlideDirection.RightToLeft: return Axis.Horizontal; case SlideDirection.BottomToTop: case SlideDirection.TopToBottom: return Axis.Vertical; default: throw new ArgumentOutOfRangeException(); } } private bool reverseValue => Direction == SlideDirection.RightToLeft || Direction == SlideDirection.TopToBottom; private Image m_FillImage; private Transform m_FillTransform; private RectTransform m_FillContainerRect; private Transform m_HandleTransform; private RectTransform m_HandleContainerRect; // The offset from handle position to mouse down position private Vector2 m_Offset = Vector2.zero; private DrivenRectTransformTracker m_Tracker; // This "delayed" mechanism is required for case 1037681. private bool m_DelayedUpdateVisuals; // Size of each step. private float stepSize => wholeNumbers ? 1 : (maxValue - minValue) * 0.1f; private UISlider() { Id = new UISliderId(); } #if UNITY_EDITOR protected override void OnValidate() { MinValue = WholeNumbers ? MinValue.Round(0) : MinValue; MaxValue = WholeNumbers ? MaxValue.Round(0) : MaxValue; Value = WholeNumbers ? Value.Round(0) : Value; if (IsActive()) { UpdateCachedReferences(); m_DelayedUpdateVisuals = true; } base.OnValidate(); } #endif //UNITY_EDITOR public override void Rebuild(CanvasUpdate executing) { base.Rebuild(executing); #if UNITY_EDITOR if (executing == CanvasUpdate.Prelayout) { #pragma warning disable CS0618 OnValueChangedCallback?.Invoke(value); #pragma warning restore CS0618 OnValueChanged?.Invoke(value); } #endif //UNITY_EDITOR } protected override void Awake() { database.Add(this); base.Awake(); // OnValueChanged.AddListener(v => Debug.Log($"Slider {Id} value changed to {v}")); // OnValueIncremented.AddListener(v => Debug.Log($"Slider {Id} value incremented to {v}")); // OnValueDecremented.AddListener(v => Debug.Log($"Slider {Id} value decremented to {v}")); // OnValueReset.Event.AddListener(() => Debug.Log($"Slider {Id} value reset to {defaultValue}")); // OnValueReachedMin.Event.AddListener(() => Debug.Log($"Slider {Id} value reached min value {minValue}")); // OnValueReachedMax.Event.AddListener(() => Debug.Log($"Slider {Id} value reached max value {maxValue}")); } protected override void OnEnable() { database.Remove(null); base.OnEnable(); if (!Application.isPlaying) return; UpdateCachedReferences(); UpdateTargetProgressorMinMax(); if (ResetValueOnEnable) { ResetValue(); } else { UpdateTargetProgressorValue(); } UpdateVisuals(); UpdateLabel(minValueLabel, minValue); UpdateLabel(maxValueLabel, maxValue); } protected override void OnDisable() { database.Remove(null); m_Tracker.Clear(); UpdateTargetProgressorValue(); base.OnDisable(); } protected override void OnDestroy() { database.Remove(null); database.Remove(this); base.OnDestroy(); } private void Update() { if (!m_DelayedUpdateVisuals) return; m_DelayedUpdateVisuals = false; SetValue(Value, false); UpdateVisuals(); } protected override void OnDidApplyAnimationProperties() { // Has value changed? Various elements of the slider have the old normalisedValue assigned, we can use this to perform a comparison. // We also need to ensure the value stays within min/max. Value = ClampValue(Value); float previousNormalizedValue = normalizedValue; if (m_FillContainerRect != null) { if (m_FillImage != null && m_FillImage.type == Image.Type.Filled) { previousNormalizedValue = m_FillImage.fillAmount; } else { previousNormalizedValue = reverseValue ? 1 - FillRect.anchorMin[(int)axis] : FillRect.anchorMax[(int)axis]; } } else if (m_HandleContainerRect != null) { previousNormalizedValue = reverseValue ? 1 - HandleRect.anchorMin[(int)axis] : HandleRect.anchorMin[(int)axis]; } UpdateVisuals(); if (Mathf.Approximately(previousNormalizedValue, normalizedValue)) return; UISystemProfilerApi.AddMarker("Slider.value", this); #pragma warning disable CS0618 OnValueChangedCallback.Invoke(Value); #pragma warning restore CS0618 OnValueChanged.Invoke(Value); } private void UpdateCachedReferences() { if (FillRect && FillRect != (RectTransform)transform) { m_FillTransform = FillRect.transform; m_FillImage = FillRect.GetComponent(); if (m_FillTransform.parent != null) m_FillContainerRect = m_FillTransform.parent.GetComponent(); } else { FillRect = null; m_FillContainerRect = null; m_FillImage = null; } if (HandleRect && HandleRect != (RectTransform)transform) { m_HandleTransform = HandleRect.transform; if (m_HandleTransform.parent != null) m_HandleContainerRect = m_HandleTransform.parent.GetComponent(); } else { HandleRect = null; m_HandleContainerRect = null; } } /// Reset the current int or float value, depending on the stepper's value type, to the default value. public void ResetValue() { SetValue(defaultValue); OnValueReset.Execute(); } private float ClampValue(float input) => wholeNumbers ? input.Clamp(minValue, maxValue).Round(0) : input.Clamp(minValue, maxValue); /// Set the value of the slider without invoking OnValueChanged callback /// The new value for the slider public virtual void SetValueWithoutNotify(float input) => SetValue(input, false); /// Set the value of the slider /// The new value for the slider /// If the OnValueChanged callback should be invoked /// /// Process the input to ensure the value is between min and max value. If the input is different set the value and send the callback is required. /// public void SetValue(float newValue, bool sendCallback = true) { bool valueChanged = Math.Abs(Value - newValue) > TOLERANCE; //check if the value has changed float previousValue = Value; Value = Mathf.Clamp(newValue, minValue, maxValue); //set the new value if (wholeNumbers) Value = Value.Round(0); //round the value if wholeNumbers is true UpdateLabel(valueLabel, Value); UpdateVisuals(); if (valueChanged) { if (sendCallback) { UISystemProfilerApi.AddMarker($"{nameof(UISlider)}.{nameof(value)}", this); #pragma warning disable CS0618 OnValueChangedCallback.Invoke(Value); #pragma warning restore CS0618 OnValueChanged?.Invoke(Value); stream.SendSignal(Value); if (previousValue < Value) { OnValueIncremented?.Invoke(Value - previousValue); stream.SendSignal(new UISliderSignalData(Id.Category, Id.Name, SliderState.ValueIncremented, this)); } else if (previousValue > Value) { OnValueDecremented?.Invoke(previousValue - Value); stream.SendSignal(new UISliderSignalData(Id.Category, Id.Name, SliderState.ValueDecremented, this)); } } if (InstantProgressorUpdate) { UpdateTargetProgressorValue(); } else { PlayTargetProgressorValue(); } } if (!sendCallback) return; if (Value <= minValue) { //value is equal to the min value //invoke the OnValueReachedMin event if the value is equal to the min value OnValueReachedMin.Execute(); //stream.SendSignal(new UISliderSignalData(Id.Category, Id.Name, SliderState.ReachedMinValue, this)); } if (Value >= maxValue) { //value is equal to the max value //invoke the OnValueReachedMax event if the value is equal to the max value OnValueReachedMax.Execute(); //stream.SendSignal(new UISliderSignalData(Id.Category, Id.Name, SliderState.ReachedMaxValue, this)); } } protected override void OnRectTransformDimensionsChange() { base.OnRectTransformDimensionsChange(); //this can be invoked before OnEnabled is called //we shouldn't be accessing other objects, before OnEnable is called if (!IsActive()) return; UpdateVisuals(); } /// /// Force-update the slider. /// Useful if the properties changed and a visual update is needed. /// public void UpdateVisuals() { #if UNITY_EDITOR if (!Application.isPlaying) UpdateCachedReferences(); #endif //UNITY_EDITOR m_Tracker.Clear(); if (m_FillContainerRect != null) { m_Tracker.Add(this, FillRect, DrivenTransformProperties.Anchors); Vector2 anchorMin = Vector2.zero; Vector2 anchorMax = Vector2.one; if (m_FillImage != null && m_FillImage.type == Image.Type.Filled) { m_FillImage.fillAmount = normalizedValue; } else { if (reverseValue) anchorMin[(int)axis] = 1 - normalizedValue; else anchorMax[(int)axis] = normalizedValue; } FillRect.anchorMin = anchorMin; FillRect.anchorMax = anchorMax; } if (m_HandleContainerRect == null) return; { m_Tracker.Add(this, HandleRect, DrivenTransformProperties.Anchors); Vector2 anchorMin = Vector2.zero; Vector2 anchorMax = Vector2.one; anchorMin[(int)axis] = anchorMax[(int)axis] = (reverseValue ? (1 - normalizedValue) : normalizedValue); HandleRect.anchorMin = anchorMin; HandleRect.anchorMax = anchorMax; } } /// Update the slider's position based on the pointer event data /// Data /// Camera private void UpdateDrag(PointerEventData eventData, Camera cam) { RectTransform clickRect = m_HandleContainerRect ? m_HandleContainerRect : m_FillContainerRect; if (clickRect == null) return; if (!(clickRect.rect.size[(int)axis] > 0)) return; Vector2 position = Vector2.zero; if (!MultipleDisplayUtilities.GetRelativeMousePositionForDrag(eventData, ref position)) return; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(clickRect, position, cam, out Vector2 localCursor)) return; Rect rect = clickRect.rect; localCursor -= rect.position; float val = Mathf.Clamp01((localCursor - m_Offset)[(int)axis] / rect.size[(int)axis]); normalizedValue = reverseValue ? 1f - val : val; } private bool AllowDrag(PointerEventData eventData) => IsActive() && IsInteractable() && eventData.button == PointerEventData.InputButton.Left; public override void OnPointerDown(PointerEventData eventData) { if (!AllowDrag(eventData)) return; base.OnPointerDown(eventData); m_Offset = Vector2.zero; if (m_HandleContainerRect != null && RectTransformUtility.RectangleContainsScreenPoint(HandleRect, eventData.pointerPressRaycast.screenPosition, eventData.enterEventCamera)) { if (RectTransformUtility.ScreenPointToLocalPointInRectangle(HandleRect, eventData.pointerPressRaycast.screenPosition, eventData.pressEventCamera, out Vector2 localMousePos)) { m_Offset = localMousePos; } return; } // Outside the slider handle - jump to this point instead UpdateDrag(eventData, eventData.pressEventCamera); } public virtual void OnDrag(PointerEventData eventData) { if (!AllowDrag(eventData)) return; UpdateDrag(eventData, eventData.pressEventCamera); } public override void OnMove(AxisEventData eventData) { if (!IsActive() || !IsInteractable()) { base.OnMove(eventData); return; } switch (eventData.moveDir) { case MoveDirection.Left: if (axis == Axis.Horizontal && FindSelectableOnLeft() == null) SetValue(reverseValue ? value + stepSize : value - stepSize); else base.OnMove(eventData); break; case MoveDirection.Right: if (axis == Axis.Horizontal && FindSelectableOnRight() == null) SetValue(reverseValue ? value - stepSize : value + stepSize); else base.OnMove(eventData); break; case MoveDirection.Up: if (axis == Axis.Vertical && FindSelectableOnUp() == null) SetValue(reverseValue ? value - stepSize : value + stepSize); else base.OnMove(eventData); break; case MoveDirection.Down: if (axis == Axis.Vertical && FindSelectableOnDown() == null) SetValue(reverseValue ? value + stepSize : value - stepSize); else base.OnMove(eventData); break; } } /// /// See Selectable.FindSelectableOnLeft /// public override Selectable FindSelectableOnLeft() { if (navigation.mode == Navigation.Mode.Automatic && axis == Axis.Horizontal) return null; return base.FindSelectableOnLeft(); } /// /// See Selectable.FindSelectableOnRight /// public override Selectable FindSelectableOnRight() { if (navigation.mode == Navigation.Mode.Automatic && axis == Axis.Horizontal) return null; return base.FindSelectableOnRight(); } /// /// See Selectable.FindSelectableOnUp /// public override Selectable FindSelectableOnUp() { if (navigation.mode == Navigation.Mode.Automatic && axis == Axis.Vertical) return null; return base.FindSelectableOnUp(); } /// /// See Selectable.FindSelectableOnDown /// public override Selectable FindSelectableOnDown() { if (navigation.mode == Navigation.Mode.Automatic && axis == Axis.Vertical) return null; return base.FindSelectableOnDown(); } public virtual void OnInitializePotentialDrag(PointerEventData eventData) { eventData.useDragThreshold = false; } /// /// Sets the direction of this slider, optionally changing the layout as well. /// /// The previous direction of the slider. /// The new direction of the slider. /// Should the layout be flipped together with the slider direction public void SetDirection(SlideDirection previousDirection, SlideDirection newDirection, bool includeRectLayouts) { bool previousReverse = reverseValue; Axis previousAxis = GetAxis(previousDirection); direction = newDirection; if (!includeRectLayouts) return; if (axis != previousAxis) RectTransformUtility.FlipLayoutAxes(transform as RectTransform, true, true); if (reverseValue != previousReverse) RectTransformUtility.FlipLayoutOnAxis(transform as RectTransform, (int)axis, true, true); } private void UpdateTargetProgressorMinMax() { if (!targetProgressor) return; targetProgressor.fromValue = minValue; targetProgressor.toValue = maxValue; targetProgressor.SetValueAt(value); } private void UpdateTargetProgressorValue() { if (!targetProgressor) return; targetProgressor.SetValueAt(value); } private void PlayTargetProgressorValue() { if (!targetProgressor) return; targetProgressor.PlayToValue(value); } #region Chainable Methods /// Reference a new TMP_Text to the value label /// The TMP_Text to reference public T SetValueLabel(TMP_Text label) where T : UISlider { valueLabel = label; return (T)this; } /// Reference a new TMP_Text to the min value label /// The TMP_Text to reference public T SetMinValueLabel(TMP_Text label) where T : UISlider { minValueLabel = label; return (T)this; } /// Reference a new TMP_Text to the max value label /// The TMP_Text to reference public T SetMaxValueLabel(TMP_Text label) where T : UISlider { maxValueLabel = label; return (T)this; } /// Set a new current value for the slider /// The new value to set public T SetValue(float newValue) where T : UISlider { value = newValue; return (T)this; } /// Set a new min value /// The new min value public T SetMinValue(float newMinValue) where T : UISlider { minValue = newMinValue; return (T)this; } /// Set a new max value /// The new max value public T SetMaxValue(float newMaxValue) where T : UISlider { maxValue = newMaxValue; return (T)this; } /// Set a new default value (reset value) /// The new default value (reset value) public T SetDefaultValue(float newResetValue) where T : UISlider { defaultValue = newResetValue; return (T)this; } /// Set a new target progressor to update when the value changes /// The new target progressor to update when the value changes public T SetTargetProgressor(Progressor progressor) where T : UISlider { targetProgressor = progressor; return (T)this; } #endregion #region Static Methods /// Get all the registered sliders with the given category and name /// UISlider category /// UISlider name (from the given category) public static IEnumerable GetSliders(string category, string name) => database.Where(slider => slider.Id.Category.Equals(category)).Where(slider => slider.Id.Name.Equals(name)); /// Get all the registered sliders with the given category /// UISlider category public static IEnumerable GetAllSlidersInCategory(string category) => database.Where(slider => slider.Id.Category.Equals(category)); /// Get all the sliders that are active and enabled (all the visible/available sliders) public static IEnumerable GetAvailableSliders() => database.Where(slider => slider.isActiveAndEnabled); /// Get the selected slider (if a slider is not selected, this method returns null) public static UISlider GetSelectedSlider() => database.FirstOrDefault(slider => slider.isSelected); /// Select the slider with the given category and name (if it is active and enabled) /// UISlider category /// UISlider name (from the given category) public static bool SelectSlider(string category, string name) { UISlider slider = availableSliders.FirstOrDefault(b => b.Id.Category.Equals(category) & b.Id.Name.Equals(name)); if (slider == null) return false; slider.Select(); return true; } /// Update a value label text with the given value /// Label to update /// Value to display private static void UpdateLabel(TMP_Text targetLabel, float displayValue) { if (targetLabel == null) return; targetLabel.text = displayValue.ToString(CultureInfo.InvariantCulture); } #endregion } }