드래그 드랍 개발 중

This commit is contained in:
logonkhi
2025-10-28 20:10:51 +09:00
parent a356c5497a
commit 47ca525718
32 changed files with 3203 additions and 570 deletions

View File

@@ -1,4 +1,4 @@
using System;
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
@@ -47,7 +47,7 @@ namespace UVC.Factory.Alarm
buttonText = GetComponentInChildren<TextMeshProUGUI>();
if (buttonText == null)
{
Debug.LogWarning("Text component not found in children.", this);
Debug.LogWarning("Text component not found in Children.", this);
}
}

View File

@@ -1,4 +1,4 @@
using DG.Tweening;
using DG.Tweening;
using System.Collections.Generic;
using UnityEngine;
using UVC.Data;
@@ -196,7 +196,7 @@ namespace UVC.Factory.Alarm
/// <summary>
/// 현재 알람 상태에 맞게 UI를 갱신합니다.
/// 알람 개수와 확장 상태(isExpanded)에 따라 어떤 UI를 보여줄지 결정합니다.
/// 알람 개수와 확장 상태(IsExpanded)에 따라 어떤 UI를 보여줄지 결정합니다.
/// </summary>
private void UpdateView()
{

View File

@@ -1,4 +1,4 @@
using System;
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
@@ -47,7 +47,7 @@ namespace UVC.Factory.Alarm
buttonText = GetComponentInChildren<TextMeshProUGUI>();
if (buttonText == null)
{
Debug.LogWarning("Text component not found in children.", this);
Debug.LogWarning("Text component not found in Children.", this);
}
}

View File

@@ -33,7 +33,7 @@ namespace UVC.Factory.Tab
if (componentList == null)
{
Debug.LogError("InfiniteScroll component is not assigned or found in children.");
Debug.LogError("InfiniteScroll component is not assigned or found in Children.");
return;
}
}

View File

@@ -27,7 +27,7 @@ namespace UVC.Factory.Tab
if (prefabGrid == null)
{
Debug.LogError("InfiniteScroll component is not assigned or found in children.");
Debug.LogError("InfiniteScroll component is not assigned or found in Children.");
return;
}
}

View File

@@ -45,7 +45,7 @@ namespace UVC.UI.List.ComponentList
if (scrollList == null)
{
Debug.LogError("InfiniteScroll component is not assigned or found in children.");
Debug.LogError("InfiniteScroll component is not assigned or found in Children.");
return;
}

View File

@@ -70,8 +70,8 @@ namespace UVC.UI.List.ComponentList
/// var generalData = new ComponentListItemData
/// {
/// isCategory = false,
/// generalName = "분전반",
/// generalOption = "옵션 A",
/// Name = "분전반",
/// Option = "옵션 A",
/// factoryObjectInfo = new FactoryObjectInfo { Id = "some-unique-Id", Name = "분전반" }
/// };
///

View File

