diff --git a/Silksong.ModMenu/Elements/ScrollingPane.cs b/Silksong.ModMenu/Elements/ScrollingPane.cs index 7efaf31..d1b1051 100644 --- a/Silksong.ModMenu/Elements/ScrollingPane.cs +++ b/Silksong.ModMenu/Elements/ScrollingPane.cs @@ -1,14 +1,16 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Silksong.ModMenu.Internal; using UnityEngine; using UnityEngine.UI; +using UObject = UnityEngine.Object; namespace Silksong.ModMenu.Elements; /// -/// A vertically scrolling panel that can contain an arbitrary amount of content. +/// A scrolling panel that can contain an arbitrary amount of content. /// public class ScrollingPane : MenuDisposable, INavigableMenuEntity { @@ -19,8 +21,6 @@ public class ScrollingPane : MenuDisposable, INavigableMenuEntity /// /// The scrolling pane's content. - /// This entity cannot be a , - /// and cannot contain any children. /// public INavigableMenuEntity? Content { @@ -29,12 +29,6 @@ public INavigableMenuEntity? Content { if (field == value) return; - if (value?.GetType().IsSubclassOf(typeof(ScrollingPane)) ?? false) - throw new System.ArgumentException( - $"A {nameof(ScrollingPane)} cannot have another {nameof(ScrollingPane)} as its direct content entity.", - value.GetType().FullName - ); - field?.ClearParents(); field = value; field?.SetParents(this, contentPane); @@ -65,7 +59,7 @@ public ScrollingPane(INavigableMenuEntity content) AddScrollNavHelpers(); - OnDispose += () => Object.Destroy(scrollPane); + OnDispose += () => UObject.Destroy(scrollPane); Visibility.OnVisibilityChanged += visibleInHierarchy => scrollPane.SetActive(visibleInHierarchy); @@ -77,8 +71,45 @@ public ScrollingPane(INavigableMenuEntity content) /// public Vector2 ViewportSize { - get => scrollRect.viewport.sizeDelta; - set => scrollRect.viewport.sizeDelta = value; + get => scrollPane.RectTransform.sizeDelta; + set => scrollPane.RectTransform.sizeDelta = value; + } + + /// + /// Whether this pane scrolls only vertically, only horizontally, or in both axes. + /// + public ScrollAxes Axes + { + get => + (scrollRect.vertical ? ScrollAxes.Vertical : 0) + | (scrollRect.horizontal ? ScrollAxes.Horizontal : 0); + set + { + scrollRect.vertical = value.HasFlag(ScrollAxes.Vertical); + scrollRect.horizontal = value.HasFlag(ScrollAxes.Horizontal); + } + } + + /// + /// Semantic states for which axes a can scroll in. + /// + [Flags] + public enum ScrollAxes + { + /// + /// Exclusively vertical scrolling. + /// + Vertical = 0x01, + + /// + /// Exclusively horizontal scrolling. + /// + Horizontal = 0x10, + + /// + /// Scrolling in both the vertical and horizontal axes. + /// + Both = Vertical | Horizontal, } /// @@ -102,7 +133,7 @@ public float SmoothScrollTime public void ScrollTo(MenuElement element, bool smooth = false) { if (element.VisibleInHierarchy) - focusController.ScrollTo(element.RectTransform, smooth); + focusController.ScrollTo(element.Container.transform, smooth); } /// @@ -117,7 +148,7 @@ public void ScrollTo(int index, bool smooth = false) { var element = Content?.AllElements().ElementAtOrDefault(index); if (element != null && element.VisibleInHierarchy) - focusController.ScrollTo(element.RectTransform, smooth); + focusController.ScrollTo(element.Container.transform, smooth); } /// @@ -137,24 +168,15 @@ public void UpdateLayout(Vector2 localAnchorPos) if (resizeQueued) { resizeQueued = false; - foreach (Transform child in contentPane.transform) - { - if (child.GetComponent()) - throw new System.InvalidOperationException( - $"A {nameof(ScrollingPane)} cannot have other scrolling views nested within it." - ); - } var contentRT = (RectTransform)contentPane.transform; contentRT.sizeDelta = Vector2.zero; Content?.UpdateLayout(Vector2.zero); - var bounds = RectTransformUtility.CalculateRelativeRectTransformBounds(contentRT); - Vector2 min = bounds.min, - max = bounds.max; + var (min, max) = CalculateContentBounds(); contentRT.sizeDelta = max - min; - contentAnchor = contentRT.sizeDelta / 2f - max; + contentAnchor = contentRT.sizeDelta * 0.5f - max; anchorOffset = new Vector2(0, max.y); } @@ -191,8 +213,66 @@ public void SetGameObjectParent(GameObject parent) => private void AddScrollNavHelpers() { foreach (var element in Content?.AllElements().OfType() ?? []) + { // This component removes itself when the object it's attached to is re-parented - element.SelectableComponent.gameObject.AddComponentIfNotPresent(); + element + .SelectableComponent.gameObject.AddComponentIfNotPresent() + .container = element.Container.transform; + } + } + + /// + /// Calculates the cumulative bounds of all children of the content pane in local coordinates, + /// skipping over anything that is contained within another ScrollRect. + /// + private (Vector2 min, Vector2 max) CalculateContentBounds() + { + Vector3 min = Vector3.one * float.MaxValue, + max = Vector3.one * float.MinValue; + Matrix4x4 worldToLocal = contentPane.transform.worldToLocalMatrix; + + foreach ( + Transform descendant in EnumerateDescendantsConditional( + contentPane.transform, + shouldSkip: ChildrenOfScrollPanesExceptScrollbars + ) + ) + { + if (descendant is not RectTransform rt || !descendant.gameObject.activeInHierarchy) + continue; + foreach (Vector3 corner in rt.GetCorners()) + { + Vector3 localCorner = worldToLocal.MultiplyPoint3x4(corner); + min = Vector3.Min(localCorner, min); + max = Vector3.Max(localCorner, max); + } + } + + return (min, max); + + static bool ChildrenOfScrollPanesExceptScrollbars(Transform x) => + x.parent.TryGetComponent(out var sliderCtrl) + && (!sliderCtrl.VerticalSlider || x != sliderCtrl.VerticalSlider.transform) + && (!sliderCtrl.HorizontalSlider || x != sliderCtrl.HorizontalSlider.transform); + } + + /// + /// Enumerates the entire Transform hierarchy, skipping branches when it finds + /// a transform for which the given predicate is true. + /// + private static IEnumerable EnumerateDescendantsConditional( + Transform transform, + Func shouldSkip + ) + { + foreach (Transform item in transform) + { + if (shouldSkip(item)) + continue; + yield return item; + foreach (var item2 in EnumerateDescendantsConditional(item, shouldSkip)) + yield return item2; + } } #endregion diff --git a/Silksong.ModMenu/Internal/CustomScrollRect.cs b/Silksong.ModMenu/Internal/CustomScrollRect.cs new file mode 100644 index 0000000..5a76532 --- /dev/null +++ b/Silksong.ModMenu/Internal/CustomScrollRect.cs @@ -0,0 +1,25 @@ +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace Silksong.ModMenu.Internal; + +/// +/// Identical to the regular , except it fixes the known bug +/// (See this Unity help forum post) +/// where mousewheel/touchpad scrolling is always the opposite of what it should intuitively be for one scroll direction. +/// +internal class CustomScrollRect : ScrollRect +{ + public override void OnScroll(PointerEventData data) + { + if (horizontal) + { + if (vertical) + data.scrollDelta *= new Vector2(-1, 1); + else + data.scrollDelta *= -1; + } + base.OnScroll(data); + } +} diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index ff4e78b..389a2c7 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -73,6 +73,7 @@ private MenuPrefabs(UIManager uiManager) textLabelTemplate.name = "TextLabel"; textLabelTemplate.GetComponent().raycastTarget = false; Object.DontDestroyOnLoad(textLabelTemplate); + textLabelTemplate.RectTransform.sizeDelta = new Vector2(0, 105); textChoiceTemplate = Object.Instantiate( canvas.FindChild("GameOptionsMenuScreen/Content/CamShakeSetting")! @@ -99,6 +100,7 @@ private MenuPrefabs(UIManager uiManager) textButtonTemplate.SetActive(false); textButtonTemplate.name = "TextButtonContainer"; Object.DontDestroyOnLoad(textButtonTemplate); + textButtonTemplate.RectTransform.sizeDelta = Vector2.zero; var buttonChild = textButtonTemplate.FindChild("GameOptionsButton")!; buttonChild.name = "TextButton"; @@ -106,6 +108,8 @@ private MenuPrefabs(UIManager uiManager) // so it won't be removed by the LocalizedTextExtensions buttonChild.RemoveComponent(); buttonChild.FindChild("Menu Button Text")!.RemoveComponent(); + var buttonChildRT = buttonChild.RectTransform; + buttonChildRT.sizeDelta = buttonChildRT.sizeDelta with { x = 0 }; // Add a (centered) description to the menu button GameObject clonedDescription = Object.Instantiate(choiceChild.FindChild("Description")!); @@ -152,6 +156,7 @@ private MenuPrefabs(UIManager uiManager) sliderTemplate.SetActive(false); sliderTemplate.name = "SliderContainer"; Object.DontDestroyOnLoad(sliderTemplate); + sliderTemplate.RectTransform.sizeDelta = Vector2.zero; var sliderChild = sliderTemplate.FindChild("MasterSlider")!; sliderChild.name = "Slider"; @@ -273,7 +278,7 @@ out GameObject contentPane var obj = Object.Instantiate(scrollPaneTemplate); scrollRect = obj.GetComponent(); focusController = obj.GetComponent(); - contentPane = obj.FindChild("Content")!; + contentPane = obj.FindChild("Viewport/Content")!; return obj; } @@ -289,33 +294,41 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) var scrollPane = new GameObject("ScrollPane") { layer = (int)PhysLayers.UI }; scrollPane.SetActive(false); Object.DontDestroyOnLoad(scrollPane); - scrollPane.AddComponent().color = Color.clear; // necessary for the RectMask2D - scrollPane.AddComponent(); - var scrollPaneRT = (RectTransform)scrollPane.transform; - scrollPaneRT.sizeDelta = new Vector2(1570, 876f); + var scrollPaneRT = scrollPane.AddComponent(); + scrollPaneRT.sizeDelta = new Vector2( + 1510, + Mathf.Ceil(SpacingConstants.VSPACE_MEDIUM * 8.334f) + ); scrollPaneRT.pivot = new Vector2(0.5f, 1); + var viewport = new GameObject("Viewport") { layer = (int)PhysLayers.UI }; + viewport.transform.SetParentReset(scrollPane.transform); + viewport.AddComponent().color = Color.clear; // necessary for the RectMask2D + viewport.AddComponent(); + var viewportRT = viewport.RectTransform; + viewportRT.FitToParent(); + var content = new GameObject("Content") { layer = (int)PhysLayers.UI }; - content.transform.SetParentReset(scrollPane.transform); - content.AddComponent().color = Color.clear; // ensures it has a RectTransform - var contentRT = (RectTransform)content.transform; + content.transform.SetParentReset(viewport.transform); + content.AddComponent().color = Color.clear; // ensures it has a RectTransform, used for visualization in some tests + var contentRT = content.RectTransform; contentRT.anchorMax = contentRT.anchorMin = contentRT.pivot = new Vector2(0.5f, 1); #endregion - #region "Scrollbar" + #region Vertical "Scrollbar" - var scrollbar = Object.Instantiate( + var vScrollbar = Object.Instantiate( uiManager.achievementsMenuScreen.gameObject.FindChild("Content/Scrollbar")!, scrollPaneRT, false ); - scrollbar.name = "Scrollbar"; + vScrollbar.name = "Scrollbar V"; - ((RectTransform)scrollbar.transform.Find("Background")).FitToParentVertical(); + vScrollbar.FindChild("Background")!.RectTransform.FitToParentVertical(); - var scrollbarRT = (RectTransform)scrollbar.transform; - var slideAreaRT = (RectTransform)scrollbarRT.Find("Sliding Area"); - var handleRT = (RectTransform)slideAreaRT.Find("Handle/TopFleur"); + var vScrollbarRT = vScrollbar.RectTransform; + var slideAreaRT = vScrollbar.FindChild("Sliding Area")!.RectTransform; + var handleRT = vScrollbar.FindChild("Sliding Area/Handle/TopFleur")!.RectTransform; slideAreaRT.FitToParent(); slideAreaRT.sizeDelta = new Vector2(0, -150); @@ -328,27 +341,49 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) Object.DestroyImmediate(slideAreaRT.Find("Handle").gameObject); handleRT.gameObject.name = "Handle"; - scrollbarRT.sizeDelta = new Vector2(50, 0); - scrollbarRT.FitToParentVertical(anchorX: 1); - scrollbarRT.anchoredPosition3D = scrollbarRT.anchoredPosition3D with { z = -1 }; - Object.DestroyImmediate(scrollbar.GetComponent()); - var scrollSlider = scrollbar.AddComponent(); - scrollSlider.handleRect = handleRT; - scrollSlider.direction = Slider.Direction.BottomToTop; - scrollSlider.maxValue = 1; - scrollSlider.minValue = 0; + vScrollbarRT.sizeDelta = new Vector2(50, 0); + vScrollbarRT.FitToParentVertical(anchorX: 1); + vScrollbarRT.anchoredPosition3D = vScrollbarRT.anchoredPosition3D with { z = -1 }; + vScrollbarRT.pivot = new Vector2(0, 0.5f); + Object.DestroyImmediate(vScrollbar.GetComponent()); + var vScrollSlider = vScrollbar.AddComponent(); + vScrollSlider.handleRect = handleRT; + vScrollSlider.direction = Slider.Direction.BottomToTop; + vScrollSlider.maxValue = 1; + vScrollSlider.minValue = 0; + vScrollSlider.value = 1; + + #endregion + #region Horizontal "Scrollbar" + + var hScrollbar = Object.Instantiate(vScrollbar, scrollPane.transform, false); + hScrollbar.name = "Scrollbar H"; + var hScrollbarRT = hScrollbar.RectTransform; + hScrollbarRT.anchorMin = hScrollbarRT.anchorMax = new Vector2(0.5f, 0); + hScrollbarRT.pivot = new Vector2(1, 0.5f); + hScrollbar.AddComponent().fitToWidth = true; + + var hScrollSlider = hScrollbar.GetComponent(); + hScrollSlider.direction = Slider.Direction.TopToBottom; + hScrollSlider.value = 0.5f; #endregion - var scrollRect = scrollPane.AddComponent(); + var scrollRect = scrollPane.AddComponent(); scrollRect.horizontal = false; scrollRect.vertical = true; scrollRect.verticalScrollbarVisibility = ScrollRect.ScrollbarVisibility.AutoHide; + scrollRect.horizontalScrollbarVisibility = ScrollRect.ScrollbarVisibility.AutoHide; scrollRect.movementType = ScrollRect.MovementType.Clamped; scrollRect.scrollSensitivity = 80; - scrollRect.viewport = scrollPaneRT; + scrollRect.viewport = viewportRT; scrollRect.content = contentRT; - scrollPane.AddComponent().VerticalSlider = scrollSlider; + scrollRect.normalizedPosition = new Vector2(0.5f, 1); + + var sliderController = scrollPane.AddComponent(); + sliderController.VerticalSlider = vScrollSlider; + sliderController.HorizontalSlider = hScrollSlider; + scrollPane.AddComponent(); return scrollPane; diff --git a/Silksong.ModMenu/Internal/OnChildTransformsChangeHelper.cs b/Silksong.ModMenu/Internal/OnChildTransformsChangeHelper.cs index 4b0ab56..5ab9514 100644 --- a/Silksong.ModMenu/Internal/OnChildTransformsChangeHelper.cs +++ b/Silksong.ModMenu/Internal/OnChildTransformsChangeHelper.cs @@ -34,7 +34,7 @@ protected void Update() var childRT = (RectTransform)child; if ( childPositions.TryGetValue(child, out var oldPos) - && !ApproximatelyEqual(oldPos, childRT.anchoredPosition) + && !Vector2.Approximately(oldPos, childRT.anchoredPosition) ) { doInvoke = true; @@ -43,8 +43,5 @@ protected void Update() } if (doInvoke) OnChildrenChanged?.Invoke(); - - static bool ApproximatelyEqual(Vector2 one, Vector2 two) => - Mathf.Approximately(one.x, two.x) && Mathf.Approximately(one.y, two.y); } } diff --git a/Silksong.ModMenu/Internal/RotatedParentSizeFitter.cs b/Silksong.ModMenu/Internal/RotatedParentSizeFitter.cs new file mode 100644 index 0000000..e2b0a36 --- /dev/null +++ b/Silksong.ModMenu/Internal/RotatedParentSizeFitter.cs @@ -0,0 +1,37 @@ +using UnityEngine; +using UnityEngine.EventSystems; + +namespace Silksong.ModMenu.Internal; + +/// +/// Used to rotate a UI object 90 degrees and fit it to the size of its parent in one or both axes. +/// +/// +/// RectTransform anchor stretching doesn't react to rotation. Which means, for example, if you rotate +/// a vertical slider 90 degrees to make a horizontal slider, you can't use its anchors to fit it to +/// the width of its parent. Which is where this component comes in. +/// +[RequireComponent(typeof(RectTransform))] +internal class RotatedParentSizeFitter : UIBehaviour +{ + RectTransform RT, + parentRT; + public bool fitToWidth = false, + fitToHeight = false; + + protected override void Awake() + { + base.Awake(); + RT = (RectTransform)transform; + parentRT = (RectTransform)transform.parent; + } + + protected void LateUpdate() + { + RT.rotation = Quaternion.Euler(0, 0, 90); + RT.sizeDelta = new Vector2( + fitToHeight ? parentRT.rect.height : RT.sizeDelta.x, + fitToWidth ? parentRT.rect.width : RT.sizeDelta.y + ); + } +} diff --git a/Silksong.ModMenu/Internal/ScrollFocusController.cs b/Silksong.ModMenu/Internal/ScrollFocusController.cs index cff2939..9d0dde4 100644 --- a/Silksong.ModMenu/Internal/ScrollFocusController.cs +++ b/Silksong.ModMenu/Internal/ScrollFocusController.cs @@ -19,7 +19,7 @@ internal class ScrollFocusController : UIBehaviour ScrollRect scrollRect; Coroutine? smoothScrollRoutine; - bool firstAppearance = true; + bool menuAppearing = true; protected override void Awake() { @@ -27,14 +27,47 @@ protected override void Awake() scrollRect = GetComponent(); } - protected override void OnDisable() + protected override void OnEnable() { - base.OnDisable(); - firstAppearance = true; + base.OnEnable(); + menuAppearing = true; } /// - /// Scrolls the viewport so it's centered on given . + /// Used by to instant-scroll a selectable into view + /// ONLY if this is the first time the scroll pane is appearing. + /// + /// + /// This Github comment + /// describes the problem that this is solving in more detail. In brief, selectables receive + /// identical OnSelect events when a menu screen is appearing and when the mouse moves away from + /// them without immediately touching another selectable, and we want different auto-focusing + /// behaviour for these two cases. + /// + internal void ScrollToIfOnMenuShow(Transform target) + { + if (menuAppearing) + { + ScrollTo(target, smooth: false); + + // Prevent unintended instant-focuses when a screen has multiple scroll panes + var menu = GetComponentInParent(); + if (menu) + { + foreach ( + var child in menu.transform.GetComponentsInChildren(true) + ) + child.SetMenuAppeared(); + } + } + menuAppearing = false; + } + + internal void SetMenuAppeared() => menuAppearing = false; + + /// + /// Scrolls this viewport and all scrolling viewports this one is nested within so that the + /// entire hierarchy of viewports is centered on given . /// /// The transform to scroll to. /// @@ -43,72 +76,57 @@ protected override void OnDisable() /// public void ScrollTo(Transform target, bool smooth = false) { - firstAppearance = false; + foreach (var controller in GetComponentsInParent()) + controller.ScrollToInternal(target, smooth); + } + + private void ScrollToInternal(Transform target, bool smooth) + { + menuAppearing = false; if (smoothScrollRoutine != null) { StopCoroutine(smoothScrollRoutine); smoothScrollRoutine = null; } - Vector2 newPos = GetScrollPoint(target.position); - newPos = newPos with - { - x = scrollRect.horizontal ? newPos.x : 0.5f, - y = scrollRect.vertical ? newPos.y : 0.5f, - }; + if (smooth) + smoothScrollRoutine = StartCoroutine(Coro()); + else + scrollRect.normalizedPosition = GetScrollPoint(target.position); - if (!smooth) - { - scrollRect.normalizedPosition = newPos; - return; - } - - smoothScrollRoutine = StartCoroutine(Coro()); IEnumerator Coro() { - Vector2 oldPos = scrollRect.normalizedPosition; + Vector2 oldPos = scrollRect.normalizedPosition, + newPos = GetScrollPoint(target.position); + var curveX = AnimationCurve.EaseInOut(0, oldPos.x, smoothScrollTime, newPos.x); var curveY = AnimationCurve.EaseInOut(0, oldPos.y, smoothScrollTime, newPos.y); for (float time = 0; time <= smoothScrollTime; time += Time.deltaTime) { + // Scroll point is re-evaluated each frame so that nested ScrollRects all + // end at the appropriate scroll position for the target's final world position + newPos = GetScrollPoint(target.position); + + curveX.SetKeys([curveX.keys[0], curveX.keys[^1] with { value = newPos.x }]); + curveY.SetKeys([curveY.keys[0], curveY.keys[^1] with { value = newPos.y }]); + scrollRect.normalizedPosition = new Vector2( curveX.Evaluate(time), curveY.Evaluate(time) ); yield return null; } - scrollRect.normalizedPosition = newPos; + scrollRect.normalizedPosition = GetScrollPoint(target.position); } } - /// - /// Used by to instant-scroll a selectable into view - /// ONLY if this is the first time the scroll pane is appearing. - /// - /// - /// This Github comment - /// describes the problem that this is solving in more detail. In brief, selectables receive - /// identical OnSelect events when a menu screen is appearing and when the mouse moves away from - /// them without immediately touching another selectable, and we want different auto-focusing - /// behaviour for these two cases. - /// - internal void ScrollToIfOnMenuShow(Transform target) - { - if (firstAppearance) - ScrollTo(target, smooth: false); - firstAppearance = false; - } - /// /// The coordinates to set the scroll rect's normalized position to to scroll the /// given into the middle of the viewport. /// Vector2 GetScrollPoint(Vector2 targetWorldPos) { - if (!scrollRect) - return new Vector2(0.5f, 1); - RectTransform viewport = scrollRect.viewport, content = scrollRect.content; @@ -123,8 +141,16 @@ Vector2 GetScrollPoint(Vector2 targetWorldPos) targetPos += contentSizeOffset; targetPos.Scale(content.localScale); - Vector2 scrollPoint = (targetPos - viewportSize * 0.5f) / (contentSize - viewportSize); + Vector2 scrollPoint = Vector2.Clamp01( + (targetPos - viewportSize * 0.5f) / (contentSize - viewportSize) + ); + + scrollPoint = scrollPoint with + { + x = scrollRect.horizontal ? scrollPoint.x : 0.5f, + y = scrollRect.vertical ? scrollPoint.y : 0.5f, + }; - return new(Mathf.Clamp01(scrollPoint.x), Mathf.Clamp01(scrollPoint.y)); + return scrollPoint; } } diff --git a/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs b/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs index 4752425..ec72ec2 100644 --- a/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs +++ b/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs @@ -14,6 +14,8 @@ internal class ScrollNavigationHelper : EventTrigger ScrollRect scrollRect; ScrollFocusController focusController; + public Transform? container; + void Awake() { scrollRect = GetComponentInParent(true); @@ -42,14 +44,16 @@ void Awake() /// public override void OnSelect(BaseEventData eventData) { + Transform target = container ? container : transform; + // When keyboard/controller navigated to. if (eventData is AxisEventData) - focusController.ScrollTo(transform, smooth: true); + focusController.ScrollTo(target, smooth: true); // When force-selected. (e.x. when a menu is shown) // Can't avoid the type check or use `is` because then this would catch PointerEventData, // and instant-scrolling as a result of mouse movement is very jarring. else if (eventData.GetType() == typeof(BaseEventData)) - focusController.ScrollToIfOnMenuShow(transform); + focusController.ScrollToIfOnMenuShow(target); } /// diff --git a/Silksong.ModMenu/Internal/ScrollSliderController.cs b/Silksong.ModMenu/Internal/ScrollSliderController.cs index d9c0b81..2c5e89b 100644 --- a/Silksong.ModMenu/Internal/ScrollSliderController.cs +++ b/Silksong.ModMenu/Internal/ScrollSliderController.cs @@ -1,6 +1,7 @@ using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; +using Visibility = UnityEngine.UI.ScrollRect.ScrollbarVisibility; namespace Silksong.ModMenu.Internal; @@ -24,10 +25,10 @@ public Slider VerticalSlider set { if (m_verticalSlider) - m_verticalSlider.onValueChanged.RemoveListener(UpdateScrollRect); + m_verticalSlider.onValueChanged.RemoveListener(UpdateScrollRectVertical); m_verticalSlider = value; if (m_verticalSlider) - m_verticalSlider.onValueChanged.AddListener(UpdateScrollRect); + m_verticalSlider.onValueChanged.AddListener(UpdateScrollRectVertical); } } @@ -40,10 +41,10 @@ public Slider HorizontalSlider set { if (m_horizontalSlider) - m_horizontalSlider.onValueChanged.RemoveListener(UpdateScrollRect); + m_horizontalSlider.onValueChanged.RemoveListener(UpdateScrollRectHorizontal); m_horizontalSlider = value; if (m_horizontalSlider) - m_horizontalSlider.onValueChanged.AddListener(UpdateScrollRect); + m_horizontalSlider.onValueChanged.AddListener(UpdateScrollRectHorizontal); } } @@ -55,27 +56,30 @@ public Slider HorizontalSlider ScrollRect scrollRect; + #region Unity Messages + protected override void Awake() => scrollRect = GetComponent(); protected override void OnEnable() { - scrollRect.onValueChanged.AddListener(UpdateSliders); - - if (scrollRect.vertical && VerticalSlider) - VerticalSlider.onValueChanged.AddListener(UpdateScrollRect); - if (scrollRect.horizontal && HorizontalSlider) - HorizontalSlider.onValueChanged.AddListener(UpdateScrollRect); + if (VerticalSlider) + VerticalSlider.onValueChanged.AddListener(UpdateScrollRectVertical); + if (HorizontalSlider) + HorizontalSlider.onValueChanged.AddListener(UpdateScrollRectHorizontal); - UpdateSliders(default); + scrollRect.onValueChanged.AddListener(UpdateVerticalSlider); + scrollRect.onValueChanged.AddListener(UpdateHorizontalSlider); } protected override void OnDisable() { - scrollRect.onValueChanged.RemoveListener(UpdateSliders); + scrollRect.onValueChanged.RemoveListener(UpdateVerticalSlider); + scrollRect.onValueChanged.RemoveListener(UpdateHorizontalSlider); + if (VerticalSlider) - VerticalSlider.onValueChanged.RemoveListener(UpdateScrollRect); + VerticalSlider.onValueChanged.RemoveListener(UpdateScrollRectVertical); if (HorizontalSlider) - HorizontalSlider.onValueChanged.RemoveListener(UpdateScrollRect); + HorizontalSlider.onValueChanged.RemoveListener(UpdateScrollRectHorizontal); } protected void LateUpdate() @@ -83,44 +87,67 @@ protected void LateUpdate() if (!scrollRect || !scrollRect.content || !scrollRect.viewport) return; - if (scrollRect.vertical && VerticalSlider) + Rect viewRect = scrollRect.viewport.rect, + contentRect = scrollRect.content.rect; + + if (VerticalSlider) VerticalSlider.gameObject.SetActive( - scrollRect.verticalScrollbarVisibility == ScrollRect.ScrollbarVisibility.Permanent - || scrollRect.content.sizeDelta.y > scrollRect.viewport.sizeDelta.y + scrollRect.vertical + && ( + scrollRect.verticalScrollbarVisibility == Visibility.Permanent + || contentRect.height > viewRect.height + 0.01f + ) ); - if (scrollRect.horizontal && HorizontalSlider) + if (HorizontalSlider) HorizontalSlider.gameObject.SetActive( - scrollRect.horizontalScrollbarVisibility == ScrollRect.ScrollbarVisibility.Permanent - || scrollRect.content.sizeDelta.x > scrollRect.viewport.sizeDelta.x + scrollRect.horizontal + && ( + scrollRect.horizontalScrollbarVisibility == Visibility.Permanent + || contentRect.width > viewRect.width + 0.01f + ) ); } - void UpdateScrollRect(float _) - { - if (!scrollRect) - return; - - Vector2 pos = Vector2.one * 0.5f; + #endregion + #region Event Handlers - if (scrollRect.vertical && VerticalSlider) - pos = pos with { y = VerticalSlider.normalizedValue }; + void UpdateVerticalSlider(Vector2 v) + { + if (VerticalUpdateNeeded()) + VerticalSlider.normalizedValue = scrollRect.normalizedPosition.y; + } - if (scrollRect.horizontal && HorizontalSlider) - pos = pos with { x = HorizontalSlider.normalizedValue }; + void UpdateHorizontalSlider(Vector2 v) + { + if (HorizontalUpdateNeeded()) + HorizontalSlider.normalizedValue = scrollRect.normalizedPosition.x; + } - scrollRect.normalizedPosition = pos; + void UpdateScrollRectVertical(float v) + { + if (VerticalUpdateNeeded()) + scrollRect.verticalNormalizedPosition = VerticalSlider.normalizedValue; } - void UpdateSliders(Vector2 _) + void UpdateScrollRectHorizontal(float v) { - if (!scrollRect) - return; + if (HorizontalUpdateNeeded()) + scrollRect.horizontalNormalizedPosition = HorizontalSlider.normalizedValue; + } - if (scrollRect.vertical && VerticalSlider) - VerticalSlider.normalizedValue = scrollRect.verticalNormalizedPosition; + #endregion + #region Utils - if (scrollRect.horizontal && HorizontalSlider) - HorizontalSlider.normalizedValue = scrollRect.horizontalNormalizedPosition; - } + bool VerticalUpdateNeeded() => + scrollRect.vertical + && VerticalSlider + && !Mathf.Approximately(scrollRect.normalizedPosition.y, VerticalSlider.normalizedValue); + + bool HorizontalUpdateNeeded() => + scrollRect.horizontal + && HorizontalSlider + && !Mathf.Approximately(scrollRect.normalizedPosition.x, HorizontalSlider.normalizedValue); + + #endregion } diff --git a/Silksong.ModMenu/Internal/VectorUtil.cs b/Silksong.ModMenu/Internal/VectorUtil.cs new file mode 100644 index 0000000..c36ebb1 --- /dev/null +++ b/Silksong.ModMenu/Internal/VectorUtil.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +namespace Silksong.ModMenu.Internal; + +internal static class VectorUtil +{ + // Using extension blocks with static methods so that usage mirrors the equivalent Mathf methods. + + extension(Vector2) + { + /// + /// True if the vectors are approximately equal. + /// See . + /// + internal static bool Approximately(Vector2 v1, Vector2 v2) => + Mathf.Approximately(v1.x, v2.x) && Mathf.Approximately(v1.y, v2.y); + + /// + /// Clamps all components of the vector to between 0 and 1. + /// See . + /// + internal static Vector2 Clamp01(Vector2 v) => v.ClampVector2(Vector2.zero, Vector2.one); + } + + extension(Vector3) + { + /// + internal static bool Approximately(Vector3 v1, Vector3 v2) => + Vector2.Approximately(v1, v2) && Mathf.Approximately(v1.z, v2.z); + + /// + internal static Vector3 Clamp01(Vector3 v) => + ((Vector3)Vector2.Clamp01(v)) with + { + z = Mathf.Clamp01(v.z), + }; + } +} diff --git a/Silksong.ModMenuTesting/Tests/MultipleScrollPanesTest.cs b/Silksong.ModMenuTesting/Tests/MultipleScrollPanesTest.cs deleted file mode 100644 index 74b5083..0000000 --- a/Silksong.ModMenuTesting/Tests/MultipleScrollPanesTest.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Silksong.ModMenu.Elements; -using Silksong.ModMenu.Models; -using Silksong.ModMenu.Screens; -using System.Linq; -using UnityEngine; - -namespace Silksong.ModMenuTesting.Tests; - -internal class MultipleScrollPanesTest : ModMenuTest -{ - // for finding the screen in UnityExplorer during testing - static BasicMenuScreen? screen; - - internal override string Name => "Multiple Scroll Panes"; - - internal override AbstractMenuScreen BuildMenuScreen() - { - VerticalGroup - outerContent = new() { VerticalSpacing = 460 }, - innerContentOne = new() { VerticalSpacing = SpacingConstants.VSPACE_SMALL }, - innerContentTwo = new() { VerticalSpacing = SpacingConstants.VSPACE_SMALL }; - - ScrollingPane - innerScrollOne = new(innerContentOne) { ViewportSize = new Vector2(1540, 400f) }, - innerScrollTwo = new(innerContentTwo) { ViewportSize = new Vector2(1540, 400f) }; - - AddSomeElements(innerContentOne, 10, "Foo"); - AddSomeElements(innerContentTwo, 10, "Bar"); - - outerContent.AddRange([innerScrollOne, innerScrollTwo]); - - screen = new BasicMenuScreen("Multiple Scroll Panes", outerContent) { - SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget - }; - return screen; - } - - static void AddSomeElements(VerticalGroup group, int count, string name) => - group.AddRange( - Enumerable.Range(1, count).Select(i => new ChoiceElement($"{name} {i}", ChoiceModels.ForBool())) - ); -} diff --git a/Silksong.ModMenuTesting/Tests/ScrollingGridMenuTest.cs b/Silksong.ModMenuTesting/Tests/ScrollingGridMenuTest.cs deleted file mode 100644 index b033c25..0000000 --- a/Silksong.ModMenuTesting/Tests/ScrollingGridMenuTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using Silksong.ModMenu.Elements; -using Silksong.ModMenu.Models; -using Silksong.ModMenu.Screens; - -namespace Silksong.ModMenuTesting.Tests; - -internal class ScrollingGridMenuTest : ModMenuTest -{ - private enum Spacing - { - Small, - Medium, - Large, - } - - internal override string Name => "Scrolling Menu - Grid"; - - internal override AbstractMenuScreen BuildMenuScreen() - { - GridGroup group = new(2); - ScrollingPane scrollPane = new(group); - - ListChoiceModel - hModel = ChoiceModels.ForEnum(), vModel = ChoiceModels.ForEnum(); - TextButton - hButton = new("H Space"), vButton = new("V Space"), addButton = new("Add"), removeButton = new("Remove"), addGroupButton = new("Add Non-Atomic"); - - group.Add(hButton); - group.Add(vButton); - group.Add(addButton); - group.Add(removeButton); - group.Add(addGroupButton); - - hButton.OnSubmit = () => { - hModel.MoveRight(); - hButton.ButtonText.text = $"H Space: {hModel.Value}"; - group.HorizontalSpacing = hModel.Value switch { - Spacing.Small => SpacingConstants.HSPACE_SMALL, - Spacing.Large => SpacingConstants.HSPACE_LARGE, - _ => SpacingConstants.HSPACE_MEDIUM, - }; - scrollPane.ViewportSize = scrollPane.ViewportSize with { x = group.HorizontalSpacing * group.Columns + 100 }; - }; - - vButton.OnSubmit = () => { - vModel.MoveRight(); - vButton.ButtonText.text = $"V Space: {vModel.Value}"; - group.VerticalSpacing = vModel.Value switch { - Spacing.Small => SpacingConstants.VSPACE_SMALL, - Spacing.Large => SpacingConstants.VSPACE_LARGE, - _ => SpacingConstants.VSPACE_MEDIUM, - }; - }; - - Stack addedElements = []; - - addButton.OnSubmit += () => - { - TextButton button = new($"Extra {addedElements.Count + 1}"); - group.Add(button); - addedElements.Push(button); - addButton.ButtonText.text = $"Add (Count: {addedElements.Count})"; - }; - - removeButton.OnSubmit += () => - { - if (addedElements.Count > 0) - { - IMenuEntity button = addedElements.Pop(); - group.Remove(button); - if (button is IDisposable x) - x.Dispose(); - addButton.ButtonText.text = $"Add (Count: {addedElements.Count})"; - } - }; - - addGroupButton.OnSubmit += () => { - VerticalGroup subGroup = new() { VerticalSpacing = SpacingConstants.VSPACE_SMALL }; - subGroup.AddRange([new TextButton("This is part of"), new TextButton("a VerticalGroup")]); - group.Add(subGroup); - addedElements.Push(subGroup); - addButton.ButtonText.text = $"Add (Count: {addedElements.Count})"; - }; - - hModel.Value = Spacing.Large; - hButton.OnSubmit.Invoke(); - vModel.Value = Spacing.Small; - vButton.OnSubmit.Invoke(); - for (int i = 0; i < 13; i++) - addButton.OnSubmit.Invoke(); - - return new BasicMenuScreen("Scrolling Grid Menu", scrollPane) { - SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget - }; - } -} diff --git a/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs index f4ae0d0..f7e5dad 100644 --- a/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs +++ b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs @@ -16,14 +16,18 @@ private enum Spacing { Large, } - internal override string Name => "Scrolling Menu - Vertical"; + internal override string Name => "Scrolling Menu Test"; internal override AbstractMenuScreen BuildMenuScreen() { - screen = new ScrollingMenuScreen("Scrolling Vertical Menu") { + screen = new ScrollingMenuScreen("Scrolling Menu Test") { SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget }; + // Despite this setting, we should not see the horizontal scrollbar, + // because no element should be wider than the default width of a scroll pane. + screen.ScrollPane.Axes = ScrollingPane.ScrollAxes.Both; + ChoiceElement spacing = new("Spacing", ChoiceModels.ForEnum()); spacing.OnValueChanged += value => screen.Content.VerticalSpacing = value switch @@ -51,7 +55,18 @@ internal override AbstractMenuScreen BuildMenuScreen() } }; screen.Add(elementAdder); - elementAdder.Model.SetValue(10); + + // Adding one of everything to make sure the horizontal scrollbar doesn't appear + screen.Add(new TextLabel("Label")); + screen.Add(new TextButton("Button")); + screen.Add(new KeyBindElement("KeyBind")); + screen.Add(new DynamicDescriptionChoiceElement("DynamicChoice", ChoiceModels.ForBool(), "desc left", "desc right")); + + var textinput = new TextInput("Input", TextModels.ForStrings()); + textinput.Model.Value = "placeholder"; + screen.Add(textinput); + + elementAdder.Model.SetValue(5); return screen; } diff --git a/Silksong.ModMenuTesting/Tests/ScrollingPaneTests.cs b/Silksong.ModMenuTesting/Tests/ScrollingPaneTests.cs new file mode 100644 index 0000000..e7d27bf --- /dev/null +++ b/Silksong.ModMenuTesting/Tests/ScrollingPaneTests.cs @@ -0,0 +1,158 @@ +using Silksong.ModMenu.Elements; +using Silksong.ModMenu.Models; +using Silksong.ModMenu.Screens; +using UnityEngine; +using UnityEngine.UI; + +namespace Silksong.ModMenuTesting.Tests; + +internal class ScrollingPaneTests : ModMenuTest +{ + // for finding the screen in UnityExplorer during testing + static PaginatedMenuScreen? screen; + + internal override string Name => "Scrolling Pane Tests"; + + internal override AbstractMenuScreen BuildMenuScreen() + { + screen = new PaginatedMenuScreen("Scrolling Pane Tests") + { + SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget + }; + screen.AddPage(ScrollPaneSiblings()); + screen.AddPage(ScrollPaneNesting()); + screen.AddPage(ScrollScrollPanePane()); + screen.AddPage(OffCenterSelectables()); + screen.AddPage(DoubleAxesAutoFocus()); + return screen; + } + + /// + /// For testing how multiple scroll panes in one layout behave + /// + static GridGroup ScrollPaneSiblings() + { + VerticalGroup + innerContentOne = new(), + innerContentTwo = new(); + + for (int i = 1; i <= 15; i++) + { + innerContentOne.Add(new TextButton($"Hollow {i}")); + innerContentTwo.Add(new TextButton($"Knight {i}")); + } + + ScrollingPane + innerScrollOne = new(innerContentOne) { ViewportSize = new Vector2(500, 875), SmoothScrollTime = 0.5f }, + innerScrollTwo = new(innerContentTwo) { ViewportSize = new Vector2(500, 875), SmoothScrollTime = 0.5f }; + + GridGroup outerContent = new(2) { HorizontalSpacing = 600 }; + outerContent.Add(innerScrollOne); + outerContent.Add(innerScrollTwo); + + return outerContent; + } + + /// + /// For testing how multiple nested & sibling scroll panes of varying scroll axes in one layout behave + /// + static ScrollingPane ScrollPaneNesting() { + VerticalGroup + outerContent = new() { VerticalSpacing = 460 }; + + GridGroup + innerContentOne = new(2) { HorizontalSpacing = 480 }, + innerContentTwo = new(4) { HorizontalSpacing = 480 }, + innerContentThree = new(2) { HorizontalSpacing = 480 }; + + ScrollingPane + outerScroll = new(outerContent) { ViewportSize = new Vector2(1480, 860), SmoothScrollTime = 0.5f }, + innerScrollOne = new(innerContentOne) { ViewportSize = new Vector2(1300, 350f), SmoothScrollTime = 0.5f }, + innerScrollTwo = new(innerContentTwo) + { + ViewportSize = new Vector2(1360, 350f), + Axes = ScrollingPane.ScrollAxes.Horizontal, + SmoothScrollTime = 0.5f + }, + innerScrollThree = new(innerContentThree) { ViewportSize = new Vector2(1300, 350f), SmoothScrollTime = 0.5f }; + + for (int i = 1; i <= 12; i++) + { + innerContentOne.Add(new TextButton($"Hollow {i}")); + innerContentTwo.Add(new TextButton($"Knight {i}")); + innerContentThree.Add(new TextButton($"Silksong {i}")); + } + + outerContent.AddRange([innerScrollOne, innerScrollTwo, innerScrollThree]); + + return outerScroll; + } + + /// + /// For testing horizontal scrolling when sliders are involved + /// (since their Selectable is offset from their visual center) + /// + static ScrollingPane OffCenterSelectables() { + VerticalGroup group = new(); + + for (int i = 1; i <= 2; i++) + { + group.AddRange([ + new TextButton($"Thin Button {i}"), + new SliderElement($"Wide Slider {i}", SliderModels.ForInts(0, 5)), + new ChoiceElement($"Wide Choice {i}", ChoiceModels.ForBool()) + ]); + } + + ScrollingPane scroll = new(group) { ViewportSize = new Vector2(1300, 875), Axes = ScrollingPane.ScrollAxes.Horizontal }; + + scroll.scrollRect.content.GetComponent().color = new(0, 0, 1, 0.3f); + scroll.scrollRect.viewport.GetComponent().color = new(1, 0, 0, 0.3f); + scroll.scrollRect.viewport.GetComponent().enabled = false; + + return scroll; + } + + /// + /// For testing auto-focusing that requires scrolling in two axes. + /// + static ScrollingPane DoubleAxesAutoFocus() { + FreeGroup group = new(); + + TextButton tl = new("Top Left"), + br = new("Bottom Right"); + + group.Add(tl, new Vector2(-300, 0)); + group.Add(br, new Vector2(300, -600)); + + tl.ConnectSymmetric(br, NavigationDirection.Right); + tl.ConnectSymmetric(br, NavigationDirection.Down); + + ScrollingPane scroll = new(group) + { + ViewportSize = new Vector2(600, 500), + Axes = ScrollingPane.ScrollAxes.Both + }; + + scroll.scrollRect.content.GetComponent().color = new(0, 0, 1, 0.3f); + scroll.scrollRect.viewport.GetComponent().color = new(1, 0, 0, 0.3f); + scroll.scrollRect.viewport.GetComponent().enabled = false; + + return scroll; + } + + /// + /// For testing how scroll panes whose content is another scroll pane behave + /// + static ScrollingPane ScrollScrollPanePane() { + VerticalGroup content = new(); + for (int i = 1; i <= 15; i++) + content.Add(new TextButton($"Test {i}")); + + ScrollingPane scrollOne = new(content) { ViewportSize = new Vector2(1100, 1075), SmoothScrollTime = 0.5f }, + scrollTwo = new(scrollOne) { ViewportSize = new Vector2(1300, 975), SmoothScrollTime = 0.5f }, + finalScroll = new(scrollTwo) { ViewportSize = new Vector2(1500, 875), SmoothScrollTime = 0.5f }; + + return finalScroll; + } +}