InfiniteScroll

JJW·2025년 2월 14일
0

Unity

목록 보기
22/34

오늘은 무한 스크롤 (InfiniteScroll)에 대해 알아보도록 하겠습니다.

개념 및 정의

➡️ 제한된 수의 오브젝트를 재활용하여 플레이어나 사용자에게 끝없이 진행하는 것 처럼 보이게 하는 기법입니다.


사용처

➡️ 주요 사용처는 끝없는 환경을 구현하거나 UI ScrollView에 사용됩니다.
➡️ 이 글에서는 UI ScrollView에 대해 알아보도록 하겠습니다.


설명

  • Horizontal 무한 스크롤을 구현할 때, 좌측과 우측에 기준점을 설정하고, 스크롤로 인해 UI 오브젝트가 이 기준점을 넘어서면 해당 오브젝트를 반대쪽으로 이동시켜 재사용하는 방식입니다.

참고 Github

InfiniteScrollView

코드

1. InfiniteScrollView

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System;
using UnityEngine.EventSystems;
using System.Diagnostics;

namespace UnityHelp.UI
{
    [RequireComponent(typeof(ScrollRect))]
    public abstract class InfiniteScrollView : UIBehaviour
    {
        public int cellPoolSize = 20;                                                               // 크기 지정
        public float spacing = 0f;                                                                  // 셀 간의 간격
        public Vector2 padding;                                                                     // 좌측 사단 여백 조절
        public float extendVisibleRange;                                                            // 여유 범위

        public InfiniteCell cellPrefab;                                                             // 셀 프리팹
        public ScrollRect scrollRect;                                                               // ScrollRect
        public List<InfiniteCellData> dataList = new List<InfiniteCellData>();                      // 데이터 모델 리스트
        public List<InfiniteCell> cellList = new List<InfiniteCell>();                              // 현재 셀 인스턴스 리스트
        protected Queue<InfiniteCell> cellPool = new Queue<InfiniteCell>();                         // 셀을 관리하기 위한 큐
        protected YieldInstruction waitEndOfFrame = new WaitForEndOfFrame();                        // 코루틴에서 사용하기 위한 인스턴스
        private Coroutine snappingProcesser;                                                        // 스냅 애니메이션 코루틴
        public event Action onRectTransformUpdate;                                                  // Rect 업데이트 시 호출 이벤트
        public event Action<InfiniteCell> onCellSelected;                                           // 셀 선택 이벤트
        public Action onRefresh;                                                                    // 스크롤 뷰 업데이트 이벤트

        /// <summary>
        /// 스크롤 뷰가 초기화 되었는지에 대한 프로퍼티
        /// </summary>
        public bool IsInitialized
        {
            get;
            private set;
        }

        /// <summary>
        /// 초기화 함수
        /// </summary>
        public virtual void Initialize()
        {
            if (IsInitialized)
                return;
            scrollRect = GetComponent<ScrollRect>();
            scrollRect.onValueChanged.AddListener(OnValueChanged);
            for (int i = 0; i < cellPoolSize; i++)
            {
                var newCell = Instantiate(cellPrefab, scrollRect.content);
                newCell.gameObject.SetActive(false);
                cellPool.Enqueue(newCell);
            }
            IsInitialized = true;
        }

        /// <summary>
        /// 스크롤 위치가 변경될 때 호출되는 메서드
        /// 자식 클래스에서 구체적인 셀 배치 및 재활용 로직을 강요하기 위한 ㅇㅇ
        /// </summary>
        /// <param name="normalizedPosition"></param>
        protected abstract void OnValueChanged(Vector2 normalizedPosition);

        /// <summary>
        /// 스크롤 뷰의 상태를 새로 고치는 역할
        /// </summary>
        public abstract void Refresh();

        /// <summary>
        /// Data 추가 메서드
        /// </summary>
        /// <param name="data"></param>
        public virtual void Add(InfiniteCellData data)
        {
            // 초기화가 되지 않았다면 IsInitialized 호출
            if (!IsInitialized)
            {
                Initialize();
            }

            // 추가되었으니 Count를 Index로 설정
            data.index = dataList.Count;

            // dataList 데이터 추가
            dataList.Add(data);

            // cellList null 추가 및 할당 되지 않음을 표시
            cellList.Add(null);
        }

        /// <summary>
        /// Data 삭제 메서드
        /// </summary>
        /// <param name="index"></param>
        public virtual void Remove(int index)
        {
            // 초기화가 되지 않았다면 IsInitialized 호출
            if (!IsInitialized)
            {
                Initialize();
            }

            // 데이터 리스트의 개수가 0이라면
            if (dataList.Count == 0)
                return;

            // 해당 인덱스의 데이터 제거
            dataList.RemoveAt(index);

            // UI 업데이트
            Refresh();
        }

