namespace Gpm.Ui { using System; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; // 이 partial 클래스는 InfiniteScroll의 스크롤 이동 관련 로직을 담당합니다. // 특정 아이템으로 이동하거나, 스크롤 위치를 직접 제어하는 기능을 구현합니다. public partial class InfiniteScroll : IMoveScroll { /// /// 특정 아이템으로 이동할 때, 뷰포트 내에서 해당 아이템을 정렬할 위치를 지정합니다. /// public enum MoveToType { /// /// 아이템을 뷰포트의 상단(Vertical) 또는 왼쪽(Horizontal)에 맞춥니다. /// MOVE_TO_TOP = 0, /// /// 아이템을 뷰포트의 중앙에 맞춥니다. /// MOVE_TO_CENTER, /// /// 아이템을 뷰포트의 하단(Vertical) 또는 오른쪽(Horizontal)에 맞춥니다. /// MOVE_TO_BOTTOM } /// /// 스크롤 이동 애니메이션을 제어하는 정적 클래스입니다. /// ScrollMoveTo 컴포넌트를 사용하여 부드러운 스크롤 효과를 구현합니다. /// public static class Control { /// /// 지정된 아이템 인덱스로 일정 시간 동안 부드럽게 스크롤합니다. /// /// 대상 InfiniteScroll 인스턴스 /// 이동할 아이템의 인덱스 /// 뷰포트 내 정렬 방식 /// 애니메이션 시간 (0이면 즉시 이동) public static void MoveTo(InfiniteScroll scroll, int itemIndex, MoveToType moveToType, float time = 0) { // ScrollMoveTo 컴포넌트가 없으면 동적으로 추가합니다. ScrollMoveTo moveto = scroll.gameObject.GetComponent(); if (moveto == null) { moveto = scroll.gameObject.AddComponent(); } // 이동 목표 설정 moveto.Set(itemIndex, moveToType, time); // 애니메이션 커브 설정 (부드러운 시작과 끝) moveto.curve = AnimationCurve.EaseInOut(0, 0, 1, 1); // 이동 완료 후 컴포넌트 자동 파괴 설정 moveto.autoDestory = true; // 애니메이션 재생 moveto.Play(); } /// /// 지정된 스크롤 비율(0.0 ~ 1.0)로 일정 시간 동안 부드럽게 스크롤합니다. /// /// 대상 InfiniteScroll 인스턴스 /// 이동할 스크롤 비율 (0.0 = 시작, 1.0 = 끝) /// 애니메이션 시간 (0이면 즉시 이동) public static void MoveTo(InfiniteScroll scroll, float scrollRate, float time = 0) { ScrollMoveTo moveto = scroll.gameObject.GetComponent(); if (moveto == null) { moveto = scroll.gameObject.AddComponent(); } moveto.Set(scrollRate, time); moveto.curve = AnimationCurve.EaseInOut(0, 0, 1, 1); moveto.autoDestory = true; moveto.Play(); } } // 스크롤 위치 비교 시 오차를 보정하기 위한 값입니다. (콘텐츠 크기의 0.01%) // 부동 소수점 연산의 정밀도 문제로 인해 정확히 0 또는 1이 되지 않는 경우를 처리합니다. private const float UPDOWN_MULTIPLY = 2.0f; private const int NEEDITEM_MORE_LINE = 1; private const int NEEDITEM_EXTRA_ADD = 2; protected ScrollRect scrollRect = null; protected RectTransform viewport = null; protected float sizeInterpolationValue = 0.0001f; // 0.01% /// /// 특정 데이터(InfiniteScrollData)에 해당하는 아이템으로 스크롤을 이동합니다. /// /// 이동할 대상 데이터 /// 뷰포트 내 정렬 방식 /// 이동에 걸리는 시간 (0이면 즉시 이동) /// /// /// InfiniteScrollData targetData = myScroll.GetData(10); /// if (targetData != null) /// { /// myScroll.MoveTo(targetData, InfiniteScroll.MoveToType.MOVE_TO_CENTER, 0.5f); /// } /// /// public void MoveTo(InfiniteScrollData data, MoveToType moveToType, float time = 0) { MoveTo(GetItemIndex(data), moveToType, time); } /// /// 현재 화면에 보이는 아이템 목록 기준의 인덱스로 스크롤을 이동합니다. /// /// 이동할 아이템의 인덱스 (화면에 보이는 아이템 기준) /// 뷰포트 내 정렬 방식 /// 이동에 걸리는 시간 (0이면 즉시 이동) /// /// /// // 50번째 아이템으로 0.3초 동안 스크롤 (아이템이 뷰포트 상단에 오도록) /// myScroll.MoveTo(50, InfiniteScroll.MoveToType.MOVE_TO_TOP, 0.3f); /// /// public void MoveTo(int itemIndex, MoveToType moveToType, float time = 0) { if (isInitialize == false) { Initialize(); } if (IsValidItemIndex(itemIndex) == true) { if (time > 0) { // 시간이 지정된 경우 Control 클래스를 통해 부드럽게 이동 Control.MoveTo(this, itemIndex, moveToType, time); } else { // 즉시 이동 SetScrollPosition(GetMovePosition(itemIndex, moveToType)); Control.MoveTo(this, itemIndex, moveToType, 0); } } } /// /// 전체 데이터 목록 기준의 인덱스로 스크롤을 이동합니다. /// 필터링이 적용된 상태에서, 필터링과 상관없이 원본 데이터의 인덱스를 사용하고 싶을 때 유용합니다. /// /// 이동할 데이터의 인덱스 (전체 데이터 리스트 기준) /// 뷰포트 내 정렬 방식 /// 이동에 걸리는 시간 (0이면 즉시 이동) /// /// /// // 원본 데이터 리스트의 100번째 데이터로 즉시 이동 /// myScroll.MoveToFromDataIndex(100, InfiniteScroll.MoveToType.MOVE_TO_CENTER); /// /// public void MoveToFromDataIndex(int dataIndex, MoveToType moveToType, float time = 0) { if (isInitialize == false) { Initialize(); } if (IsValidDataIndex(dataIndex) == true) { MoveTo(dataList[dataIndex].itemIndex, moveToType, time); } } /// /// 전체 스크롤 범위의 특정 비율(0.0 ~ 1.0)로 스크롤을 이동합니다. /// /// 이동할 비율 (0.0 = 시작, 0.5 = 중간, 1.0 = 끝) /// 이동에 걸리는 시간 (0이면 즉시 이동) /// /// /// // 스크롤을 80% 위치로 1초 동안 이동 /// myScroll.MoveTo(0.8f, 1.0f); /// /// public void MoveTo(float scrollRate, float time = 0) { if (isInitialize == false) { Initialize(); } if (time > 0) { Control.MoveTo(this, scrollRate, time); } else { SetScrollPosition(GetMovePosition(scrollRate)); Control.MoveTo(this, scrollRate, 0); // 위치 보정을 위해 호출 } } /// /// 스크롤을 가장 처음 위치로 이동합니다. /// /// /// /// // '맨 위로' 버튼 클릭 시 호출 /// public void OnClick_MoveToTop() /// { /// myScroll.MoveToFirstData(); /// } /// /// public void MoveToFirstData() { if (isInitialize == false) { Initialize(); } UpdateContentSize(); Vector2 normalizedPosition; if (layout.IsVertical() == true) { normalizedPosition = Vector2.one; // 세로 스크롤의 시작은 (0, 1) } else { normalizedPosition = Vector2.zero; // 가로 스크롤의 시작은 (0, 0) } if (scrollRect.normalizedPosition != normalizedPosition) { scrollRect.normalizedPosition = normalizedPosition; isUpdateArea = true; } MoveTo(0); // 0% 위치로 이동 } /// /// 스크롤을 가장 마지막 위치로 이동합니다. /// /// /// /// // '맨 아래로' 버튼 클릭 시 호출 /// public void OnClick_MoveToBottom() /// { /// myScroll.MoveToLastData(); /// } /// /// public void MoveToLastData() { if (isInitialize == false) { Initialize(); } UpdateContentSize(); Vector2 normalizedPosition; if (layout.IsVertical() == true) { normalizedPosition = Vector2.zero; // 세로 스크롤의 끝은 (0, 0) } else { normalizedPosition = Vector2.one; // 가로 스크롤의 끝은 (1, 0) } if (scrollRect.normalizedPosition != normalizedPosition) { scrollRect.normalizedPosition = normalizedPosition; isUpdateArea = true; } MoveTo(1); // 100% 위치로 이동 } /// /// 스크롤이 가장 처음에 도달했는지 확인합니다. /// /// 처음 위치에 있으면 true, 아니면 false를 반환합니다. /// /// /// if (myScroll.IsMoveToFirstData()) /// { /// Debug.Log("스크롤이 맨 위에 있습니다."); /// } /// /// public bool IsMoveToFirstData() { if (isInitialize == false) { Initialize(); } if (NeedUpdateItem() == true) { UpdateShowItem(false); } float contentPosition = GetContentPosition(); float viewportSize = GetViewportSize(); float contentSize = GetContentSize(); return IsMoveToFirstData(contentPosition, viewportSize, contentSize); } /// /// 스크롤이 가장 마지막에 도달했는지 확인합니다. /// /// 마지막 위치에 있으면 true, 아니면 false를 반환합니다. /// /// /// if (myScroll.IsMoveToLastData()) /// { /// Debug.Log("스크롤이 맨 아래에 있습니다."); /// // 추가 데이터 로딩 로직 호출 가능 /// } /// /// public bool IsMoveToLastData() { if (isInitialize == false) { Initialize(); } float contentPosition = GetContentPosition(); float viewportSize = GetViewportSize(); float contentSize = GetContentSize(); return IsMoveToLastData(contentPosition, viewportSize, contentSize); } /// /// 지정된 스크롤 비율에 해당하는 content의 anchoredPosition을 계산합니다. /// /// 계산할 스크롤 비율 (0.0 ~ 1.0) /// 계산된 스크롤 위치(Vector2) public Vector2 GetMovePosition(float scrollRate) { float viewportSize = GetViewportSize(); float contentSize = GetContentSize(); // 전체 스크롤 가능 범위에서 비율만큼의 위치를 계산 float move = (contentSize - viewportSize) * Mathf.Clamp01(scrollRate); move = Math.Max(0.0f, move); move = ItemPostionFromOffset(move); return layout.GetAxisVector(content.anchoredPosition, move); } /// /// 지정된 아이템 인덱스로 이동하기 위한 content의 anchoredPosition을 계산합니다. /// /// 대상 아이템의 인덱스 /// 뷰포트 내 정렬 방식 /// 계산된 스크롤 위치(Vector2) public Vector2 GetMovePosition(int itemIndex, MoveToType moveToType) { float move = GetMoveOffset(itemIndex, moveToType); return layout.GetAxisVector(content.anchoredPosition, move); } /// /// 현재 스크롤 content의 anchoredPosition을 가져옵니다. /// /// 현재 스크롤 위치(Vector2) public Vector2 GetScrollPosition() { return content.anchoredPosition; } /// /// 스크롤 content의 anchoredPosition을 지정된 위치로 설정합니다. /// 이 메서드는 스크롤 위치를 즉시 변경합니다. /// /// 설정할 위치(Vector2) public void SetScrollPosition(Vector2 position) { content.anchoredPosition = position; float contentPosition = GetContentPosition(); if (cachedData.contentPosition != contentPosition) { cachedData.contentPosition = contentPosition; isUpdateArea = true; // 위치가 변경되었으므로 업데이트 필요 플래그 설정 } } /// /// 스크롤의 주 축(Vertical이면 Y, Horizontal이면 X) 위치를 설정합니다. /// /// 설정할 주 축의 위치 값 public void SetScrollPosition(float movePosition) { Vector2 prevPosition = GetScrollPosition(); SetScrollPosition(layout.GetAxisVector(prevPosition, movePosition)); } /// /// 스크롤 content의 위치와 크기를 초기화합니다. /// 데이터는 그대로 유지됩니다. /// public void ClearScrollContent() { content.anchoredPosition = Vector2.zero; content.sizeDelta = Vector2.zero; cachedData.Clear(); CheckScrollData(); } /// /// 내부 ScrollRect의 onValueChanged 이벤트에 리스너를 추가합니다. /// 사용자가 스크롤을 움직일 때마다 콜백을 받고 싶을 때 사용합니다. /// /// 추가할 리스너(콜백 함수) /// /// /// void Start() /// { /// myScroll.AddScrollValueChangedLisnter(OnScrollMoved); /// } /// /// void OnScrollMoved(Vector2 position) /// { /// // position은 0.0 ~ 1.0 사이의 정규화된 값입니다. /// Debug.Log($"스크롤 위치 변경: {position}"); /// } /// /// public void AddScrollValueChangedLisnter(UnityAction listener) { if (isInitialize == false) { Initialize(); } scrollRect.onValueChanged.AddListener(listener); } // 스크롤이 시작 지점에 있는지 내부적으로 계산하는 함수 private bool IsMoveToFirstData(float position, float viewportSize, float contentSize) { bool isShow = false; // 콘텐츠 크기가 뷰포트보다 작으면 항상 시작/끝에 있는 것으로 간주 if (viewportSize > contentSize) { isShow = true; } else { // 부동 소수점 오차를 감안하여 비교 float interpolation = contentSize * sizeInterpolationValue; if (-position > -interpolation) { isShow = true; } } return isShow; } // 스크롤이 끝 지점에 있는지 내부적으로 계산하는 함수 private bool IsMoveToLastData(float position, float viewportSize, float contentSize) { bool isShow = false; if (viewportSize > contentSize) { isShow = true; } else { float interpolation = contentSize * sizeInterpolationValue; // 끝 지점 계산 (뷰포트 크기 + 현재 위치 - 콘텐츠 크기) if (-(viewportSize + position - contentSize) <= interpolation) { isShow = true; } } return isShow; } /// /// 특정 아이템으로 이동하기 위한 스크롤 content의 오프셋(offset)을 계산합니다. /// /// 대상 아이템 인덱스 /// 뷰포트 내 정렬 방식 /// 계산된 오프셋 값 protected float GetMoveOffset(int itemIndex, MoveToType moveToType) { float viewportSize = GetViewportSize(); float contentSize = GetContentSize(); float move = 0.0f; float itemSize = GetItemSize(itemIndex); float distance = GetItemDistance(itemIndex); // 아이템의 시작 위치 move = distance; // 정렬 방식에 따라 오프셋 조정 switch (moveToType) { case MoveToType.MOVE_TO_CENTER: { // (뷰포트 중앙) - (아이템 중앙) move -= viewportSize * 0.5f - itemSize * 0.5f; break; } case MoveToType.MOVE_TO_BOTTOM: { // (뷰포트 하단) - (아이템 하단) move -= viewportSize - itemSize; break; } } // 계산된 위치가 스크롤 가능 범위를 벗어나지 않도록 제한 move = Mathf.Clamp(move, 0.0f, contentSize - viewportSize); move = Math.Max(0.0f, move); return ItemPostionFromOffset(move); } // ScrollRect의 onValueChanged 이벤트에 연결되어 호출되는 메서드 private void OnValueChanged(Vector2 value) { // 스크롤이 시작/끝 지점에 도달했는지 확인하고 이벤트를 발생시킵니다. bool prevIsStartLine = isStartLine; isStartLine = IsMoveToFirstData(); if (prevIsStartLine != isStartLine) { onStartLine.Invoke(isStartLine); changeValue = true; } bool prevIsEndLine = isEndLine; isEndLine = IsMoveToLastData(); if (prevIsEndLine != isEndLine) { onEndLine.Invoke(isEndLine); changeValue = true; } // 스크롤이 움직였으므로 화면에 보이는 아이템을 갱신합니다. UpdateShowItem(); } } }