#nullable enable using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; namespace UVC.UI.List.Tree { /// /// TreeListItem의 드래그 & 드롭 입력을 처리하는 컴포넌트입니다. /// public class TreeListItemDragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { private static readonly List s_RaycastResults = new List(32); /// 부모 TreeListItem. private TreeListItem? treeListItem; /// 부모 TreeList. private TreeList? treeList; /// 드래그 & 드롭 매니저. private TreeListDragDropManager? dragDropManager; private RectTransform? rectTransform; /// 드래그 시각 피드백용 CanvasGroup. private CanvasGroup? canvasGroup; /// 드래그 시작 전 원본 알파값. private float originalAlpha = 1f; private Vector2 dragOffset = Vector2.zero; /// 드래그 중 적용할 알파값. [SerializeField] private float dragAlpha = 0.5f; /// 드래그 활성화 여부. [SerializeField] private bool enableDragDrop = true; /// 드롭 위치 표시 라인/블록. private Image? dropIndicator; private RectTransform? dropIndicatorRect; /// 드롭 표시기의 부모(RectTransform). private RectTransform? dropIndicatorParent; /// 드래그 시작 시의 원본 부모/순서. private Transform? originalParent; private int originalSiblingIndex; private RectTransform? treeListRootParent; /// /// 드롭 위치의 상/하 경계 비율.0.3이면 상하30%는 위/아래, 가운데40%는 자식으로 판단. /// [SerializeField, Range(0.05f, 0.45f)] private float dropEdgeRatio = 0.3f; // Auto-scroll settings [Header("Auto Scroll While Dragging")] [SerializeField] private bool autoScrollWhileDragging = true; [SerializeField, Tooltip("픽셀 단위 초당 스크롤 속도")] private float autoScrollPixelsPerSecond = 800f; [SerializeField, Tooltip("뷰포트 상/하단 가장자리에서 오토 스크롤을 시작하는 패딩(픽셀)")] private float autoScrollEdgePadding = 24f; private Vector2 _lastPointerScreenPos; private void Awake() { rectTransform = GetComponent(); // CanvasGroup 가져오기 (없으면 생성) canvasGroup = GetComponent(); if (canvasGroup == null) { canvasGroup = gameObject.AddComponent(); } originalAlpha = canvasGroup.alpha; } /// /// 외부에서 드래그 매니저 및 상위 컨텍스트를 주입합니다. /// public void SetDragDropManager(TreeListItem item, TreeList list, TreeListDragDropManager manager) { treeListItem = item; treeList = list; dragDropManager = manager; treeListRootParent = list.Root.parent as RectTransform; //Debug.Log($"[TreeListItemDragHandler] 드래그 핸들러 설정: {item.Data?.Name ?? "Unknown"}"); } public void OnBeginDrag(PointerEventData eventData) { if (!enableDragDrop || treeListItem?.Data == null || dragDropManager == null) { return; } if (eventData.button != PointerEventData.InputButton.Left) { return; } RectTransformUtility.ScreenPointToLocalPointInRectangle( treeListRootParent, eventData.position, null, out var localPoint); dragOffset = localPoint; _lastPointerScreenPos = eventData.position; //Debug.Log($"[OnPointerDown] {treeListItem.Data.Name}에 포인터 다운, offset: {dragOffset}"); } public void OnDrag(PointerEventData eventData) { if (!enableDragDrop || dragDropManager == null || treeListItem?.Data == null || treeList == null || rectTransform == null) { Debug.LogWarning("[OnDrag] 필수 컴포넌트 누락 또는 비활성화됨"); return; } if (eventData.button != PointerEventData.InputButton.Left) { return; } _lastPointerScreenPos = eventData.position; //Debug.Log($"[OnDrag] {treeListItem.Data.Name} 드래그 중"); if (!dragDropManager.IsDragging) { dragDropManager.StartDrag(treeListItem.Data); originalParent = rectTransform.parent; originalSiblingIndex = rectTransform.GetSiblingIndex(); if (canvasGroup != null) { canvasGroup.alpha = dragAlpha; canvasGroup.blocksRaycasts = false; } CreateDropIndicator(); //Debug.Log($"[OnDrag] {treeListItem.Data.Name} 드래그 시작"); } // 마우스 위의 드롭 대상 찾기 var targetItem = GetItemAtMousePosition(eventData.position); if (targetItem != null) { var targetRect = targetItem.GetComponent(); if (targetRect != null) { var pos = GetDropPosition(targetRect); dragDropManager.OnDragOver(targetItem.Data, pos); } else { dragDropManager.OnDragOver(targetItem.Data); } } else { dragDropManager.OnDragOver((TreeListItemData?)null); } // 드롭 위치 표시 갱신(위/아래/자식) UpdateDropIndicator(targetItem); } private void Update() { // 지속 오토 스크롤 및 표시기 갱신(마우스가 움직이지 않아도 동작) if (!autoScrollWhileDragging || dragDropManager == null || !dragDropManager.IsDragging) return; if (treeListItem?.Data == null || dragDropManager.DraggedItem != treeListItem.Data) return; AutoScrollIfNeeded(); // 스크롤로 콘텐츠가 움직이면 표시기도 갱신 var targetItem = GetItemAtMousePosition(_lastPointerScreenPos); UpdateDropIndicator(targetItem); } public void OnEndDrag(PointerEventData eventData) { if (!enableDragDrop || dragDropManager == null) { return; } //Debug.Log("[OnPointerUp] 드래그 완료"); if (canvasGroup != null) { canvasGroup.alpha = originalAlpha; canvasGroup.blocksRaycasts = true; } if (originalParent != null) { rectTransform?.SetParent(originalParent); if (rectTransform != null) { rectTransform.SetSiblingIndex(originalSiblingIndex); } } HideDropIndicator(); // 드래그가 시작되지 않았으면 종료 처리만 if (!dragDropManager.IsDragging) { dragDropManager.EndDrag(); return; } var targetItem = GetItemAtMousePosition(eventData.position); if (treeListItem?.Data != null) { var result = dragDropManager.TryDrop(targetItem?.Data); //Debug.Log($"[OnPointerUp] 드롭 결과: {(result ? "성공" : "실패")}"); if (result) HandleDropSuccess(treeListItem.Data, targetItem); } // 계약상 TryDrop은 EndDrag를 호출하지 않음 → 여기서 종료 dragDropManager.EndDrag(); } private void AutoScrollIfNeeded() { var sr = treeList != null ? treeList.GetComponent() : null; if (sr == null || sr.content == null) return; var viewport = sr.viewport != null ? sr.viewport : sr.transform as RectTransform; if (viewport == null) return; // 콘텐츠가 스크롤 가능하지 않으면 종료 float scrollableHeight = Mathf.Max(0f, sr.content.rect.height - viewport.rect.height); if (scrollableHeight <= 1f) { return; } // 마우스의 Y 위치와 뷰포트 상/하단 비교(월드 좌표) Vector3[] corners = new Vector3[4]; viewport.GetWorldCorners(corners); float bottomY = corners[0].y; float topY = corners[1].y; float mouseY = _lastPointerScreenPos.y; float dt = Time.unscaledDeltaTime; float pixelsPerSec = autoScrollPixelsPerSecond; if (mouseY > topY - autoScrollEdgePadding) { // 위로 스크롤(정규화 값 증가) float factor = Mathf.Clamp01((mouseY - (topY - autoScrollEdgePadding)) / autoScrollEdgePadding); float deltaNorm = (pixelsPerSec * dt / scrollableHeight) * factor; sr.verticalNormalizedPosition = Mathf.Clamp01(sr.verticalNormalizedPosition + deltaNorm); } else if (mouseY < bottomY + autoScrollEdgePadding) { // 아래로 스크롤(정규화 값 감소) float factor = Mathf.Clamp01(((bottomY + autoScrollEdgePadding) - mouseY) / autoScrollEdgePadding); float deltaNorm = (pixelsPerSec * dt / scrollableHeight) * factor; sr.verticalNormalizedPosition = Mathf.Clamp01(sr.verticalNormalizedPosition - deltaNorm); } } private void UpdateDragPosition(PointerEventData eventData) { if (rectTransform == null || treeList == null) { return; } var canvasRect = treeList.GetComponent(); if (canvasRect == null) { return; } if (RectTransformUtility.ScreenPointToLocalPointInRectangle( treeListRootParent, eventData.position, null, out var canvasLocalPoint)) { var currentPosition = rectTransform.anchoredPosition; rectTransform.anchoredPosition = new Vector2( currentPosition.x, canvasLocalPoint.y - dragOffset.y ); } } private void CreateDropIndicator() { if (dropIndicator != null) { return; } var existingDropIndicator = FindDropIndicatorInRoot(); if (existingDropIndicator != null) { dropIndicator = existingDropIndicator.GetComponent(); dropIndicatorRect = existingDropIndicator.GetComponent(); dropIndicatorParent = treeListRootParent; //Debug.Log("[CreateDropIndicator] 기존 DropIndicator를 재사용합니다"); return; } dropIndicatorParent = treeListRootParent; if (dropIndicatorParent == null) { Debug.LogError("[CreateDropIndicator] EntryRoot/Content를 찾을 수 없습니다"); return; } var indicatorGo = new GameObject("DropIndicator"); indicatorGo.transform.SetParent(dropIndicatorParent, false); dropIndicator = indicatorGo.AddComponent(); dropIndicator.color = new Color(0.2f, 0.8f, 1f, 0.5f); dropIndicatorRect = indicatorGo.GetComponent(); if (dropIndicatorRect != null) { dropIndicatorRect.anchorMin = new Vector2(0, 1f); dropIndicatorRect.anchorMax = new Vector2(1, 1f); dropIndicatorRect.pivot = new Vector2(0, 1f); dropIndicatorRect.sizeDelta = new Vector2(0, 2f); } dropIndicator.raycastTarget = false; indicatorGo.SetActive(false); //Debug.Log("[CreateDropIndicator] 드롭 위치 표시 막대 생성됨"); } private void UpdateDropIndicator(TreeListItem? targetItem) { if (dropIndicator == null || dropIndicatorRect == null || dropIndicatorParent == null) { return; } if (targetItem == null) { dropIndicator.gameObject.SetActive(false); return; } var targetRect = targetItem.GetComponent(); if (targetRect == null) { dropIndicator.gameObject.SetActive(false); return; } var dropPosition = GetDropPosition(targetRect); dropIndicator.gameObject.SetActive(true); Vector3[] targetCorners = new Vector3[4]; targetRect.GetWorldCorners(targetCorners); float targetWorldX = targetCorners[0].x; float targetWorldY = targetCorners[0].y; Vector3[] parentCorners = new Vector3[4]; dropIndicatorParent.GetWorldCorners(parentCorners); float parentWorldY = parentCorners[0].y; float parentWorldX = parentCorners[0].x; float parentHeight = parentCorners[1].y - parentCorners[0].y; float parentWidth = parentCorners[3].x - parentCorners[0].x; float relativeX = targetWorldX - parentWorldX; float relativeY = targetWorldY - parentWorldY; float pivotAdjustedX = relativeX - (parentWidth * dropIndicatorParent.pivot.x); float pivotAdjustedY = relativeY - (parentHeight * dropIndicatorParent.pivot.y) + targetRect.rect.height / 2; float indicatorX = 0; float indicatorY = 0; float indicatorHeight = 3f; switch (dropPosition) { case TreeDropPosition.Above: indicatorX = pivotAdjustedX; indicatorY = pivotAdjustedY + (targetRect.rect.height / 2); indicatorHeight = 2f; break; case TreeDropPosition.Below: indicatorX = pivotAdjustedX; indicatorY = pivotAdjustedY - (targetRect.rect.height / 2); indicatorHeight = 2f; break; case TreeDropPosition.InsideAsChild: indicatorX = pivotAdjustedX; indicatorY = pivotAdjustedY + (targetRect.rect.height / 2); indicatorHeight = targetRect.rect.height; break; } dropIndicatorRect.anchoredPosition = new Vector2(indicatorX, indicatorY); dropIndicatorRect.sizeDelta = new Vector2(dropIndicatorRect.sizeDelta.x, indicatorHeight); //Debug.Log($"[UpdateDropIndicator] {targetItem?.Data?.Name} 위치: {dropPosition}, targetY: {targetWorldY}, parentY: {parentWorldY}, indicatorY: {indicatorY}"); } private void HideDropIndicator() { if (dropIndicator != null) { dropIndicator.gameObject.SetActive(false); } } private GameObject? FindDropIndicatorInRoot() { if (treeListRootParent == null) { Debug.LogWarning("[FindDropIndicatorInRoot] treeListRootParent가 null입니다"); return null; } for (int i = 0; i < treeListRootParent.childCount; i++) { Transform child = treeListRootParent.GetChild(i); if (child.name == "DropIndicator") { return child.gameObject; } } return null; } private TreeListItem? GetItemAtMousePosition(Vector2 screenPosition) { if (treeList == null) { return null; } var es = EventSystem.current; if (es == null) { return null; } s_RaycastResults.Clear(); var eventData = new PointerEventData(es) { position = screenPosition }; es.RaycastAll(eventData, s_RaycastResults); //Debug.Log($"[GetItemAtMousePosition] Raycast 결과: {s_RaycastResults.Count}개"); for (int i = 0; i < s_RaycastResults.Count; i++) { var result = s_RaycastResults[i]; var item = result.gameObject.GetComponentInParent(); if (item != null) { //Debug.Log($"[GetItemAtMousePosition] 찾은 아이템: {item.Data?.Name ?? "Unknown"}"); return item; } } return null; } private void HandleDropSuccess(TreeListItemData? draggedData, TreeListItem? targetItem) { if (draggedData == null || treeList == null) { return; } if (targetItem == null) { MoveToRoot(draggedData); treeList.ScheduleFlattenedUpdate(); return; } var targetData = targetItem.Data; if (targetData == null) { return; } var dropPosition = GetDropPosition(targetItem.GetComponent()); //Debug.Log($"[HandleDropSuccess] 드롭 위치: {targetItem?.Data?.Name} {dropPosition}"); switch (dropPosition) { case TreeDropPosition.InsideAsChild: MoveAsChild(draggedData, targetData); break; case TreeDropPosition.Above: MoveBefore(draggedData, targetData); break; case TreeDropPosition.Below: MoveAfter(draggedData, targetData); break; } treeList.ScheduleFlattenedUpdate(); } private TreeDropPosition GetDropPosition(RectTransform targetRect) { var height = targetRect.rect.height; var thresholdUpper = height * dropEdgeRatio; var thresholdLower = height * (1f - dropEdgeRatio); RectTransformUtility.ScreenPointToLocalPointInRectangle( targetRect, Input.mousePosition, null, out var localMousePos); float relativeY = localMousePos.y + height / 2f; //0..height 기준 if (relativeY <= thresholdUpper) { return TreeDropPosition.Below; // 좌표계 보정에 따라 아래/위 반전 가능 → 아래쪽 영역 } if (relativeY >= thresholdLower) { return TreeDropPosition.Above; // 위쪽 영역 } return TreeDropPosition.InsideAsChild; } private void MoveToRoot(TreeListItemData draggedData) { //Debug.Log($"[MoveToRoot] {draggedData.Name}을(를) 루트로 이동"); if (treeList == null) { return; } var root = treeList.Root; if (root == null) { return; } if (draggedData.Parent == null) { treeList.Items.Remove(draggedData); treeList.SwapItem(draggedData, treeList.Items.Count); } else { treeList.AddCloneItem(draggedData); } //Debug.Log($"[MoveToRoot] {draggedData.Name}을(를) 루트 레벨의 끝에 추가"); } private void MoveAsChild(TreeListItemData draggedData, TreeListItemData targetData) { targetData.AddCloneChild(draggedData); //Debug.Log($"[MoveAsChild] {draggedData.Name}을(를) {targetData.Name}의 자식으로 이동"); } private void MoveBefore(TreeListItemData draggedData, TreeListItemData targetData) { if (draggedData.Parent == targetData.Parent) { if (draggedData.Parent != null) { var draggedIndex = draggedData.Parent.Children.IndexOf(draggedData); draggedData.Parent.Children.Remove(draggedData); var targetIndex = draggedData.Parent.Children.IndexOf(targetData); if (targetIndex >= 0) { draggedData.Parent.SwapChild(draggedData, targetIndex); //Debug.Log($"[MoveBefore] {draggedData.Name}을(를) {targetData.Name} 앞으로 {targetIndex} 순서 변경"); } } else { var treeListItems = treeList?.Items; if (treeListItems != null) { treeListItems.Remove(draggedData); var targetIndex = treeListItems.IndexOf(targetData); if (targetIndex >= 0) { treeList?.SwapItem(draggedData, targetIndex); //Debug.Log($"[MoveBefore] {draggedData.Name}을(를) {targetData.Name} 앞으로 {targetIndex} 순서 변경(루트레벨)"); } } } } else { var parentData = targetData.Parent; if (parentData != null) { var targetIndex = parentData.Children.IndexOf(targetData); if (targetIndex >= 0) { parentData.AddCloneAtChild(draggedData, targetIndex); //Debug.Log($"[MoveBefore] {draggedData.Name}을(를) {targetData.Name} 앞으로 {targetIndex} 이동"); } } else { var treeListItems = treeList?.Items; if (treeListItems != null) { var targetIndex = treeListItems.IndexOf(targetData); if (targetIndex >= 0) { treeList?.AddCloneItemAt(draggedData, targetIndex); //Debug.Log($"[MoveBefore] {draggedData.Name}을(를) {targetData.Name} 앞으로 {targetIndex} 이동 (루트 레벨)"); } } } } } private void MoveAfter(TreeListItemData draggedData, TreeListItemData targetData) { if (draggedData.Parent == targetData.Parent) { if (draggedData.Parent != null) { var draggedIndex = draggedData.Parent.Children.IndexOf(draggedData); draggedData.Parent.Children.Remove(draggedData); var targetIndex = draggedData.Parent.Children.IndexOf(targetData); if (targetIndex >= 0) { draggedData.Parent.SwapChild(draggedData, targetIndex + 1); //Debug.Log($"[MoveAfter] {draggedData.Name}을(를) {targetData.Name} 뒤로 {targetIndex} 순서 변경"); } } else { var treeListItems = treeList?.Items; if (treeListItems != null) { treeListItems.Remove(draggedData); var targetIndex = treeListItems.IndexOf(targetData); if (targetIndex >= 0) { treeList?.SwapItem(draggedData, targetIndex + 1); //Debug.Log($"[MoveAfter] {draggedData.Name}을(를) {targetData.Name} 뒤로 {targetIndex} 순서 변경(루트레벨)"); } } } } else { var parentData = targetData.Parent; if (parentData != null) { var targetIndex = parentData.Children.IndexOf(targetData); if (targetIndex >= 0) { parentData.AddCloneAtChild(draggedData, targetIndex + 1); //Debug.Log($"[MoveAfter] {draggedData.Name}을(를) {targetData.Name} 뒤로 {targetIndex} 이동"); } } else { var treeListItems = treeList?.Items; if (treeListItems != null) { var targetIndex = treeListItems.IndexOf(targetData); if (targetIndex >= 0) { treeList?.AddCloneItemAt(draggedData, targetIndex + 1); //Debug.Log($"[MoveAfter] {draggedData.Name}을(를) {targetData.Name} 뒤로 {targetIndex} 이동 (루트 레벨)"); } } } } } } }