From 4ca3ad3cc1ac13171f41a19da71b6b1f98d7235d Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Sat, 25 Apr 2026 22:13:19 -0400 Subject: [PATCH 1/9] add support for nesting ScrollingPanes --- Silksong.ModMenu/Elements/ScrollingPane.cs | 76 ++++++++++---- .../Internal/ScrollNavigationHelper.cs | 17 ++-- ...lPanesTest.cs => NestedScrollPanesTest.cs} | 29 +++--- .../Tests/ScrollingGridMenuTest.cs | 98 ------------------- 4 files changed, 82 insertions(+), 138 deletions(-) rename Silksong.ModMenuTesting/Tests/{MultipleScrollPanesTest.cs => NestedScrollPanesTest.cs} (51%) delete mode 100644 Silksong.ModMenuTesting/Tests/ScrollingGridMenuTest.cs diff --git a/Silksong.ModMenu/Elements/ScrollingPane.cs b/Silksong.ModMenu/Elements/ScrollingPane.cs index 7efaf31..51b6ba9 100644 --- a/Silksong.ModMenu/Elements/ScrollingPane.cs +++ b/Silksong.ModMenu/Elements/ScrollingPane.cs @@ -1,9 +1,11 @@ -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; @@ -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); @@ -137,24 +131,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); } @@ -195,6 +180,55 @@ private void AddScrollNavHelpers() element.SelectableComponent.gameObject.AddComponentIfNotPresent(); } + /// + /// 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 child in EnumerateDescendantsConditional( + contentPane.transform, + shouldSkip: x => x.parent.GetComponent() + ) + ) + { + if (child is not RectTransform rt) + 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); + } + + /// + /// 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, + Predicate shouldSkip + ) + { + foreach (Transform item in transform) + { + if (shouldSkip(item)) + continue; + yield return item; + foreach (var item2 in EnumerateDescendantsConditional(item, shouldSkip)) + yield return item2; + } + } + #endregion #region Methods that delegate to Content diff --git a/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs b/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs index 4752425..3c83728 100644 --- a/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs +++ b/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs @@ -1,5 +1,4 @@ using System; -using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; @@ -12,16 +11,16 @@ namespace Silksong.ModMenu.Internal; internal class ScrollNavigationHelper : EventTrigger { ScrollRect scrollRect; - ScrollFocusController focusController; + ScrollFocusController[] focusControllers = []; void Awake() { scrollRect = GetComponentInParent(true); - focusController = GetComponentInParent(true); + focusControllers = GetComponentsInParent(true); if (!scrollRect) throw new InvalidOperationException($"Failed to find containing {nameof(ScrollRect)}."); - if (!focusController) + if (focusControllers.Length == 0) throw new InvalidOperationException( $"Failed to find containing {nameof(ScrollFocusController)}." ); @@ -44,12 +43,18 @@ public override void OnSelect(BaseEventData eventData) { // When keyboard/controller navigated to. if (eventData is AxisEventData) - focusController.ScrollTo(transform, smooth: true); + { + foreach (var focusController in focusControllers) + focusController.ScrollTo(transform, 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); + { + foreach (var focusController in focusControllers) + focusController.ScrollToIfOnMenuShow(transform); + } } /// diff --git a/Silksong.ModMenuTesting/Tests/MultipleScrollPanesTest.cs b/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs similarity index 51% rename from Silksong.ModMenuTesting/Tests/MultipleScrollPanesTest.cs rename to Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs index 74b5083..1dedd3b 100644 --- a/Silksong.ModMenuTesting/Tests/MultipleScrollPanesTest.cs +++ b/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs @@ -1,17 +1,16 @@ 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 +internal class NestedScrollPanesTest : ModMenuTest { // for finding the screen in UnityExplorer during testing static BasicMenuScreen? screen; - internal override string Name => "Multiple Scroll Panes"; + internal override string Name => "Scrolling Menu - Nested Panes"; internal override AbstractMenuScreen BuildMenuScreen() { @@ -20,23 +19,27 @@ internal override AbstractMenuScreen BuildMenuScreen() innerContentOne = new() { VerticalSpacing = SpacingConstants.VSPACE_SMALL }, innerContentTwo = new() { VerticalSpacing = SpacingConstants.VSPACE_SMALL }; + GridGroup + innerContentThree = new(4); + ScrollingPane + outerScroll = new(outerContent) { ViewportSize = new Vector2(1690, 876) }, innerScrollOne = new(innerContentOne) { ViewportSize = new Vector2(1540, 400f) }, - innerScrollTwo = new(innerContentTwo) { ViewportSize = new Vector2(1540, 400f) }; + innerScrollTwo = new(innerContentTwo) { ViewportSize = new Vector2(1540, 400f) }, + innerScrollThree = new(innerContentThree) { ViewportSize = new Vector2(1540, 400f) }; - AddSomeElements(innerContentOne, 10, "Foo"); - AddSomeElements(innerContentTwo, 10, "Bar"); + foreach(int i in Enumerable.Range(1, 8)) + { + innerContentOne.Add(new TextButton($"Hollow {i}")); + innerContentTwo.Add(new TextButton($"Knight {i}")); + innerContentThree.Add(new TextButton($"Silksong {i}")); + } - outerContent.AddRange([innerScrollOne, innerScrollTwo]); + outerContent.AddRange([innerScrollOne, innerScrollTwo, innerScrollThree]); - screen = new BasicMenuScreen("Multiple Scroll Panes", outerContent) { + screen = new BasicMenuScreen("Nested Scroll Panes", outerScroll) { 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 - }; - } -} From 290cd4043b802aa7acb68268a6ad1275348a9506 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Sun, 26 Apr 2026 01:56:49 -0400 Subject: [PATCH 2/9] add basic horizontal scrolling support to ScrollPanes --- Silksong.ModMenu/Elements/ScrollingPane.cs | 57 ++++++++++++-- Silksong.ModMenu/Internal/MenuPrefabs.cs | 74 +++++++++++++------ .../Internal/RotatedParentSizeFitter.cs | 37 ++++++++++ .../Internal/ScrollSliderController.cs | 22 ++++-- .../Tests/NestedScrollPanesTest.cs | 28 ++++--- 5 files changed, 173 insertions(+), 45 deletions(-) create mode 100644 Silksong.ModMenu/Internal/RotatedParentSizeFitter.cs diff --git a/Silksong.ModMenu/Elements/ScrollingPane.cs b/Silksong.ModMenu/Elements/ScrollingPane.cs index 51b6ba9..661faac 100644 --- a/Silksong.ModMenu/Elements/ScrollingPane.cs +++ b/Silksong.ModMenu/Elements/ScrollingPane.cs @@ -71,8 +71,50 @@ public ScrollingPane(INavigableMenuEntity content) /// public Vector2 ViewportSize { - get => scrollRect.viewport.sizeDelta; - set => scrollRect.viewport.sizeDelta = value; + get => ((RectTransform)scrollPane.transform).sizeDelta; + set => ((RectTransform)scrollPane.transform).sizeDelta = value; + } + + /// + /// Whether this pane scrolls only vertically, only horizontally, or in both axes. + /// + public ScrollAxes Axes + { + get + { + if (scrollRect.vertical && scrollRect.horizontal) + return ScrollAxes.Both; + else if (scrollRect.vertical) + return ScrollAxes.Vertical; + else + return ScrollAxes.Horizontal; + } + set + { + scrollRect.vertical = value == ScrollAxes.Vertical || value == ScrollAxes.Both; + scrollRect.horizontal = value == ScrollAxes.Horizontal || value == ScrollAxes.Both; + } + } + + /// + /// Semantic states for which axes a can scroll in. + /// + public enum ScrollAxes + { + /// + /// Exclusively vertical scrolling. + /// + Vertical, + + /// + /// Exclusively horizontal scrolling. + /// + Horizontal, + + /// + /// Scrolling in both the vertical and horizontal axes. + /// + Both, } /// @@ -191,13 +233,13 @@ private void AddScrollNavHelpers() Matrix4x4 worldToLocal = contentPane.transform.worldToLocalMatrix; foreach ( - Transform child in EnumerateDescendantsConditional( + Transform descendant in EnumerateDescendantsConditional( contentPane.transform, - shouldSkip: x => x.parent.GetComponent() + shouldSkip: ChildrenOfScrollPanesExceptScrollbars ) ) { - if (child is not RectTransform rt) + if (descendant is not RectTransform rt || !descendant.gameObject.activeInHierarchy) continue; foreach (Vector3 corner in rt.GetCorners()) { @@ -208,6 +250,11 @@ Transform child in EnumerateDescendantsConditional( } 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); } /// diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index 7312708..121c7b6 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -221,7 +221,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; } @@ -237,32 +237,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(); + scrollPane.AddComponent().color = Color.clear; // ensures it has a RectTransform var scrollPaneRT = (RectTransform)scrollPane.transform; - scrollPaneRT.sizeDelta = new Vector2(1570, 876f); + scrollPaneRT.sizeDelta = new Vector2( + 1480, + 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 = (RectTransform)viewport.transform; + viewportRT.FitToParent(); + var content = new GameObject("Content") { layer = (int)PhysLayers.UI }; - content.transform.SetParentReset(scrollPane.transform); + content.transform.SetParentReset(viewport.transform); content.AddComponent().color = Color.clear; // ensures it has a RectTransform var contentRT = (RectTransform)content.transform; 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(); + ((RectTransform)vScrollbar.transform.Find("Background")).FitToParentVertical(); - var scrollbarRT = (RectTransform)scrollbar.transform; - var slideAreaRT = (RectTransform)scrollbarRT.Find("Sliding Area"); + var vScrollbarRT = (RectTransform)vScrollbar.transform; + var slideAreaRT = (RectTransform)vScrollbarRT.Find("Sliding Area"); var handleRT = (RectTransform)slideAreaRT.Find("Handle/TopFleur"); slideAreaRT.FitToParent(); @@ -276,15 +285,31 @@ 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 = (RectTransform)hScrollbar.transform; + 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 @@ -294,9 +319,14 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) scrollRect.verticalScrollbarVisibility = 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/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/ScrollSliderController.cs b/Silksong.ModMenu/Internal/ScrollSliderController.cs index d9c0b81..089b7dc 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; @@ -83,16 +84,25 @@ 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 + ) ); - 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 + ) ); } diff --git a/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs b/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs index 1dedd3b..4c01d20 100644 --- a/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs +++ b/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs @@ -1,6 +1,5 @@ using Silksong.ModMenu.Elements; using Silksong.ModMenu.Screens; -using System.Linq; using UnityEngine; namespace Silksong.ModMenuTesting.Tests; @@ -15,20 +14,24 @@ internal class NestedScrollPanesTest : ModMenuTest internal override AbstractMenuScreen BuildMenuScreen() { VerticalGroup - outerContent = new() { VerticalSpacing = 460 }, - innerContentOne = new() { VerticalSpacing = SpacingConstants.VSPACE_SMALL }, - innerContentTwo = new() { VerticalSpacing = SpacingConstants.VSPACE_SMALL }; + outerContent = new() { VerticalSpacing = 460 }; GridGroup - innerContentThree = new(4); + innerContentOne = new(2) { HorizontalSpacing = 480 }, + innerContentTwo = new(4) { HorizontalSpacing = 480 }, + innerContentThree = new(2) { HorizontalSpacing = 480 }; ScrollingPane - outerScroll = new(outerContent) { ViewportSize = new Vector2(1690, 876) }, - innerScrollOne = new(innerContentOne) { ViewportSize = new Vector2(1540, 400f) }, - innerScrollTwo = new(innerContentTwo) { ViewportSize = new Vector2(1540, 400f) }, - innerScrollThree = new(innerContentThree) { ViewportSize = new Vector2(1540, 400f) }; - - foreach(int i in Enumerable.Range(1, 8)) + outerScroll = new(outerContent) { ViewportSize = new Vector2(1480, 860) }, + innerScrollOne = new(innerContentOne) { ViewportSize = new Vector2(1360, 350f) }, + innerScrollTwo = new(innerContentTwo) + { + ViewportSize = new Vector2(1360, 350f), + Axes = ScrollingPane.ScrollAxes.Horizontal + }, + innerScrollThree = new(innerContentThree) { ViewportSize = new Vector2(1360, 350f) }; + + for (int i = 1; i <= 12; i++) { innerContentOne.Add(new TextButton($"Hollow {i}")); innerContentTwo.Add(new TextButton($"Knight {i}")); @@ -37,7 +40,8 @@ internal override AbstractMenuScreen BuildMenuScreen() outerContent.AddRange([innerScrollOne, innerScrollTwo, innerScrollThree]); - screen = new BasicMenuScreen("Nested Scroll Panes", outerScroll) { + screen = new BasicMenuScreen("Nested Scroll Panes", outerScroll) + { SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget }; return screen; From 5c696d3546cee4d30da45e5288ad98b1ee429738 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Sun, 26 Apr 2026 03:08:59 -0400 Subject: [PATCH 3/9] adjust element prefabs widths for more reasonable horizontal scrolling Some of those prefabs thought they were 1920 units wide. It was causing problems. --- Silksong.ModMenu/Internal/MenuPrefabs.cs | 8 +++++++- .../Tests/ScrollingMenuTest.cs | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index 121c7b6..d7634b5 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -72,6 +72,7 @@ private MenuPrefabs(UIManager uiManager) textLabelTemplate.name = "TextLabel"; textLabelTemplate.GetComponent().raycastTarget = false; Object.DontDestroyOnLoad(textLabelTemplate); + ((RectTransform)textLabelTemplate.transform).sizeDelta = new Vector2(0, 105); textChoiceTemplate = Object.Instantiate( canvas.FindChild("GameOptionsMenuScreen/Content/CamShakeSetting")! @@ -98,6 +99,7 @@ private MenuPrefabs(UIManager uiManager) textButtonTemplate.SetActive(false); textButtonTemplate.name = "TextButtonContainer"; Object.DontDestroyOnLoad(textButtonTemplate); + ((RectTransform)textButtonTemplate.transform).sizeDelta = Vector2.zero; var buttonChild = textButtonTemplate.FindChild("GameOptionsButton")!; buttonChild.name = "TextButton"; @@ -105,6 +107,8 @@ private MenuPrefabs(UIManager uiManager) // so it won't be removed by the LocalizedTextExtensions buttonChild.RemoveComponent(); buttonChild.FindChild("Menu Button Text")!.RemoveComponent(); + var buttonChildRT = (RectTransform)buttonChild.transform; + buttonChildRT.sizeDelta = buttonChildRT.sizeDelta with { x = 0 }; // Add a (centered) description to the menu button GameObject clonedDescription = Object.Instantiate(choiceChild.FindChild("Description")!); @@ -151,6 +155,7 @@ private MenuPrefabs(UIManager uiManager) sliderTemplate.SetActive(false); sliderTemplate.name = "SliderContainer"; Object.DontDestroyOnLoad(sliderTemplate); + ((RectTransform)sliderTemplate.transform).sizeDelta = Vector2.zero; var sliderChild = sliderTemplate.FindChild("MasterSlider")!; sliderChild.name = "Slider"; @@ -240,7 +245,7 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) scrollPane.AddComponent().color = Color.clear; // ensures it has a RectTransform var scrollPaneRT = (RectTransform)scrollPane.transform; scrollPaneRT.sizeDelta = new Vector2( - 1480, + 1510, Mathf.Ceil(SpacingConstants.VSPACE_MEDIUM * 8.334f) ); scrollPaneRT.pivot = new Vector2(0.5f, 1); @@ -317,6 +322,7 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) 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 = viewportRT; diff --git a/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs index f4ae0d0..1fa9e2c 100644 --- a/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs +++ b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs @@ -24,6 +24,10 @@ internal override AbstractMenuScreen BuildMenuScreen() 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; } From ea7ecb515de35d75b9e5cf190bf7a4f065d6fc4d Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Sun, 26 Apr 2026 18:21:01 -0400 Subject: [PATCH 4/9] improve scroll autofocusing for nested & sibling scroll panes ScrollTo() calls now propogate up all nested scroll panes. The first ScrollToIfOnMenuShow() call prevents any other scroll pane under the same MenuScreen hierarchy from firing it again. --- .../Internal/OnChildTransformsChangeHelper.cs | 5 +- .../Internal/ScrollFocusController.cs | 114 +++++++++++------- .../Internal/ScrollNavigationHelper.cs | 16 +-- Silksong.ModMenu/Internal/VectorUtil.cs | 38 ++++++ .../Tests/NestedScrollPanesTest.cs | 93 ++++++++++++-- .../Tests/ScrollingMenuTest.cs | 2 +- 6 files changed, 199 insertions(+), 69 deletions(-) create mode 100644 Silksong.ModMenu/Internal/VectorUtil.cs 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/ScrollFocusController.cs b/Silksong.ModMenu/Internal/ScrollFocusController.cs index cff2939..1039c30 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 scrollPaneAppearing = 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(); + scrollPaneAppearing = 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 (scrollPaneAppearing) + { + 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(); + } + } + scrollPaneAppearing = false; + } + + internal void SetMenuAppeared() => scrollPaneAppearing = 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); + } + + internal void ScrollToInternal(Transform target, bool smooth) + { + scrollPaneAppearing = 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 3c83728..5bbea13 100644 --- a/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs +++ b/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs @@ -11,16 +11,16 @@ namespace Silksong.ModMenu.Internal; internal class ScrollNavigationHelper : EventTrigger { ScrollRect scrollRect; - ScrollFocusController[] focusControllers = []; + ScrollFocusController focusController; void Awake() { scrollRect = GetComponentInParent(true); - focusControllers = GetComponentsInParent(true); + focusController = GetComponentInParent(true); if (!scrollRect) throw new InvalidOperationException($"Failed to find containing {nameof(ScrollRect)}."); - if (focusControllers.Length == 0) + if (!focusController) throw new InvalidOperationException( $"Failed to find containing {nameof(ScrollFocusController)}." ); @@ -43,18 +43,12 @@ public override void OnSelect(BaseEventData eventData) { // When keyboard/controller navigated to. if (eventData is AxisEventData) - { - foreach (var focusController in focusControllers) - focusController.ScrollTo(transform, smooth: true); - } + focusController.ScrollTo(transform, 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)) - { - foreach (var focusController in focusControllers) - focusController.ScrollToIfOnMenuShow(transform); - } + focusController.ScrollToIfOnMenuShow(transform); } /// 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/NestedScrollPanesTest.cs b/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs index 4c01d20..81bcf7d 100644 --- a/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs +++ b/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs @@ -1,18 +1,35 @@ using Silksong.ModMenu.Elements; +using Silksong.ModMenu.Models; using Silksong.ModMenu.Screens; using UnityEngine; +using UnityEngine.UI; namespace Silksong.ModMenuTesting.Tests; internal class NestedScrollPanesTest : ModMenuTest { // for finding the screen in UnityExplorer during testing - static BasicMenuScreen? screen; + static PaginatedMenuScreen? screen; internal override string Name => "Scrolling Menu - Nested Panes"; internal override AbstractMenuScreen BuildMenuScreen() { + screen = new PaginatedMenuScreen("Nested Scroll Panes") + { + SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget + }; + screen.AddPage(DoubleAxesAutoFocus()); + screen.AddPage(OffCenterSelectables()); + screen.AddPage(BasicScrollPaneNesting()); + screen.AddPage(ScrollScrollPanePane()); + return screen; + } + + /// + /// For testing how multiple scroll panes of varying scroll axes in one layout behave together + /// + static ScrollingPane BasicScrollPaneNesting() { VerticalGroup outerContent = new() { VerticalSpacing = 460 }; @@ -22,14 +39,15 @@ internal override AbstractMenuScreen BuildMenuScreen() innerContentThree = new(2) { HorizontalSpacing = 480 }; ScrollingPane - outerScroll = new(outerContent) { ViewportSize = new Vector2(1480, 860) }, - innerScrollOne = new(innerContentOne) { ViewportSize = new Vector2(1360, 350f) }, + 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 + Axes = ScrollingPane.ScrollAxes.Horizontal, + SmoothScrollTime = 1 }, - innerScrollThree = new(innerContentThree) { ViewportSize = new Vector2(1360, 350f) }; + innerScrollThree = new(innerContentThree) { ViewportSize = new Vector2(1300, 350f), SmoothScrollTime = 0.5f }; for (int i = 1; i <= 12; i++) { @@ -39,11 +57,68 @@ internal override AbstractMenuScreen BuildMenuScreen() } outerContent.AddRange([innerScrollOne, innerScrollTwo, innerScrollThree]); - - screen = new BasicMenuScreen("Nested Scroll Panes", outerScroll) + + 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++) { - SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget + group.AddRange([ + new TextButton($"Thin Button {i}"), + new SliderElement($"Wide Slider {i}", SliderModels.ForInts(0, 5)), + new ChoiceElement($"Wide Choice {i}", ChoiceModels.ForBool()) + ]); + } + + return new ScrollingPane(group) { ViewportSize = new Vector2(1300, 875), Axes = ScrollingPane.ScrollAxes.Horizontal }; + } + + /// + /// 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 }; - return screen; + + 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; } } diff --git a/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs index 1fa9e2c..c69b859 100644 --- a/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs +++ b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs @@ -64,7 +64,7 @@ internal override AbstractMenuScreen BuildMenuScreen() var textinput = new TextInput("Input", TextModels.ForStrings()); textinput.Model.Value = "placeholder"; - screen.Add(textinput); + screen.Add(textinput); elementAdder.Model.SetValue(5); From 2eadaa8b196db77dc9f530fc8d5da6dd55e8c22a Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Sun, 26 Apr 2026 18:31:18 -0400 Subject: [PATCH 5/9] fix off-center horizontal scroll target for slider elements --- Silksong.ModMenu/Elements/ScrollingPane.cs | 6 +++++- Silksong.ModMenu/Internal/ScrollNavigationHelper.cs | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Silksong.ModMenu/Elements/ScrollingPane.cs b/Silksong.ModMenu/Elements/ScrollingPane.cs index 661faac..f7311be 100644 --- a/Silksong.ModMenu/Elements/ScrollingPane.cs +++ b/Silksong.ModMenu/Elements/ScrollingPane.cs @@ -218,8 +218,12 @@ 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; + } } /// diff --git a/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs b/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs index 5bbea13..ec72ec2 100644 --- a/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs +++ b/Silksong.ModMenu/Internal/ScrollNavigationHelper.cs @@ -1,4 +1,5 @@ using System; +using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; @@ -13,6 +14,8 @@ internal class ScrollNavigationHelper : EventTrigger ScrollRect scrollRect; ScrollFocusController focusController; + public Transform? container; + void Awake() { scrollRect = GetComponentInParent(true); @@ -41,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); } /// From 0bd075c3174bd6e65f86c9850ad6b2e9c9afbd85 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Sun, 26 Apr 2026 19:43:03 -0400 Subject: [PATCH 6/9] auto-scrolling on two axes now works --- Silksong.ModMenu/Elements/ScrollingPane.cs | 6 +- .../Internal/ScrollFocusController.cs | 14 +-- .../Internal/ScrollSliderController.cs | 87 +++++++++++-------- .../Tests/ScrollingMenuTest.cs | 4 +- ...rollPanesTest.cs => ScrollingPaneTests.cs} | 56 +++++++++--- 5 files changed, 109 insertions(+), 58 deletions(-) rename Silksong.ModMenuTesting/Tests/{NestedScrollPanesTest.cs => ScrollingPaneTests.cs} (68%) diff --git a/Silksong.ModMenu/Elements/ScrollingPane.cs b/Silksong.ModMenu/Elements/ScrollingPane.cs index f7311be..561abcb 100644 --- a/Silksong.ModMenu/Elements/ScrollingPane.cs +++ b/Silksong.ModMenu/Elements/ScrollingPane.cs @@ -10,7 +10,7 @@ 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 { @@ -138,7 +138,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); } /// @@ -153,7 +153,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); } /// diff --git a/Silksong.ModMenu/Internal/ScrollFocusController.cs b/Silksong.ModMenu/Internal/ScrollFocusController.cs index 1039c30..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 scrollPaneAppearing = true; + bool menuAppearing = true; protected override void Awake() { @@ -30,7 +30,7 @@ protected override void Awake() protected override void OnEnable() { base.OnEnable(); - scrollPaneAppearing = true; + menuAppearing = true; } /// @@ -46,7 +46,7 @@ protected override void OnEnable() /// internal void ScrollToIfOnMenuShow(Transform target) { - if (scrollPaneAppearing) + if (menuAppearing) { ScrollTo(target, smooth: false); @@ -60,10 +60,10 @@ var child in menu.transform.GetComponentsInChildren(true) child.SetMenuAppeared(); } } - scrollPaneAppearing = false; + menuAppearing = false; } - internal void SetMenuAppeared() => scrollPaneAppearing = false; + internal void SetMenuAppeared() => menuAppearing = false; /// /// Scrolls this viewport and all scrolling viewports this one is nested within so that the @@ -80,9 +80,9 @@ public void ScrollTo(Transform target, bool smooth = false) controller.ScrollToInternal(target, smooth); } - internal void ScrollToInternal(Transform target, bool smooth) + private void ScrollToInternal(Transform target, bool smooth) { - scrollPaneAppearing = false; + menuAppearing = false; if (smoothScrollRoutine != null) { StopCoroutine(smoothScrollRoutine); diff --git a/Silksong.ModMenu/Internal/ScrollSliderController.cs b/Silksong.ModMenu/Internal/ScrollSliderController.cs index 089b7dc..2c5e89b 100644 --- a/Silksong.ModMenu/Internal/ScrollSliderController.cs +++ b/Silksong.ModMenu/Internal/ScrollSliderController.cs @@ -25,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); } } @@ -41,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); } } @@ -56,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() @@ -92,7 +95,7 @@ protected void LateUpdate() scrollRect.vertical && ( scrollRect.verticalScrollbarVisibility == Visibility.Permanent - || contentRect.height > viewRect.height + || contentRect.height > viewRect.height + 0.01f ) ); @@ -101,36 +104,50 @@ protected void LateUpdate() scrollRect.horizontal && ( scrollRect.horizontalScrollbarVisibility == Visibility.Permanent - || contentRect.width > viewRect.width + || contentRect.width > viewRect.width + 0.01f ) ); } - void UpdateScrollRect(float _) - { - if (!scrollRect) - return; + #endregion + #region Event Handlers - Vector2 pos = Vector2.one * 0.5f; - - 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.ModMenuTesting/Tests/ScrollingMenuTest.cs b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs index c69b859..f7e5dad 100644 --- a/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs +++ b/Silksong.ModMenuTesting/Tests/ScrollingMenuTest.cs @@ -16,11 +16,11 @@ 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 }; diff --git a/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs b/Silksong.ModMenuTesting/Tests/ScrollingPaneTests.cs similarity index 68% rename from Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs rename to Silksong.ModMenuTesting/Tests/ScrollingPaneTests.cs index 81bcf7d..e7d27bf 100644 --- a/Silksong.ModMenuTesting/Tests/NestedScrollPanesTest.cs +++ b/Silksong.ModMenuTesting/Tests/ScrollingPaneTests.cs @@ -6,30 +6,57 @@ namespace Silksong.ModMenuTesting.Tests; -internal class NestedScrollPanesTest : ModMenuTest +internal class ScrollingPaneTests : ModMenuTest { // for finding the screen in UnityExplorer during testing static PaginatedMenuScreen? screen; - internal override string Name => "Scrolling Menu - Nested Panes"; + internal override string Name => "Scrolling Pane Tests"; internal override AbstractMenuScreen BuildMenuScreen() { - screen = new PaginatedMenuScreen("Nested Scroll Panes") + screen = new PaginatedMenuScreen("Scrolling Pane Tests") { SelectOnShowBehaviour = SelectOnShowBehaviour.NeverForget }; - screen.AddPage(DoubleAxesAutoFocus()); - screen.AddPage(OffCenterSelectables()); - screen.AddPage(BasicScrollPaneNesting()); + screen.AddPage(ScrollPaneSiblings()); + screen.AddPage(ScrollPaneNesting()); screen.AddPage(ScrollScrollPanePane()); + screen.AddPage(OffCenterSelectables()); + screen.AddPage(DoubleAxesAutoFocus()); return screen; } /// - /// For testing how multiple scroll panes of varying scroll axes in one layout behave together + /// For testing how multiple scroll panes in one layout behave /// - static ScrollingPane BasicScrollPaneNesting() { + 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 }; @@ -45,7 +72,7 @@ static ScrollingPane BasicScrollPaneNesting() { { ViewportSize = new Vector2(1360, 350f), Axes = ScrollingPane.ScrollAxes.Horizontal, - SmoothScrollTime = 1 + SmoothScrollTime = 0.5f }, innerScrollThree = new(innerContentThree) { ViewportSize = new Vector2(1300, 350f), SmoothScrollTime = 0.5f }; @@ -77,7 +104,13 @@ static ScrollingPane OffCenterSelectables() { ]); } - return new ScrollingPane(group) { ViewportSize = new Vector2(1300, 875), Axes = ScrollingPane.ScrollAxes.Horizontal }; + 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; } /// @@ -87,7 +120,7 @@ static ScrollingPane DoubleAxesAutoFocus() { FreeGroup group = new(); TextButton tl = new("Top Left"), - br = new("Bottom Right"); + br = new("Bottom Right"); group.Add(tl, new Vector2(-300, 0)); group.Add(br, new Vector2(300, -600)); @@ -101,6 +134,7 @@ static ScrollingPane DoubleAxesAutoFocus() { 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; From 5a9d0f420950d689926e50940e9f6e231740e360 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Wed, 29 Apr 2026 12:08:55 -0400 Subject: [PATCH 7/9] miscellaneous ScrollingPane cleanup ScrollAxes is now a flags enum, EnumerateDescendants Predicate is now a Func, and .RectTransform extension is used in place of casts/etc. --- Silksong.ModMenu/Elements/ScrollingPane.cs | 29 +++++++++------------- Silksong.ModMenu/Internal/MenuPrefabs.cs | 19 +++++++------- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/Silksong.ModMenu/Elements/ScrollingPane.cs b/Silksong.ModMenu/Elements/ScrollingPane.cs index 561abcb..d1b1051 100644 --- a/Silksong.ModMenu/Elements/ScrollingPane.cs +++ b/Silksong.ModMenu/Elements/ScrollingPane.cs @@ -71,8 +71,8 @@ public ScrollingPane(INavigableMenuEntity content) /// public Vector2 ViewportSize { - get => ((RectTransform)scrollPane.transform).sizeDelta; - set => ((RectTransform)scrollPane.transform).sizeDelta = value; + get => scrollPane.RectTransform.sizeDelta; + set => scrollPane.RectTransform.sizeDelta = value; } /// @@ -80,41 +80,36 @@ public Vector2 ViewportSize /// public ScrollAxes Axes { - get - { - if (scrollRect.vertical && scrollRect.horizontal) - return ScrollAxes.Both; - else if (scrollRect.vertical) - return ScrollAxes.Vertical; - else - return ScrollAxes.Horizontal; - } + get => + (scrollRect.vertical ? ScrollAxes.Vertical : 0) + | (scrollRect.horizontal ? ScrollAxes.Horizontal : 0); set { - scrollRect.vertical = value == ScrollAxes.Vertical || value == ScrollAxes.Both; - scrollRect.horizontal = value == ScrollAxes.Horizontal || value == ScrollAxes.Both; + 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, + Vertical = 0x01, /// /// Exclusively horizontal scrolling. /// - Horizontal, + Horizontal = 0x10, /// /// Scrolling in both the vertical and horizontal axes. /// - Both, + Both = Vertical | Horizontal, } /// @@ -267,7 +262,7 @@ static bool ChildrenOfScrollPanesExceptScrollbars(Transform x) => /// private static IEnumerable EnumerateDescendantsConditional( Transform transform, - Predicate shouldSkip + Func shouldSkip ) { foreach (Transform item in transform) diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index 8838024..0d55cec 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -294,8 +294,7 @@ 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; // ensures it has a RectTransform - var scrollPaneRT = (RectTransform)scrollPane.transform; + var scrollPaneRT = scrollPane.AddComponent(); scrollPaneRT.sizeDelta = new Vector2( 1510, Mathf.Ceil(SpacingConstants.VSPACE_MEDIUM * 8.334f) @@ -306,13 +305,13 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) viewport.transform.SetParentReset(scrollPane.transform); viewport.AddComponent().color = Color.clear; // necessary for the RectMask2D viewport.AddComponent(); - var viewportRT = (RectTransform)viewport.transform; + var viewportRT = viewport.RectTransform; viewportRT.FitToParent(); var content = new GameObject("Content") { layer = (int)PhysLayers.UI }; content.transform.SetParentReset(viewport.transform); - content.AddComponent().color = Color.clear; // ensures it has a RectTransform - var contentRT = (RectTransform)content.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 @@ -325,11 +324,11 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) ); vScrollbar.name = "Scrollbar V"; - ((RectTransform)vScrollbar.transform.Find("Background")).FitToParentVertical(); + vScrollbar.FindChild("Background")!.RectTransform.FitToParentVertical(); - var vScrollbarRT = (RectTransform)vScrollbar.transform; - var slideAreaRT = (RectTransform)vScrollbarRT.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); @@ -359,7 +358,7 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) var hScrollbar = Object.Instantiate(vScrollbar, scrollPane.transform, false); hScrollbar.name = "Scrollbar H"; - var hScrollbarRT = (RectTransform)hScrollbar.transform; + var hScrollbarRT = hScrollbar.RectTransform; hScrollbarRT.anchorMin = hScrollbarRT.anchorMax = new Vector2(0.5f, 0); hScrollbarRT.pivot = new Vector2(1, 0.5f); hScrollbar.AddComponent().fitToWidth = true; From 83e750035010e35f7fa50af38a573987c8b7d849 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Wed, 29 Apr 2026 12:17:00 -0400 Subject: [PATCH 8/9] use .RectTransform extension in more places --- Silksong.ModMenu/Internal/MenuPrefabs.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Silksong.ModMenu/Internal/MenuPrefabs.cs b/Silksong.ModMenu/Internal/MenuPrefabs.cs index 0d55cec..b75b907 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -73,7 +73,7 @@ private MenuPrefabs(UIManager uiManager) textLabelTemplate.name = "TextLabel"; textLabelTemplate.GetComponent().raycastTarget = false; Object.DontDestroyOnLoad(textLabelTemplate); - ((RectTransform)textLabelTemplate.transform).sizeDelta = new Vector2(0, 105); + textLabelTemplate.RectTransform.sizeDelta = new Vector2(0, 105); textChoiceTemplate = Object.Instantiate( canvas.FindChild("GameOptionsMenuScreen/Content/CamShakeSetting")! @@ -100,7 +100,7 @@ private MenuPrefabs(UIManager uiManager) textButtonTemplate.SetActive(false); textButtonTemplate.name = "TextButtonContainer"; Object.DontDestroyOnLoad(textButtonTemplate); - ((RectTransform)textButtonTemplate.transform).sizeDelta = Vector2.zero; + textButtonTemplate.RectTransform.sizeDelta = Vector2.zero; var buttonChild = textButtonTemplate.FindChild("GameOptionsButton")!; buttonChild.name = "TextButton"; @@ -108,7 +108,7 @@ private MenuPrefabs(UIManager uiManager) // so it won't be removed by the LocalizedTextExtensions buttonChild.RemoveComponent(); buttonChild.FindChild("Menu Button Text")!.RemoveComponent(); - var buttonChildRT = (RectTransform)buttonChild.transform; + var buttonChildRT = buttonChild.RectTransform; buttonChildRT.sizeDelta = buttonChildRT.sizeDelta with { x = 0 }; // Add a (centered) description to the menu button @@ -156,7 +156,7 @@ private MenuPrefabs(UIManager uiManager) sliderTemplate.SetActive(false); sliderTemplate.name = "SliderContainer"; Object.DontDestroyOnLoad(sliderTemplate); - ((RectTransform)sliderTemplate.transform).sizeDelta = Vector2.zero; + sliderTemplate.RectTransform.sizeDelta = Vector2.zero; var sliderChild = sliderTemplate.FindChild("MasterSlider")!; sliderChild.name = "Slider"; From e331b9d5d49dfb590ba78f4bd99074728bfa5095 Mon Sep 17 00:00:00 2001 From: kaycodes13 Date: Wed, 29 Apr 2026 13:15:52 -0400 Subject: [PATCH 9/9] fix the backwards horizontal mousewheel scrolling issue --- Silksong.ModMenu/Internal/CustomScrollRect.cs | 25 +++++++++++++++++++ Silksong.ModMenu/Internal/MenuPrefabs.cs | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Silksong.ModMenu/Internal/CustomScrollRect.cs 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 b75b907..389a2c7 100644 --- a/Silksong.ModMenu/Internal/MenuPrefabs.cs +++ b/Silksong.ModMenu/Internal/MenuPrefabs.cs @@ -369,7 +369,7 @@ private static GameObject ConstructScrollPanePrefab(UIManager uiManager) #endregion - var scrollRect = scrollPane.AddComponent(); + var scrollRect = scrollPane.AddComponent(); scrollRect.horizontal = false; scrollRect.vertical = true; scrollRect.verticalScrollbarVisibility = ScrollRect.ScrollbarVisibility.AutoHide;