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(); 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; } } }