        /// <summary>
        /// 특정 인덱스의 셀로 스크롤을 스냅하는 기능을 구현
        /// </summary>
        /// <param name="index"></param>
        /// <param name="duration"></param>
        public abstract void Snap(int index, float duration);

        /// <summary>
        /// 마지막 데이터 셀로 부드럽게 이동시키는 메서드
        /// </summary>
        /// <param name="duration"></param>
        public void SnapLast(float duration)
        {
            // dataList의 마지막 인덱스를 계산하여 snap 함수 호출
            Snap(dataList.Count - 1, duration);
        }

        /// <summary>
        /// 스냅 애니메이션을 시작하는 함수로, content의 anchoredPosition을 target 위치로 duration 시간 동안 부드럽게 이동시킵니다.
        /// </summary>
        /// <param name="target"></param>
        /// <param name="duration"></param>
        protected void DoSnapping(Vector2 target, float duration)
        {
            // 오브젝트가 비활성화 상태라면 안함
            if (!gameObject.activeInHierarchy)
                return;

            // 기존 스냅 중단
            StopSnapping();

            // 새로운 코루틴을 시작하고 저장
            snappingProcesser = StartCoroutine(ProcessSnapping(target, duration));
        }

        /// <summary>
        /// 진행 중인 스냅 코루틴 정지
        /// </summary>
        public void StopSnapping()
        {
            // 진행중인 코루틴이 있다면 정지하고 참조를 null로 설정
            if (snappingProcesser != null)
            {
                StopCoroutine(snappingProcesser);
                snappingProcesser = null;
            }
        }

        /// <summary>
        /// 스냅 애니메이션을 진행하는 코루틴
        /// </summary>
        /// <param name="target"></param>
        /// <param name="duration"></param>
        /// <returns></returns>
        private IEnumerator ProcessSnapping(Vector2 target, float duration)
        {
            // ScrollRect 속도 0
            scrollRect.velocity = Vector2.zero;

            Vector2 startPos = scrollRect.content.anchoredPosition;
            float t = 0;

            // 보간 진행
            while (t < 1f)
            {
                if (duration <= 0)
                    t = 1;
                else
                    t += Time.deltaTime / duration;

                scrollRect.content.anchoredPosition = Vector2.Lerp(startPos, target, t);
                var normalizedPos = scrollRect.normalizedPosition;
                if (normalizedPos.y < 0 || normalizedPos.x > 1)
                {
                    break;
                }
                yield return null;
            }
            if (duration <= 0)
                OnValueChanged(scrollRect.normalizedPosition);
            snappingProcesser = null;
        }

        /// <summary>
        /// 특정 인덱스에 해당하는 셀을 풀에서 꺼내 데이터 할당 후 지정된 위치에 배치하는 메서드
        /// </summary>
        /// <param name="index"></param>
        /// <param name="pos"></param>
        protected void SetupCell(int index, Vector2 pos)
        {
            if (cellList[index] == null)
            {
                var cell = cellPool.Dequeue();
                cell.gameObject.SetActive(true);
                cell.CellData = dataList[index];
                cell.RectTransform.anchoredPosition = pos;
                cellList[index] = cell;
                cell.onSelected += OnCellSelected;
            }
        }

        /// <summary>
        /// 더 이상 화면에 표시할 필요가 없는 셀을 회수하고 풀로 돌려보내는 메서드
        /// </summary>
        /// <param name="index"></param>
        protected void RecycleCell(int index)
        {
            if (cellList[index] != null)
            {
                var cell = cellList[index];
                cellList[index] = null;
                cellPool.Enqueue(cell);
                cell.gameObject.SetActive(false);
                cell.onSelected -= OnCellSelected;
            }
        }

        /// <summary>
        /// 셀이 선택될 시 호출되는 메서드
        /// </summary>
        /// <param name="selectedCell"></param>
        private void OnCellSelected(InfiniteCell selectedCell)
        {
            onCellSelected?.Invoke(selectedCell);
        }

        /// <summary>
        /// 스크롤 뷰의 모든 데이터를 삭제하고 셀과 컨텐츠의 상태를 초기화하는 메서드
        /// </summary>
        public virtual void Clear()
        {
            if (IsInitialized == false)
                Initialize();
            scrollRect.velocity = Vector2.zero;
            scrollRect.content.anchoredPosition = Vector2.zero;
            dataList.Clear();
            for (int i = 0; i < cellList.Count; i++)
            {
                RecycleCell(i);
            }
            cellList.Clear();
            Refresh();
        }

        /// <summary>
        /// UI의 Rect 변경될 때 자동  호출되는 메서드
        /// </summary>
        protected override void OnRectTransformDimensionsChange()
        {
            base.OnRectTransformDimensionsChange();
            if (scrollRect)
            {
                onRectTransformUpdate?.Invoke();
            }
        }
    }
}
  • 각 타입에 맞는 무한 스크롤 클래스들이 상속받게 될 기능을 구현한 추상 클래스입니다.

2. HorizontalInfiniteScroll

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using static UnityEngine.Analytics.IAnalytic;

namespace UnityHelp.UI
{
    /// <summary>
    /// 무한 스크롤 뷰의 기본 기능을 제공하는 InfiniteScrollView를 상속 받아 수평 스크롤 전용으로 동작하는 클래스
    /// </summary>
    public class HorizontalInfiniteScrollView : InfiniteScrollView
    {
        public bool isAtLeft = true;                // 스크롤 뷰가 왼쪽 끝에 가까운지 여부
        public bool isAtRight = true;               // 스크롤 뷰가 오른쪽 끝에 가까운지 여부

        /// <summary>
        /// 초기화 메서드
        /// </summary>
        public override void Initialize()
        {
            base.Initialize();
            isAtLeft = true;
            isAtRight = true;
        }

        /// <summary>
        /// 스크롤 뷰의 수평 스크롤 위치가 바뀔 때마다 호출되는 메서드
        /// </summary>
        /// <param name="normalizedPosition"></param>
        protected override void OnValueChanged(Vector2 normalizedPosition)
        {
            // dataList가 없다면
            if (dataList.Count == 0)
                return;

            /// 뷰포트 영역 계산
            // 너비 계산
            float viewportInterval = scrollRect.viewport.rect.width;        

            // x좌표를 반전하여 뷰포트의 현재 왼쪽 경계 위치
            float minViewport = -scrollRect.content.anchoredPosition.x;     

            // 실제 가시 범위에 여유 영역을 더한 범위
            Vector2 viewportRange = new Vector2(minViewport - extendVisibleRange, minViewport + viewportInterval + extendVisibleRange);

            // 위치 리셋
            float contentWidth = padding.x;                                 

            /// 셀 재활용
            for (int i = 0; i < dataList.Count; i++)
            {
                // 각 데이터 항목에 대해, 해당 셀이 차지하는 가로 영역(visibleRange)을 계산
                var visibleRange = new Vector2(contentWidth, contentWidth + dataList[i].cellSize.x);

                // 만약 이 visibleRange가 뷰포트 범위밖에 있다면, RecycleCell(i)를 호출하여 해당 셀을 회수
                if (visibleRange.y < viewportRange.x || visibleRange.x > viewportRange.y)
                {
                    RecycleCell(i);
                }

                // 각 셀의 너비와 spacing만큼 contentWidth를 증가시켜 다음 셀의 시작 위치를 계산합니다.
                contentWidth += dataList[i].cellSize.x + spacing;
            }

            // 다시 위치 리셋
            contentWidth = padding.x;             
            
            /// 셀 설정
            for (int i = 0; i < dataList.Count; i++)
            {
                // 각 데이터 항목에 대해, 해당 셀이 차지하는 가로 영역(visibleRange)을 계산
                var visibleRange = new Vector2(contentWidth, contentWidth + dataList[i].cellSize.x);

                // visibleRange가 뷰포트 내에 존재하면 SetupCell(i, new Vector2(contentWidth, 0))를 호출하여 해당 셀을 활성화하고 지정된 위치에 배치.
                if (visibleRange.y >= viewportRange.x && visibleRange.x <= viewportRange.y)
                {
                    SetupCell(i, new Vector2(contentWidth, 0));

                    // 조건에 따라 sibling 순서를 조정
                    if (visibleRange.y >= viewportRange.x)
                        cellList[i].transform.SetAsLastSibling();
                    else
                        cellList[i].transform.SetAsFirstSibling();
                }

                // contentWidth를 각 셀의 너비 + spacing만큼 증가
                contentWidth += dataList[i].cellSize.x + spacing;
            }

            // 스크롤 가능한 전체 콘텐츠 너비가 뷰포트 너비보다 큰 경우
            if (scrollRect.content.sizeDelta.x > viewportInterval)
            {
                isAtLeft = viewportRange.x + extendVisibleRange <= dataList[0].cellSize.x;
                isAtRight = scrollRect.content.sizeDelta.x - viewportRange.y + extendVisibleRange <= dataList[dataList.Count - 1].cellSize.x;
            }
            // 만약 콘텐츠가 뷰포트보다 작으면 양쪽 끝 모두 true로 설정
            else
            {
                isAtLeft = true;
                isAtRight = true;
            }
        }

        /// <summary>
        /// 스크롤 뷰의 상태를 새로 고치는 메서드
        /// </summary>
        public sealed override void Refresh()
        {
            // 초기화 안한 경우 초기화
            if (!IsInitialized)
            {
                Initialize();
            }

            // 만약 뷰포트의 너비가 0이면 코루틴 DelayToRefresh()를 통해 다음 프레임에 새로고침을 진행합니다.
            if (scrollRect.viewport.rect.width == 0)
                StartCoroutine(DelayToRefresh());
            // 그렇지 않으면 바로 DoRefresh()를 호출
            else
                DoRefresh();
        }

        /// <summary>
        /// 콘텐츠 전체의 너비를 다시 계산하고, 모든 셀을 리셋한 후 뷰를 업데이트하는 메서드
        /// </summary>
        private void DoRefresh()
        {
            // 초기 너비 계산
            float width = padding.x;

            // 각 데이터 항목의 셀 너비와 spacing을 누적하여 전체 너비를 계산
            for (int i = 0; i < dataList.Count; i++)
            {
                width += dataList[i].cellSize.x + spacing;
            }

            // cellList에 있는 모든 셀을 순회하면서 RecycleCell()을 호출해, 현재 활성화된 셀을 모두 풀로 회수
            for (int i = 0; i < cellList.Count; i++)
            {
                RecycleCell(i);
            }

            // 계산된 width에 padding.y 더함
            width += padding.y;

            // 해당 값을 content의 sizeDelta.x로 설정
            scrollRect.content.sizeDelta = new Vector2(width, scrollRect.content.sizeDelta.y);

            // 새로 계산된 normalizedPosition을 기반으로 OnValueChanged()를 호출
            OnValueChanged(scrollRect.normalizedPosition);

            // onRefresh 이벤트를 Invoke하여 추가 갱신 작업 실행
            onRefresh?.Invoke();
        }

        /// <summary>
        /// 뷰포트의 크기가 0인 상황에 대해, 한 프레임 대기 후 DoRefresh()를 호출하는 메서드
        /// </summary>
        /// <returns></returns>
        private IEnumerator DelayToRefresh()
        {
            yield return waitEndOfFrame;
            DoRefresh();
        }

        /// <summary>
        /// 지정된 인덱스의 셀을 기준으로 스크롤 뷰의 content를 부드럽게 이동시켜 해당 셀이 뷰포트에 나타나도록 하는 메서드
        /// </summary>
        /// <param name="index"></param>
        /// <param name="duration"></param>
        public override void Snap(int index, float duration)
        {
            // 초기화가 안되었다면 return
            if (!IsInitialized)
                return;

            // 찾으려는 인덱스가 dataList.Count보다 높다면 return
            if (index >= dataList.Count)
                return;

            // 콘텐츠 너비가 뷰포트보다 크다면 return
            if (scrollRect.content.rect.width < scrollRect.viewport.rect.width)
                return;

            // 초기 값 세팅
            float width = padding.x;

            // index 이전의 모든 셀의 너비와 spacing을 누적해 목표 x 위치를 계산
            for (int i = 0; i < index; i++)
            {
                width += dataList[i].cellSize.x + spacing;
            }

            // 콘텐츠가 뷰포트 너비를 초과하는 최대 값과 비교하여 width를 클램프
            width = Mathf.Min(scrollRect.content.rect.width - scrollRect.viewport.rect.width, width);

            // 약 현재 content의 anchoredPosition.x가 계산된 width와 다르다면
            if (scrollRect.content.anchoredPosition.x != width)
            {
                DoSnapping(new Vector2(-width, 0), duration);
            }
        }

        /// <summary>
        /// 지정된 인덱스의 데이터 셀을 제거한 후, 콘텐츠의 위치를 조정하여 스크롤 뷰를 업데이트하는 메서드
        /// </summary>
        /// <param name="index"></param>
        public override void Remove(int index)
        {
            var removeCell = dataList[index];
            base.Remove(index);
            scrollRect.content.anchoredPosition -= new Vector2(removeCell.cellSize.x + spacing, 0);
        }
    }
}
  • 무한 스크롤 뷰의 기본 기능을 제공하는 InfiniteScrollView를 상속 받아 수평 스크롤 전용으로 동작하는 클래스입니다.

3. VerticalInfiniteScrollView

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using static UnityEngine.Analytics.IAnalytic;
using UnityHelp.UI;

namespace UnityHelp.UI
{
    /// <summary>
    /// InfiniteScrollView를 상속하여, 기본적인 무한 스크롤 로직을 활용하면서, 세로 방향(Vertical) 스크롤 뷰를 구현하는 클래스
    /// </summary>
    public class VerticalInfiniteScrollView : InfiniteScrollView
    {
        // // 콘텐츠가 스크롤뷰의 상단 또는 하단에 가까운지 여부를 나타내는 플래그 변수
        public bool isAtTop = true;
        public bool isAtBottom = true;
        
        /// <summary>
        /// 초기화 메서드
        /// </summary>
        public override void Initialize()
        {
            base.Initialize();
            isAtTop = true;
            isAtBottom = true;
        }

        /// <summary>
        /// 스크롤이 이동할 때마다 호출되며, 셀을 재활용하거나 배치하는 메서드
        /// </summary>
        /// <param name="normalizedPosition"></param>
        protected override void OnValueChanged(Vector2 normalizedPosition)
        {
            // 데이터가 없으면 반환
            if (dataList.Count == 0)
                return;

            // 뷰포트 높이 계산
            float viewportInterval = scrollRect.viewport.rect.height;

            // 현재 스크롤 위치
            float minViewport = scrollRect.content.anchoredPosition.y;

            // 뷰포트의 가시 범위 (+ 여유 범위) 설정
            Vector2 viewportRange = new Vector2(minViewport - extendVisibleRange, minViewport + viewportInterval + extendVisibleRange);

            // 초기 값 세팅
            float contentHeight = padding.x;

            // dataList.Count 만큼 순회
            for (int i = 0; i < dataList.Count; i++)
            {
                // 셀의 세로 범위는 contentHeight에서 시작해 셀의 높이(cellSize.y)만큼 확장
                var visibleRange = new Vector2(contentHeight, contentHeight + dataList[i].cellSize.y);

                // 만약 셀의 visibleRange가 뷰포트 범위(viewportRange) 밖에 있다면
                if (visibleRange.y < viewportRange.x || visibleRange.x > viewportRange.y)
                {
                    RecycleCell(i);
                }
                // 다음 셀의 위치 계산
                contentHeight += dataList[i].cellSize.y + spacing;
            }

            // 초기 값 세팅
            contentHeight = padding.x;

            // dataList.Count 만큼 순회
            for (int i = 0; i < dataList.Count; i++)
            {
                // // 셀의 세로 범위는 contentHeight에서 시작해 셀의 높이(cellSize.y)만큼 확장
                var visibleRange = new Vector2(contentHeight, contentHeight + dataList[i].cellSize.y);

                // 뷰포트 범위 내에 있다면 
                if (visibleRange.y >= viewportRange.x && visibleRange.x <= viewportRange.y)
                {
                    SetupCell(i, new Vector2(0, -contentHeight));

                    // 조건에 따른 Silbing 순서 조정
                    if (visibleRange.y >= viewportRange.x)
                        cellList[i].transform.SetAsLastSibling();
                    else
                        cellList[i].transform.SetAsFirstSibling();
                }
                // 각 행마다 셀의 높이와 spacing을 더해 다음 행의 시작 위치를 결정
                contentHeight += dataList[i].cellSize.y + spacing;
            }

            // 만약 전체 콘텐츠 높이가 뷰포트 높이보다 크다면
            if (scrollRect.content.sizeDelta.y > viewportInterval)
            {
                isAtTop = viewportRange.x + extendVisibleRange <= 0.001f;
                isAtBottom = scrollRect.content.sizeDelta.y - viewportRange.y + extendVisibleRange <= 0.001f;
            }
            else
            {
                isAtTop = true;
                isAtBottom = true;
            }
        }

        /// <summary>
        /// 스크롤 뷰의 전체 상태를 새로 고치고, 콘텐츠의 크기를 재계산한 후 셀들을 다시 배치하는 메서드
        /// </summary>
        public sealed override void Refresh()
        {
            // 초기화 되지 않았다면 초기화
            if (!IsInitialized)
            {
                Initialize();
            }

            // 만약 뷰포트의 높이가 0이면(레이아웃이 아직 완료되지 않은 경우) DelayToRefresh() 코루틴을 실행
            if (scrollRect.viewport.rect.height == 0)
                StartCoroutine(DelayToRefresh());
            else
                DoRefresh();
        }

        /// <summary>
        /// 전체 콘텐츠의 높이를 계산하고, 현재 활성화된 모든 셀을 회수한 뒤 새로 배치하는 메서드
        /// </summary>
        private void DoRefresh()
        {
            // 시작 높이를 padding.x로 설정한 후, 행 단위로 각 행의 높이(첫 번째 셀의 cellSize.y)와 spacing을 누적
            float height = padding.x;
            for (int i = 0; i < dataList.Count; i++)
            {
                height += dataList[i].cellSize.y + spacing;
            }

            // cellList에 있는 각 셀을 RecycleCell()로 회수하여 풀로 반환
            for (int i = 0; i < cellList.Count; i++)
            {
                RecycleCell(i);
            }
            // 최종 높이에 padding.y를 더해 content의 sizeDelta.y를 업데이트
            height += padding.y;
            scrollRect.content.sizeDelta = new Vector2(scrollRect.content.sizeDelta.x, height);

            // OnValueChanged()를 호출하여 현재 스크롤 위치에 맞게 셀들을 재배치하고, onRefresh 이벤트를 호출
            OnValueChanged(scrollRect.normalizedPosition);
            onRefresh?.Invoke();
        }

        /// <summary>
        /// 뷰포트의 높이가 0일 때, 한 프레임 대기한 후 DoRefresh()를 호출하여 새로고침을 진행하는 메서드
        /// </summary>
        /// <returns></returns>
        private IEnumerator DelayToRefresh()
        {
            yield return waitEndOfFrame;
            DoRefresh();
        }

        /// <summary>
        /// 주어진 인덱스의 셀이 포함된 행을 기준으로, 콘텐츠를 스냅(부드러운 이동)하여 해당 행이 뷰포트에 나타나도록 하는 메서드
        /// </summary>
        /// <param name="index"></param>
        /// <param name="duration"></param>
        public override void Snap(int index, float duration)
        {

            // 초기화가 안되었다면 return
            if (!IsInitialized)
                return;

            // index 보다 dataList.Count가 작거나 같으면 return
            if (index >= dataList.Count)
                return;

            // 콘텐츠 높이가 뷰포트보다 작으면 return
            if (scrollRect.content.rect.height < scrollRect.viewport.rect.height)
                return;

            // 각 셀의 높이 (dataList[i].cellSize.y)와 셀 간 간격 (spacing)을 누적해서 계산
            float height = padding.x;
            for (int i = 0; i < index; i++)
            {
                height += dataList[i].cellSize.y + spacing;
            }

            // 목표 높이가 최대 가능한 스크롤 범위를 초과하지 않도록 제한
            height = Mathf.Min(scrollRect.content.rect.height - scrollRect.viewport.rect.height, height);

            // 현재 스크롤 위치(anchoredPosition.y)와 height 값이 다를 경우 스냅 이동 실행
            if (scrollRect.content.anchoredPosition.y != height)
            {
                DoSnapping(new Vector2(0, height), duration);
            }
        }

        /// <summary>
        /// 지정된 인덱스의 데이터 셀을 제거한 후, 콘텐츠의 위치를 조정하여 스크롤 뷰를 업데이트하는 메서드
        /// </summary>
        /// <param name="index"></param>
        public override void Remove(int index)
        {
            var removeCell = dataList[index];
            base.Remove(index);
            scrollRect.content.anchoredPosition -= new Vector2(0, removeCell.cellSize.y + spacing);
        }
    }
}
  • InfiniteScrollView를 상속하여, 기본적인 무한 스크롤 로직을 활용하면서, 세로 방향 스크롤 뷰를 구현하는 클래스입니다.

4. GridInfiniteScrollView

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using static UnityEngine.Analytics.IAnalytic;
using UnityHelp.UI;

namespace UnityHelp.UI
{
    /// <summary>
    ///  InfiniteScrollView의 공통 기능(셀 풀 관리, Refresh, Snap 등)을 상속받아 수직 방향의 그리드 형태 무한 스크롤을 구현하는 클래스
    /// </summary>
    public class VerticalGridInfiniteScrollView : InfiniteScrollView
    {
        // 콘텐츠가 스크롤뷰의 상단 또는 하단에 가까운지 여부를 나타내는 플래그 변수
        public bool isAtTop = true;
        public bool isAtBottom = true;

        // 한 행(row)에 들어가는 셀(열)의 수를 의미하는 변수
        public int columeCount = 1;

        /// <summary>
        /// 스크롤뷰의 스크롤 위치가 바뀔 때마다 호출되어, 보이는 셀들을 새로 설정하거나 화면 밖에 있는 셀들을 회수하는 메서드
        /// </summary>
        /// <param name="normalizedPosition"></param>
        protected override void OnValueChanged(Vector2 normalizedPosition)
        {
            // 0이라면 1로 고정
            if (columeCount <= 0)
            {
                columeCount = 1;
            }

            // 뷰포트의 높이를 가져옴
            float viewportInterval = scrollRect.viewport.rect.height;

            // 콘텐츠의 현재 anchoredPosition.y 값을 사용하여, 스크롤된 수직 오프셋 얻기
            float minViewport = scrollRect.content.anchoredPosition.y;

            // 현재 뷰포트의 상단과 하단 위치를 정의
            Vector2 viewportRange = new Vector2(minViewport, minViewport + viewportInterval);

            // 초기 값 세팅
            float contentHeight = padding.x;

            /// 셀 회수

            // 외부 루프는 한 행씩 처리하며, 인덱스를 columeCount 단위로 증가
            for (int i = 0; i < dataList.Count; i += columeCount)
            {
                // 각 행에 있는 셀들을 처리
                for (int j = 0; j < columeCount; j++)
                {
                    int index = i + j;

                    // index가 dataList.Count보다 크거나 같다면
                    if (index >= dataList.Count)
                        break;

                    // 셀의 세로 범위는 contentHeight에서 시작해 셀의 높이(cellSize.y)만큼 확장
                    var visibleRange = new Vector2(contentHeight, contentHeight + dataList[index].cellSize.y);

                    // 만약 셀의 visibleRange가 뷰포트 범위(viewportRange) 밖에 있다면
                    if (visibleRange.y < viewportRange.x || visibleRange.x > viewportRange.y)
                    {
                        RecycleCell(index);
                    }
                }

                // 한 행의 첫 번째 셀의 높이와 spacing을 더하여 다음 행의 시작 위치를 계산
                contentHeight += dataList[i].cellSize.y + spacing;
            }

            // 초기 값 세팅
            contentHeight = padding.x;

            /// 셀 설정

            // 외부 루프는 한 행씩 처리하며, 인덱스를 columeCount 단위로 증가
            for (int i = 0; i < dataList.Count; i += columeCount)
            {
                // 각 행에 있는 셀들을 처리
                for (int j = 0; j < columeCount; j++)
                {
                    int index = i + j;

                    // index가 dataList.Count보다 크거나 같다면
                    if (index >= dataList.Count)
                        break;

                    // 셀의 세로 범위는 contentHeight에서 시작해 셀의 높이(cellSize.y)만큼 확장
                    var visibleRange = new Vector2(contentHeight, contentHeight + dataList[index].cellSize.y);

                    // 뷰포트 범위 내에 있다면 
                    if (visibleRange.y >= viewportRange.x && visibleRange.x <= viewportRange.y)
                    {
                        // x 좌표: (cellSize.x + spacing) * j → 열 번호(j)에 따라 좌우 배치
                        // y 좌표: -contentHeight → 상단에서 아래로 내려가는 방향으로 배치.
                        SetupCell(index, new Vector2(padding.x + (dataList[index].cellSize.x + spacing) * j, -contentHeight));

                        // 조건에 따른 Sibling 순서 조정
                        if (visibleRange.y >= viewportRange.x)
                            cellList[index].transform.SetAsLastSibling();
                        else
                            cellList[index].transform.SetAsFirstSibling();
                    }
                }
                // 각 행마다 셀의 높이와 spacing을 더해 다음 행의 시작 위치를 결정
                contentHeight += dataList[i].cellSize.y + spacing;
            }

            // 만약 전체 콘텐츠 높이가 뷰포트 높이보다 크다면
            if (scrollRect.content.sizeDelta.y > viewportInterval)
            {
                isAtTop = viewportRange.x + extendVisibleRange <= dataList[0].cellSize.y;
                isAtBottom = scrollRect.content.sizeDelta.y - viewportRange.y + extendVisibleRange <= dataList[dataList.Count - 1].cellSize.y;
            }
            else
            {
                isAtTop = true;
                isAtBottom = true;
            }
        }

        /// <summary>
        /// 스크롤 뷰의 전체 상태를 새로 고치고, 콘텐츠의 크기를 재계산한 후 셀들을 다시 배치하는 메서드
        /// </summary>
        public sealed override void Refresh()
        {
            // 초기화 되지 않았다면 초기화
            if (!IsInitialized)
            {
                Initialize();
            }

            // 만약 뷰포트의 높이가 0이면(레이아웃이 아직 완료되지 않은 경우) DelayToRefresh() 코루틴을 실행
            if (scrollRect.viewport.rect.height == 0)
                StartCoroutine(DelayToRefresh());
            else
                DoRefresh();
        }

