#nullable enable using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; namespace UVC.UI.List.Tree { /// /// TreeListItem의 드래그 & 드롭 입력을 처리하는 컴포넌트입니다. /// /// 역할: /// 1) 마우스 입력 처리: 드래그 시작/진행/종료 /// 2) 드래그 시각 피드백: 알파 변경, 드롭 위치 표시기 갱신 /// 3) 마우스 위치 기반 드롭 대상 탐색 및 드롭 위치 판정 /// 4) 드래그 매니저(TreeListDragDropManager)와 이벤트 연동 /// /// 이벤트 흐름(IBeginDragHandler/IDragHandler/IEndDragHandler): /// - OnBeginDrag: 드래그 시작 준비(안전성 검증, 오프셋 계산) /// - OnDrag: 최초 드래그 프레임에 StartDrag, DropIndicator 생성/갱신 /// - OnEndDrag: 알파/레이캐스트 복구, DropIndicator 숨김, TryDrop 수행 /// /// 시각 피드백: /// - CanvasGroup.alpha를 일시적으로 낮춰 드래그 중임을 표시 /// - DropIndicator(얇은 선 또는 블록)로 삽입 위치/자식 투입 위치를 표시 /// public class TreeListItemDragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { /// 부모 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; private void Awake() { rectTransform = GetComponent(); // CanvasGroup 가져오기 (없으면 생성) canvasGroup = GetComponent(); if (canvasGroup == null) { canvasGroup = gameObject.AddComponent(); } originalAlpha = canvasGroup.alpha; } /// /// 외부에서 드래그 매니저 및 상위 컨텍스트를 주입합니다. /// /// 이 핸들러가 속한 아이템. /// 아이템 컨테이너(TreeList). /// 드래그 & 드롭 매니저. 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"}"); } /// /// 드래그가 시작될 때 호출됩니다. /// OnPointerDown 이후 마우스가 임계치 이상 이동하면 자동 호출됩니다. /// /// 포인터 이벤트 데이터. public void OnBeginDrag(PointerEventData eventData) { Debug.Log($"[OnPointerDown]"); if (!enableDragDrop || treeListItem?.Data == null || dragDropManager == null) { return; } // 마우스 버튼이 왼쪽이 아니면 무시 if (eventData.button != PointerEventData.InputButton.Left) { return; } // 마우스 위치의 로컬 좌표 저장(필요 시 Y 오프셋 계산에 사용) RectTransformUtility.ScreenPointToLocalPointInRectangle( treeListRootParent, eventData.position, null, out var localPoint); dragOffset = localPoint; Debug.Log($"[OnPointerDown] {treeListItem.Data.Name}에 포인터 다운, offset: {dragOffset}"); } /// /// 드래그 중 프레임마다 호출됩니다. /// 최초 1프레임에 드래그 상태 진입 및 표시기 생성, 이후 hover 대상/표시기 갱신을 수행합니다. /// /// 포인터 이벤트 데이터. 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; } 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} 드래그 시작"); } // 필요 시 실제 UI를 마우스를 따라 이동시키려면 아래 호출을 활성화 //UpdateDragPosition(eventData); // 마우스 위의 드롭 대상 찾기 var targetItem = GetItemAtMousePosition(eventData.position); dragDropManager.OnDragOver(targetItem?.Data); // 드롭 위치 표시 갱신(위/아래/자식) UpdateDropIndicator(targetItem); } /// /// 드래그가 종료될 때 호출됩니다(마우스 버튼 해제). /// 상태/시각 피드백 복구 후 TryDrop을 수행합니다. /// /// 포인터 이벤트 데이터. public void OnEndDrag(PointerEventData eventData) { Debug.Log($"[OnPointerUp]"); 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); } dragDropManager.EndDrag(); } /// /// 드래그 중 아이템이 마우스를 따라다니도록 위치를 업데이트합니다. /// Y축만 이동(X축 고정). /// /// 포인터 이벤트 데이터. private void UpdateDragPosition(PointerEventData eventData) { if (rectTransform == null || treeList == null) { return; } var canvasRect = treeList.GetComponent(); if (canvasRect == null) { return; } // 스크린 좌표를 캔버스 로컬 좌표로 변환 if (RectTransformUtility.ScreenPointToLocalPointInRectangle( treeListRootParent, //canvasRect, eventData.position, null, out var canvasLocalPoint)) { // ✅ Y축만 업데이트 (X는 고정) // 드래그 오프셋을 고려한 Y 위치만 계산 var currentPosition = rectTransform.anchoredPosition; rectTransform.anchoredPosition = new Vector2( currentPosition.x, // ✅ X는 그대로 유지 canvasLocalPoint.y - dragOffset.y // ✅ Y만 마우스를 따라 이동 ); } } /// /// 드롭 위치 표시기(DropIndicator)를 생성하거나 재사용합니다. /// private void CreateDropIndicator() { Debug.Log($"[CreateDropIndicator] dropIndicator != null:{dropIndicator != null}"); if (dropIndicator != null) { return; } //기존에 생성된 DropIndicator가 있는지 확인 var existingDropIndicator = FindDropIndicatorInRoot(); if (existingDropIndicator != null) { // 기존 DropIndicator 사용 dropIndicator = existingDropIndicator.GetComponent(); dropIndicatorRect = existingDropIndicator.GetComponent(); dropIndicatorParent = treeListRootParent; Debug.Log("[CreateDropIndicator] 기존 DropIndicator를 재사용합니다"); return; } // ✅ Root(root)를 부모로 설정 // 계층 구조: TreeList > ScrollView > Viewport > Content > Root > TreeListItem dropIndicatorParent = treeListRootParent;// rectTransform?.parent as RectTransform; if (dropIndicatorParent == null) { Debug.LogError("[CreateDropIndicator] EntryRoot/Content를 찾을 수 없습니다"); return; } // 새로운 GameObject 생성 var indicatorGo = new GameObject("DropIndicator"); indicatorGo.transform.SetParent(dropIndicatorParent, false); // Image 컴포넌트 추가 dropIndicator = indicatorGo.AddComponent(); dropIndicator.color = new Color(0.2f, 0.8f, 1f, 0.5f); // 반투명 파란색 // RectTransform 설정 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); // 높이 3 } dropIndicator.raycastTarget = false; indicatorGo.SetActive(false); Debug.Log("[CreateDropIndicator] 드롭 위치 표시 막대 생성됨"); } /// /// 드롭 위치 표시기를 갱신합니다. /// 복잡도: 대상 RectTransform의 월드 코너를 부모 기준 로컬 좌표로 환산하여, /// 위/아래/자식(블록) 위치를 정확히 계산합니다. /// /// 현재 마우스 아래의 대상 아이템(null이면 숨김). 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); // 🎯 아이템의 월드 X 좌표 (왼쪽 끝) float targetWorldX = targetCorners[0].x; // 아이템의 월드 Y 좌표 float targetWorldY = targetCorners[0].y; // 부모(Root)의 월드 좌표 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; // 🎯 월드 X를 부모 기준 로컬 X로 변환 float relativeX = targetWorldX - parentWorldX; // 월드 Y를 부모 기준 로컬 Y로 변환 float relativeY = targetWorldY - parentWorldY; // 🎯 부모의 pivot을 고려한 로컬 X 계산 float pivotAdjustedX = relativeX - (parentWidth * dropIndicatorParent.pivot.x); // 부모의 pivot을 고려한 로컬 Y 계산 float pivotAdjustedY = relativeY - (parentHeight * dropIndicatorParent.pivot.y) + targetRect.rect.height / 2; float indicatorX = 0; float indicatorY = 0; float indicatorHeight = 3f; // 기본 높이 switch (dropPosition) { case DropPosition.Above: // 대상 아이템 위 (아이템 높이의 절반) indicatorX = pivotAdjustedX; indicatorY = pivotAdjustedY + (targetRect.rect.height / 2); indicatorHeight = 2f; // 얇은 선 break; case DropPosition.Below: // 대상 아이템 아래 indicatorX = pivotAdjustedX; indicatorY = pivotAdjustedY - (targetRect.rect.height / 2); indicatorHeight = 2f; // 얇은 선 break; case DropPosition.InsideAsChild: // 대상 아이템 중앙 indicatorX = pivotAdjustedX; indicatorY = pivotAdjustedY + (targetRect.rect.height / 2); //indicatorHeight 크기 때문에 반영 // 🎯 targetRect의 높이만큼 설정 indicatorHeight = targetRect.rect.height; break; } // ✅ DropIndicator 위치 설정 dropIndicatorRect.anchoredPosition = new Vector2(indicatorX, indicatorY); // 🎯 DropIndicator 높이 설정 dropIndicatorRect.sizeDelta = new Vector2(dropIndicatorRect.sizeDelta.x, indicatorHeight); //dropIndicatorRect의 x 시작 위치를 targetRect.rect의 위치와 맞춤 Debug.Log($"[UpdateDropIndicator] {targetItem?.Data?.Name} 위치: {dropPosition}, targetY: {targetWorldY}, parentY: {parentWorldY}, indicatorY: {indicatorY}"); } /// /// 드롭 위치 표시기를 숨깁니다. /// private void HideDropIndicator() { if (dropIndicator != null) { dropIndicator.gameObject.SetActive(false); } } /// /// treeListRootParent의 직접 자식 중 "DropIndicator" GameObject를 찾습니다. /// /// 찾은 GameObject. 없으면 null. private GameObject? FindDropIndicatorInRoot() { if (treeListRootParent == null) { Debug.LogWarning("[FindDropIndicatorInRoot] treeListRootParent가 null입니다"); return null; } // 1️. treeListRootParent의 모든 직접 자식을 순회 for (int i = 0; i < treeListRootParent.childCount; i++) { Transform child = treeListRootParent.GetChild(i); // 2️. 자식의 이름이 "DropIndicator"인지 확인 if (child.name == "DropIndicator") { Debug.Log("[FindDropIndicatorInRoot] DropIndicator를 찾았습니다"); return child.gameObject; } } Debug.Log("[FindDropIndicatorInRoot] DropIndicator를 찾지 못했습니다"); return null; } /// /// 스크린 좌표에 위치한 TreeListItem을 찾습니다. /// /// 스크린 좌표. /// 찾은 TreeListItem. 없으면 null. private TreeListItem? GetItemAtMousePosition(Vector2 screenPosition) { if (treeList == null) { return null; } // Raycast로 UI 요소 찾기 var results = new System.Collections.Generic.List(); var eventData = new PointerEventData(EventSystem.current) { position = screenPosition }; EventSystem.current.RaycastAll(eventData, results); Debug.Log($"[GetItemAtMousePosition] Raycast 결과: {results.Count}개"); foreach (var result in results) { var item = result.gameObject.GetComponentInParent(); if (item != null)// && item != treeListItem) { Debug.Log($"[GetItemAtMousePosition] 찾은 아이템: {item.Data?.Name ?? "Unknown"}"); return item; } } return null; } /// /// 드롭 성공 후 데이터 구조를 갱신합니다. /// 루트 드롭/형제 간 이동/자식으로 이동 등을 처리합니다. /// /// 드래그한 데이터. /// 드롭 대상 UI 아이템(null: 루트로 드롭). private void HandleDropSuccess(TreeListItemData? draggedData, TreeListItem? targetItem) { if (draggedData == null || treeList == null) { return; } // 드롭 대상이 없으면 (빈 공간에 드롭) 루트로 이동 if (targetItem == null) { Debug.Log($"[HandleDropSuccess] 루트로 이동"); MoveToRoot(draggedData); treeList.UpdateFlattenedItemDataList(); return; } var targetData = targetItem.Data; if (targetData == null) { return; } // 드롭 위치 판단: 대상의 위/아래 또는 자식으로 var dropPosition = GetDropPosition(targetItem.GetComponent()); Debug.Log($"[HandleDropSuccess] 드롭 위치: {targetItem?.Data?.Name} {dropPosition}"); switch (dropPosition) { case DropPosition.InsideAsChild: // 대상의 자식으로 이동 MoveAsChild(draggedData, targetData); break; case DropPosition.Above: // 대상 위에 위치 (같은 부모 내에서) MoveBefore(draggedData, targetData); break; case DropPosition.Below: // 대상 아래에 위치 (같은 부모 내에서) MoveAfter(draggedData, targetData); break; } treeList.UpdateFlattenedItemDataList(); } /// /// 대상 RectTransform 내 마우스 Y 위치로 위/아래/자식 드롭 위치를 판정합니다. /// 상/하 30% → 위/아래, 중간 40% → 자식. /// /// 대상 아이템 RectTransform. /// 드롭 위치. private DropPosition GetDropPosition(RectTransform targetRect) { // 목표: 마우스 Y 위치를 기반으로 위/아래/안쪽 판단 // 상위 1/3: 위 // 중간 1/3: 자식 // 하위 1/3: 아래 var height = targetRect.rect.height; var thresholdUpper = height * 0.3f; var thresholdLower = height * 0.7f; // 월드 좌표에서 로컬 좌표로 변환 RectTransformUtility.ScreenPointToLocalPointInRectangle( targetRect, Input.mousePosition, null, out var localMousePos); // 로컬 Y 좌표로 판단 (RectTransform의 피벗이 중앙이면 -height/2 ~ height/2) float relativeY = localMousePos.y; //Debug.Log($"GetDropPosition height:{height}, relativeY:{relativeY}, thresholdUpper:{thresholdUpper}, thresholdLower:{thresholdLower}"); if (relativeY > -thresholdUpper) { return DropPosition.Above; } else if (relativeY < -thresholdLower) { return DropPosition.Below; } else { return DropPosition.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;// FindParentOfItem(targetData); //RemoveFromParent(draggedData); 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($"[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 + 1); 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 + 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} 이동 (루트 레벨)"); } } } } } /// /// 드롭 위치를 나타냅니다. /// private enum DropPosition { /// 위 (형제 아이템으로 앞쪽) Above, /// 안쪽 (자식으로) InsideAsChild, /// 아래 (형제 아이템으로 뒤쪽) Below } } }