Files

364 lines
10 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;
[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);
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);
if (_centerContentWhenNotScrollable && !_isScrollable)
{
CenterContent(top, bottom);
Normalized = 0f;
}
else
{
SetNormalized(Normalized);
}
UpdateTrackVisibility();
UpdateHandle();
}
public void SetNormalized(float normalized)
{
Normalized = Mathf.Clamp01(normalized);
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)
{
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;
}
if (_useCustomHandleSizing)
{
var ratio = Mathf.Clamp01(_viewport.rect.height / (_scrollRange + _viewport.rect.height));
var height = Mathf.Max(_trackRect.rect.height * ratio, _minHandleHeight);
_handleRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
}
var half = _handleRect.rect.height * 0.5f;
var min = _trackRect.rect.yMin + half;
var max = _trackRect.rect.yMax - half;
var y = Mathf.Lerp(max, min, Normalized);
var position = _handleRect.anchoredPosition;
position.y = y;
_handleRect.anchoredPosition = position;
}
private void UpdateTrackVisibility()
{
if (_trackRect == null)
return;
if (_hideTrackWhenNotScrollable)
_trackRect.gameObject.SetActive(_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;
}
return found;
}
}
}