Files
A-Fairytale-Gone-Bad-Briar-…/Assets/Scripts/UI/Menus/VerticalScrollbar.cs
2026-05-13 14:20:25 +01:00

350 lines
11 KiB
C#

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
#if UNITY_EDITOR
using UnityEditor;
#endif
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace BriarQueen.UI.Menus
{
[RequireComponent(typeof(RectTransform))]
public class VerticalScrollbar : MonoBehaviour, IDragHandler, IPointerDownHandler
{
private static readonly Vector3[] Corners = new Vector3[4];
[Header("Hierarchy")]
[SerializeField] private RectTransform _viewport;
[SerializeField] private RectTransform _content;
[SerializeField] private RectTransform _trackRect;
[SerializeField] private RectTransform _handleRect;
[SerializeField] private Image _scrollBarImage;
[Header("Scroll Settings")]
[SerializeField] private float _wheelPixels = 80f;
[SerializeField] private float _padSpeed = 900f;
[SerializeField] private float _inputSystemWheelScale = 0.05f;
[Header("Handle")]
[SerializeField] private bool _useCustomHandleSizing;
[SerializeField] private float _minHandleHeight = 24f;
[Header("Alignment")]
[SerializeField] private bool _centerContentWhenNotScrollable = true;
[SerializeField] private float _topInset = 6f;
[SerializeField] private float _bottomInset = 6f;
[Header("Track")]
[SerializeField] private bool _hideTrackWhenNotScrollable = true;
#if UNITY_EDITOR
[Header("Editor Debug")]
[SerializeField]
[Range(0f, 1f)]
private float _debugNormalized;
#endif
private bool _isScrollable;
private float _scrollRange;
private Camera _uiCamera;
public float Normalized { get; private set; }
private void Awake()
{
var canvas = GetComponentInParent<Canvas>();
if (canvas != null && canvas.renderMode == RenderMode.ScreenSpaceCamera)
_uiCamera = canvas.worldCamera;
}
private void Start()
{
Rebuild();
}
private void Update()
{
HandleMouseWheel();
}
#if UNITY_EDITOR
private void OnValidate()
{
if (_viewport == null || _content == null || _trackRect == null || _handleRect == null)
return;
if (Application.isPlaying)
return;
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
if (!TryGetContentBounds(out var top, out var bottom))
return;
var contentHeight = top - bottom;
var viewportHeight = _viewport.rect.height - _topInset - _bottomInset;
_isScrollable = contentHeight > viewportHeight;
_scrollRange = Mathf.Max(0f, contentHeight - viewportHeight);
Debug.Log($"[Scrollbar] OnValidate — contentHeight:{contentHeight} viewportHeight:{viewportHeight} isScrollable:{_isScrollable} scrollRange:{_scrollRange}");
Normalized = Mathf.Clamp01(_debugNormalized);
if (_centerContentWhenNotScrollable && !_isScrollable)
{
CenterContent(top, bottom);
Normalized = 0f;
}
else
{
var offset = Mathf.Lerp(0f, _scrollRange, Normalized);
SetContentY(offset);
if (Normalized <= 0.0001f)
AlignFirstChildToTop();
}
UpdateTrackVisibility();
UpdateHandle();
}
#endif
public void OnDrag(PointerEventData eventData)
{
DragHandle(eventData);
}
public void OnPointerDown(PointerEventData eventData)
{
DragHandle(eventData);
}
public void Rebuild()
{
if (_viewport == null || _content == null)
return;
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
if (!TryGetContentBounds(out var top, out var bottom))
return;
var contentHeight = top - bottom;
var viewportHeight = _viewport.rect.height - _topInset - _bottomInset;
_isScrollable = contentHeight > viewportHeight;
_scrollRange = Mathf.Max(0f, contentHeight - viewportHeight);
Debug.Log($"[Scrollbar] Rebuild — contentHeight:{contentHeight} viewportHeight:{viewportHeight} isScrollable:{_isScrollable} scrollRange:{_scrollRange}");
if (_centerContentWhenNotScrollable && !_isScrollable)
{
CenterContent(top, bottom);
Normalized = 0f;
}
else
{
SetNormalized(Normalized);
}
UpdateTrackVisibility();
UpdateHandle();
}
public void SetNormalized(float normalized)
{
Normalized = Mathf.Clamp01(normalized);
Debug.Log($"[Scrollbar] SetNormalized:{normalized} isScrollable:{_isScrollable} scrollRange:{_scrollRange}");
if (!_isScrollable)
return;
var offset = Mathf.Lerp(0f, _scrollRange, Normalized);
SetContentY(offset);
if (Normalized <= 0.0001f)
AlignFirstChildToTop();
UpdateHandle();
}
private void CenterContent(float top, float bottom)
{
var contentCenter = (top + bottom) * 0.5f;
var viewportCenter = (_viewport.rect.yMin + _viewport.rect.yMax) * 0.5f;
var delta = viewportCenter - contentCenter;
var position = _content.anchoredPosition;
position.y += delta;
_content.anchoredPosition = position;
}
private void AlignFirstChildToTop()
{
RectTransform first = null;
for (var i = 0; i < _content.childCount; i++)
{
var child = _content.GetChild(i) as RectTransform;
if (child != null && child.gameObject.activeSelf)
{
first = child;
break;
}
}
if (first == null)
return;
first.GetWorldCorners(Corners);
var childTop = _viewport.InverseTransformPoint(Corners[1]).y;
var targetTop = _viewport.rect.yMax - _topInset;
var delta = targetTop - childTop;
var position = _content.anchoredPosition;
position.y += delta;
_content.anchoredPosition = position;
}
private void ScrollByPixels(float pixels)
{
if (!_isScrollable)
return;
var current = Normalized * _scrollRange;
var next = Mathf.Clamp(current + pixels, 0f, _scrollRange);
Normalized = _scrollRange > 0f ? next / _scrollRange : 0f;
SetNormalized(Normalized);
}
private void DragHandle(PointerEventData eventData)
{
if (!_isScrollable || _trackRect == null || _handleRect == null)
return;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(_trackRect, eventData.position, _uiCamera,
out var localPoint))
return;
var halfHandleHeight = _handleRect.rect.height * 0.5f;
var min = _trackRect.rect.yMin + halfHandleHeight;
var max = _trackRect.rect.yMax - halfHandleHeight;
var y = Mathf.Clamp(localPoint.y, min, max);
var normalized = 1f - Mathf.InverseLerp(min, max, y);
SetNormalized(normalized);
}
private void HandleMouseWheel()
{
var wheel = ReadMouseWheelDelta();
if (Mathf.Abs(wheel) > 0.01f)
ScrollByPixels(-wheel * _wheelPixels);
}
private float ReadMouseWheelDelta()
{
#if ENABLE_INPUT_SYSTEM
if (Mouse.current != null)
return Mouse.current.scroll.ReadValue().y * _inputSystemWheelScale;
#elif ENABLE_LEGACY_INPUT_MANAGER
return Input.mouseScrollDelta.y;
#endif
return 0f;
}
private void SetContentY(float y)
{
Debug.Log($"[Scrollbar] SetContentY:{y}");
var position = _content.anchoredPosition;
position.y = y;
_content.anchoredPosition = position;
#if UNITY_EDITOR
if (!Application.isPlaying)
{
Canvas.ForceUpdateCanvases();
SceneView.RepaintAll();
}
#endif
}
private void UpdateHandle()
{
if (_trackRect == null || _handleRect == null)
return;
if (!_isScrollable)
{
_handleRect.anchoredPosition = Vector2.zero;
return;
}
var half = _handleRect.rect.height * 0.5f;
var min = _trackRect.rect.yMin + half;
var max = _trackRect.rect.yMax - half;
var yPos = Mathf.Lerp(max, min, Normalized);
Debug.Log($"[Scrollbar] UpdateHandle — half:{half} trackYMin:{_trackRect.rect.yMin} trackYMax:{_trackRect.rect.yMax} min:{min} max:{max} yPos:{yPos} Normalized:{Normalized}");
var position = _handleRect.anchoredPosition;
position.y = yPos;
_handleRect.anchoredPosition = position;
}
private void UpdateTrackVisibility()
{
if (_trackRect == null)
return;
if (_hideTrackWhenNotScrollable)
{
_trackRect.gameObject.SetActive(_isScrollable);
_scrollBarImage.enabled = _isScrollable;
}
}
private bool TryGetContentBounds(out float top, out float bottom)
{
top = float.MinValue;
bottom = float.MaxValue;
var found = false;
for (var i = 0; i < _content.childCount; i++)
{
var child = _content.GetChild(i) as RectTransform;
if (child == null || !child.gameObject.activeSelf)
continue;
child.GetWorldCorners(Corners);
for (var c = 0; c < 4; c++)
{
var local = _viewport.InverseTransformPoint(Corners[c]);
top = Mathf.Max(top, local.y);
bottom = Mathf.Min(bottom, local.y);
}
found = true;
Debug.Log($"[Scrollbar] Child: {child.name} top:{top} bottom:{bottom} height:{top - bottom}");
}
Debug.Log($"[Scrollbar] Found:{found} ContentHeight:{top - bottom} ViewportHeight:{_viewport.rect.height}");
return found;
}
}
}