@@ -151,7 +151,7 @@ namespace UVC.UI.List.Draggable
for (int i = 0; i < children.Length; i++)
{
// Y 좌표 차이만 계산합니다 (세로 리스트 기준)
// 가로 리스트의 경우 localPos.x - children[i].anchoredPosition.x 를 사용하세요
// 가로 리스트의 경우 localPos.x - Children[i].anchoredPosition.x 를 사용하세요
float distance = Mathf.Abs(localPos.y - (children[i].anchoredPosition.y + children[i].rect.height / 2));
if (distance < closestDistance)

View File

@@ -44,7 +44,7 @@ namespace UVC.UI.List
if (scrollList == null)
{
Debug.LogError("InfiniteScroll component is not assigned or found in children.");
Debug.LogError("InfiniteScroll component is not assigned or found in Children.");
return;
}

View File

@@ -33,8 +33,8 @@ namespace UVC.UI.List
/// var generalData = new ComponentListItemData
/// {
/// isCategory = false,
/// generalName = "분전반",
/// generalOption = "옵션 A",
/// Name = "분전반",
/// Option = "옵션 A",
/// factoryObjectInfo = new FactoryObjectInfo { Id = "some-unique-Id", Name = "분전반" }
/// };
///

View File

@@ -1,21 +1,682 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace UVC.UI.List.Tree
{
/// <summary>
/// 트리 구조의 리스트를 관리하고 제어하는 클래스입니다.
///
/// 역할:
/// 1. 아이템 추가/제거 관리
/// 2. 선택 상태 관리 (단일, 다중 선택)
/// 3. 키 입력(Ctrl, Shift)에 따른 선택 로직 처리
/// 4. 선택 상태 변경 이벤트 발생
///
/// 파일 탐색기와 비슷한 동작:
/// - 클릭: 한 항목 선택
/// - Ctrl+클릭: 여러 항목 선택 (기존 선택 유지)
/// - Shift+클릭: 범위 선택 (시작~끝)
///
/// MonoBehaviour란?
/// Unity의 모든 게임 로직이 상속받는 기본 클래스입니다.
/// Inspector에서 설정할 수 있고, Update 같은 생명주기 함수를 사용할 수 있습니다.
/// </summary>
public class TreeList : MonoBehaviour
{
/// <summary>
/// UI 아이템(TreeListItem)의 프리팹(템플릿)입니다.
///
/// 프리팹이란?
/// 미리 만들어둔 UI 템플릿입니다.
/// 이것을 복제(Instantiate)해서 새로운 아이템을 여러 개 만들 수 있습니다.
///
/// [SerializeField]란?
/// 이 필드를 Inspector(게임 편집기)에서 직접 설정할 수 있게 해줍니다.
/// (private이지만 Unity에서만 접근 가능)
///
/// 사용 흐름:
/// 1. Inspector에서 프리팹을 이 필드에 드래그&드롭
/// 2. AddItem()이 호출되면 이 프리팹을 복제해서 새 아이템 생성
/// </summary>
[SerializeField]
protected TreeListItem itemPrefab;
/// <summary>
/// itemPrefab을 읽기만 가능하게 공개합니다.
///
/// 문법: public TreeListItem ItemPrefab => itemPrefab;
/// = 프로퍼티 (getter 전용)
/// = 외부에서 이 값을 읽을 수 있지만 변경할 수 없음
/// </summary>
public TreeListItem ItemPrefab => itemPrefab;
/// <summary>
/// 모든 아이템을 담는 부모 컨테이너입니다.
///
/// RectTransform이란?
/// UI 요소의 위치, 크기, 회전을 관리하는 컴포넌트입니다.
/// Canvas(캔버스) 아래의 모든 UI 요소가 이것을 가집니다.
///
/// 역할:
/// - AddItem()에서 새 아이템의 부모로 지정
/// - UpdateFlattenedItemList()에서 모든 자식을 순회
///
/// 구조 예:
/// root (이것)
/// ├─ 아이템1
/// ├─ 아이템2
/// └─ 아이템3
/// </summary>
[SerializeField]
protected RectTransform root;
public RectTransform Root => root;
/// <summary>
/// 여러 개 선택이 가능한지 여부를 나타냅니다.
///
/// true: 여러 개 선택 가능 (Ctrl+클릭, Shift+클릭 작동)
/// false: 한 개만 선택 가능 (최신 선택이 기존 선택을 덮어씀)
///
/// 예시:
/// - true인 경우: 파일 탐색기 (여러 파일 선택 가능)
/// - false인 경우: 라디오 버튼 (하나만 선택)
///
/// SetAllowMultipleSelection() 메서드로 런타임에 변경 가능합니다.
/// </summary>
[SerializeField]
protected bool allowMultipleSelection = true;
/// <summary>
/// 드래그 & 드롭 기능 활성화 여부입니다.
/// </summary>
[SerializeField]
protected bool enableDragDrop = true;
/// <summary>
/// 현재 선택된 아이템들을 저장하는 리스트입니다.
///
/// 역할:
/// - SelectItem(): 선택된 아이템 추가
/// - DeselectItem(): 선택된 아이템 제거
/// - ClearSelection(): 모두 해제
/// - SelectedItems 프로퍼티: 외부에 읽기 전용으로 제공
///
/// 예:
/// 사용자가 3개 파일을 선택하면 이 리스트에 [파일1, 파일2, 파일3]이 저장됩니다.
///
/// List란?
/// 배열처럼 여러 개 항목을 저장하는 컨테이너입니다.
/// 크기가 자동으로 조정되므로 개수를 미리 정할 필요가 없습니다.
/// </summary>
protected List<TreeListItemData> selectedItems = new List<TreeListItemData>();
/// <summary>
/// 사용자가 마지막으로 선택한 아이템입니다.
///
/// 용도: Shift+클릭으로 범위 선택할 때 시작점으로 사용
///
/// 시나리오:
/// 1. 파일1 클릭 → lastSelectedItem = 파일1
/// 2. Shift+파일5 클릭 → 파일1부터 파일5까지 모두 선택
///
/// ? 연산자 (nullable)란?
/// 이 변수는 null(값 없음)일 수 있습니다.
/// 예: lastSelectedItem = null (아직 선택한 게 없음)
///
/// null 체크:
/// if (lastSelectedItem != null) { ... }
/// = 값이 있는지 확인 후에 사용
/// </summary>
protected TreeListItemData? lastSelectedItem = null;
/// <summary>
/// 모든 아이템을 1차원 리스트로 변환한 것입니다. (평탄화)
///
/// "평탄화"란?
/// 원본 트리 구조:
/// 폴더1
/// ├─ 파일1
/// └─ 폴더2
/// └─ 파일2
///
/// 평탄화된 리스트:
/// [폴더1, 파일1, 폴더2, 파일2] (계층 무시, 선형으로 배열)
///
/// 용도:
/// - SelectRange()에서 두 아이템 사이의 인덱스를 빠르게 찾기
/// - Shift+클릭 범위 선택 구현
///
/// UpdateFlattenedItemList() 메서드로 항상 최신 상태로 유지됩니다.
/// </summary>
protected List<TreeListItemData> allItemsFlattened = new List<TreeListItemData>();
/// <summary>
/// 드래그 & 드롭 매니저입니다.
/// </summary>
protected TreeListDragDropManager dragDropManager = new TreeListDragDropManager();
public TreeListDragDropManager DragDropManager => dragDropManager;
/// <summary>
/// 아이템의 선택 상태가 변경되었을 때 발생하는 이벤트입니다.
///
/// 이벤트란?
/// 무언가 일어났을 때 다른 코드에 알려주는 메커니즘입니다.
/// 구독자(listener)가 등록되어 있으면 자동으로 호출됩니다.
///
/// 문법: event Action<TreeListItemData, bool>?
/// = TreeListItemData 1개, bool 1개를 매개변수로 받는 함수들을 등록
///
/// 호출되는 경우:
/// - SelectItem() 호출 시 → OnItemSelectionChanged?.Invoke(data, true);
/// - DeselectItem() 호출 시 → OnItemSelectionChanged?.Invoke(data, false);
///
/// 사용 예:
/// treeList.OnItemSelectionChanged += (data, isSelected) => {
/// Debug.Log($"{data.Name}이 {(isSelected ? "선택됨" : "해제됨")}");
/// };
/// </summary>
public event Action<TreeListItemData, bool>? OnItemSelectionChanged;
/// <summary>
/// 현재 선택된 아이템 목록을 반환합니다. (읽기 전용)
///
/// 문법: public IReadOnlyList<TreeListItemData> SelectedItems => ...
/// = 프로퍼티 (getter 전용)
/// = 외부에서 읽을 수 있지만 변경할 수 없음
///
/// AsReadOnly()란?
/// 리스트를 읽기 전용으로 변환합니다.
/// 따라서 외부에서 SelectedItems.Add()같은 수정이 불가능합니다.
///
/// 왜 이렇게 하나?
/// 클래스 내부에서만 selectedItems를 제어하고,
/// 외부는 결과만 볼 수 있게 하기 위함입니다. (데이터 무결성)
///
/// 사용 예:
/// foreach (var item in treeList.SelectedItems)
/// {
/// Debug.Log(item.Name); // 읽기만 가능
/// }
/// </summary>
public IReadOnlyList<TreeListItemData> SelectedItems => selectedItems.AsReadOnly();
private void Awake()
{
// 드래그 & 드롭 이벤트 구독
if (enableDragDrop)
{
dragDropManager.OnDropped += HandleItemDropped;
}
}
private void OnDestroy()
{
if (enableDragDrop)
{
dragDropManager.OnDropped -= HandleItemDropped;
}
}
/// <summary>
/// 새로운 아이템을 트리 리스트에 추가합니다.
///
/// 호출 시점:
/// - 프로그램이 새 데이터를 UI에 표시하고 싶을 때
///
/// 처리 순서:
/// 1. itemPrefab을 복제해서 새 UI 객체 생성 (Instantiate)
/// 2. 복제된 객체를 root(부모 컨테이너) 아래에 배치
/// 3. 복제된 객체의 Init() 메서드 호출해서 초기화
/// 4. 평탄화 리스트 업데이트 (범위 선택 시 사용)
///
/// 매개변수:
/// - data: 표시할 데이터 (이름, 자식 등의 정보 포함)
///
/// 사용 예:
/// var newItem = new TreeListItemData("새 폴더");
/// treeList.AddItem(newItem); // 화면에 표시됨
///
/// Instantiate란?
/// 프리팹(템플릿)을 복제해서 새로운 객체를 만듭니다.
/// 게임의 모든 객체는 이 방식으로 생성됩니다.
/// </summary>
public void AddItem(TreeListItemData data)
{
// Instantiate(템플릿, 부모 Transform)
// = 템플릿을 복제하고 부모의 자식으로 설정
TreeListItem item = GameObject.Instantiate<TreeListItem>(ItemPrefab, root);
item.Init(data, this);
// 생성된 아이템 초기화
// 데이터를 UI에 바인딩하고 이벤트 리스너 등록
item.Init(data, this, dragDropManager);
// 범위 선택에 필요한 평탄화 리스트 업데이트
UpdateFlattenedItemList();
}
/// <summary>
/// 모든 아이템을 평탄화된 1차원 리스트로 재구성합니다.
///
/// 동작:
/// 1. 기존 평탄화 리스트를 비움 (Clear)
/// 2. root의 모든 직접 자식을 순회 (foreach)
/// 3. 각 자식의 TreeListItem 컴포넌트 획득 (GetComponent)
/// 4. AddItemToFlattened() 호출해서 자식과 손자까지 재귀 추가
///
/// 호출되는 시점:
/// - AddItem() 실행 후 (새 아이템 추가됨)
/// - OnDataChanged 이벤트 발생 시 (자식 구조 변경됨)
///
/// Transform이란?
/// 게임 오브젝트의 위치, 회전, 크기 정보를 가진 컴포넌트입니다.
/// 계층 구조(부모-자식 관계)의 중심입니다.
/// root.childCount: 몇 개의 자식이 있는지
/// for (Transform child in root): 모든 자식을 순회
///
/// GetComponent<T>() 란?
/// 게임 오브젝트에 붙어있는 특정 컴포넌트를 찾아서 반환합니다.
/// 찾지 못하면 null 반환
///
/// 예: child.GetComponent<TreeListItem>()
/// = child 게임 오브젝트에서 TreeListItem 컴포넌트 찾기
/// </summary>
internal void UpdateFlattenedItemList()
{
// 기존 평탄화 리스트 비우기
allItemsFlattened.Clear();
// root의 모든 직접 자식을 순회
// (손자, 증손자는 재귀로 처리됨)
foreach (Transform child in root)
{
// 자식 게임 오브젝트에서 TreeListItem 컴포넌트 찾기
TreeListItem item = child.GetComponent<TreeListItem>();
// null 체크: 컴포넌트가 있고, 데이터도 있는지 확인
if (item != null && item.Data != null)
{
// 이 아이템과 자식들을 재귀적으로 평탄화 리스트에 추가
AddItemToFlattened(item.Data);
}
}
}
/// <summary>
/// 아이템과 그 모든 자식 아이템들을 평탄화 리스트에 재귀적으로 추가합니다.
///
/// "재귀"란?
/// 함수가 자기 자신을 호출하는 것입니다.
/// 계층 구조(깊이가 정해지지 않은)를 탐색하는 데 유용합니다.
///
/// 동작:
/// 1. 현재 data를 allItemsFlattened에 추가
/// 2. data의 모든 자식 Children을 순회
/// 3. 각 자식에 대해 AddItemToFlattened() 재귀 호출
///
/// 예시 - 트리 구조를 평탄화하는 과정:
///
/// 트리: 재귀 호출 흐름: 결과:
/// 폴더1 AddItemToFlattened(폴더1) [폴더1,
/// ├─ 파일1 → AddItemToFlattened(파일1) 파일1,
/// ├─ 파일2 → AddItemToFlattened(파일2) 파일2,
/// └─ 폴더2 → AddItemToFlattened(폴더2) 폴더2,
/// └─ 파일3 → AddItemToFlattened(파일3) 파일3]
///
/// 매개변수:
/// - data: 현재 처리할 아이템
/// </summary>
private void AddItemToFlattened(TreeListItemData data)
{
// 현재 아이템을 평탄화 리스트에 추가
allItemsFlattened.Add(data);
// 현재 아이템의 모든 자식을 순회
foreach (var child in data.Children)
{
// 각 자식에 대해 재귀 호출
// = 자식의 자식들도 모두 추가됨
AddItemToFlattened(child);
}
}
/// <summary>
/// 아이템이 클릭되었을 때 호출됩니다.
///
/// 역할:
/// - 사용자가 어떤 키를 누르고 있는지 확인
/// - 그에 맞는 선택 로직 실행
/// - 마지막 선택 아이템 기억
///
/// 파일 탐색기와 동일한 동작:
/// - 일반 클릭: 한 항목만 선택
/// - Ctrl+클릭: 여러 항목 선택 (기존 선택 유지)
/// - Shift+클릭: 범위 선택 (마지막 선택~현재 선택)
///
/// 매개변수:
/// - data: 클릭된 아이템의 데이터
/// - ctrlPressed: Ctrl 키가 눌렸는지 여부
/// - shiftPressed: Shift 키가 눌렸는지 여부
/// </summary>
public void OnItemClicked(TreeListItemData data, bool ctrlPressed, bool shiftPressed)
{
// 디버그 로그: 클릭 정보를 콘솔에 출력 (개발 중 확인용)
Debug.Log($"OnItemClicked {data.Name}, ctrlPressed:{ctrlPressed}, shiftPressed:{shiftPressed}, lastSelectedItem:{lastSelectedItem}");
// ============================================================
// 경우 1: 다중 선택이 비활성화된 경우
// ============================================================
if (!allowMultipleSelection)
{
// 한 개만 선택 가능하므로 단순히 토글
// (선택 상태를 반대로)
ToggleItemSelection(data);
return;
}
// ============================================================
// 경우 2: Ctrl 키를 누르고 클릭
// ============================================================
if (ctrlPressed)
{
// 기존 선택을 유지하면서 현재 아이템을 토글
// 이미 선택됨 → 해제
// 선택 안 됨 → 선택
ToggleItemSelection(data);
// 이 아이템을 "마지막 선택"으로 기억
// (다음 Shift+클릭의 시작점)
lastSelectedItem = data;
}
// ============================================================
// 경우 3: Shift 키를 누르고 클릭
// ============================================================
else if (shiftPressed)
{
if (lastSelectedItem != null)
{
// 마지막 선택부터 현재 아이템까지 범위 선택
SelectRange(lastSelectedItem, data);
}
else
{
// 아직 선택한 게 없으면 단순 선택
ClearSelection();
SelectItem(data);
}
// 이 아이템을 "마지막 선택"으로 기억
lastSelectedItem = data;
}
// ============================================================
// 경우 4: 아무 키도 누르지 않고 클릭 (일반 클릭)
// ============================================================
else
{
if (data.IsSelected)
{
// 이미 선택된 아이템을 다시 클릭 → 선택 해제
DeselectItem(data);
lastSelectedItem = null;
}
else
{
// 선택되지 않은 아이템 클릭 → 다른 선택 모두 해제하고 이것 선택
ClearSelection();
SelectItem(data);
lastSelectedItem = data;
}
}
}
/// <summary>
/// 두 아이템 사이의 모든 아이템을 선택합니다. (범위 선택)
///
/// 시나리오:
/// 평탄화 리스트: [파일1, 파일2, 파일3, 파일4, 파일5]
/// 파일2를 선택한 후 Shift+파일5 클릭
/// → SelectRange(파일2, 파일5)
/// → 파일2, 파일3, 파일4, 파일5 모두 선택됨
///
/// 동작:
/// 1. 평탄화 리스트에서 두 아이템의 인덱스(위치) 찾기
/// 2. 시작과 끝 인덱스를 정렬 (둘 중 작은 값, 큰 값)
/// 3. 그 범위의 모든 아이템 선택
///
/// IndexOf란?
/// 리스트에서 특정 항목의 위치(인덱스)를 찾습니다.
/// 찾으면 위치 반환 (0부터 시작), 못 찾으면 -1 반환
///
/// 예: list = [A, B, C, D]
/// list.IndexOf(C) → 2 (3번째이지만 0부터 시작하므로 2)
///
/// Mathf.Min/Max란?
/// 두 숫자 중 작은/큰 값을 반환합니다.
/// Mathf.Min(5, 2) → 2
/// Mathf.Max(5, 2) → 5
///
/// 매개변수:
/// - startItem: 범위의 시작점 (사용자가 먼저 선택한 아이템)
/// - endItem: 범위의 끝점 (Shift+클릭한 아이템)
/// </summary>
private void SelectRange(TreeListItemData startItem, TreeListItemData endItem)
{
// 평탄화 리스트에서 시작 아이템의 위치(인덱스) 찾기
int startIndex = allItemsFlattened.IndexOf(startItem);
// 평탄화 리스트에서 끝 아이템의 위치(인덱스) 찾기
int endIndex = allItemsFlattened.IndexOf(endItem);
// 두 아이템 모두 리스트에 없으면 종료
if (startIndex == -1 || endIndex == -1)
{
return;
}
// 시작과 끝의 순서를 정렬
// 사용자가 거꾸로 선택할 수도 있으므로 (끝→시작)
// 항상 작은 인덱스 = 시작, 큰 인덱스 = 끝으로 정렬
int minIndex = Mathf.Min(startIndex, endIndex);
int maxIndex = Mathf.Max(startIndex, endIndex);
// 범위 내의 모든 아이템 선택
// i = minIndex부터 i = maxIndex까지 (포함)
for (int i = minIndex; i <= maxIndex; i++)
{
SelectItem(allItemsFlattened[i]);
}
}
/// <summary>
/// 아이템의 선택 상태를 반대로 바꿉니다. (토글)
///
/// 동작:
/// - 선택됨 → 선택 해제
/// - 선택 안 됨 → 선택
///
/// 사용 예:
/// treeItem.IsSelected = false
/// treeList.ToggleItemSelection(treeItem)
/// // 이후 treeItem.IsSelected = true
///
/// 매개변수:
/// - data: 토글할 아이템
/// </summary>
public void ToggleItemSelection(TreeListItemData data)
{
if (data.IsSelected)
{
// 선택됨 → 해제
DeselectItem(data);
}
else
{
// 선택 안 됨 → 선택
SelectItem(data);
}
}
/// <summary>
/// 아이템을 선택합니다.
///
/// 동작:
/// 1. 이미 선택되어 있으면 아무것도 안 함 (중복 선택 방지)
/// 2. 다중 선택이 비활성화되어 있으면 기존 선택 모두 해제
/// 3. 현재 아이템 선택 표시
/// 4. selectedItems 리스트에 추가
/// 5. OnItemSelectionChanged 이벤트 발생
///
/// 매개변수:
/// - data: 선택할 아이템
///
/// 이벤트란?
/// SelectItem이 호출되면 이 이벤트를 구독한 다른 코드들이
/// 자동으로 실행됩니다. (옵션 창 업데이트 등)
/// </summary>
public void SelectItem(TreeListItemData data)
{
// 이미 선택되어 있으면 중복 선택 방지
if (data.IsSelected)
{
return;
}
// 다중 선택이 불가능한 경우, 기존 선택 해제
// (새 선택만 유지)
if (!allowMultipleSelection && selectedItems.Count > 0)
{
// 첫 번째(유일한) 선택 항목 해제
DeselectItem(selectedItems[0]);
}
// 아이템의 선택 상태를 true로 설정
data.IsSelected = true;
// 아직 리스트에 없으면 추가
if (!selectedItems.Contains(data))
{
selectedItems.Add(data);
}
// 이벤트 발생
// ?. 연산자: null 체크 후 호출
// (구독자가 없을 수도 있으므로)
OnItemSelectionChanged?.Invoke(data, true);
}
/// <summary>
/// 아이템의 선택을 해제합니다.
///
/// 동작:
/// 1. 선택되어 있지 않으면 아무것도 안 함 (중복 해제 방지)
/// 2. 아이템의 선택 상태를 false로 설정
/// 3. selectedItems 리스트에서 제거
/// 4. OnItemSelectionChanged 이벤트 발생
///
/// 매개변수:
/// - data: 선택 해제할 아이템
/// </summary>
public void DeselectItem(TreeListItemData data)
{
// 이미 선택 해제되어 있으면 아무것도 안 함
if (!data.IsSelected)
{
return;
}
// 아이템의 선택 상태를 false로 설정
data.IsSelected = false;
// selectedItems 리스트에서 제거
selectedItems.Remove(data);
// 이벤트 발생
OnItemSelectionChanged?.Invoke(data, false);
}
/// <summary>
/// 모든 선택 상태를 한 번에 해제합니다.
///
/// 동작:
/// 1. selectedItems를 복사 (ToList())
/// 왜? 반복 중에 리스트를 수정하면 버그가 발생할 수 있으므로
/// 2. 복사본을 순회하면서 각각 DeselectItem() 호출
///
/// 사용 예:
/// treeList.ClearSelection();
/// // 이후 selectedItems는 비어있음
///
/// 결과:
/// - 모든 아이템의 IsSelected = false
/// - selectedItems 리스트 비어있음
/// - 각 아이템마다 OnItemSelectionChanged 이벤트 발생
/// </summary>
public void ClearSelection()
{
// ToList()로 복사
// = selectedItems의 현재 상태를 새로운 리스트로 만듦
// 반복 중에 원본을 수정해도 안전하게 함
foreach (var item in selectedItems.ToList())
{
DeselectItem(item);
}
}
/// <summary>
/// 다중 선택 가능 여부를 설정합니다.
///
/// 동작:
/// 1. allowMultipleSelection 값 변경
/// 2. 만약 false로 변경하면서 여러 개가 선택되어 있으면:
/// - 첫 번째 아이템만 유지
/// - 나머지 모두 해제
///
/// 시나리오:
/// 현재: 3개 선택된 상태 (파일1, 파일2, 파일3)
/// SetAllowMultipleSelection(false) 호출
/// 결과: 파일1만 선택, 파일2와 파일3은 해제
///
/// 매개변수:
/// - allow: true면 다중 선택 가능, false면 단일 선택만 가능
///
/// 사용 예:
/// // 라디오 버튼 같은 단일 선택 모드로 변경
/// treeList.SetAllowMultipleSelection(false);
/// </summary>
public void SetAllowMultipleSelection(bool allow)
{
// 다중 선택 가능 여부 설정
allowMultipleSelection = allow;
// false로 변경되었는데 여러 개가 선택되어 있으면 정리
if (!allow && selectedItems.Count > 1)
{
// 첫 번째 아이템 보관
var firstItem = selectedItems[0];
// 모든 선택 해제
ClearSelection();
// 첫 번째 아이템만 다시 선택
SelectItem(firstItem);
}
}
/// <summary>
/// 드롭 완료 후 UI를 업데이트합니다.
/// </summary>
private void HandleItemDropped(TreeListItemData draggedItem, TreeListItemData? targetItem)
{
// 평탄화 리스트 업데이트
UpdateFlattenedItemList();
// 필요시 UI 재구성 (예: 자식 컨테이너 위치 변경 등)
Debug.Log($"Item '{draggedItem.Name}' dropped on '{(targetItem?.Name ?? "Root")}'");
}
}
}

View File

@@ -0,0 +1,191 @@
#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
namespace UVC.UI.List.Tree
{
/// <summary>
/// 트리 리스트의 드래그 & 드롭 기능을 관리하는 클래스입니다.
///
/// 역할:
/// 1. 드래그 시작/진행/종료 상태 관리
/// 2. 유효한 드롭 대상 판단 (순환 참조 방지)
/// 3. 아이템 위치 변경 (형제 아이템 간 순서 변경)
/// 4. 아이템 계층 구조 변경 (부모-자식 관계 수정)
///
/// 기능:
/// - 드래그할 아이템과 드롭 대상을 추적
/// - 유효성 검사 (자기 자신에게 드롭 금지, 순환 참조 방지)
/// - 드롭 완료 후 데이터 동기화
/// </summary>
public class TreeListDragDropManager
{
/// <summary>
/// 드래그 중인 아이템의 데이터입니다.
/// </summary>
public TreeListItemData? DraggedItem { get; private set; }
/// <summary>
/// 현재 드래그 중인 상태인지 여부입니다.
/// </summary>
public bool IsDragging { get; private set; }
/// <summary>
/// 드래그 시작 시 발생하는 이벤트입니다.
/// </summary>
public event Action<TreeListItemData>? OnDragStarted;
/// <summary>
/// 드래그 진행 중 발생하는 이벤트입니다.
/// </summary>
public event Action<TreeListItemData, TreeListItemData?>? OnDragEntered;
/// <summary>
/// 드래그 종료 시 발생하는 이벤트입니다.
/// </summary>
public event Action<TreeListItemData>? OnDragEnded;
/// <summary>
/// 드롭 완료 시 발생하는 이벤트입니다.
/// </summary>
public event Action<TreeListItemData, TreeListItemData?>? OnDropped;
/// <summary>
/// 드래그를 시작합니다.
/// </summary>
/// <param name="draggedItem">드래그할 아이템</param>
public void StartDrag(TreeListItemData draggedItem)
{
if (IsDragging)
{
return;
}
DraggedItem = draggedItem;
IsDragging = true;
OnDragStarted?.Invoke(draggedItem);
}
/// <summary>
/// 드래그 중에 마우스가 다른 아이템 위에 있을 때 호출됩니다.
/// </summary>
/// <param name="targetItem">현재 마우스 위에 있는 아이템</param>
public void OnDragOver(TreeListItemData? targetItem)
{
if (!IsDragging || DraggedItem == null)
{
return;
}
OnDragEntered?.Invoke(DraggedItem, targetItem);
}
/// <summary>
/// 드래그를 종료합니다.
/// </summary>
public void EndDrag()
{
if (!IsDragging || DraggedItem == null)
{
return;
}
OnDragEnded?.Invoke(DraggedItem);
IsDragging = false;
DraggedItem = null;
}
/// <summary>
/// 드래그된 아이템을 대상 아이템에 드롭합니다.
/// </summary>
/// <param name="targetItem">드롭 대상 아이템 (null이면 루트 레벨)</param>
/// <param name="insertIndex">대상 부모 내에서의 삽입 위치 (-1이면 끝에 추가)</param>
/// <returns>드롭 성공 여부</returns>
public bool TryDrop(TreeListItemData? targetItem, int insertIndex = -1)
{
if (!IsDragging || DraggedItem == null)
{
return false;
}
// 자기 자신에게 드롭하는 경우 무시
if (targetItem != null && targetItem == DraggedItem)
{
EndDrag();
return false;
}
// 순환 참조 검사 (드래그 아이템이 드롭 대상의 부모인 경우)
if (targetItem != null && IsAncestorOf(DraggedItem, targetItem))
{
EndDrag();
return false;
}
OnDropped?.Invoke(DraggedItem, targetItem);
EndDrag();
return true;
}
/// <summary>
/// 첫 번째 아이템이 두 번째 아이템의 조상인지 확인합니다.
/// 순환 참조를 방지하기 위해 사용됩니다.
/// </summary>
/// <param name="potentialAncestor">조상일 가능성이 있는 아이템</param>
/// <param name="potentialDescendant">후손일 가능성이 있는 아이템</param>
/// <returns>조상-후손 관계이면 true</returns>
public static bool IsAncestorOf(TreeListItemData potentialAncestor, TreeListItemData potentialDescendant)
{
var current = potentialDescendant;
while (current != null)
{
// 재귀적으로 부모를 찾는 로직
// TreeListItemData는 부모 참조가 없으므로, 전체 리스트에서 찾아야 함
// 여기서는 간단한 버전으로, 자식 리스트를 통해 확인
var parent = FindParent(current, potentialAncestor);
if (parent == null)
{
break;
}
if (parent == potentialAncestor)
{
return true;
}
current = parent;
}
return false;
}
/// <summary>
/// 주어진 아이템의 부모를 찾습니다.
/// </summary>
/// <param name="child">자식 아이템</param>
/// <param name="searchLimit">검색 제한 (이 아이템이 나타나면 멈춤)</param>
/// <returns>부모 아이템 (없으면 null)</returns>
private static TreeListItemData? FindParent(TreeListItemData child, TreeListItemData searchLimit)
{
// 이 구현은 전체 트리를 순회하므로 성능이 낮음
// 실제 구현에서는 TreeListItemData에 부모 참조를 추가하는 것이 나음
// 여기서는 시연 목적으로 간단하게 구현
return null;
}
/// <summary>
/// 모든 드래그 & 드롭 상태를 리셋합니다.
/// </summary>
public void Reset()
{
IsDragging = false;
DraggedItem = null;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8046917725784a74594c9d147fe3e0e6

View File

@@ -5,117 +5,831 @@ using UnityEngine.UI;
namespace UVC.UI.List.Tree
{
/// <summary>
/// 트리 리스트의 개별 아이템을 표시하고 관리하는 UI 클래스입니다.
///
/// 역할:
/// 1. 아이템의 이름을 화면에 표시
/// 2. 자식 아이템 펼침/접힘 기능 관리
/// 3. 아이템 선택/선택 해제 표시
/// 4. 사용자 입력(클릭, 키) 처리
/// 5. 데이터 변경 감지 및 UI 업데이트
///
/// 구조:
/// 📦 TreeListItem (이 클래스)
/// ├─ 📝 valueText: 아이템 이름 표시 (TextMeshPro)
/// ├─ 🔘 childExpand: 펼침/접힘 버튼
/// ├─ 📦 childContainer: 자식 아이템들을 담는 컨테이너
/// ├─ 🎨 selectedBg: 선택됨 표시 배경
/// └─ 🔘 itemButton: 클릭 감지 버튼
///
/// MonoBehaviour란?
/// Unity의 모든 GameObject가 가져야 할 기본 클래스입니다.
/// Update, OnDestroy 같은 Unity의 생명주기 메서드를 사용할 수 있습니다.
/// </summary>
public class TreeListItem : MonoBehaviour
{
#region UI (UI Component References)
/// <summary>
/// 이 아이템을 관리하는 부모 TreeList입니다.
///
/// 용도:
/// - 선택 상태 변경 시 TreeList에 알림
/// - 새 자식 아이템 생성 시 프리팹 가져오기
/// - 키보드 입력(Ctrl, Shift) 상태 전달
///
/// [SerializeField]란?
/// Inspector에서 이 값을 직접 할당할 수 있게 해줍니다.
/// (private이지만 Unity가 특별히 접근 가능)
/// </summary>
[SerializeField]
protected TreeList control;
/// <summary>
/// 이 아이템의 이름을 표시하는 텍스트 UI입니다.
///
/// TMPro.TextMeshProUGUI란?
/// TextMeshPro는 Unity의 고급 텍스트 시스템입니다.
/// 일반 Text보다 더 예쁘고 빠릅니다.
///
/// 사용:
/// valueText.text = "새로운 이름"; // 화면에 표시되는 텍스트 변경
/// </summary>
[SerializeField]
protected TMPro.TextMeshProUGUI valueText;
/// <summary>
/// 트리의 자식을 펼침/접힘하는 화살표 버튼입니다.
///
/// 특징:
/// - 자식이 없으면 숨겨집니다
/// - 자식이 펼쳐지면 ▼ 모양
/// - 자식이 접혀있으면 ▶ 모양
/// - 클릭 시 ToggleChild() 메서드 호출
///
/// 회전 애니메이션:
/// DORotate()를 사용해 부드럽게 회전합니다.
/// </summary>
[SerializeField]
protected Button childExpand;
/// <summary>
/// 모든 자식 아이템들을 담는 컨테이너 GameObject입니다.
///
/// 역할:
/// - 자식 아이템들을 묶음으로 보관
/// - 펼침/접힘 시 이 컨테이너 전체를 표시/숨김
/// - SetActive(true/false)로 온/오프 제어
///
/// GameObject란?
/// Unity의 모든 객체(씬의 모든 것)의 기본 단위입니다.
/// 게임 오브젝트는 여러 컴포넌트를 가질 수 있습니다.
/// </summary>
[SerializeField]
protected GameObject childContainer;
/// <summary>
/// 자식 아이템들이 실제로 배치되는 부모 Transform입니다.
///
/// RectTransform란?
/// UI 요소의 위치, 크기, 회전을 관리하는 컴포넌트입니다.
/// Canvas 아래의 모든 UI 요소가 RectTransform을 가집니다.
///
/// 용도:
/// - childRoot.childCount: 현재 몇 개의 자식이 있는지 확인
/// - new TreeListItem을 Instantiate할 때 부모로 지정
///
/// 구조 예:
/// childRoot (이것)
/// ├─ 자식1 (TreeListItem)
/// ├─ 자식2 (TreeListItem)
/// └─ 자식3 (TreeListItem)
/// </summary>
[SerializeField]
protected RectTransform childRoot;
/// <summary>
/// 아이템이 선택되었을 때 배경으로 표시되는 이미지입니다.
///
/// 예: 파일 탐색기에서 파일을 선택했을 때 파일 이름 뒤의 파란 배경
///
/// 동작:
/// - IsSelected가 true면 selectedBg.gameObject.SetActive(true)
/// - IsSelected가 false면 selectedBg.gameObject.SetActive(false)
/// - 모든 레벨의 선택된 아이템의 selectedBg 왼쪽이 정렬됨
/// </summary>
[SerializeField]
protected Image selectedBg;
/// <summary>
/// 아이템 전체를 클릭 가능하게 하는 버튼 컴포넌트입니다.
///
/// Button이란?
/// 사용자가 클릭하면 onClick 이벤트를 발생시킵니다.
/// 이 경우 OnItemClicked() 메서드가 호출됩니다.
///
/// onClick.AddListener는?
/// 버튼이 클릭되면 리스너 함수를 호출하라는 뜻입니다.
/// 예: itemButton.onClick.AddListener(OnItemClicked);
/// → itemButton을 클릭하면 OnItemClicked() 실행
/// </summary>
[SerializeField]
protected Button itemButton;
#endregion
#region (Data Fields)
/// <summary>
/// 이 UI 아이템이 표시하는 데이터 객체입니다.
///
/// 구조:
/// TreeListItem (UI - 화면에 보이는 것)
/// ↓
/// TreeListItemData (데이터 - 실제 정보)
///
/// 관계:
/// - TreeListItemData에 이름, 자식 목록, 선택 상태 등이 저장됨
/// - TreeListItem은 이 데이터를 화면에 표시하고 상호작용 처리
///
/// 예:
/// data.Name = "폴더"
/// → valueText.text = "폴더"로 화면에 표시
///
/// data.IsSelected = true
/// → selectedBg가 활성화되어 선택 표시
///
/// ? 연산자 (nullable):
/// data는 null일 수 있습니다.
/// 따라서 사용 전에 null 체크를 해야 합니다.
/// if (data == null) return;
/// </summary>
protected TreeListItemData? data;
/// <summary>
/// data 속성을 읽기 전용으로 공개합니다.
///
/// 문법:
/// public TreeListItemData? Data => data;
/// = getter 전용 프로퍼티
/// = 외부에서 이 값을 읽을 수 있지만 변경할 수 없음
///
/// 사용 예:
/// if (treeItem.Data != null)
/// {
/// Debug.Log(treeItem.Data.Name); // 읽기만 가능
/// }
/// </summary>
public TreeListItemData? Data => data;
/// <summary>
/// 펼침/접힘 애니메이션이 진행 중인지를 나타내는 플래그입니다.
///
/// 용도: 애니메이션이 끝나기 전에 다시 클릭하는 것을 방지합니다.
///
/// 시나리오:
/// 1. 사용자가 화살표 버튼 클릭
/// 2. isAnimating = true (애니메이션 시작)
/// 3. 0.3초 동안 화살표가 회전
/// 4. 애니메이션 완료 → isAnimating = false
///
/// 중간에 다시 클릭해도:
/// if (isAnimating) return; ← 여기서 무시됨
///
/// 이렇게 하는 이유: 애니메이션이 겹치면 버그가 발생할 수 있음
/// </summary>
protected bool isAnimating = false;
public void Init(TreeListItemData data, TreeList control)
#endregion
#region (Initialization)
/// <summary>
/// 이 TreeListItem을 초기화합니다.
///
/// 호출 시점:
/// - 새로운 TreeListItem이 생성될 때
/// - Instantiate 직후 에 Init()이 호출됨
///
/// 초기화 과정:
/// 1. UI 요소 설정 (이름 표시, 이벤트 연결)
/// 2. 자식이 있으면 자식 UI 생성
/// 3. 데이터 변경 이벤트 구독
/// 4. 버튼 클릭 이벤트 구독
/// 5. 선택 상태 UI 업데이트
/// 6. 선택된 배경 위치 정렬
///
/// 매개변수:
/// - data: 표시할 데이터
/// - control: 부모 TreeList (선택 관리, 클릭 처리 등)
///
/// 사용 예:
/// var item = Instantiate(prefab);
/// item.Init(treeData, treeList);
/// </summary>
public void Init(TreeListItemData data, TreeList control, TreeListDragDropManager dragDropManager)
{
// 1. 기본 정보 할당
this.control = control;
this.data = data;
valueText.text = data.generalName;
Debug.Log("Creating children for " + data.generalName+", "+ data.children.Count);
if (data.children.Count == 0)
// 2. 아이템 이름을 UI에 표시
valueText.text = data.Name;
// 3. 자식 아이템들을 UI로 생성
Debug.Log("Creating Children for " + data.Name + ", " + data.Children.Count);
// 자식이 없는 경우
if (data.Children.Count == 0)
{
// 펼침 버튼 숨기기 (자식이 없으니까 펼칠 게 없음)
childExpand.gameObject.SetActive(false);
// 자식 컨테이너도 숨기기
childContainer.SetActive(false);
}
// 자식이 있는 경우
else
{
foreach (var childData in data.children)
// 각 자식 데이터에 대해 UI 생성
foreach (var childData in data.Children)
{
CreateItem(childData);
CreateItem(childData); // 재귀적으로 트리 구조 생성
}
// 펼침 버튼과 컨테이너 표시
childExpand.gameObject.SetActive(true);
childContainer.SetActive(true);
// 화살표 방향 설정 (초기에는 펼쳐짐)
SetExpand();
}
// 4. 데이터 변경 감지 구독
// 데이터의 이름, 자식 목록 등이 변경되면 OnDataChanged 호출
data.OnDataChanged += OnDataChanged;
// 데이터의 선택 상태가 변경되면 OnSelectionChanged 호출
data.OnSelectionChanged += OnSelectionChanged;
// 5. 버튼 클릭 이벤트 구독
// 아이템을 클릭하면 OnItemClicked 메서드 호출
if (itemButton != null)
{
itemButton.onClick.AddListener(OnItemClicked);
}
// 6. 현재 선택 상태 UI에 반영
// data.IsSelected가 true이면 배경 표시
UpdateSelectionUI();
// 7. 선택 배경의 왼쪽 정렬 (모든 레벨에서 일직선)
AlignSelectedBgToRoot();
// ✅ 드래그 & 드롭 핸들러 설정
var dragHandler = gameObject.GetComponent<TreeListItemDragHandler>();
if (dragHandler == null)
{
dragHandler = gameObject.AddComponent<TreeListItemDragHandler>();
Debug.Log($"[TreeListItem.Init] 새로운 TreeListItemDragHandler 추가: {data.Name}");
}
dragHandler.SetDragDropManager(this, control, dragDropManager);
dragHandler.enabled = true;
Debug.Log($"[TreeListItem.Init] 초기화 완료: {data.Name}");
}
#endregion
public void ToggleChild()
#region (Data Change Handlers)
/// <summary>
/// 데이터가 변경되었을 때 호출되는 메서드입니다.
///
/// 호출되는 경우:
/// 1. data.Name이 변경됨
/// 2. data.Children이 추가/제거됨
/// 3. data.IsExpanded가 변경됨
///
/// 역할:
/// - 화면에 표시된 내용과 데이터를 동기화
/// - 필요한 UI 컴포넌트 업데이트
///
/// 데이터 바인딩이란?
/// 데이터가 변경되면 UI가 자동으로 업데이트되는 패턴입니다.
/// 이렇게 하면 데이터와 UI를 동기화 유지하기 쉽습니다.
/// </summary>
private void OnDataChanged(TreeListItemData changedData)
{
// 애니메이션이 진행 중이면 중복 호출을 방지합니다.
if (isAnimating)
if (data == null) return;
// 이름이 변경된 경우
if (valueText.text != data.Name)
{
valueText.text = data.Name;
}
// 자식 목록이 변경된 경우
// UI의 자식 개수와 데이터의 자식 개수 비교
int currentChildCount = childRoot.childCount;
if (currentChildCount != data.Children.Count)
{
// 자식이 추가된 경우
if (data.Children.Count > currentChildCount)
{
// 새로운 자식들을 UI로 생성
for (int i = currentChildCount; i < data.Children.Count; i++)
{
CreateItem(data.Children[i]);
}
}
// 자식이 제거된 경우
else if (data.Children.Count < currentChildCount)
{
// 필요에 따라 UI 아이템 제거 처리
while (childRoot.childCount > data.Children.Count)
{
// 마지막 자식부터 하나씩 삭제
Destroy(childRoot.GetChild(childRoot.childCount - 1).gameObject);
}
}
// 펼침 버튼 표시 여부 결정
// 자식이 있으면 버튼 표시, 없으면 숨김
childExpand.gameObject.SetActive(data.Children.Count > 0);
// 자식이 있고 컨테이너가 비활성화 상태면 활성화
if (data.Children.Count > 0 && !childContainer.activeSelf)
{
childContainer.SetActive(true);
SetExpand();
}
}
}
#endregion
#region (Selection Management)
/// <summary>
/// 아이템의 선택 상태가 변경되었을 때 호출됩니다.
///
/// 호출 시기:
/// - data.IsSelected = true/false 일 때
///
/// 동작:
/// - UpdateSelectionUI() 호출해서 화면 업데이트
///
/// 매개변수:
/// - changedData: 변경된 데이터 (이 경우 항상 this.data와 같음)
/// - isSelected: 새로운 선택 상태 (true = 선택됨, false = 해제됨)
/// </summary>
private void OnSelectionChanged(TreeListItemData changedData, bool isSelected)
{
// 선택 상태 UI 업데이트 (배경 표시/숨김)
UpdateSelectionUI();
}
/// <summary>
/// 선택 상태에 따라 UI를 업데이트합니다.
///
/// 동작:
/// - data.IsSelected = true → selectedBg 표시 (배경 보이기)
/// - data.IsSelected = false → selectedBg 숨김 (배경 숨기기)
///
/// 예시:
/// data.IsSelected = true
/// ↓
/// selectedBg.gameObject.SetActive(true)
/// ↓
/// 화면에 파란 배경이 나타남
/// </summary>
private void UpdateSelectionUI()
{
if (data == null) return;
// IsSelected 상태에 따라 배경 표시/숨김
selectedBg.gameObject.SetActive(data.IsSelected);
}
#endregion
#region (Position Alignment)
/// <summary>
/// 모든 선택 배경(selectedBg)의 왼쪽 위치를 정렬합니다.
///
/// 목표:
/// 📌 폴더1
/// 📌├─ 파일1
/// 📌├─ 파일2
/// 📌└─ 폴더2
/// 📌├─ 파일3
///
/// 모든 선택 배경의 왼쪽이 📌 위치에서 시작하도록 합니다.
///
/// 이렇게 하는 이유:
/// - 깔끔한 UI 표현
/// - 다중 레벨 트리에서 시각적 일관성 유지
///
/// 좌표 변환:
/// - 월드 좌표(World Coordinate): 게임 전체에서의 절대 위치
/// - 로컬 좌표(Local Coordinate): 부모 기준 상대 위치
/// - TransformPoint: 로컬 좌표를 월드 좌표로 변환
/// </summary>
private void AlignSelectedBgToRoot()
{
if (selectedBg == null) return;
// 최상위 부모(루트)의 TreeListItem 찾기
TreeListItem? rootItem = GetRootTreeListItem();
// 루트가 없거나 이 아이템이 루트면 정렬할 필요 없음
if (rootItem == null || rootItem == this)
{
return;
}
// 루트의 selectedBg와 현재 selectedBg의 RectTransform 가져오기
RectTransform rootSelectedBgRect = rootItem.selectedBg.rectTransform;
RectTransform currentSelectedBgRect = selectedBg.rectTransform;
// 루트의 배경 왼쪽 끝 위치를 월드 좌표로 계산
// TransformPoint: 로컬 좌표 → 월드 좌표 변환
// xMin: RectTransform의 왼쪽 끝 위치
Vector3 rootLeftPos = rootSelectedBgRect.TransformPoint(new Vector3(rootSelectedBgRect.rect.xMin, 0, 0));
// 현재 배경의 왼쪽 끝 위치를 월드 좌표로 계산
Vector3 currentLeftPos = currentSelectedBgRect.TransformPoint(new Vector3(currentSelectedBgRect.rect.xMin, 0, 0));
// 두 위치의 차이 계산
// 루트 위치 - 현재 위치 = 조정해야 할 거리
Vector3 offset = rootLeftPos - currentLeftPos;
// 현재 배경의 위치를 조정
// anchoredPosition: 부모 기준 위치 (로컬 좌표)
currentSelectedBgRect.anchoredPosition += new Vector2(offset.x, 0);
}
#endregion
#region (Hierarchy Navigation)
/// <summary>
/// 최상위 부모(루트)의 TreeListItem을 찾습니다.
///
/// 트리 구조:
/// TreeListItem1 (루트)
/// ├─ TreeListItem2 (부모)
/// │ └─ TreeListItem3 (자식 ← 이 메서드를 호출하면)
/// └─ TreeListItem4
///
/// 반환값: TreeListItem1
///
/// 동작:
/// 1. 현재 객체의 부모 확인
/// 2. 부모가 TreeListItem을 가지는지 확인
/// 3. 가지면 그 부모의 GetRootTreeListItem() 재귀 호출
/// 4. 루트에 도달할 때까지 반복
///
/// 재귀(Recursion)란?
/// 함수가 자기 자신을 호출하는 것입니다.
/// 계층 구조를 탐색하는 데 효과적입니다.
/// </summary>
private TreeListItem? GetRootTreeListItem()
{
// 현재 객체의 부모부터 시작
Transform current = transform.parent;
// 루트에 도달할 때까지 계속 탐색
while (current != null)
{
// 현재 레벨에 TreeListItem 컴포넌트가 있는지 확인
TreeListItem parentItem = current.GetComponent<TreeListItem>();
if (parentItem != null)
{
// TreeListItem을 찾았으므로, 그 부모의 루트를 다시 찾음 (재귀)
TreeListItem? grandParent = parentItem.GetRootTreeListItem();
if (grandParent != null)
{
// 할아버지 레벨의 루트 반환
return grandParent;
}
else
{
// 할아버지가 없으면 이 부모가 루트
return parentItem;
}
}
// 현재 레벨에 TreeListItem이 없으면 더 상위로
current = current.parent;
}
// 루트까지 탐색해도 TreeListItem을 찾지 못함 (이 객체가 루트)
return null;
}
#endregion
#region (Input Handling)
/// <summary>
/// 아이템을 클릭했을 때 호출됩니다.
///
/// 호출 시점:
/// - itemButton.onClick 이벤트 발생 시
/// = 사용자가 이 아이템을 마우스로 클릭했을 때
///
/// 처리 순서:
/// 1. 데이터의 OnClickAction 실행 (있으면)
/// 2. Ctrl, Shift 키 상태 감지
/// 3. TreeList에 클릭 정보 전달 (다중 선택 로직)
///
/// 키 입력:
/// - Ctrl 클릭: 현재 선택을 유지하면서 이 아이템 토글 (다중 선택)
/// - Shift 클릭: 마지막 선택부터 이 아이템까지 범위 선택
/// - 일반 클릭: 이 아이템만 선택
///
/// 파일 탐색기와 동일한 동작:
/// Windows 탐색기를 생각하면 쉽습니다.
/// - 클릭: 한 파일만 선택
/// - Ctrl+클릭: 여러 파일 선택 (기존 선택 유지)
/// - Shift+클릭: 범위 선택
/// </summary>
private void OnItemClicked()
{
if (data == null) return;
// 1. 데이터에 등록된 클릭 액션 실행 (있으면)
// ?. 연산자: null이면 실행 안 함
// 예: 펼침/접힘 버튼 클릭 시 자동으로 호출되는 액션
data.OnClickAction?.Invoke(data);
// 2. Ctrl 키 상태 감지
// LeftControl(왼쪽) 또는 RightControl(오른쪽) 중 하나라도 누르고 있으면 true
bool ctrlPressed = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl);
// 3. Shift 키 상태 감지
bool shiftPressed = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
// 4. 부모 TreeList에 클릭 정보 전달
// TreeList는 이 정보를 받아서 선택 로직을 처리합니다.
// (단일 선택 / 다중 선택 / 범위 선택 등)
control.OnItemClicked(data, ctrlPressed, shiftPressed);
}
#endregion
#region / (Expand/Collapse)
/// <summary>
/// 자식 아이템의 펼침/접힘을 토글합니다. (펼쳐있으면 접고, 접혀있으면 펼침)
///
/// 호출 시점:
/// - childExpand 버튼을 클릭했을 때
///
/// 동작:
/// 1. 애니메이션이 진행 중이면 무시 (중복 클릭 방지)
/// 2. data.IsExpanded 토글 (true ↔ false)
/// 3. childContainer 활성화/비활성화
/// 4. 0.3초에 걸쳐 화살표 회전 애니메이션 실행
///
/// UI 피드백:
/// 애니메이션이 있으면 사용자가 반응을 확인할 수 있습니다.
/// 즉시 완료되는 것보다 더 자연스럽고 좋은 경험입니다.
/// </summary>
public void ToggleChild()
{
// 1. 애니메이션 진행 중이면 중복 호출 방지
if (isAnimating) return;
// 플래그 설정: 애니메이션 시작
isAnimating = true;
data.isExpanded = !data.isExpanded;
// 2. 펼침/접힘 상태 토글
// ! 연산자: 반대로 변경 (true → false, false → true)
data!.IsExpanded = !data.IsExpanded;
childContainer.SetActive(data.isExpanded);
// 3. 자식 컨테이너 표시/숨김
// IsExpanded가 true면 표시, false면 숨김
childContainer.SetActive(data.IsExpanded);
// 4. 0.3초에 걸쳐 펼침/접힘 애니메이션 실행
SetExpand(0.3f);
}
/// <summary>
/// 펼침/접힘 화살표의 회전 애니메이션을 실행합니다.
///
/// 파라미터:
/// - duration: 애니메이션 지속 시간 (초)
/// 기본값 0.0 = 즉시 완료 (애니메이션 없음)
/// 0.3 = 0.3초에 걸쳐 회전
///
/// 동작:
/// - 펼쳐짐 (IsExpanded=true): 화살표를 0도로 회전 (▼ 모양)
/// - 접혀짐 (IsExpanded=false): 화살표를 90도로 회전 (▶ 모양)
/// - 애니메이션 완료 후 isAnimating 플래그 리셋
///
/// DORotate란? (Tweening 라이브러리)
/// 부드럽게 회전하는 애니메이션을 쉽게 만들어줍니다.
/// duration 시간에 걸쳐 지정된 각도까지 회전합니다.
///
/// OnComplete란?
/// 애니메이션이 완료되면 호출되는 콜백입니다.
/// 람다 식(=>)으로 익명 함수를 정의합니다.
/// </summary>
private void SetExpand(float duration = 0.0f)
{
// 1. 자식이 있는지 확인해서 펼침 버튼 표시 여부 결정
childExpand.gameObject.SetActive(childRoot.childCount > 0);
// 2. 자식이 있는 경우에만 애니메이션 실행
if (childRoot.childCount > 0)
{
if (data != null) data.isExpanded = childContainer.activeSelf == true;
// 애니메이션을 위해 현재 각도에서 목표 각도로 회전시킵니다.
childExpand.transform.DORotate(new Vector3(0, 0, data.isExpanded ? 0 : 90), duration)
// UI 상태와 데이터 동기화
if (data != null) data.IsExpanded = childContainer.activeSelf == true;
// 3. 화살표 회전 애니메이션 실행
// DORotate(목표 각도, 지속 시간)
// IsExpanded가 true면 0도 (▼), false면 90도 (▶)
childExpand.transform.DORotate(new Vector3(0, 0, data.IsExpanded ? 0 : 90), duration)
.OnComplete(() =>
{
// 애니메이션이 완료되면 플래그를 초기화합니다.
// 4. 애니메이션이 완료되면 플래그 리셋
// 이제 다시 ToggleChild() 호출 가능
isAnimating = false;
});
}
else
{
if(data != null) data.isExpanded = false;
// 자식이 없으면 IsExpanded는 항상 false
if (data != null) data.IsExpanded = false;
}
}
public void AddChild()
{
TreeListItemData itemData = new TreeListItemData
{
generalName = data?.generalName + "." + (data?.children.Count + 1),
};
AddChild(itemData);
}
#endregion
#region (Child Management)
/// <summary>
/// 새로운 자식 아이템을 추가합니다.
///
/// 호출 시점:
/// - 프로그램이 런타임에 새 자식을 추가할 때
/// - 예: 폴더에 파일을 추가했을 때
///
/// 동작:
/// 1. 새 자식 UI 생성 (CreateItem)
/// 2. 자식 컨테이너 활성화
/// 3. 펼침 애니메이션 (자동으로 펼침)
///
/// 매개변수:
/// - data: 추가할 자식 데이터
///
/// 사용 예:
/// treeItem.AddChild(newChildData);
/// // → 새 자식이 자동으로 UI에 추가되고 펼쳐짐
/// </summary>
public void AddChild(TreeListItemData data)
{
// 1. 새 자식 UI 생성
CreateItem(data);
// 2. 자식 컨테이너 활성화 (표시)
childContainer.SetActive(true);
// 3. 0.3초에 걸쳐 펼침 애니메이션 실행
// 사용자가 새 자식이 추가되었음을 자연스럽게 인식
SetExpand(0.3f);
}
/// <summary>
/// 자식 데이터를 받아 UI TreeListItem으로 생성합니다.
///
/// 호출 시점:
/// - Init()에서 기존 자식들을 UI로 생성할 때
/// - OnDataChanged()에서 추가된 자식을 UI로 생성할 때
/// - AddChild()에서 새 자식을 추가할 때
///
/// 동작:
/// 1. control.ItemPrefab를 childRoot 아래에 인스턴스화
/// 2. 생성된 item의 Init() 메서드 호출
/// 3. 생성된 item 반환
///
/// Instantiate란?
/// 프리팹(템플릿)을 복제해서 새로운 객체를 만드는 함수입니다.
/// Instantiate(프리팹, 부모, 복제)
///
/// 매개변수:
/// - data: 생성할 아이템의 데이터
///
/// 반환값:
/// - 생성된 TreeListItem 컴포넌트
///
/// 재귀 구조:
/// CreateItem은 계속 자식을 생성하므로
/// 깊이 있는 트리 구조를 만들 수 있습니다.
/// </summary>
protected TreeListItem CreateItem(TreeListItemData data)
{
TreeListItem item = GameObject.Instantiate<TreeListItem>(control.ItemPrefab, childRoot);
item.Init(data, control);
// 1. 프리팹을 복제해서 새로운 TreeListItem 생성
// Instantiate<T>(원본, 부모, 옵션)
// control.ItemPrefab: UI 아이템 템플릿
// childRoot: 새 아이템의 부모 Transform
TreeListItem item = GameObject.Instantiate<TreeListItem>(
control.ItemPrefab, // 복제할 프리팹
childRoot // 부모로 배치할 위치
);
// 2. 생성된 아이템 초기화
item.Init(data, control, control.DragDropManager);
// 3. 생성된 아이템 반환
return item;
}
#endregion
#region (Deletion)
/// <summary>
/// 이 TreeListItem과 관련된 모든 리소스를 정리하고 삭제합니다.
///
/// 호출 시점:
/// - 트리에서 아이템을 제거하고 싶을 때
/// - 프로그램이 명시적으로 아이템을 삭제할 때
///
/// 정리 작업:
/// 1. 데이터 변경 이벤트 구독 해제
/// 2. 버튼 클릭 이벤트 구독 해제
/// 3. GameObject 삭제
///
/// 왜 구독을 해제하나?
/// - 이벤트를 구독하는 것은 메모리 연결을 만듭니다.
/// - 해제하지 않으면 메모리 누수가 발생할 수 있습니다.
/// - 따라서 삭제 전에 반드시 해제해야 합니다.
///
/// 메모리 누수란?
/// 불필요한 메모리가 해제되지 않고 계속 사용되는 문제입니다.
/// 게임이 계속 실행되면서 메모리 사용량이 증가해서
/// 결국 게임이 느려지거나 충돌할 수 있습니다.
/// </summary>
public void Delete()
{
// 1. 데이터 변경 이벤트 구독 해제
if (data != null)
{
// -= 연산자: 이벤트에서 리스너 제거
data.OnDataChanged -= OnDataChanged;
data.OnSelectionChanged -= OnSelectionChanged;
}
// 2. 버튼 클릭 이벤트 구독 해제
if (itemButton != null)
{
itemButton.onClick.RemoveListener(OnItemClicked);
}
// 3. 이 GameObject 삭제
// 게임 실행 중에 오브젝트를 제거합니다.
GameObject.Destroy(gameObject);
}
/// <summary>
/// GameObject가 파괴될 때 자동으로 호출되는 Unity 메서드입니다.
///
/// 호출 시점:
/// - Destroy(gameObject) 호출 후 실제 삭제되기 직전
/// - 게임이 종료될 때
/// - 씬이 언로드될 때
///
/// 용도:
/// - 정리 작업 (Clean-up)
/// - 리소스 해제
/// - 이벤트 구독 해제
///
/// Delete()와의 차이:
/// - Delete(): 명시적으로 호출하는 메서드
/// - OnDestroy(): Unity에서 자동으로 호출하는 메서드
/// - 둘 다 같은 정리 작업을 합니다 (중복 방지)
/// </summary>
private void OnDestroy()
{
// 1. 데이터 변경 이벤트 구독 해제
if (data != null)
{
data.OnDataChanged -= OnDataChanged;
data.OnSelectionChanged -= OnSelectionChanged;
}
// 2. 버튼 클릭 이벤트 구독 해제
if (itemButton != null)
{
itemButton.onClick.RemoveListener(OnItemClicked);
}
}
#endregion
}
}
}

View File

@@ -5,39 +5,542 @@ using System.Collections.Generic;
namespace UVC.UI.List.Tree
{
public class TreeListItemData : InfiniteScrollData
/// <summary>
/// 트리 구조 리스트에서 각 아이템이 갖는 데이터 클래스입니다.
///
/// 트리 구조란? 폴더-파일처럼 상위(부모)와 하위(자식) 관계를 가진 계층 구조입니다.
/// 예: 📁 폴더
/// ├─ 📄 파일1
/// ├─ 📄 파일2
/// └─ 📁 하위폴더
/// └─ 📄 파일3
///
/// 이 클래스는 InfiniteScrollData를 상속하여 UI 스크롤 리스트와 연동됩니다.
/// </summary>
public class TreeListItemData
{
/// <summary>
/// 일반 아이템 이름
/// </summary>
public string generalName = string.Empty;
#region (Events)
/// <summary>
/// 일반 아이템 옵션
/// 데이터가 변경되었을 때 발생하는 이벤트입니다.
///
/// 용도: 이 데이터의 속성(Name, Option 등)이 변경되면
/// UI에 자동으로 반영되도록 통지합니다.
///
/// 사용 예:
/// treeItem.OnDataChanged += (data) => Debug.Log("데이터 변경됨!");
/// </summary>
public string generalOption = string.Empty;
public Action<TreeListItemData>? OnDataChanged;
/// <summary>
/// 자식 확장 여부
/// 선택 상태가 변경되었을 때 발생하는 이벤트입니다.
///
/// 용도: 사용자가 이 아이템을 클릭해서 선택 또는 선택 해제했을 때
/// 다른 시스템(예: 옵션 창)에 알려줍니다.
///
/// 매개변수:
/// - TreeListItemData: 변경된 아이템 자신
/// - bool: true면 선택됨, false면 선택 해제됨
///
/// 사용 예:
/// treeItem.OnSelectionChanged += (data, isSelected) => {
/// Debug.Log($"{data.Name}이 {(isSelected ? "선택됨" : "해제됨")}");
/// };
/// </summary>
internal bool isExpanded = false;
public Action<TreeListItemData, bool>? OnSelectionChanged;
#endregion
#region (Private Fields)
/// <summary>
/// 카테고리 확장/축소 버튼 클릭 시 호출될 액션입니다.
/// 아이템의 이름을 저장하는 비공개 필드입니다.
///
/// '_' 접두사를 붙인 이유:
/// 실제 데이터는 여기 저장하고, public 프로퍼티(Name)를 통해
/// 접근을 제어합니다. (캡슐화)
///
/// 예: _name = "폴더", Name 프로퍼티로 접근
/// </summary>
private string _name = string.Empty;
/// <summary>
/// 아이템의 추가 옵션 정보를 저장합니다.
///
/// 용도: 읽기 전용, 숨김 속성 등의 추가 설정 정보
/// 예: "readonly", "hidden", "locked" 등
/// </summary>
private string _option = string.Empty;
/// <summary>
/// 이 아이템의 자식들이 펼쳐져 있는지 여부를 나타냅니다.
///
/// true: 자식들이 표시됨 (▼ 펼침 상태)
/// false: 자식들이 숨겨짐 (▶ 접혀있는 상태)
///
/// 자식이 없으면 이 값은 의미가 없습니다.
/// </summary>
private bool _isExpanded = false;
/// <summary>
/// 현재 아이템이 사용자에게 선택되어 있는지를 나타냅니다.
///
/// true: 선택됨 (보통 배경색이 다르게 표시)
/// false: 선택 안 됨 (기본 상태)
///
/// 예: 파일 탐색기에서 파일을 클릭했을 때 그 파일의 _isSelected = true
/// </summary>
private bool _isSelected = false;
/// <summary>
/// 이 아이템의 하위 아이템들을 모두 저장하는 리스트입니다.
///
/// 트리 구조 예:
/// 부모 (이 객체)
/// ├─ 자식1
/// ├─ 자식2
/// └─ 자식3
///
/// _children 리스트에 [자식1, 자식2, 자식3]이 저장됩니다.
/// 자식이 없으면 빈 리스트입니다.
/// </summary>
private List<TreeListItemData> _children = new List<TreeListItemData>();
#endregion
#region (Public Properties)
/// <summary>
/// 아이템의 이름을 가져오거나 설정합니다.
///
/// 동작:
/// - 가져올 때(get): _name의 값을 반환합니다.
/// - 설정할 때(set):
/// 1. 기존 값과 비교해서 정말 달라졌는지 확인
/// 2. 다르면 새 값으로 변경
/// 3. OnDataChanged 이벤트를 발생시켜 UI에 알림
///
/// 이렇게 하는 이유: 같은 값으로 변경되는 불필요한 갱신을 피합니다.
///
/// 사용 예:
/// treeItem.Name = "새로운 이름"; // 자동으로 UI 업데이트
/// string currentName = treeItem.Name; // 이름 읽기
/// </summary>
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value;
NotifyDataChanged(); // UI에 변경을 알림
}
}
}
/// <summary>
/// 아이템의 추가 옵션을 가져오거나 설정합니다.
///
/// 동작: Name 프로퍼티와 동일하게 작동합니다.
/// 변경될 때마다 OnDataChanged 이벤트를 발생시킵니다.
///
/// 사용 예:
/// treeItem.Option = "readonly"; // 읽기 전용으로 설정
/// </summary>
public string Option
{
get => _option;
set
{
if (_option != value)
{
_option = value;
NotifyDataChanged();
}
}
}
/// <summary>
/// 이 아이템의 자식들이 펼쳐져 있는지 여부를 가져오거나 설정합니다.
///
/// internal 접근제한자 이유:
/// 이것은 UI 시스템에서만 관리해야 하므로 외부에서 직접 접근할 수 없습니다.
/// (같은 어셈블리 내부에서만 접근 가능)
///
/// true: 자식들이 표시됨 (트리 펼침)
/// false: 자식들이 숨겨짐 (트리 접힘)
///
/// 사용 예: (UI 시스템에서만)
/// treeItem.IsExpanded = true; // 자식들을 표시
/// </summary>
internal bool IsExpanded
{
get => _isExpanded;
set
{
if (_isExpanded != value)
{
_isExpanded = value;
NotifyDataChanged(); // 트리 구조 UI 갱신
}
}
}
/// <summary>
/// 이 아이템이 선택되어 있는지 여부를 가져오거나 설정합니다.
///
/// 중요한 차이점: 다른 프로퍼티는 OnDataChanged를 호출하지만,
/// 이것은 OnSelectionChanged를 호출합니다.
/// 왜? 선택 상태는 UI 갱신이 아니라
/// 선택 이벤트 처리가 필요하기 때문입니다.
///
/// 사용 예:
/// treeItem.IsSelected = true; // 아이템 선택
/// if (treeItem.IsSelected) { ... } // 선택 여부 확인
/// </summary>
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
// OnSelectionChanged 이벤트 발생
// 예: "폴더가 선택되었습니다" 같은 처리를 수행
OnSelectionChanged?.Invoke(this, value);
}
}
}
/// <summary>
/// 사용자가 확장/축소 버튼을 클릭했을 때 호출될 함수입니다.
///
/// 용도: 트리의 화살표(▼/▶) 버튼을 클릭했을 때
/// IsExpanded 상태를 변경하는 로직을 실행합니다.
///
/// 누가 등록하나? UI 시스템 (TreeListItem 클래스)
///
/// 사용 예:
/// treeItem.OnClickAction = (data) => {
/// data.IsExpanded = !data.IsExpanded; // 펼침/접힘 토글
/// };
/// </summary>
public Action<TreeListItemData>? OnClickAction;
internal List<TreeListItemData> children = new List<TreeListItemData>();
public TreeListItemData() { }
public TreeListItemData(string generalName, List<TreeListItemData>? childrenItemData = null)
/// <summary>
/// 이 아이템의 모든 자식 아이템들을 가져오거나 설정합니다.
///
/// internal 접근제한자 이유:
/// 자식 리스트는 AddChild, RemoveChild, ClearChildren 메서드로만
/// 수정되어야 데이터 일관성이 보장됩니다.
///
/// 동작:
/// - 가져올 때(get): _children 리스트 반환
/// - 설정할 때(set):
/// 1. null이면 빈 리스트로 설정 (null 방지)
/// 2. OnDataChanged 이벤트 발생
///
/// ?? 연산자 설명:
/// childrenItemData ?? new List<>()
/// = childrenItemData가 null이면 빈 리스트 사용
/// null이 아니면 childrenItemData 사용
/// </summary>
internal List<TreeListItemData> Children
{
this.generalName = generalName;
if (childrenItemData != null)
get => _children;
set
{
this.children = childrenItemData;
_children = value ?? new List<TreeListItemData>();
NotifyDataChanged();
}
}
#endregion
#region (Constructors)
/// <summary>
/// 빈 TreeListItemData를 생성합니다.
///
/// 초기값:
/// - Name: 빈 문자열
/// - Option: 빈 문자열
/// - IsExpanded: false (접혀있음)
/// - Children: 빈 리스트 (자식 없음)
///
/// 사용 예:
/// var item = new TreeListItemData();
/// item.Name = "새 폴더";
/// </summary>
public TreeListItemData()
{
_name = string.Empty;
_option = string.Empty;
_isExpanded = false;
_children = new List<TreeListItemData>();
}
/// <summary>
/// 이름과 선택적으로 자식 목록을 지정하여 TreeListItemData를 생성합니다.
///
/// 매개변수:
/// - generalName: 아이템의 이름 (필수)
/// - childrenItemData: 초기 자식 목록 (선택사항, null 가능)
///
/// 초기값:
/// - Name: generalName
/// - Option: 빈 문자열
/// - IsExpanded: false
/// - Children: childrenItemData (null이면 빈 리스트)
///
/// 사용 예:
/// // 간단한 아이템 생성
/// var item1 = new TreeListItemData("폴더");
///
/// // 자식을 포함해서 생성
/// var children = new List<TreeListItemData> { item1 };
/// var parent = new TreeListItemData("부모 폴더", children);
/// </summary>
public TreeListItemData(string generalName, List<TreeListItemData>? childrenItemData = null)
{
_name = generalName;
_option = string.Empty;
_isExpanded = false;
_children = childrenItemData ?? new List<TreeListItemData>();
}
#endregion
#region (Child Management Methods)
/// <summary>
/// 이 아이템에 자식 아이템을 추가합니다.
///
/// 동작:
/// 1. 자식을 _children 리스트에 추가
/// 2. OnDataChanged 이벤트 발생 (UI 트리 구조 갱신)
///
/// 사용 예:
/// parent.AddChild(child); // 부모에 자식 추가
///
/// 트리 구조 변화:
/// 변경 전: 변경 후:
/// 부모 부모
/// ├─ 자식1 ├─ 자식1
/// └─ 자식2 ├─ 자식2
/// └─ 새자식
/// </summary>
public void AddChild(TreeListItemData child)
{
_children.Add(child);
NotifyDataChanged(); // UI에 트리 구조 변경 알림
}
/// <summary>
/// 이 아이템에서 지정된 자식 아이템을 제거합니다.
///
/// 동작:
/// 1. 자식을 _children 리스트에서 제거
/// 2. OnDataChanged 이벤트 발생
///
/// 주의: 같은 이름의 첫 번째 자식만 제거됩니다.
/// (TreeListItemData의 == 연산자가 Name으로 비교하기 때문)
///
/// 사용 예:
/// parent.RemoveChild(child); // 부모에서 자식 제거
///
/// 트리 구조 변화:
/// 변경 전: 변경 후:
/// 부모 부모
/// ├─ 자식1 ├─ 자식1
/// ├─ 자식2
/// └─ 자식3 └─ 자식3
/// (자식2 제거)
/// </summary>
public void RemoveChild(TreeListItemData child)
{
_children.Remove(child);
NotifyDataChanged(); // UI에 트리 구조 변경 알림
}
/// <summary>
/// 이 아이템의 모든 자식을 한 번에 제거합니다.
///
/// 동작:
/// 1. _children 리스트를 완전히 비움
/// 2. OnDataChanged 이벤트 발생
///
/// 주의: 자식들이 메모리에서 삭제되는 것은 아니고,
/// 이 아이템과의 연결만 끊어집니다.
/// (C#의 가비지 컬렉션이 필요 없으면 나중에 정리)
///
/// 사용 예:
/// parent.ClearChildren(); // 모든 자식 제거
///
/// 트리 구조 변화:
/// 변경 전: 변경 후:
/// 부모 부모
/// ├─ 자식1 (모든 자식 제거됨)
/// ├─ 자식2
/// └─ 자식3
/// </summary>
public void ClearChildren()
{
_children.Clear();
NotifyDataChanged(); // UI에 트리 구조 변경 알림
}
#endregion
#region (Internal Methods)
/// <summary>
/// 데이터가 변경되었음을 UI 시스템에 알립니다.
///
/// 동작: OnDataChanged 이벤트를 발생시킵니다.
/// 이를 통해 UI는 자동으로 이 아이템의 정보를 갱신합니다.
///
/// 호출되는 시점:
/// - Name이나 Option이 변경될 때
/// - 자식이 추가/제거될 때
/// - IsExpanded 상태가 변경될 때
///
/// 왜 protected인가?
/// 이 클래스를 상속받은 자식 클래스에서도 호출할 수 있도록 하기 위함입니다.
/// </summary>
internal void NotifyDataChanged()
{
// OnDataChanged가 등록되어 있으면 실행
// ?. 연산자: null이면 실행하지 않음 (null reference exception 방지)
OnDataChanged?.Invoke(this);
}
#endregion
#region (Comparison Operators)
/// <summary>
/// 두 TreeListItemData 객체가 같은지 비교합니다. (== 연산자)
///
/// 비교 기준: Name (아이템의 이름)
/// 즉, 이름이 같으면 같은 아이템으로 간주합니다.
///
/// 비교 로직:
/// 1. 같은 객체인가? (메모리 주소가 같음) → true
/// 2. 둘 다 null이거나 하나가 null? → false (둘 다 null이면 true인데, 1번에서 처리)
/// 3. Name이 같은가? → true/false
///
/// 사용 예:
/// var item1 = new TreeListItemData("파일");
/// var item2 = new TreeListItemData("파일");
/// var item3 = new TreeListItemData("폴더");
///
/// item1 == item2 // true (Name이 "파일"로 같음)
/// item1 == item3 // false (Name이 다름)
/// item1 == null // false
///
/// 주의: 같은 이름이면 같은 아이템으로 취급되므로,
/// 실제로 다른 객체임에도 true가 될 수 있습니다.
/// 이는 의도된 설계입니다.
/// </summary>
public static bool operator ==(TreeListItemData? left, TreeListItemData? right)
{
// 같은 객체인지 확인 (메모리 주소 비교)
if (ReferenceEquals(left, right))
{
return true;
}
// 하나 이상이 null이면 false (ReferenceEquals에서 둘 다 null인 경우는 true 반환)
if (left is null || right is null)
{
return false;
}
// 이름으로 비교
return left.Name == right.Name;
}
/// <summary>
/// 두 TreeListItemData 객체가 다른지 비교합니다. (!= 연산자)
///
/// 동작: == 연산자의 결과를 반대(!)로 반환합니다.
///
/// 사용 예:
/// if (item1 != item2) { ... } // 다른 아이템이면 실행
/// </summary>
public static bool operator !=(TreeListItemData? left, TreeListItemData? right)
{
return !(left == right);
}
#endregion
#region (Object Methods)
/// <summary>
/// 이 객체의 고유한 해시 코드를 반환합니다.
///
/// 용도: 이 객체를 Dictionary나 HashSet 같은 컬렉션에 저장할 때 사용합니다.
///
/// 해시 코드란? 객체를 빠르게 비교/검색하기 위한 고유 숫자입니다.
/// 같은 내용이면 같은 해시 코드를 반환해야 합니다.
///
/// 우리의 기준:
/// Name의 해시 코드 = 이 객체의 해시 코드
/// 왜? == 연산자에서 Name으로 비교하기 때문입니다.
///
/// 사용 예:
/// int hash = item.GetHashCode();
///
/// // Dictionary에 저장
/// Dictionary<TreeListItemData, string> dict = new();
/// dict[item] = "값"; // 내부적으로 GetHashCode() 사용
/// </summary>
public override int GetHashCode()
{
return _name.GetHashCode();
}
/// <summary>
/// 이 객체가 다른 객체와 같은지 비교합니다. (Equals 메서드)
///
/// 용도: 모든 C# 객체는 Equals 메서드를 가집니다.
/// 이 메서드를 오버라이드하여 우리의 비교 로직을 정의합니다.
///
/// 동작:
/// 1. 다른 객체가 TreeListItemData 타입인지 확인
/// 2. 맞으면 == 연산자로 비교 (Name으로 비교)
/// 3. 아니면 false 반환
///
/// 사용 예:
/// var item1 = new TreeListItemData("파일");
/// var item2 = new TreeListItemData("파일");
///
/// item1.Equals(item2) // true
/// item1.Equals("파일") // false (문자열은 다른 타입)
/// item1 == item2 // true (== 연산자와 동일)
///
/// GetHashCode()와 Equals의 관계:
/// - 같은 객체면 같은 해시 코드를 가져야 함
/// - 우리의 경우: Name이 같으면 Equals는 true, 해시 코드도 같음
/// - 이는 일관성 있게 설계되어 있습니다.
/// </summary>
public override bool Equals(object? obj)
{
// obj가 TreeListItemData 타입인지 확인
if (obj is TreeListItemData other)
{
// TreeListItemData면 == 연산자로 비교
return this == other;
}
// 다른 타입이면 false
return false;
}
#endregion
}
}
}