        /// <summary>
        /// 전체 콘텐츠의 높이를 계산하고, 현재 활성화된 모든 셀을 회수한 뒤 새로 배치하는 메서드
        /// </summary>
        private void DoRefresh()
        {
            // 시작 높이를 padding.x로 설정한 후, 행 단위로 각 행의 높이(첫 번째 셀의 cellSize.y)와 spacing을 누적
            float height = padding.x;
            for (int i = 0; i < dataList.Count; i += columeCount)
            {
                height += dataList[i].cellSize.y + spacing;
            }

            // cellList에 있는 각 셀을 RecycleCell()로 회수하여 풀로 반환
            for (int i = 0; i < cellList.Count; i++)
            {
                RecycleCell(i);
            }

            // 최종 높이에 padding.y를 더해 content의 sizeDelta.y를 업데이트
            height += padding.y;
            scrollRect.content.sizeDelta = new Vector2(scrollRect.content.sizeDelta.x, height);

            // OnValueChanged()를 호출하여 현재 스크롤 위치에 맞게 셀들을 재배치하고, onRefresh 이벤트를 호출
            OnValueChanged(scrollRect.normalizedPosition);
            onRefresh?.Invoke();
        }

        /// <summary>
        /// 뷰포트의 높이가 0일 때, 한 프레임 대기한 후 DoRefresh()를 호출하여 새로고침을 진행하는 메서드
        /// </summary>
        /// <returns></returns>
        private IEnumerator DelayToRefresh()
        {
            yield return waitEndOfFrame;
            DoRefresh();
        }

        /// <summary>
        /// 주어진 인덱스의 셀이 포함된 행을 기준으로, 콘텐츠를 스냅(부드러운 이동)하여 해당 행이 뷰포트에 나타나도록 하는 메서드
        /// </summary>
        /// <param name="index"></param>
        /// <param name="duration"></param>
        public override void Snap(int index, float duration)
        {
            // 초기화가 안되었다면 return
            if (!IsInitialized)
                return;

            // index 보다 dataList.Count가 작거나 같으면 return
            if (index >= dataList.Count)
                return;

            // rowNumber = index / columeCount로, 해당 셀이 몇 번째 행에 위치하는지 결정
            var rowNumber = index / columeCount;

            // padding.x에서 시작하여, 목표 행 이전의 모든 행의 높이(각 행의 첫 셀의 cellSize.y + spacing)를 누적
            var height = padding.x;
            for (int i = 0; i < rowNumber; i++)
            {
                height += dataList[i * columeCount].cellSize.y + spacing;
            }

            // 계산된 높이가 콘텐츠의 최대 이동 가능 범위를 넘지 않도록 Mathf.Min을 사용해 클램핑
            height = Mathf.Min(scrollRect.content.rect.height - scrollRect.viewport.rect.height, height);

            // 현재 anchoredPosition.y와 계산된 높이가 다르면, DoSnapping()을 호출하여 부드러운 스냅 애니메이션으로 콘텐츠를 이동
            if (scrollRect.content.anchoredPosition.y != height)
            {
                DoSnapping(new Vector2(0, height), duration);
            }
        }
    }
}
  • InfiniteScrollView의 공통 기능을 상속받아 수직 방향의 그리드 형태 무한 스크롤을 구현하는 클래스입니다.
  • Horizontal 이나 Vertical이나 큰 차이가 없어 하나로 대체합니다.

문제점

문제점 1 : 기존 Github 코드에서 padding의 영역에 따라 컨텐츠 내의 범위가 이상하게 잡히는 오류가 있었습니다.


// 기존 코드
SetupCell(index, new Vector2(contentWidth, (dataList[index].cellSize.y + spacing) * -j));

// 수정 코드
SetupCell(index, new Vector2(contentWidth, -padding.y + (dataList[index].cellSize.y + spacing) * -j));
  • padding의 값을 넣어주어 수정하였습니다.

문제점 2 : CanvasScaler의 Reference Resolution의 크기에 따라 Grid InfiniteScrollView의 오류가 발생하였습니다.

  • 해당 문제는 Content의 사이즈가 비상식적으로 크거나 작은 경우 발생하는 문제였습니다.
  • 문제 해결을 위해 셀이 들어갈만하게 충분한 row나 column을 설정하고 Content의 사이즈를 조절하는 것으로 해결하였습니다.

느낀 점

  • InfiniteScrollView 같은 경우 Github에서 이미 검증이 완료된 내용들이 많아서 해당 코드들을 분석하는게 더 이해하기 편할 것 같아서 사용했습니다.
    원래는 그냥 사용하면 작동일 될 줄 알았는데 오류도 있는 부분이 있었고 문제를 수정하면서 더 알아가는 것 같습니다.
    그리고 검색하면서 본 InfiniteScrollView는 각각의 개발자마다 다른 방식을 사용하는 것도 신기하였고 특히 NHN의 InfiniteScrollView가 가장 신기하였습니다.
    저도 나중엔 NHN의 방식처럼 코드를 구현해보고싶다는 생각이 드는 학습 시간이였습니다.
profile
Unity 게임 개발자를 준비하는 취업준비생입니다..

0개의 댓글