Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 107 additions & 27 deletions Silksong.ModMenu/Elements/ScrollingPane.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A vertically scrolling panel that can contain an arbitrary amount of content.
/// A scrolling panel that can contain an arbitrary amount of content.
/// </summary>
public class ScrollingPane : MenuDisposable, INavigableMenuEntity
{
Expand All @@ -19,8 +21,6 @@ public class ScrollingPane : MenuDisposable, INavigableMenuEntity

/// <summary>
/// The scrolling pane's content.
/// This entity cannot be a <see cref="ScrollingPane"/>,
/// and cannot contain any <see cref="ScrollingPane"/> children.
/// </summary>
public INavigableMenuEntity? Content
{
Expand All @@ -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);
Expand Down Expand Up @@ -65,7 +59,7 @@ public ScrollingPane(INavigableMenuEntity content)

AddScrollNavHelpers();

OnDispose += () => Object.Destroy(scrollPane);
OnDispose += () => UObject.Destroy(scrollPane);
Visibility.OnVisibilityChanged += visibleInHierarchy =>
scrollPane.SetActive(visibleInHierarchy);

Expand All @@ -77,8 +71,45 @@ public ScrollingPane(INavigableMenuEntity content)
/// </summary>
public Vector2 ViewportSize
{
get => scrollRect.viewport.sizeDelta;
set => scrollRect.viewport.sizeDelta = value;
get => scrollPane.RectTransform.sizeDelta;
set => scrollPane.RectTransform.sizeDelta = value;
}

/// <summary>
/// Whether this pane scrolls only vertically, only horizontally, or in both axes.
/// </summary>
public ScrollAxes Axes
Comment thread
flibber-hk marked this conversation as resolved.
{
get =>
(scrollRect.vertical ? ScrollAxes.Vertical : 0)
| (scrollRect.horizontal ? ScrollAxes.Horizontal : 0);
set
{
scrollRect.vertical = value.HasFlag(ScrollAxes.Vertical);
scrollRect.horizontal = value.HasFlag(ScrollAxes.Horizontal);
}
}

/// <summary>
/// Semantic states for which axes a <see cref="ScrollingPane"/> can scroll in.
/// </summary>
[Flags]
public enum ScrollAxes
Comment thread
kaycodes13 marked this conversation as resolved.
{
/// <summary>
/// Exclusively vertical scrolling.
/// </summary>
Vertical = 0x01,

/// <summary>
/// Exclusively horizontal scrolling.
/// </summary>
Horizontal = 0x10,

/// <summary>
/// Scrolling in both the vertical and horizontal axes.
/// </summary>
Both = Vertical | Horizontal,
}

/// <summary>
Expand All @@ -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);
}

/// <summary>
Expand All @@ -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);
}

/// <summary>
Expand All @@ -137,24 +168,15 @@ public void UpdateLayout(Vector2 localAnchorPos)
if (resizeQueued)
{
resizeQueued = false;
foreach (Transform child in contentPane.transform)
{
if (child.GetComponent<ScrollRect>())
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);
}

Expand Down Expand Up @@ -191,8 +213,66 @@ public void SetGameObjectParent(GameObject parent) =>
private void AddScrollNavHelpers()
{
foreach (var element in Content?.AllElements().OfType<SelectableElement>() ?? [])
{
// This component removes itself when the object it's attached to is re-parented
element.SelectableComponent.gameObject.AddComponentIfNotPresent<ScrollNavigationHelper>();
element
.SelectableComponent.gameObject.AddComponentIfNotPresent<ScrollNavigationHelper>()
.container = element.Container.transform;
}
}

/// <summary>
/// Calculates the cumulative bounds of all children of the content pane in local coordinates,
/// skipping over anything that is contained within another ScrollRect.
/// </summary>
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<ScrollSliderController>(out var sliderCtrl)
&& (!sliderCtrl.VerticalSlider || x != sliderCtrl.VerticalSlider.transform)
&& (!sliderCtrl.HorizontalSlider || x != sliderCtrl.HorizontalSlider.transform);
}