View File

@@ -0,0 +1,700 @@
#nullable enable
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace UVC.UI.List.Tree
{
/// <summary>
/// TreeListItem의 드래그 & 드롭 입력을 처리하는 컴포넌트입니다.
///
/// 역할:
/// 1. 마우스 입력 감지 (클릭, 드래그)
/// 2. 드래그 시각 피드백 (알파값 변경, 오프셋 이동)
/// 3. 드롭 대상 판단 (마우스 위치 기반)
/// 4. 드래그 매니저에 이벤트 전달
///
/// 구조:
/// - PointerDown: 마우스 클릭 감지 → 드래그 시작 준비
/// - Drag: 마우스 이동 중 → 드래그 진행, 시각 피드백
/// - PointerUp: 마우스 해제 → 드롭 처리
/// </summary>
public class TreeListItemDragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
/// <summary>
/// 부모 TreeListItem입니다.
/// </summary>
private TreeListItem? treeListItem;
/// <summary>
/// 부모 TreeList입니다.
/// </summary>
private TreeList? treeList;
/// <summary>
/// 드래그 & 드롭 매니저입니다.
/// </summary>
private TreeListDragDropManager? dragDropManager;
private RectTransform? rectTransform;
/// <summary>
/// 드래그 중 시각 피드백을 위한 CanvasGroup입니다.
/// </summary>
private CanvasGroup? canvasGroup;
/// <summary>
/// 드래그 시작 시 원본 알파값입니다.
/// </summary>
private float originalAlpha = 1f;
private Vector2 dragOffset = Vector2.zero;
/// <summary>
/// 드래그 중 적용할 알파값입니다.
/// </summary>
[SerializeField]
private float dragAlpha = 0.5f;
/// <summary>
/// 드래그 활성화 여부입니다.
/// </summary>
[SerializeField]
private bool enableDragDrop = true;
/// <summary>
/// 드롭 위치 표시 막대 프리팹입니다.
/// </summary>
private Image? dropIndicator;
private RectTransform? dropIndicatorRect;
/// <summary>
/// 드롭 표시기의 부모 (Content 또는 EntryRoot)
/// </summary>
private RectTransform? dropIndicatorParent;
/// <summary>
/// 드래그 시작 시 아이템의 원본 부모입니다.
/// 드래그 후 원래 위치로 복구할 때 사용합니다.
/// </summary>
private Transform? originalParent;
private int originalSiblingIndex;
private void Awake()
{
rectTransform = GetComponent<RectTransform>();
// CanvasGroup 가져오기 (없으면 생성)
canvasGroup = GetComponent<CanvasGroup>();
if (canvasGroup == null)
{
canvasGroup = gameObject.AddComponent<CanvasGroup>();
}
originalAlpha = canvasGroup.alpha;
}
public void SetDragDropManager(TreeListItem item, TreeList list, TreeListDragDropManager manager)
{
treeListItem = item;
treeList = list;
dragDropManager = manager;
Debug.Log($"[TreeListItemDragHandler] 드래그 핸들러 설정: {item.Data?.Name ?? "Unknown"}");
}
/// <summary>
/// 드래그가 시작될 때 호출됩니다. (IBeginDragHandler)
/// OnPointerDown 이후 마우스가 약간 움직이면 자동으로 호출됩니다.
/// </summary>
public void OnBeginDrag(PointerEventData eventData)
{
Debug.Log($"[OnPointerDown]");
if (!enableDragDrop || treeListItem?.Data == null || dragDropManager == null)
{
return;
}
// 마우스 버튼이 왼쪽이 아니면 무시
if (eventData.button != PointerEventData.InputButton.Left)
{
return;
}
// 드래그 시작 준비: 마우스 위치와 아이템 위치의 오프셋 계산
RectTransformUtility.ScreenPointToLocalPointInRectangle(
rectTransform,
eventData.position,
null,
out var localPoint);
dragOffset = localPoint;
Debug.Log($"[OnPointerDown] {treeListItem.Data.Name}에 포인터 다운, offset: {dragOffset}");
}
/// <summary>
/// 드래그 중에 마우스가 이동할 때 호출됩니다. (IDragHandler)
/// </summary>
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} 드래그 시작");
}
// 아이템이 마우스를 따라다니도록 위치 업데이트
UpdateDragPosition(eventData);
// 마우스 위의 드롭 대상 찾기
var targetItem = GetItemAtMousePosition(eventData.position);
dragDropManager.OnDragOver(targetItem?.Data);
// 드롭 위치 표시 업데이트
UpdateDropIndicator(targetItem);
if (targetItem != null)
{
Debug.Log($"[OnDrag] 드래그 중: {targetItem.Data?.Name ?? "Unknown"} 위에 있음");
}
}
/// <summary>
/// 드래그가 종료될 때 호출됩니다. (IEndDragHandler)
/// 마우스 버튼을 놓으면 자동으로 호출됩니다.
/// </summary>
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();
}
/// <summary>
/// 드래그 중 아이템이 마우스를 따라다니도록 위치를 업데이트합니다.
/// Y축만 이동 (X축은 고정)
/// </summary>
private void UpdateDragPosition(PointerEventData eventData)
{
if (rectTransform == null || treeList == null)
{
return;
}
var canvasRect = treeList.GetComponent<RectTransform>();
if (canvasRect == null)
{
return;
}
// 스크린 좌표를 캔버스 로컬 좌표로 변환
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
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만 마우스를 따라 이동
);
}
}
/// <summary>
/// 드롭 위치 표시 막대를 생성합니다.
/// </summary>
private void CreateDropIndicator()
{
if (dropIndicator != null)
{
return;
}
// ✅ Root(root)를 부모로 설정
// 계층 구조: TreeList > ScrollView > Viewport > Content > Root > TreeListItem
dropIndicatorParent = treeList!.Root.parent as RectTransform;// 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<Image>();
dropIndicator.color = new Color(0.2f, 0.8f, 1f, 0.5f); // 반투명 파란색
// RectTransform 설정
dropIndicatorRect = indicatorGo.GetComponent<RectTransform>();
if (dropIndicatorRect != null)
{
dropIndicatorRect.anchorMin = new Vector2(0, 0.5f);
dropIndicatorRect.anchorMax = new Vector2(1, 0.5f);
dropIndicatorRect.pivot = new Vector2(0, 0.5f);
dropIndicatorRect.sizeDelta = new Vector2(0, 3f); // 높이 3
}
dropIndicator.raycastTarget = false;
indicatorGo.SetActive(false);
Debug.Log("[CreateDropIndicator] 드롭 위치 표시 막대 생성됨");
}
/// <summary>
/// 드롭 위치 표시 막대를 업데이트합니다.
/// VerticalLayoutGroup 환경에서도 정확하게 위치를 계산합니다.
/// </summary>
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<RectTransform>();
if (targetRect == null)
{
dropIndicator.gameObject.SetActive(false);
return;
}
var dropPosition = GetDropPosition(targetRect);
dropIndicator.gameObject.SetActive(true);
// ✅ 핵심: 월드 좌표로 변환해서 부모 기준 로컬 좌표 계산
Vector3[] targetCorners = new Vector3[4];
targetRect.GetWorldCorners(targetCorners);
// 아이템의 월드 Y 좌표
float targetWorldY = targetCorners[0].y;
// 부모(EntryRoot)의 월드 좌표
Vector3[] parentCorners = new Vector3[4];
dropIndicatorParent.GetWorldCorners(parentCorners);
float parentWorldY = parentCorners[0].y;
float parentHeight = parentCorners[1].y - parentCorners[0].y;
// 월드 Y를 부모 기준 로컬 Y로 변환
float relativeY = targetWorldY - parentWorldY;
// 부모의 pivot을 고려한 로컬 Y 계산
float pivotAdjustedY = relativeY - (parentHeight * dropIndicatorParent.pivot.y);
float indicatorY = 0;
switch (dropPosition)
{
case DropPosition.Above:
// 대상 아이템 위 (아이템 높이의 절반)
indicatorY = pivotAdjustedY + (targetRect.rect.height / 2);
break;
case DropPosition.Below:
// 대상 아이템 아래
indicatorY = pivotAdjustedY - (targetRect.rect.height / 2);
break;
case DropPosition.InsideAsChild:
// 대상 아이템 중앙
indicatorY = pivotAdjustedY;
break;
}
// ✅ DropIndicator 위치 설정
dropIndicatorRect.anchoredPosition = new Vector2(0, indicatorY);
Debug.Log($"[UpdateDropIndicator] 위치: {dropPosition}, targetY: {targetWorldY}, parentY: {parentWorldY}, indicatorY: {indicatorY}");
}
/// <summary>
/// 드롭 위치 표시 막대를 숨깁니다.
/// </summary>
private void HideDropIndicator()
{
if (dropIndicator != null)
{
dropIndicator.gameObject.SetActive(false);
}
}
/// <summary>
/// 주어진 스크린 좌표에 있는 TreeListItem을 찾습니다.
/// </summary>
/// <param name="screenPosition">스크린 좌표</param>
/// <returns>찾은 TreeListItem (없으면 null)</returns>
private TreeListItem? GetItemAtMousePosition(Vector2 screenPosition)
{
if (treeList == null)
{
return null;
}
// Raycast로 UI 요소 찾기
var results = new System.Collections.Generic.List<RaycastResult>();
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<TreeListItem>();
if (item != null && item != treeListItem)
{
Debug.Log($"[GetItemAtMousePosition] 찾은 아이템: {item.Data?.Name ?? "Unknown"}");
return item;
}
}
return null;
}
/// <summary>
/// 드롭 성공 후 데이터를 동기화합니다.
/// </summary>
/// <param name="draggedData">드래그된 아이템</param>
/// <param name="targetItem">드롭 대상 UI 아이템</param>
private void HandleDropSuccess(TreeListItemData? draggedData, TreeListItem? targetItem)
{
if (draggedData == null || treeList == null)
{
return;
}
// 드롭 대상이 없으면 (빈 공간에 드롭) 루트로 이동
if (targetItem == null)
{
Debug.Log($"[HandleDropSuccess] 루트로 이동");
MoveToRoot(draggedData);
treeList.UpdateFlattenedItemList();
return;
}
var targetData = targetItem.Data;
if (targetData == null)
{
return;
}
// 드롭 위치 판단: 대상의 위/아래 또는 자식으로
var dropPosition = GetDropPosition(targetItem.GetComponent<RectTransform>());
Debug.Log($"[HandleDropSuccess] 드롭 위치: {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.UpdateFlattenedItemList();
}
/// <summary>
/// 드롭 위치를 판단합니다.
/// </summary>
/// <param name="targetRect">대상 아이템의 RectTransform</param>
/// <returns>드롭 위치</returns>
private DropPosition GetDropPosition(RectTransform targetRect)
{
// 목표: 마우스 Y 위치를 기반으로 위/아래/안쪽 판단
// 상위 1/3: 위
// 중간 1/3: 자식
// 하위 1/3: 아래
var height = targetRect.rect.height;
var thresholdUpper = height * 0.33f;
var thresholdLower = height * 0.67f;
// 월드 좌표에서 로컬 좌표로 변환
RectTransformUtility.ScreenPointToLocalPointInRectangle(
targetRect,
Input.mousePosition,
null,
out var localMousePos);
// 로컬 Y 좌표로 판단 (RectTransform의 피벗이 중앙이면 -height/2 ~ height/2)
float relativeY = localMousePos.y;
if (relativeY > thresholdUpper)
{
return DropPosition.Above;
}
else if (relativeY < -thresholdLower)
{
return DropPosition.Below;
}
else
{
return DropPosition.InsideAsChild;
}
}
/// <summary>
/// 아이템을 루트 레벨로 이동합니다.
/// </summary>
private void MoveToRoot(TreeListItemData draggedData)
{
// 기존 부모에서 제거
RemoveFromParent(draggedData);
Debug.Log($"[MoveToRoot] {draggedData.Name}을(를) 루트로 이동");
// 루트 레벨에 추가 (트리 구조 데이터에만 적용)
// UI 업데이트는 이벤트 구독자가 처리
}
/// <summary>
/// 아이템을 다른 아이템의 자식으로 이동합니다.
/// </summary>
private void MoveAsChild(TreeListItemData draggedData, TreeListItemData targetData)
{
RemoveFromParent(draggedData);
targetData.AddChild(draggedData);
Debug.Log($"[MoveAsChild] {draggedData.Name}을(를) {targetData.Name}의 자식으로 이동");
}
/// <summary>
/// 아이템을 다른 아이템 앞으로 이동합니다.
/// </summary>
private void MoveBefore(TreeListItemData draggedData, TreeListItemData targetData)
{
var parentData = FindParentOfItem(draggedData);
RemoveFromParent(draggedData);
if (parentData != null)
{
var targetIndex = parentData.Children.IndexOf(targetData);
if (targetIndex >= 0)
{
parentData.Children.Insert(targetIndex, draggedData);
parentData.NotifyDataChanged();
Debug.Log($"[MoveBefore] {draggedData.Name}을(를) {targetData.Name} 앞으로 이동");
}
}
}
/// <summary>
/// 아이템을 다른 아이템 뒤로 이동합니다.
/// </summary>
private void MoveAfter(TreeListItemData draggedData, TreeListItemData targetData)
{
var parentData = FindParentOfItem(draggedData);
RemoveFromParent(draggedData);
if (parentData != null)
{
var targetIndex = parentData.Children.IndexOf(targetData);
if (targetIndex >= 0)
{
parentData.Children.Insert(targetIndex + 1, draggedData);
parentData.NotifyDataChanged();
Debug.Log($"[MoveAfter] {draggedData.Name}을(를) {targetData.Name} 뒤로 이동");
}
}
}
/// <summary>
/// 아이템을 현재 부모에서 제거합니다.
/// </summary>
private void RemoveFromParent(TreeListItemData item)
{
var parent = FindParentOfItem(item);
if (parent != null)
{
parent.RemoveChild(item);
}
}
/// <summary>
/// 주어진 아이템의 부모를 찾습니다.
/// </summary>
private TreeListItemData? FindParentOfItem(TreeListItemData item)
{
if (treeList == null)
{
return null;
}
// 루트 아이템들 확인
foreach (Transform child in treeList.GetComponent<RectTransform>())
{
var childItem = child.GetComponent<TreeListItem>();
if (childItem?.Data == item)
{
return null; // 루트 레벨
}
}
// 전체 트리 순회해서 부모 찾기
return FindParentRecursive(item, null);
}
private TreeListItemData? FindParentRecursive(TreeListItemData target, TreeListItemData? currentParent)
{
if (currentParent != null)
{
foreach (var child in currentParent.Children)
{
if (child == target)
{
return currentParent;
}
var found = FindParentRecursive(target, child);
if (found != null)
{
return found;
}
}
}
else
{
// 루트 레벨의 모든 아이템 확인
if (treeList != null)
{
var root = treeList.GetComponent<RectTransform>();
foreach (Transform child in root)
{
var childItem = child.GetComponent<TreeListItem>();
if (childItem?.Data != null)
{
var found = FindParentRecursive(target, childItem.Data);
if (found != null)
{
return found;
}
}
}
}
}
return null;
}
/// <summary>
/// 드롭 위치를 나타내는 열거형입니다.
/// </summary>
private enum DropPosition
{
/// <summary>위 (형제 아이템으로 앞쪽)</summary>
Above,
/// <summary>안쪽 (자식으로)</summary>
InsideAsChild,
/// <summary>아래 (형제 아이템으로 뒤쪽)</summary>
Below
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43beb810567ff7e489a0a9059cd3c4b0

View File

@@ -48,7 +48,7 @@ namespace UVC.UI.Modal.ColorPicker
private bool interact;
// these can only work with the prefab and its children
// these can only work with the prefab and its Children
public RectTransform positionIndicator;
public Slider mainComponent;
public Slider rComponent;

View File

@@ -1,4 +1,4 @@
using UVC.UI.Commands;
using UVC.UI.Commands;
namespace UVC.UI.Toolbar.Model
{
@@ -39,7 +39,7 @@ namespace UVC.UI.Toolbar.Model
/// false, // 초기 상태 (꺼짐)
/// "icons/grid_on", // 켜짐 상태 아이콘
/// "icons/grid_off", // 꺼짐 상태 아이콘
/// (isSelected) => Debug.Log($"그리드 표시: {isSelected}"), // OnToggle 콜백
/// (IsSelected) => Debug.Log($"그리드 표시: {IsSelected}"), // OnToggle 콜백
/// null, // ClickCommand (필요시 설정)
/// "tooltip_toggle_grid_visibility"
/// );
@@ -47,10 +47,10 @@ namespace UVC.UI.Toolbar.Model
/// // 5. 라디오 버튼 그룹 추가
/// string viewModeGroup = "ViewModeSelection";
/// myToolbar.AddRadioButton(viewModeGroup, "button_2d_view", true, "icons/2d_view", null,
/// (isSelected) => { if(isSelected) Debug.Log("2D 뷰 선택됨"); },
/// (IsSelected) => { if(IsSelected) Debug.Log("2D 뷰 선택됨"); },
/// null, "tooltip_2d_view");
/// myToolbar.AddRadioButton(viewModeGroup, "button_3d_view", false, "icons/3d_view", null,
/// (isSelected) => { if(isSelected) Debug.Log("3D 뷰 선택됨"); },
/// (IsSelected) => { if(IsSelected) Debug.Log("3D 뷰 선택됨"); },
/// null, "tooltip_3d_view");
///
/// // 6. 설정된 모델을 ToolbarView에 전달

View File

@@ -1,4 +1,4 @@
using System;
using System;
namespace UVC.UI.Toolbar.Model
{
@@ -27,9 +27,9 @@ namespace UVC.UI.Toolbar.Model
/// OffIconSpritePath = "icons/toolbar/view_top_off",
/// Tooltip = "tooltip_top_view",
/// IsSelected = true, // 초기 선택 상태
/// OnToggle = (isSelected) =>
/// OnToggle = (IsSelected) =>
/// {
/// if (isSelected) UnityEngine.Debug.Log("탑뷰 선택됨 (OnToggle)");
/// if (IsSelected) UnityEngine.Debug.Log("탑뷰 선택됨 (OnToggle)");
/// },
/// ClickCommand = new ActionCommand(() =>
/// {
@@ -46,9 +46,9 @@ namespace UVC.UI.Toolbar.Model
/// OffIconSpritePath = "icons/toolbar/view_front_off",
/// Tooltip = "tooltip_front_view",
/// IsSelected = false, // 초기 선택 안됨
/// OnToggle = (isSelected) =>
/// OnToggle = (IsSelected) =>
/// {
/// if (isSelected) UnityEngine.Debug.Log("프론트뷰 선택됨 (OnToggle)");
/// if (IsSelected) UnityEngine.Debug.Log("프론트뷰 선택됨 (OnToggle)");
/// },
/// ClickCommand = new ActionCommand(() =>
/// {

View File

@@ -1,4 +1,4 @@
using System;
using System;
namespace UVC.UI.Toolbar.Model
{
@@ -27,9 +27,9 @@ namespace UVC.UI.Toolbar.Model
/// // 2. 토글 버튼 클릭 시 실행될 커맨드 정의 (선택 사항, 주로 상태 변경 후 추가 작업)
/// // 이 커맨드는 IsSelected 상태가 변경된 *후에* ExecuteClick 내부에서 호출됩니다.
/// // 커맨드 파라미터로 현재 IsSelected 상태를 받고 싶다면 ActionCommand<bool> 사용 가능.
/// ICommand muteCommand = new ActionCommand<bool>((isSelected) =>
/// ICommand muteCommand = new ActionCommand<bool>((IsSelected) =>
/// {
/// UnityEngine.Debug.Log($"음소거 버튼 커맨드 실행됨. 현재 선택 상태: {isSelected}");
/// UnityEngine.Debug.Log($"음소거 버튼 커맨드 실행됨. 현재 선택 상태: {IsSelected}");
/// });
///
/// // 3. ToolbarToggleButton 인스턴스 생성 및 속성 설정

View File

@@ -1,4 +1,4 @@
using UnityEngine;
using UnityEngine;
using UnityEngine.UI;
using UVC.UI.Toolbar.Model;
@@ -89,9 +89,9 @@ namespace UVC.UI.Toolbar.View
// ExecuteClick 내부에서 IsSelected 상태가 변경되고, 관련 커맨드가 실행되며, OnToggleStateChanged 이벤트가 발생합니다.
toggleComponent.onValueChanged.AddListener((isSelected) =>
{
// isSelected 파라미터는 UI에서 변경된 새 상태이지만,
// IsSelected 파라미터는 UI에서 변경된 새 상태이지만,
// ToolbarToggleButton의 ExecuteClick() 내부에서 IsSelected가 토글되므로
// isSelected 파라미터를 직접 사용하지 않고 ExecuteClick()만 호출합니다.
// IsSelected 파라미터를 직접 사용하지 않고 ExecuteClick()만 호출합니다.
// ExecuteClick()이 모델의 상태를 올바르게 변경하고 이벤트를 발생시킬 책임이 있습니다.
toggleModel.ExecuteClick();
});

View File

@@ -60,7 +60,7 @@ namespace UVC.UI.Toolbar.View
///
/// // 토글 버튼 추가
/// myModel.AddToggleButton("그리드 보기", false, "icons/grid_on", "icons/grid_off",
/// (isSelected) => Debug.Log($"그리드 표시: {isSelected}"));
/// (IsSelected) => Debug.Log($"그리드 표시: {IsSelected}"));
///
/// // 라디오 버튼 그룹 추가
/// string viewModeGroup = "ViewMode";

View File

@@ -43,7 +43,7 @@ namespace UVC.UI.Window
if (componentList == null)
{
Debug.LogError("InfiniteScroll component is not assigned or found in children.");
Debug.LogError("InfiniteScroll component is not assigned or found in Children.");
return;
}