From fb78769170e6e1be179b7585463d390ab38ada3b Mon Sep 17 00:00:00 2001 From: lrc <571244399@qq.com> Date: Sun, 26 Jul 2026 10:43:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=AA=97=E5=8F=A3=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Editor/Form.meta | 8 + Editor/Form/UIFormControllerEditor.cs | 145 ++++ Editor/Form/UIFormControllerEditor.cs.meta | 11 + Editor/Form/UITitleBarEditor.cs | 36 + Editor/Form/UITitleBarEditor.cs.meta | 11 + Runtime/Form/Controllers.meta | 8 + Runtime/Form/Controllers/AutoHideDirection.cs | 14 + .../Controllers/AutoHideDirection.cs.meta | 11 + Runtime/Form/Controllers/IUIFormTarget.cs | 39 ++ .../Form/Controllers/IUIFormTarget.cs.meta | 11 + Runtime/Form/Controllers/UIFormController.cs | 647 ++++++++++++++++++ .../Form/Controllers/UIFormController.cs.meta | 11 + Runtime/Form/Controllers/UITitleBar.cs | 131 ++++ Runtime/Form/Controllers/UITitleBar.cs.meta | 11 + Runtime/Form/Controllers/WindowState.cs | 20 + Runtime/Form/Controllers/WindowState.cs.meta | 11 + Runtime/Form/UIFormManager.cs | 290 ++++++++ Runtime/Form/UIFormManager.cs.meta | 11 + 18 files changed, 1426 insertions(+) create mode 100644 Editor/Form.meta create mode 100644 Editor/Form/UIFormControllerEditor.cs create mode 100644 Editor/Form/UIFormControllerEditor.cs.meta create mode 100644 Editor/Form/UITitleBarEditor.cs create mode 100644 Editor/Form/UITitleBarEditor.cs.meta create mode 100644 Runtime/Form/Controllers.meta create mode 100644 Runtime/Form/Controllers/AutoHideDirection.cs create mode 100644 Runtime/Form/Controllers/AutoHideDirection.cs.meta create mode 100644 Runtime/Form/Controllers/IUIFormTarget.cs create mode 100644 Runtime/Form/Controllers/IUIFormTarget.cs.meta create mode 100644 Runtime/Form/Controllers/UIFormController.cs create mode 100644 Runtime/Form/Controllers/UIFormController.cs.meta create mode 100644 Runtime/Form/Controllers/UITitleBar.cs create mode 100644 Runtime/Form/Controllers/UITitleBar.cs.meta create mode 100644 Runtime/Form/Controllers/WindowState.cs create mode 100644 Runtime/Form/Controllers/WindowState.cs.meta create mode 100644 Runtime/Form/UIFormManager.cs create mode 100644 Runtime/Form/UIFormManager.cs.meta diff --git a/Editor/Form.meta b/Editor/Form.meta new file mode 100644 index 0000000..7366297 --- /dev/null +++ b/Editor/Form.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2f18f75958efeb8428a4689e959e4350 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Form/UIFormControllerEditor.cs b/Editor/Form/UIFormControllerEditor.cs new file mode 100644 index 0000000..fb99b95 --- /dev/null +++ b/Editor/Form/UIFormControllerEditor.cs @@ -0,0 +1,145 @@ +using UnityEditor; +using UnityEngine; + +namespace XericUIEditor.Form +{ + [CustomEditor(typeof(XericUI.Form.UIFormController))] + public class UIFormControllerEditor : Editor + { + private XericUI.Form.UIFormController _controller; + + private void OnEnable() + { + _controller = (XericUI.Form.UIFormController)target; + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + DrawBasicSection(); + DrawEdgeSection(); + DrawDebugSection(); + + serializedObject.ApplyModifiedProperties(); + } + + #region 基本设置 + + private void DrawBasicSection() + { + EditorGUILayout.LabelField("窗口基本设置", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_WindowTitle"), new GUIContent("窗口标题 (Title)")); + EditorGUILayout.Space(); + } + + #endregion + + #region 边缘行为 + + private void DrawEdgeSection() + { + EditorGUILayout.LabelField("边缘吸附", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_EnableAutoSnap"), new GUIContent("启用边缘吸附 (Auto Snap)")); + if (_controller.AutoSnapEnabled) + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_SnapThreshold"), new GUIContent("吸附阈值 (Snap Threshold, px)")); + } + + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("自动隐藏", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_EnableAutoHide"), new GUIContent("启用自动隐藏 (Auto Hide)")); + if (_controller.AutoHideEnabled) + { + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_AutoHideDirection"), new GUIContent("隐藏方向 (Direction)")); + } + + EditorGUILayout.Space(); + } + + #endregion + + #region 调试与测试 + + private void DrawDebugSection() + { + EditorGUILayout.LabelField("调试", EditorStyles.boldLabel); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_DebugMode"), new GUIContent("调试模式 (Debug Mode)")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_DebugLogEvents"), new GUIContent("事件日志 (Log Events)")); + + EditorGUILayout.Space(); + + // 当前状态 + EditorGUILayout.LabelField("当前状态", EditorStyles.boldLabel); + + var state = _controller.CurrentState; + Color stateColor; + string stateText; + + switch (state) + { + case XericUI.Form.WindowState.Normal: + stateColor = Color.white; + stateText = "● 窗口化 (Normal)"; + break; + case XericUI.Form.WindowState.Maximized: + stateColor = Color.green; + stateText = "■ 全屏 (Maximized)"; + break; + case XericUI.Form.WindowState.Minimized: + stateColor = Color.gray; + stateText = "○ 最小化 (Minimized)"; + break; + case XericUI.Form.WindowState.EdgeHidden: + stateColor = new Color(1f, 0.6f, 0f); + stateText = "◄ 边缘隐藏 (Edge Hidden)"; + break; + default: + stateColor = Color.white; + stateText = state.ToString(); + break; + } + + GUI.color = stateColor; + EditorGUILayout.LabelField(stateText, EditorStyles.miniBoldLabel); + GUI.color = Color.white; + + EditorGUILayout.Space(); + + // 手动测试按钮 + EditorGUILayout.LabelField("手动测试", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("全屏/还原")) + { + _controller.ToggleMaximize(); + SceneView.RepaintAll(); + } + if (GUILayout.Button("模拟进入")) + { + _controller.SimulateEnter(); + SceneView.RepaintAll(); + } + if (GUILayout.Button("模拟退出")) + { + _controller.SimulateExit(); + SceneView.RepaintAll(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 调试提示 + if (!Application.isPlaying && _controller.DebugMode) + { + EditorGUILayout.HelpBox( + "调试模式已启用 — 在 SceneView 中移动鼠标即可预览。\n" + + "蓝色边框 = 窗口范围 | 绿色 = 鼠标悬停中 | 橙色 = 吸附阈值范围", + MessageType.Info); + } + } + + #endregion + } +} diff --git a/Editor/Form/UIFormControllerEditor.cs.meta b/Editor/Form/UIFormControllerEditor.cs.meta new file mode 100644 index 0000000..9488ea3 --- /dev/null +++ b/Editor/Form/UIFormControllerEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f105e0b75b54e142b3844d8f657e32a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Form/UITitleBarEditor.cs b/Editor/Form/UITitleBarEditor.cs new file mode 100644 index 0000000..e458dd0 --- /dev/null +++ b/Editor/Form/UITitleBarEditor.cs @@ -0,0 +1,36 @@ +using UnityEditor; +using UnityEngine; + +namespace XericUIEditor.Form +{ + [CustomEditor(typeof(XericUI.Form.UITitleBar))] + public class UITitleBarEditor : Editor + { + private XericUI.Form.UITitleBar _titleBar; + + private void OnEnable() + { + _titleBar = (XericUI.Form.UITitleBar)target; + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.LabelField("标题栏控件引用", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "将标题栏下的子控件拖入下方槽位即可。\n" + + "UITitleBar 会在 Start 时自动向上查找父级的 UIFormController 并绑定事件。", + MessageType.Info); + + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_TitleText"), new GUIContent("标题文本 (Title Text)")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_CloseButton"), new GUIContent("关闭按钮 (Close)")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_MaximizeButton"), new GUIContent("最大化按钮 (Maximize)")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_MinimizeButton"), new GUIContent("最小化按钮 (Minimize)")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_AutoSnapToggle"), new GUIContent("吸附开关 (Auto Snap)")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("m_AutoHideToggle"), new GUIContent("隐藏开关 (Auto Hide)")); + + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Editor/Form/UITitleBarEditor.cs.meta b/Editor/Form/UITitleBarEditor.cs.meta new file mode 100644 index 0000000..abe633b --- /dev/null +++ b/Editor/Form/UITitleBarEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c45b2d7be6212e479b5cf00a0369596 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Form/Controllers.meta b/Runtime/Form/Controllers.meta new file mode 100644 index 0000000..5b0f65a --- /dev/null +++ b/Runtime/Form/Controllers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d1e580c430b3308419647d63c73b21c2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Form/Controllers/AutoHideDirection.cs b/Runtime/Form/Controllers/AutoHideDirection.cs new file mode 100644 index 0000000..c3dab5c --- /dev/null +++ b/Runtime/Form/Controllers/AutoHideDirection.cs @@ -0,0 +1,14 @@ +namespace XericUI.Form +{ + /// + /// 自动隐藏滑动方向。 + /// 窗口贴边后可沿此方向滑出屏幕,鼠标进入对应边缘区域后滑回。 + /// + public enum AutoHideDirection + { + Left, + Right, + Up, + Down + } +} diff --git a/Runtime/Form/Controllers/AutoHideDirection.cs.meta b/Runtime/Form/Controllers/AutoHideDirection.cs.meta new file mode 100644 index 0000000..a26cdbd --- /dev/null +++ b/Runtime/Form/Controllers/AutoHideDirection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6441e2d1b03e7a942a1c958f979e9d3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Form/Controllers/IUIFormTarget.cs b/Runtime/Form/Controllers/IUIFormTarget.cs new file mode 100644 index 0000000..5d50570 --- /dev/null +++ b/Runtime/Form/Controllers/IUIFormTarget.cs @@ -0,0 +1,39 @@ +using UnityEngine; + +namespace XericUI.Form +{ + /// + /// 窗体悬停目标接口。 + /// 实现此接口的组件可注册到 UIFormManager,由管理器统一检测鼠标悬停状态。 + /// + public interface IUIFormTarget + { + /// + /// 进入范围(屏幕空间 Rect,原点为左下角,单位为像素)。 + /// 鼠标进入此范围时触发 OnPointerEnter。 + /// + Rect GetEnterRect(); + + /// + /// 退出范围(屏幕空间 Rect,原点为左下角,单位为像素)。 + /// 应大于等于 EnterRect,鼠标离开此范围时触发 OnPointerExit,避免边界抖动。 + /// + Rect GetExitRect(); + + /// + /// 鼠标进入范围内时由 UIFormManager 调用。 + /// + void OnPointerEnter(); + + /// + /// 鼠标离开退出范围时由 UIFormManager 调用。 + /// + void OnPointerExit(); + + /// + /// 此目标是否处于激活状态。 + /// 返回 false 时管理器跳过此目标的检测。 + /// + bool IsActive { get; } + } +} diff --git a/Runtime/Form/Controllers/IUIFormTarget.cs.meta b/Runtime/Form/Controllers/IUIFormTarget.cs.meta new file mode 100644 index 0000000..94bc4cc --- /dev/null +++ b/Runtime/Form/Controllers/IUIFormTarget.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 756fa65ea23a49d4ea270f1697aaf89f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Form/Controllers/UIFormController.cs b/Runtime/Form/Controllers/UIFormController.cs new file mode 100644 index 0000000..7ba8998 --- /dev/null +++ b/Runtime/Form/Controllers/UIFormController.cs @@ -0,0 +1,647 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace XericUI.Form +{ + #region 枚举与结构体 + + /// 范围坐标空间枚举 + public enum FormRangeSpace + { + RelativeOffset, + CanvasLocal, + AnchorToSelf, + AnchorToCanvas + } + + [System.Serializable] + public struct RangeRect + { + [Tooltip("坐标空间")] + public FormRangeSpace space; + + [Tooltip("范围定义。\nRelativeOffset: x,y=像素偏移; w,h=像素宽高(0=用参考尺寸)\nCanvasLocal: Canvas局部坐标\nAnchorToSelf: x,y=锚点0-1; w,h=尺寸比例0-1\nAnchorToCanvas: x,y=锚点0-1; w,h=尺寸比例0-1")] + public Rect rect; + } + + #endregion + + /// + /// UI 窗体控制器 — 挂载到窗口根 GameObject 上,自身即为完整窗口。 + /// 支持全屏/还原、边缘吸附、自动隐藏、拖拽移动。 + /// 标题栏功能由独立的 UITitleBar 子组件提供,Controller 只提供公开 API。 + /// + [AddComponentMenu("Xeric UI Vessel/Form/UI Form Controller")] + [RequireComponent(typeof(RectTransform))] + public class UIFormController : MonoBehaviour, IUIFormTarget, IBeginDragHandler, IDragHandler, IEndDragHandler + { + #region 序列化字段 + + [Header("窗口基本设置")] + [SerializeField, Tooltip("窗口标题,供外部读取")] + private string m_WindowTitle = "Window"; + + [Header("边缘吸附")] + [SerializeField, Tooltip("是否启用边缘自动吸附")] + private bool m_EnableAutoSnap = true; + + [SerializeField, Tooltip("吸附阈值(像素),窗口距边缘小于此值时自动贴边")] + private float m_SnapThreshold = 8f; + + [Header("自动隐藏")] + [SerializeField, Tooltip("是否启用自动隐藏(贴边后滑出屏幕)")] + private bool m_EnableAutoHide; + + [SerializeField, Tooltip("自动隐藏的滑动方向")] + private AutoHideDirection m_AutoHideDirection = AutoHideDirection.Left; + + [Header("调试")] + [SerializeField, Tooltip("编辑器调试模式")] + private bool m_DebugMode = true; + + [SerializeField, Tooltip("输出事件日志")] + private bool m_DebugLogEvents; + + #endregion + + #region 公开属性 + + public string WindowTitle => m_WindowTitle; + + /// 是否处于全屏状态 + public bool IsMaximized => _windowState == WindowState.Maximized; + + /// 是否处于边缘隐藏状态 + public bool IsEdgeHidden => _windowState == WindowState.EdgeHidden; + + /// 当前窗口状态 + public WindowState CurrentState => _windowState; + + /// 自动吸附是否开启 + public bool AutoSnapEnabled => m_EnableAutoSnap; + + /// 自动隐藏是否开启 + public bool AutoHideEnabled => m_EnableAutoHide; + + /// 自动隐藏方向 + public AutoHideDirection HideDirection => m_AutoHideDirection; + + /// 编辑器调试模式 + public bool DebugMode => m_DebugMode; + + #endregion + + #region 内部状态 + + private RectTransform _rectTransform; + private Canvas _canvas; + private RectTransform _canvasRt; + + private WindowState _windowState = WindowState.Normal; + + // 拖拽 + private Vector2 _dragOffset; + + // 全屏还原数据 + private Vector2 _savePos; + private Vector2 _saveSize; + private Vector2 _saveAnchorMin; + private Vector2 _saveAnchorMax; + + // 边缘隐藏 + private Vector2 _edgeVisiblePos; + private Vector2 _edgeHiddenPos; + private Coroutine _autoHideCoroutine; + + // 悬停 + private bool _isHovering; + + #endregion + + #region 生命周期 + + protected virtual void Awake() + { + _rectTransform = (RectTransform)transform; + _canvas = GetComponentInParent(); + _canvasRt = _canvas != null ? (RectTransform)_canvas.transform : null; + } + + protected virtual void OnEnable() + { + if (UIFormManager.Main != null) + UIFormManager.Main.RegisterWindow(this); + } + + protected virtual void OnDisable() + { + StopAutoHideMonitor(); + + if (_windowState == WindowState.EdgeHidden && UIFormManager.Main != null) + UIFormManager.Main.UnregisterEdgeHidden(this); + + if (UIFormManager.Main != null) + { + UIFormManager.Main.UnregisterWindow(this); + UIFormManager.Main.UnregisterEdgeHidden(this); + } + + UnsubscribeEditorUpdate(); + _isHovering = false; + } + + #endregion + + #region IUIFormTarget 实现 + + public bool IsActive => isActiveAndEnabled; + + public Rect GetEnterRect() => GetWindowScreenRect(); + public Rect GetExitRect() => GetWindowScreenRect(); + + public void OnPointerEnter() + { + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 鼠标进入窗口范围: {name}", this); + } + + public void OnPointerExit() + { + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 鼠标离开窗口范围: {name}", this); + } + + #endregion + + #region === 公开 API(供 UITitleBar 及外部调用) === + + /// 全屏/还原切换 + public void ToggleMaximize() + { + if (IsMaximized) + RestoreFromFullscreen(); + else + MaximizeToFullscreen(); + } + + /// 最小化窗口 + public void Minimize() + { + if (_windowState == WindowState.Minimized) return; + if (UIFormManager.Main == null) return; + + UIFormManager.Main.MinimizeWindow(this); + } + + /// 关闭窗口(Destroy GameObject) + public void Close() + { + if (UIFormManager.Main != null) + UIFormManager.Main.CloseWindow(this); + + if (Application.isPlaying) + Destroy(gameObject); + else + DestroyImmediate(gameObject); + } + + /// 设置边缘吸附开关 + public void SetAutoSnapEnabled(bool enabled) + { + m_EnableAutoSnap = enabled; + } + + /// 设置自动隐藏开关 + public void SetAutoHideEnabled(bool enabled) + { + m_EnableAutoHide = enabled; + + if (!enabled && _windowState == WindowState.EdgeHidden) + { + // 关闭自动隐藏 → 还原窗口 + SlideInFromEdge(); + } + } + + /// 设置自动隐藏方向 + public void SetAutoHideDirection(AutoHideDirection direction) + { + m_AutoHideDirection = direction; + } + + #endregion + + #region === 全屏/还原 === + + private void MaximizeToFullscreen() + { + if (_windowState == WindowState.Maximized) return; + + _savePos = _rectTransform.anchoredPosition; + _saveSize = _rectTransform.sizeDelta; + _saveAnchorMin = _rectTransform.anchorMin; + _saveAnchorMax = _rectTransform.anchorMax; + + _rectTransform.anchorMin = Vector2.zero; + _rectTransform.anchorMax = Vector2.one; + _rectTransform.offsetMin = Vector2.zero; + _rectTransform.offsetMax = Vector2.zero; + + _windowState = WindowState.Maximized; + + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 窗口全屏: {name}", this); + } + + private void RestoreFromFullscreen() + { + if (_windowState != WindowState.Maximized) return; + + _rectTransform.anchorMin = _saveAnchorMin; + _rectTransform.anchorMax = _saveAnchorMax; + _rectTransform.anchoredPosition = _savePos; + _rectTransform.sizeDelta = _saveSize; + + _windowState = WindowState.Normal; + + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 窗口还原: {name}", this); + } + + #endregion + + #region === 拖拽 === + + public void OnBeginDrag(PointerEventData eventData) + { + if (_windowState == WindowState.EdgeHidden || _windowState == WindowState.Minimized) + return; + + // 全屏拖拽 → 先还原 + if (IsMaximized) + { + RestoreFromFullscreen(); + // 将窗口移到鼠标附近 + _rectTransform.anchoredPosition = eventData.position; + } + + _dragOffset = _rectTransform.anchoredPosition - eventData.position; + } + + public void OnDrag(PointerEventData eventData) + { + if (_windowState == WindowState.EdgeHidden || _windowState == WindowState.Minimized) + return; + + _rectTransform.anchoredPosition = eventData.position + _dragOffset; + + if (m_EnableAutoSnap) + CheckEdgeSnap(); + } + + public void OnEndDrag(PointerEventData eventData) + { + if (_windowState == WindowState.EdgeHidden || _windowState == WindowState.Minimized) + return; + + // 拖拽结束 → 如果贴边且启用自动隐藏,则触发 + if (m_EnableAutoHide && IsSnappedToEdge()) + { + AutoHideToEdge(); + } + } + + #endregion + + #region === 边缘吸附 === + + private void CheckEdgeSnap() + { + if (_canvasRt == null) return; + + Vector2 canvasSize = _canvasRt.rect.size; + Vector2 pos = _rectTransform.anchoredPosition; + Vector2 size = _rectTransform.rect.size; + + bool snapped = false; + + // 左 + if (Mathf.Abs(pos.x) < m_SnapThreshold) + { + pos.x = 0; + snapped = true; + } + // 右 + else if (Mathf.Abs(pos.x + size.x - canvasSize.x) < m_SnapThreshold) + { + pos.x = canvasSize.x - size.x; + snapped = true; + } + + _rectTransform.anchoredPosition = pos; + } + + /// 检测窗口是否贴边 + private bool IsSnappedToEdge() + { + if (_canvasRt == null) return false; + + Vector2 canvasSize = _canvasRt.rect.size; + Vector2 pos = _rectTransform.anchoredPosition; + Vector2 size = _rectTransform.rect.size; + + return Mathf.Abs(pos.x) < 1f + || Mathf.Abs(pos.x + size.x - canvasSize.x) < 1f; + } + + /// 获取最近的边缘方向 + public AutoHideDirection GetClosestEdge() + { + if (_canvasRt == null) return AutoHideDirection.Left; + + Vector2 canvasSize = _canvasRt.rect.size; + Vector2 pos = _rectTransform.anchoredPosition; + Vector2 size = _rectTransform.rect.size; + + float distLeft = Mathf.Abs(pos.x); + float distRight = Mathf.Abs(pos.x + size.x - canvasSize.x); + + return distLeft < distRight ? AutoHideDirection.Left : AutoHideDirection.Right; + } + + #endregion + + #region === 自动隐藏 === + + private void AutoHideToEdge() + { + if (_windowState == WindowState.EdgeHidden) return; + if (_canvasRt == null) return; + + Vector2 canvasSize = _canvasRt.rect.size; + Vector2 pos = _rectTransform.anchoredPosition; + Vector2 size = _rectTransform.rect.size; + + _edgeVisiblePos = pos; + + AutoHideDirection dir = m_AutoHideDirection; + if (dir == AutoHideDirection.Left || dir == AutoHideDirection.Right) + dir = GetClosestEdge(); + + switch (dir) + { + case AutoHideDirection.Left: + _edgeHiddenPos = new Vector2(-size.x, pos.y); + break; + case AutoHideDirection.Right: + _edgeHiddenPos = new Vector2(canvasSize.x, pos.y); + break; + case AutoHideDirection.Down: + _edgeHiddenPos = new Vector2(pos.x, -size.y); + break; + case AutoHideDirection.Up: + _edgeHiddenPos = new Vector2(pos.x, canvasSize.y); + break; + } + + _rectTransform.anchoredPosition = _edgeHiddenPos; + _windowState = WindowState.EdgeHidden; + + if (UIFormManager.Main != null) + UIFormManager.Main.RegisterEdgeHidden(this, dir); + + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 自动隐藏 (方向={dir}): {name}", this); + } + + /// 由 Manager 调用 — 从边缘滑出 + public void SlideInFromEdge() + { + if (_windowState != WindowState.EdgeHidden) return; + + StopAutoHideMonitor(); + _rectTransform.anchoredPosition = _edgeVisiblePos; + _windowState = WindowState.Normal; + + if (UIFormManager.Main != null) + UIFormManager.Main.UnregisterEdgeHidden(this); + + // 开始监控鼠标是否离开窗口 + _autoHideCoroutine = StartCoroutine(MonitorMouseExit()); + + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 从边缘滑出: {name}", this); + } + + /// 监控鼠标是否离开窗口范围,离开则自动隐藏 + private IEnumerator MonitorMouseExit() + { + while (_windowState == WindowState.Normal && m_EnableAutoHide) + { + yield return null; + + Rect screenRect = GetWindowScreenRect(); + if (!screenRect.Contains(Input.mousePosition)) + { + AutoHideToEdge(); + yield break; + } + } + } + + private void StopAutoHideMonitor() + { + if (_autoHideCoroutine != null) + { + StopCoroutine(_autoHideCoroutine); + _autoHideCoroutine = null; + } + } + + #endregion + + #region === 窗口屏幕范围 === + + /// 获取窗口自身在屏幕空间中的 Rect + public Rect GetWindowScreenRect() + { + if (_rectTransform == null || _canvas == null) return Rect.zero; + + Vector3[] corners = new Vector3[4]; + _rectTransform.GetWorldCorners(corners); + + Camera cam = _canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : _canvas.worldCamera; + + Vector2 min = Vector2.positiveInfinity; + Vector2 max = Vector2.negativeInfinity; + + for (int i = 0; i < 4; i++) + { + Vector2 sp = cam != null + ? RectTransformUtility.WorldToScreenPoint(cam, corners[i]) + : (Vector2)corners[i]; + + min = Vector2.Min(min, sp); + max = Vector2.Max(max, sp); + } + + return new Rect(min.x, min.y, max.x - min.x, max.y - min.y); + } + + #endregion + + #region === 悬停检测(供 Manager 调用) === + + public bool CheckHover(Vector2 screenPoint) + { + Rect windowRect = GetWindowScreenRect(); + + if (!_isHovering) + { + if (windowRect.Contains(screenPoint)) + { + _isHovering = true; + OnPointerEnter(); + } + } + else + { + if (!windowRect.Contains(screenPoint)) + { + _isHovering = false; + OnPointerExit(); + } + } + + return _isHovering; + } + + public void SimulateEnter() + { + _isHovering = true; + + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 模拟进入: {name}", this); + } + + public void SimulateExit() + { + _isHovering = false; + + if (m_DebugLogEvents) + Debug.Log($"[UIFormController] 模拟退出: {name}", this); + } + + #endregion + + #region 编辑器调试 + +#if UNITY_EDITOR + + [System.NonSerialized] private bool _editorDebugSubscribed; + [System.NonSerialized] private Vector2 _editorMouseScreenPos; + + private void SubscribeEditorUpdate() + { + if (_editorDebugSubscribed) return; + UnityEditor.SceneView.duringSceneGui += OnSceneGUI; + _editorDebugSubscribed = true; + } + + private void UnsubscribeEditorUpdate() + { + if (!_editorDebugSubscribed) return; + UnityEditor.SceneView.duringSceneGui -= OnSceneGUI; + _editorDebugSubscribed = false; + } + + protected virtual void OnValidate() + { + if (m_DebugMode) + SubscribeEditorUpdate(); + else + UnsubscribeEditorUpdate(); + } + + /// + /// SceneView 重绘回调 — 在 GUI 上下文中获取鼠标屏幕坐标并执行悬停检测。 + /// 使用 GUIUtility.GUIToScreenPoint 将 SceneView 内的 GUI 坐标转为 Unity 屏幕坐标。 + /// + private void OnSceneGUI(UnityEditor.SceneView sceneView) + { + if (this == null) + { + UnsubscribeEditorUpdate(); + return; + } + + if (Application.isPlaying) return; + if (!m_DebugMode) return; + + // Event.current.mousePosition 是相对于 SceneView 窗口的 GUI 坐标 + // GUIUtility.GUIToScreenPoint 将其转换为屏幕坐标(左下角为原点) + Vector2 guiPos = Event.current.mousePosition; + _editorMouseScreenPos = GUIUtility.GUIToScreenPoint(guiPos); + + CheckHover(_editorMouseScreenPos); + } + + protected virtual void OnDrawGizmosSelected() + { + if (!m_DebugMode) return; + if (_canvas == null) return; + if (_canvasRt == null) return; + + Rect screenRect = GetWindowScreenRect(); + if (screenRect.width <= 0 || screenRect.height <= 0) return; + + // 悬停时高亮边框 + Color borderColor = _isHovering + ? new Color(0f, 1f, 0f, 0.6f) + : new Color(0f, 0.7f, 1f, 0.3f); + Gizmos.color = borderColor; + + DrawScreenRectOnCanvas(screenRect, borderColor, _canvas, _canvasRt); + + // 边缘吸附阈值可视化 + if (m_EnableAutoSnap) + { + Gizmos.color = new Color(1f, 0.6f, 0f, 0.15f); + DrawScreenRectOnCanvas( + new Rect(screenRect.x + m_SnapThreshold, screenRect.y, + screenRect.width - m_SnapThreshold * 2, screenRect.height), + Gizmos.color, _canvas, _canvasRt); + } + } + + private static void DrawScreenRectOnCanvas(Rect screenRect, Color color, Canvas canvas, RectTransform canvasRt) + { + if (screenRect.width <= 0 || screenRect.height <= 0) return; + + Gizmos.color = color; + Camera uiCam = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera; + + Vector3 p0 = ScreenToWorldOnCanvas(new Vector3(screenRect.xMin, screenRect.yMin), uiCam, canvasRt); + Vector3 p1 = ScreenToWorldOnCanvas(new Vector3(screenRect.xMax, screenRect.yMin), uiCam, canvasRt); + Vector3 p2 = ScreenToWorldOnCanvas(new Vector3(screenRect.xMax, screenRect.yMax), uiCam, canvasRt); + Vector3 p3 = ScreenToWorldOnCanvas(new Vector3(screenRect.xMin, screenRect.yMax), uiCam, canvasRt); + + Gizmos.DrawLine(p0, p1); + Gizmos.DrawLine(p1, p2); + Gizmos.DrawLine(p2, p3); + Gizmos.DrawLine(p3, p0); + } + + private static Vector3 ScreenToWorldOnCanvas(Vector3 screenPoint, Camera uiCamera, RectTransform canvasRt) + { + RectTransformUtility.ScreenPointToWorldPointInRectangle(canvasRt, (Vector2)screenPoint, uiCamera, out Vector3 wp); + return wp; + } + +#endif + + #endregion + } +} diff --git a/Runtime/Form/Controllers/UIFormController.cs.meta b/Runtime/Form/Controllers/UIFormController.cs.meta new file mode 100644 index 0000000..0d4bbd5 --- /dev/null +++ b/Runtime/Form/Controllers/UIFormController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ffb69c61a83ce7b4ca1eadba0f145096 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Form/Controllers/UITitleBar.cs b/Runtime/Form/Controllers/UITitleBar.cs new file mode 100644 index 0000000..52b3b48 --- /dev/null +++ b/Runtime/Form/Controllers/UITitleBar.cs @@ -0,0 +1,131 @@ +#if dUI_TextMeshPro +using UI_Text = TMPro.TextMeshProUGUI; +#else +using UI_Text = UnityEngine.UI.Text; +#endif + +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace XericUI.Form +{ + /// + /// 窗口标题栏 — 独立子组件,挂载在 UIFormController 的子对象上。 + /// Start 时向上查找父级 UIFormController,绑定按钮事件到其公开 API。 + /// 与窗口完全解耦:Controller 不关心标题栏是否存在。 + /// + [AddComponentMenu("Xeric UI Vessel/Form/UI Title Bar")] + public class UITitleBar : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler + { + [Header("控件引用")] + [SerializeField, Tooltip("标题文本")] + private UI_Text m_TitleText; + + [SerializeField, Tooltip("关闭按钮")] + private Button m_CloseButton; + + [SerializeField, Tooltip("最大化按钮")] + private Button m_MaximizeButton; + + [SerializeField, Tooltip("最小化按钮")] + private Button m_MinimizeButton; + + [SerializeField, Tooltip("自动吸附 Toggle")] + private Toggle m_AutoSnapToggle; + + [SerializeField, Tooltip("自动隐藏 Toggle")] + private Toggle m_AutoHideToggle; + + private UIFormController _controller; + + private void Start() + { + _controller = GetComponentInParent(); + + if (_controller == null) + { + Debug.LogWarning($"[UITitleBar] 未在父级找到 UIFormController: {name}", this); + return; + } + + BindEvents(); + RefreshUI(); + } + + private void Update() + { + if (_controller == null) return; + + // 实时同步标题(可能由外部修改) + if (m_TitleText != null && m_TitleText.text != _controller.WindowTitle) + m_TitleText.text = _controller.WindowTitle; + } + + #region 事件绑定 + + private void BindEvents() + { + if (m_CloseButton != null) + m_CloseButton.onClick.AddListener(() => _controller.Close()); + + if (m_MaximizeButton != null) + m_MaximizeButton.onClick.AddListener(() => _controller.ToggleMaximize()); + + if (m_MinimizeButton != null) + m_MinimizeButton.onClick.AddListener(() => _controller.Minimize()); + + if (m_AutoSnapToggle != null) + { + m_AutoSnapToggle.isOn = _controller.AutoSnapEnabled; + m_AutoSnapToggle.onValueChanged.AddListener(v => _controller.SetAutoSnapEnabled(v)); + } + + if (m_AutoHideToggle != null) + { + m_AutoHideToggle.isOn = _controller.AutoHideEnabled; + m_AutoHideToggle.onValueChanged.AddListener(v => _controller.SetAutoHideEnabled(v)); + } + } + + #endregion + + #region 拖拽转发 + + public void OnBeginDrag(PointerEventData eventData) + { + if (_controller != null) + _controller.OnBeginDrag(eventData); + } + + public void OnDrag(PointerEventData eventData) + { + if (_controller != null) + _controller.OnDrag(eventData); + } + + public void OnEndDrag(PointerEventData eventData) + { + if (_controller != null) + _controller.OnEndDrag(eventData); + } + + #endregion + + #region UI 刷新 + + private void RefreshUI() + { + if (m_TitleText != null) + m_TitleText.text = _controller.WindowTitle; + + if (m_AutoSnapToggle != null) + m_AutoSnapToggle.isOn = _controller.AutoSnapEnabled; + + if (m_AutoHideToggle != null) + m_AutoHideToggle.isOn = _controller.AutoHideEnabled; + } + + #endregion + } +} diff --git a/Runtime/Form/Controllers/UITitleBar.cs.meta b/Runtime/Form/Controllers/UITitleBar.cs.meta new file mode 100644 index 0000000..6fb4967 --- /dev/null +++ b/Runtime/Form/Controllers/UITitleBar.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5e6e4624592b3248a4c65c33cd21d5e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Form/Controllers/WindowState.cs b/Runtime/Form/Controllers/WindowState.cs new file mode 100644 index 0000000..3c7ab03 --- /dev/null +++ b/Runtime/Form/Controllers/WindowState.cs @@ -0,0 +1,20 @@ +namespace XericUI.Form +{ + /// + /// 窗口当前状态。 + /// + public enum WindowState + { + /// 普通窗口化 + Normal, + + /// 全屏最大化 + Maximized, + + /// 最小化(隐藏并保存在 Manager 中) + Minimized, + + /// 边缘自动隐藏(贴边滑出屏幕) + EdgeHidden + } +} diff --git a/Runtime/Form/Controllers/WindowState.cs.meta b/Runtime/Form/Controllers/WindowState.cs.meta new file mode 100644 index 0000000..f0e9ec8 --- /dev/null +++ b/Runtime/Form/Controllers/WindowState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 692b97f1c71f9414b8bb68dd6ed33718 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Form/UIFormManager.cs b/Runtime/Form/UIFormManager.cs new file mode 100644 index 0000000..f02d048 --- /dev/null +++ b/Runtime/Form/UIFormManager.cs @@ -0,0 +1,290 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace XericUI.Form +{ + /// + /// UI 窗体管理器 — 窗口管理中心。 + /// 挂载在 Canvas 所在 GameObject 上(或通过代码自动创建), + /// 管理所有窗口的注册、最小化恢复、边缘触发隐藏窗口的显隐调度。 + /// + [AddComponentMenu("Xeric UI Vessel/Form/UI Form Manager")] + [DefaultExecutionOrder(32000)] + public class UIFormManager : MonoBehaviour + { + [Header("边缘触发")] + [SerializeField, Tooltip("边缘触发区域大小(x=宽度方向,y=高度方向),鼠标进入此区域时触发对应方向隐藏窗口的显示")] + private Vector2 m_EdgeTriggerSize = new Vector2(80, 60); + + [Header("调试")] + [SerializeField, Tooltip("输出事件日志")] + private bool m_EnableDebugLog; + + #region 静态单例 + + private static UIFormManager s_Main; + + /// 全局单例引用 + public static UIFormManager Main => s_Main; + + #endregion + + #region 内部状态 + + // 所有窗口 + private readonly List _windows = new List(); + + // 最小化窗口数据 + private struct MinimizedData + { + public Vector2 anchoredPosition; + public Vector2 sizeDelta; + public Vector2 anchorMin; + public Vector2 anchorMax; + } + private readonly Dictionary _minimized = new(); + + // 边缘隐藏窗口(按方向分组) + private readonly Dictionary> _edgeHidden = new() + { + { AutoHideDirection.Left, new List() }, + { AutoHideDirection.Right, new List() }, + { AutoHideDirection.Up, new List() }, + { AutoHideDirection.Down, new List() }, + }; + + // 边缘触发状态跟踪(避免重复触发) + private AutoHideDirection? _lastEdgeTrigger; + + #endregion + + #region 生命周期 + + protected virtual void Awake() + { + if (s_Main != null && s_Main != this) + { + Debug.LogWarning($"[UIFormManager] 已存在单例,销毁重复实例: {name}", this); + Destroy(this); + return; + } + + s_Main = this; + } + + protected virtual void OnEnable() + { + if (s_Main == null) s_Main = this; + } + + protected virtual void OnDisable() + { + if (s_Main == this) s_Main = null; + } + + protected virtual void OnDestroy() + { + if (s_Main == this) s_Main = null; + _windows.Clear(); + _minimized.Clear(); + foreach (var list in _edgeHidden.Values) list.Clear(); + } + + protected virtual void Update() + { + CheckEdgeTriggers(); + } + + #endregion + + #region === 窗口注册 / 注销 === + + public void RegisterWindow(UIFormController window) + { + if (window == null || _windows.Contains(window)) return; + _windows.Add(window); + + if (m_EnableDebugLog) + Debug.Log($"[UIFormManager] 注册窗口: {window.name}"); + } + + public void UnregisterWindow(UIFormController window) + { + if (window == null) return; + _windows.Remove(window); + UnregisterEdgeHidden(window); + _minimized.Remove(window); + + if (m_EnableDebugLog) + Debug.Log($"[UIFormManager] 注销窗口: {window.name}"); + } + + #endregion + + #region === 边缘隐藏窗口管理 === + + /// 注册边缘隐藏窗口 + public void RegisterEdgeHidden(UIFormController window, AutoHideDirection direction) + { + if (window == null) return; + + var list = _edgeHidden[direction]; + if (!list.Contains(window)) + list.Add(window); + + if (m_EnableDebugLog) + Debug.Log($"[UIFormManager] 边缘隐藏注册: {window.name} (方向={direction})"); + } + + /// 注销边缘隐藏窗口 + public void UnregisterEdgeHidden(UIFormController window) + { + if (window == null) return; + + foreach (var list in _edgeHidden.Values) + list.Remove(window); + } + + /// 获取指定方向的隐藏窗口列表 + public IReadOnlyList GetEdgeWindows(AutoHideDirection direction) + { + return _edgeHidden[direction]; + } + + #endregion + + #region === 边缘触发检测 === + + private void CheckEdgeTriggers() + { + Vector2 mousePos = Input.mousePosition; + float sw = Screen.width; + float sh = Screen.height; + + bool leftTrigger = mousePos.x < m_EdgeTriggerSize.x; + bool rightTrigger = mousePos.x > (sw - m_EdgeTriggerSize.x); + bool bottomTrigger = mousePos.y < m_EdgeTriggerSize.y; + bool topTrigger = mousePos.y > (sh - m_EdgeTriggerSize.y); + + AutoHideDirection? activeTrigger = null; + + if (leftTrigger) activeTrigger = AutoHideDirection.Left; + else if (rightTrigger) activeTrigger = AutoHideDirection.Right; + else if (bottomTrigger) activeTrigger = AutoHideDirection.Down; + else if (topTrigger) activeTrigger = AutoHideDirection.Up; + + // 触发进入 → 显示该方向窗口 + if (activeTrigger.HasValue && activeTrigger != _lastEdgeTrigger) + { + ShowEdgeWindows(activeTrigger.Value); + } + + // 鼠标离开所有边缘触发区 + if (!activeTrigger.HasValue) + { + _lastEdgeTrigger = null; + } + } + + /// 显示指定方向的所有边缘隐藏窗口 + public void ShowEdgeWindows(AutoHideDirection direction) + { + _lastEdgeTrigger = direction; + + var list = _edgeHidden[direction]; + for (int i = list.Count - 1; i >= 0; i--) + { + var window = list[i]; + if (window == null) + { + list.RemoveAt(i); + continue; + } + + window.SlideInFromEdge(); + } + + if (m_EnableDebugLog && list.Count > 0) + Debug.Log($"[UIFormManager] 边缘触发: 方向={direction}, 显示 {list.Count} 个窗口"); + } + + #endregion + + #region === 最小化 / 恢复 === + + internal void MinimizeWindow(UIFormController window) + { + if (window == null || _minimized.ContainsKey(window)) return; + + var rt = window.transform as RectTransform; + if (rt == null) return; + + _minimized[window] = new MinimizedData + { + anchoredPosition = rt.anchoredPosition, + sizeDelta = rt.sizeDelta, + anchorMin = rt.anchorMin, + anchorMax = rt.anchorMax, + }; + + window.gameObject.SetActive(false); + + if (m_EnableDebugLog) + Debug.Log($"[UIFormManager] 最小化窗口: {window.name}"); + } + + /// 恢复已最小化的窗口 + public void RestoreWindow(UIFormController window) + { + if (window == null || !_minimized.TryGetValue(window, out var data)) return; + + var rt = window.transform as RectTransform; + if (rt == null) return; + + rt.anchorMin = data.anchorMin; + rt.anchorMax = data.anchorMax; + rt.anchoredPosition = data.anchoredPosition; + rt.sizeDelta = data.sizeDelta; + + window.gameObject.SetActive(true); + _minimized.Remove(window); + + if (m_EnableDebugLog) + Debug.Log($"[UIFormManager] 恢复窗口: {window.name}"); + } + + /// 获取所有已最小化的窗口列表 + public IReadOnlyList GetMinimizedWindows() + { + return new List(_minimized.Keys); + } + + #endregion + + #region === 关闭 === + + internal void CloseWindow(UIFormController window) + { + if (window == null) return; + + _windows.Remove(window); + UnregisterEdgeHidden(window); + _minimized.Remove(window); + + if (m_EnableDebugLog) + Debug.Log($"[UIFormManager] 关闭窗口: {window.name}"); + } + + #endregion + + #region === 查询 === + + /// 已注册窗口数量 + public int WindowCount => _windows.Count; + + /// 获取所有已注册窗口 + public IReadOnlyList GetAllWindows() => _windows; + + #endregion + } +} diff --git a/Runtime/Form/UIFormManager.cs.meta b/Runtime/Form/UIFormManager.cs.meta new file mode 100644 index 0000000..4f49fe7 --- /dev/null +++ b/Runtime/Form/UIFormManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dfd535659e1184e42b4d38016ccd70ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: