From 9c16b9e1553ab54f6582777b177be20b233fe2cf Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 24 Jun 2026 15:31:48 +0200 Subject: [PATCH] Make HUD panels draggable from any non-interactive area Drag a panel from anywhere on its surface (header, padding, labels, background) instead of only the header. Dragging is cancelled when the press starts on an interactable (Selectable toggle/slider or the color picker's UiPointerSurface), so widgets stay fully operable. Clamping is now anchor/pivot-agnostic, computed from the panel's real corners, so panels anchored bottom-center stay reachable in every screen corner. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/DraggablePanel.cs | 109 +++++++++++ Assets/Scripts/DraggablePanel.cs.meta | 2 + Assets/Scripts/PaintHud.cs | 270 +++----------------------- 3 files changed, 137 insertions(+), 244 deletions(-) create mode 100644 Assets/Scripts/DraggablePanel.cs create mode 100644 Assets/Scripts/DraggablePanel.cs.meta diff --git a/Assets/Scripts/DraggablePanel.cs b/Assets/Scripts/DraggablePanel.cs new file mode 100644 index 0000000..0fde83e --- /dev/null +++ b/Assets/Scripts/DraggablePanel.cs @@ -0,0 +1,109 @@ +using UnityEngine; +using UnityEngine.UI; +using UnityEngine.EventSystems; + +/// +/// Makes a UI panel draggable. Attach to the panel itself: it follows the pointer from any of the panel's +/// non-interactive surface (title bar, padding, labels, background) but ignores presses that begin on a +/// widget - a (toggle, slider, ...) or a (the +/// colour-picker field) - so those keep their own click/drag behaviour. It keeps a slice of the panel on +/// screen and raises it above its siblings while interacting. +/// +/// Works under any canvas scale (it tracks the pointer in the drag area's local space and applies the +/// movement delta) and under any anchor/pivot (the on-screen clamp is derived from the panel's real +/// corners in its parent, not from a fixed top-left assumption). +/// +[RequireComponent(typeof(RectTransform))] +public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler +{ + [Tooltip("Panel moved by the drag. Empty = this object's RectTransform.")] + public RectTransform target; + + [Tooltip("Panel pixels (canvas units) kept on screen on every edge so it can never be lost.")] + public float keepVisible = 48f; + + private RectTransform _area; // the panel's parent; pointer positions are tracked in its local space + private Vector2 _grabOffset; + private bool _dragging; + + private void Awake() => Resolve(); + + private void Resolve() + { + if (!target) target = transform as RectTransform; + if (target) _area = target.parent as RectTransform; + } + + public void OnPointerDown(PointerEventData eventData) + { + // Raise the panel when pressing its own surface. (A press that lands on a widget is consumed by + // that widget's own handler and never reaches us, so this rarely needs the guard - but it is cheap.) + if (!StartedOnWidget(eventData)) BringToFront(); + } + + public void OnBeginDrag(PointerEventData eventData) + { + _dragging = false; + if (!target) Resolve(); + if (!target || !_area) return; + if (StartedOnWidget(eventData)) return; // a toggle/slider/picker owns this gesture, not the panel + + _dragging = true; + BringToFront(); + // Capture grab offset; the constant frame difference between the area pivot and the panel anchor + // cancels out because we only ever apply the pointer delta on top of this offset. + if (RectTransformUtility.ScreenPointToLocalPointInRectangle( + _area, eventData.position, eventData.pressEventCamera, out var local)) + _grabOffset = target.anchoredPosition - local; + } + + public void OnDrag(PointerEventData eventData) + { + if (!_dragging || !target || !_area) return; + if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( + _area, eventData.position, eventData.pressEventCamera, out var local)) + return; + target.anchoredPosition = Clamp(local + _grabOffset); + } + + // True when the press started on an interactive widget inside the panel (which then owns the gesture). + private bool StartedOnWidget(PointerEventData eventData) + { + var hit = eventData.pointerPressRaycast.gameObject; + for (var t = hit ? hit.transform : null; t && t != target; t = t.parent) + if (t.GetComponent() || t.GetComponent()) + return true; + return false; + } + + private void BringToFront() + { + if (target) target.SetAsLastSibling(); + } + + // Anchor/pivot-agnostic clamp: keep at least `keepVisible` px of the panel inside the parent on every + // edge, computed from the panel's real corners so it is correct wherever the panel is anchored. + private Vector2 Clamp(Vector2 pos) + { + if (!_area) return pos; + Rect area = _area.rect; + Rect size = target.rect; + Vector2 pivot = target.pivot; + + // Anchor reference point in the area's local space (panels are point-anchored: anchorMin == anchorMax). + Vector2 anchorRef = new Vector2( + area.xMin + target.anchorMin.x * area.width, + area.yMin + target.anchorMin.y * area.height); + Vector2 pivotToCorner = new Vector2(pivot.x * size.width, pivot.y * size.height); + + // Bottom-left corner of the panel for this anchoredPosition. + Vector2 corner = anchorRef + pos - pivotToCorner; + + float keepX = Mathf.Min(keepVisible, size.width); + float keepY = Mathf.Min(keepVisible, size.height); + corner.x = Mathf.Clamp(corner.x, area.xMin + keepX - size.width, area.xMax - keepX); + corner.y = Mathf.Clamp(corner.y, area.yMin + keepY - size.height, area.yMax - keepY); + + return corner + pivotToCorner - anchorRef; + } +} diff --git a/Assets/Scripts/DraggablePanel.cs.meta b/Assets/Scripts/DraggablePanel.cs.meta new file mode 100644 index 0000000..77094ba --- /dev/null +++ b/Assets/Scripts/DraggablePanel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 14b82ffde5db5c547b5f454ac2791815 \ No newline at end of file diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs index 21a47f0..44f73d5 100644 --- a/Assets/Scripts/PaintHud.cs +++ b/Assets/Scripts/PaintHud.cs @@ -13,7 +13,7 @@ using UnityEngine.InputSystem.UI; /// /// The layout is a transparent root holding one independently positioned sub-panel per channel /// (Albedo, Metallic+Smoothness, Occlusion, Emission) plus a global Brush card, each with a title -/// bar that doubles as a drag handle. The layout comes from one of two sources: +/// bar and draggable from any of its non-widget surface. The layout comes from one of two sources: /// - an authored prefab assigned to (instantiated, then bound), or /// - generated in code (the fallback when no prefab is set). /// Either way the *bindings* (listeners, picker gradient sprites, painter values) live in code @@ -55,6 +55,12 @@ public class PaintHud : MonoBehaviour private const string SliderEmissionIntensity = "Slider_EmissionIntensity"; private const string SliderBrushSize = "Slider_BrushSize"; + // Panels made draggable at runtime - grabbable from any non-widget surface (both prefab and code-gen paths). + private static readonly string[] DraggablePanels = + { + PanelAlbedo, PanelMetallicSmoothness, PanelOcclusion, PanelEmission, PanelBrushSize + }; + private static readonly Color PanelColor = new Color(0.09f, 0.09f, 0.11f, 0.9f); private static readonly Color HeaderColor = new Color(0.18f, 0.18f, 0.22f, 1f); private static readonly Color Accent = new Color(0.40f, 0.75f, 1f, 1f); @@ -94,10 +100,6 @@ public class PaintHud : MonoBehaviour go.name = layoutPrefab.name; // strip the "(Clone)" suffix _panel = go; } - else - { - _panel = BuildPanelStructure(transform); - } WirePanel(_panel.transform); @@ -117,19 +119,6 @@ public class PaintHud : MonoBehaviour return f; } -#if UNITY_EDITOR - /// - /// Editor-only: builds a fresh, unbound panel subtree (stable widget names, no listeners and - /// no runtime gradient sprites) for saving as a layout prefab. The caller reparents/saves the - /// returned object and destroys it. The wiring is applied at runtime by . - /// - public GameObject CreatePanelForBake() - { - _font = LoadFont(); - return BuildPanelStructure(transform); - } -#endif - // --- Canvas --- private void EnsureCanvas() @@ -143,7 +132,7 @@ public class PaintHud : MonoBehaviour if (!scaler) scaler = gameObject.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920f, 1080f); - scaler.matchWidthOrHeight = 0.5f; + scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand; if (!GetComponent()) gameObject.AddComponent(); } @@ -161,216 +150,6 @@ public class PaintHud : MonoBehaviour #endif } - // --- Structure (layout only; matches the baked prefab) --- - - private GameObject BuildPanelStructure(Transform parent) - { - // Root: a transparent, full-canvas container (no image -> never blocks raycasts). Each channel - // lives in its own independently positioned sub-panel, so panels can be placed freely in the - // prefab now and dragged individually at runtime in a later step. - var root = NewRect(RootName, parent); - root.anchorMin = Vector2.zero; - root.anchorMax = Vector2.one; - root.offsetMin = Vector2.zero; - root.offsetMax = Vector2.zero; - - const float w = 280f; - - // Albedo channel. - var albedo = NewSubPanel(root, PanelAlbedo, new Vector2(16f, -16f), w, "Albedo"); - AddToggleStructure(albedo, ToggleAlbedo, "Paint"); - AddColorPickerStructure(albedo, PickerAlbedo); - - // Metallic + Smoothness channel. - var ms = NewSubPanel(root, PanelMetallicSmoothness, new Vector2(16f, -262f), w, "Metallic + Smoothness"); - AddToggleStructure(ms, ToggleMetallicSmoothness, "Paint"); - AddSliderStructure(ms, SliderMetallic, "Metallic", 0f, 1f, false); - AddSliderStructure(ms, SliderSmoothness, "Smoothness", 0f, 1f, false); - - // Occlusion channel. - var occ = NewSubPanel(root, PanelOcclusion, new Vector2(16f, -452f), w, "Occlusion"); - AddToggleStructure(occ, ToggleOcclusion, "Paint"); - AddSliderStructure(occ, SliderOcclusion, "Occlusion", 0f, 1f, false); - - // Emission channel. - var emission = NewSubPanel(root, PanelEmission, new Vector2(312f, -16f), w, "Emission"); - AddToggleStructure(emission, ToggleEmission, "Paint"); - AddColorPickerStructure(emission, PickerEmission); - AddSliderStructure(emission, SliderEmissionIntensity, "Emission Intensity", 0f, 8f, false); - - // Brush size (global, not a paint channel). - var brush = NewSubPanel(root, PanelBrushSize, new Vector2(312f, -286f), w, "Brush"); - AddSliderStructure(brush, SliderBrushSize, "Brush Size", 1f, 256f, true); - - return root.gameObject; - } - - /// Builds one self-contained, independently positioned channel card with a title bar. - private RectTransform NewSubPanel(RectTransform root, string name, Vector2 pos, float width, string title) - { - var panel = NewRect(name, root); - panel.anchorMin = panel.anchorMax = new Vector2(0f, 1f); - panel.pivot = new Vector2(0f, 1f); - panel.anchoredPosition = pos; - panel.sizeDelta = new Vector2(width, 0f); // width fixed; height from the ContentSizeFitter - AddImage(panel.gameObject, PanelColor); - - var vlg = panel.gameObject.AddComponent(); - vlg.padding = new RectOffset(10, 10, 10, 10); - vlg.spacing = 8f; - vlg.childAlignment = TextAnchor.UpperLeft; - vlg.childControlWidth = true; - vlg.childControlHeight = true; - vlg.childForceExpandWidth = true; - vlg.childForceExpandHeight = false; - - var fitter = panel.gameObject.AddComponent(); - fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; - - AddPanelHeader(panel, title); - return panel; - } - - private void AddPanelHeader(RectTransform panel, string title) - { - // Title bar. Its image is a raycast target so it can double as the drag handle in a later step. - var rt = NewRect("Header", panel); - var le = rt.gameObject.AddComponent(); - le.minHeight = le.preferredHeight = 24f; - AddImage(rt.gameObject, HeaderColor); - - var lbl = NewRect("Label", rt); - lbl.anchorMin = Vector2.zero; - lbl.anchorMax = Vector2.one; - lbl.offsetMin = new Vector2(8f, 0f); - lbl.offsetMax = new Vector2(-8f, 0f); - var t = MakeText(lbl.gameObject, title, 13, FontStyle.Bold, new Color(0.9f, 0.9f, 0.95f, 1f), TextAnchor.MiddleLeft); - t.raycastTarget = false; - } - - private void AddToggleStructure(RectTransform panel, string name, string label) - { - var rt = NewRect(name, panel); - var le = rt.gameObject.AddComponent(); - le.minHeight = le.preferredHeight = 22f; - - var toggle = rt.gameObject.AddComponent(); - - var box = NewRect("Box", rt); - box.anchorMin = box.anchorMax = new Vector2(0f, 0.5f); - box.pivot = new Vector2(0f, 0.5f); - box.anchoredPosition = Vector2.zero; - box.sizeDelta = new Vector2(18f, 18f); - var boxImg = AddImage(box.gameObject, BoxColor); - - var check = NewRect("Check", box); - check.anchorMin = Vector2.zero; - check.anchorMax = Vector2.one; - check.offsetMin = new Vector2(3f, 3f); - check.offsetMax = new Vector2(-3f, -3f); - var checkImg = AddImage(check.gameObject, Accent); - checkImg.raycastTarget = false; - - var lbl = NewRect("Label", rt); - lbl.anchorMin = Vector2.zero; - lbl.anchorMax = Vector2.one; - lbl.offsetMin = new Vector2(24f, 0f); - lbl.offsetMax = Vector2.zero; - var t = MakeText(lbl.gameObject, label, 14, FontStyle.Normal, Color.white, TextAnchor.MiddleLeft); - t.raycastTarget = false; - - toggle.targetGraphic = boxImg; - toggle.graphic = checkImg; - } - - private void AddSliderStructure(RectTransform panel, string name, string label, float min, float max, bool whole) - { - var rt = NewRect(name, panel); - var le = rt.gameObject.AddComponent(); - le.minHeight = le.preferredHeight = 34f; - - // Caption row (label left, value right), pinned to the top of the cell. - var lbl = NewRect("Label", rt); - lbl.anchorMin = new Vector2(0f, 1f); - lbl.anchorMax = new Vector2(1f, 1f); - lbl.pivot = new Vector2(0f, 1f); - lbl.offsetMin = new Vector2(0f, -16f); - lbl.offsetMax = new Vector2(-48f, 0f); - var lt = MakeText(lbl.gameObject, label, 13, FontStyle.Normal, new Color(0.8f, 0.8f, 0.85f, 1f), TextAnchor.MiddleLeft); - lt.raycastTarget = false; - - var valRt = NewRect("Value", rt); - valRt.anchorMin = new Vector2(1f, 1f); - valRt.anchorMax = new Vector2(1f, 1f); - valRt.pivot = new Vector2(1f, 1f); - valRt.anchoredPosition = Vector2.zero; - valRt.sizeDelta = new Vector2(56f, 16f); - var vt = MakeText(valRt.gameObject, "", 13, FontStyle.Normal, Color.white, TextAnchor.MiddleRight); - vt.raycastTarget = false; - - // Track, pinned to the bottom of the cell. - var track = NewRect("Track", rt); - track.anchorMin = new Vector2(0f, 0f); - track.anchorMax = new Vector2(1f, 0f); - track.pivot = new Vector2(0.5f, 0f); - track.anchoredPosition = Vector2.zero; - track.sizeDelta = new Vector2(0f, 16f); - var slider = track.gameObject.AddComponent(); - - var bg = NewRect("Background", track); - bg.anchorMin = new Vector2(0f, 0.5f); - bg.anchorMax = new Vector2(1f, 0.5f); - bg.pivot = new Vector2(0.5f, 0.5f); - bg.anchoredPosition = Vector2.zero; - bg.sizeDelta = new Vector2(0f, 5f); - AddImage(bg.gameObject, BoxColor); // raycast target so clicking the bar jumps the value - - var fillArea = NewRect("Fill Area", track); - fillArea.anchorMin = new Vector2(0f, 0.5f); - fillArea.anchorMax = new Vector2(1f, 0.5f); - fillArea.pivot = new Vector2(0.5f, 0.5f); - fillArea.anchoredPosition = Vector2.zero; - fillArea.sizeDelta = new Vector2(-16f, 5f); - var fill = NewRect("Fill", fillArea); - fill.anchorMin = Vector2.zero; - fill.anchorMax = Vector2.one; - fill.offsetMin = Vector2.zero; - fill.offsetMax = Vector2.zero; - var fillImg = AddImage(fill.gameObject, Accent); - fillImg.raycastTarget = false; - - var handleArea = NewRect("Handle Slide Area", track); - handleArea.anchorMin = Vector2.zero; - handleArea.anchorMax = Vector2.one; - handleArea.offsetMin = new Vector2(8f, 0f); - handleArea.offsetMax = new Vector2(-8f, 0f); - var handle = NewRect("Handle", handleArea); - handle.sizeDelta = new Vector2(14f, 0f); - var handleImg = AddImage(handle.gameObject, Color.white); - - slider.fillRect = fill; - slider.handleRect = handle; - slider.targetGraphic = handleImg; - slider.direction = Slider.Direction.LeftToRight; - slider.minValue = min; - slider.maxValue = max; - slider.wholeNumbers = whole; // serialized: the binder reads it back for value formatting - } - - private HsvColorPicker AddColorPickerStructure(RectTransform panel, string name) - { - const float square = 120f; - const float hueH = 14f; - - var rt = NewRect(name, panel); - var le = rt.gameObject.AddComponent(); - le.minHeight = le.preferredHeight = square + 6f + hueH; - - var picker = rt.gameObject.AddComponent(); - picker.Build(square, hueH); // structure only; sprites + hooks are applied by Rebind in WirePanel - return picker; - } - // --- Wiring (behaviour; shared by the code-gen and prefab paths) --- private void WirePanel(Transform panel) @@ -394,6 +173,23 @@ public class PaintHud : MonoBehaviour WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; }); WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); }); + + foreach (var name in DraggablePanels) + MakeDraggable(panel, name); + } + + /// + /// Attaches (idempotently) a to the whole panel so it can be dragged + /// from any of its non-interactive surface (header, padding, labels, background); the component + /// ignores presses that start on a widget (toggle, slider, colour picker). + /// + private static void MakeDraggable(Transform root, string panelName) + { + var panel = FindDeep(root, panelName) as RectTransform; + if (!panel) return; + var drag = panel.GetComponent(); + if (!drag) drag = panel.gameObject.AddComponent(); + drag.target = panel; } private void EnsureFonts(Transform panel) @@ -417,7 +213,7 @@ public class PaintHud : MonoBehaviour var slider = cell.GetComponentInChildren(); if (!slider) return; var valueRt = cell.Find("Value"); - var valueText = valueRt ? valueRt.GetComponent() : null; + var valueText = valueRt ? valueRt.GetComponent() : null; bool whole = slider.wholeNumbers; slider.value = value; @@ -457,20 +253,6 @@ public class PaintHud : MonoBehaviour return null; } - private Text MakeText(GameObject go, string text, int size, FontStyle style, Color color, TextAnchor anchor) - { - var t = go.AddComponent(); - t.font = _font; - t.fontSize = size; - t.fontStyle = style; - t.color = color; - t.alignment = anchor; - t.text = text; - t.horizontalOverflow = HorizontalWrapMode.Overflow; - t.verticalOverflow = VerticalWrapMode.Truncate; - return t; - } - // --- Static UI factory (shared with HsvColorPicker) --- public static RectTransform NewRect(string name, Transform parent)