Files
chameleon/Assets/Scripts/DraggablePanel.cs
T
adminandClaude Opus 4.8 f22ca4765f Persist HUD layout on drag-end instead of only on teardown
HUD panel positions and brush values were written only from
OnApplicationQuit/OnDestroy, which is not guaranteed in a build (Alt-F4,
process kill, crash), so layout work was lost. DraggablePanel now exposes
an IEndDragHandler callback that saves the layout the moment a panel is
dropped. Brush-value changes are detected against a disk baseline in
LateUpdate and flushed after a 0.5s debounce so per-frame slider drags
do not write every frame. The teardown save path is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:56:02 +02:00

121 lines
5.2 KiB
C#

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
/// <summary>
/// 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 <see cref="Selectable"/> (toggle, slider, ...) or a <see cref="UiPointerSurface"/> (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).
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[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;
// Raised once when a real panel drag ends (not for gestures that began on a widget). The HUD uses
// this to persist the layout the moment a panel is dropped. Optional - left null by other users.
public System.Action Moved;
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);
}
public void OnEndDrag(PointerEventData eventData)
{
if (!_dragging) return; // gesture began on a widget -> not our drag, nothing moved
_dragging = false;
Moved?.Invoke();
}
// 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<Selectable>() || t.GetComponent<UiPointerSurface>())
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;
}
}