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;
+ }
+}