/// <summary>
/// Enumerates the entire Transform hierarchy, skipping branches when it finds
/// a transform for which the given <paramref name="shouldSkip"/> predicate is true.
/// </summary>
private static IEnumerable<Transform> EnumerateDescendantsConditional(
Transform transform,
Func<Transform, bool> shouldSkip
)
{
foreach (Transform item in transform)
{
if (shouldSkip(item))
continue;
yield return item;
foreach (var item2 in EnumerateDescendantsConditional(item, shouldSkip))
yield return item2;
}
}

#endregion
Expand Down
25 changes: 25 additions & 0 deletions Silksong.ModMenu/Internal/CustomScrollRect.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace Silksong.ModMenu.Internal;

/// <summary>
/// Identical to the regular <see cref="ScrollRect"/>, except it fixes the known bug
/// (See <see href="https://discussions.unity.com/t/934765">this Unity help forum post</see>)
/// where mousewheel/touchpad scrolling is always the opposite of what it should intuitively be for one scroll direction.
/// </summary>
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);
}
}
89 changes: 62 additions & 27 deletions Silksong.ModMenu/Internal/MenuPrefabs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ private MenuPrefabs(UIManager uiManager)
textLabelTemplate.name = "TextLabel";
textLabelTemplate.GetComponent<Text>().raycastTarget = false;
Object.DontDestroyOnLoad(textLabelTemplate);
textLabelTemplate.RectTransform.sizeDelta = new Vector2(0, 105);
Comment thread
flibber-hk marked this conversation as resolved.

textChoiceTemplate = Object.Instantiate(
canvas.FindChild("GameOptionsMenuScreen/Content/CamShakeSetting")!
Expand All @@ -99,13 +100,16 @@ 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";
// We have to remove this component as it's on a different GameObject to the Text,
// so it won't be removed by the LocalizedTextExtensions
buttonChild.RemoveComponent<AutoLocalizeTextUI>();
buttonChild.FindChild("Menu Button Text")!.RemoveComponent<ChangeTextFontScaleOnHandHeld>();
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")!);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -273,7 +278,7 @@ out GameObject contentPane
var obj = Object.Instantiate(scrollPaneTemplate);
scrollRect = obj.GetComponent<ScrollRect>();
focusController = obj.GetComponent<ScrollFocusController>();
contentPane = obj.FindChild("Content")!;
contentPane = obj.FindChild("Viewport/Content")!;
return obj;
}

Expand All @@ -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<Image>().color = Color.clear; // necessary for the RectMask2D
scrollPane.AddComponent<RectMask2D>();
var scrollPaneRT = (RectTransform)scrollPane.transform;
scrollPaneRT.sizeDelta = new Vector2(1570, 876f);
var scrollPaneRT = scrollPane.AddComponent<RectTransform>();
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<Image>().color = Color.clear; // necessary for the RectMask2D
viewport.AddComponent<RectMask2D>();
var viewportRT = viewport.RectTransform;
viewportRT.FitToParent();

var content = new GameObject("Content") { layer = (int)PhysLayers.UI };
content.transform.SetParentReset(scrollPane.transform);
content.AddComponent<Image>().color = Color.clear; // ensures it has a RectTransform
var contentRT = (RectTransform)content.transform;
content.transform.SetParentReset(viewport.transform);
content.AddComponent<Image>().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);
Expand All @@ -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<Scrollbar>());
var scrollSlider = scrollbar.AddComponent<Slider>();
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<Scrollbar>());
var vScrollSlider = vScrollbar.AddComponent<Slider>();
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<RotatedParentSizeFitter>().fitToWidth = true;

var hScrollSlider = hScrollbar.GetComponent<Slider>();
hScrollSlider.direction = Slider.Direction.TopToBottom;
hScrollSlider.value = 0.5f;

#endregion

var scrollRect = scrollPane.AddComponent<ScrollRect>();
var scrollRect = scrollPane.AddComponent<CustomScrollRect>();
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<ScrollSliderController>().VerticalSlider = scrollSlider;
scrollRect.normalizedPosition = new Vector2(0.5f, 1);

var sliderController = scrollPane.AddComponent<ScrollSliderController>();
sliderController.VerticalSlider = vScrollSlider;
sliderController.HorizontalSlider = hScrollSlider;

scrollPane.AddComponent<ScrollFocusController>();

return scrollPane;
Expand Down
Loading