오늘은 무한 스크롤 (InfiniteScroll)에 대해 알아보도록 하겠습니다.
➡️ 제한된 수의 오브젝트를 재활용하여 플레이어나 사용자에게 끝없이 진행하는 것 처럼 보이게 하는 기법입니다.
➡️ 주요 사용처는 끝없는 환경을 구현하거나 UI ScrollView에 사용됩니다.
➡️ 이 글에서는 UI ScrollView에 대해 알아보도록 하겠습니다.

- Horizontal 무한 스크롤을 구현할 때, 좌측과 우측에 기준점을 설정하고, 스크롤로 인해 UI 오브젝트가 이 기준점을 넘어서면 해당 오브젝트를 반대쪽으로 이동시켜 재사용하는 방식입니다.
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();
}
}
}
}
- 각 타입에 맞는 무한 스크롤 클래스들이 상속받게 될 기능을 구현한 추상 클래스입니다.
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를 상속 받아 수평 스크롤 전용으로 동작하는 클래스입니다.
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를 상속하여, 기본적인 무한 스크롤 로직을 활용하면서, 세로 방향 스크롤 뷰를 구현하는 클래스입니다.
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의 사이즈를 조절하는 것으로 해결하였습니다.