오늘의 목표
오브젝트 풀을 활용한 스크롤 뷰 만들기
스테이지 등 스크롤뷰의 컨텐트 영역에 들어갈 무언가가 한정적이라면 만들지 않아도 괜찮겠지만,
예를 들어 SNS라던가, 무한히 스크롤 되는 (혹은 매우 많은 데이터가 들어있는) 경우 스크롤 뷰의 컨텐트를 모두 생성하는 것은 매우 비효율적인 방법이다.
따라서 오브젝트 풀로 데이터가 놓일 공간만 만들어 두고, 스크롤을 했을 때 인식해서 알맞은 데이터를 설정해줄 것이다.
기본적인 골자는 다음과 같다.
들어갈 데이터인 아이템이다.
public struct Item
{
public string itemFlileName;
public string title;
public string subtitle;
}
다음은 스크롤 뷰의 기본 성질을 볼 수 있는 친구이다.
OnValueChanged(Vector2 Value)로 벨류값을 받아오는데 , 이때 Value 값은 0 혹은1 사이의 값이 된다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(ScrollRect))]
[RequireComponent(typeof(RectTransform))]
public class ScrollVeiwController : MonoBehaviour
{
private ScrollRect _scrollRect;
private RectTransform _rectTransform;
//변하는 형태의 배열 ( 실행하면서 넣었다 뺄 수 잇므)
private List<Item> _items;
private void Awake()
{
_scrollRect = GetComponent<ScrollRect>();
_rectTransform = GetComponent<RectTransform>();
}
private void Start()
{
LoadData();
}
private void ReloadData()
{
//아이템 리스트가 수정되면 다시..
}
private void LoadData()
{
_items = new List<Item>
{
new Item { itemFlileName = "image1", title = "Title1", subtitle = "Subtitle1" },
new Item { itemFlileName = "image2", title = "Title2", subtitle = "Subtitle2" },
new Item { itemFlileName = "image3", title = "Title3", subtitle = "Subtitle3" },
new Item { itemFlileName = "image4", title = "Title4", subtitle = "Subtitle4" },
new Item { itemFlileName = "image5", title = "Title5", subtitle = "Subtitle5" },
new Item { itemFlileName = "image7", title = "Title7", subtitle = "Subtitle7" },
new Item { itemFlileName = "image8", title = "Title8", subtitle = "Subtitle8" },
new Item { itemFlileName = "image9", title = "Title9", subtitle = "Subtitle9" },
new Item { itemFlileName = "image10", title = "Title10", subtitle = "Subtitle10" },
};
ReloadData();
}
public void OnValueChanged(Vector2 value)
{
Debug.Log(value);
var x = _scrollRect.content.anchoredPosition.x;
var y = _scrollRect.content.anchoredPosition.y;
var w = _rectTransform.rect.width;
var h = _rectTransform.rect.height;
Debug.Log($"x:{x}, y:{y}, w:{w}, h:{h}");
}
}
ScrollView에 들어갈 한 칸 한 칸인 Cell은 다음과 같다.
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class Cell : MonoBehaviour
{[SerializeField] private Image image;
[SerializeField] private TMP_Text text;
[SerializeField] private TMP_Text subTitle;
public void SetItem(Item item)
{
image.sprite = Resources.Load<Sprite>(item.itemFlileName);
text.text = item.title;
subTitle.text = item.subtitle;
}
}
다음은 내가 약 4시간동안 혼자서 짜 본 코드다.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Unity.VisualScripting;
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(ScrollRect))]
[RequireComponent(typeof(RectTransform))]
public class ScrollVeiwController : MonoBehaviour
{
// 이걸로는 Content의 포지션을 구할 수 있다.
private ScrollRect scrollRect;
// 스크롤뷰의 rectTransform -> 왜 필요할까. .?
private RectTransform rectTransform;
//변하는 형태의 배열 ( 실행하면서 넣었다 뺄 수 있음)
private List<Item> _items;
private List<GameObject> _cells;
private float previousContentPosition;
private float threshold = 10;
[SerializeField] private CellObjectPool cellObjectPool;
public int itemCount = 100;
private void Awake()
{
// ScrollRect
scrollRect = GetComponent<ScrollRect>();
rectTransform = GetComponent<RectTransform>();
}
private void Start()
{
LoadData();
}
// public void ReloadData()
// {
// //아이템 리스트가 수정되면 다시..
//
// // 자 일단 현재 Content의 시작과 끝 위치를 구했다
// var currentContentPosition = ContentPosition();
// var currentContentStartPosition = currentContentPosition.Item1;
// var currentContentEndPosition = currentContentPosition.Item2;
//
//
// var firstItemID = _cells[0].GetComponent<Cell>().ItemID;
// var lastItemID = _cells[_cells.Count -1].GetComponent<Cell>().ItemID;
//
// // 첫 번째 셀의 포지션이, currentContentStartPosition보다 위에 있다면 (위로로스크롤해서 밖으로 나감)
// if (_cells[0].GetComponent<RectTransform>().anchoredPosition.y > currentContentStartPosition)
//
// {
// if (lastItemID + 1 < _items.Count)
// {
// AppendCellLast(lastItemID);
// DeleteCellFirst(firstItemID);
//
// }
//
// }
// // 반대로 마지막 셀의 포지션이 currentContestEndPosition보다 아래 있다면 (아래로 스크롤해서 아래로 나감)
// if (_cells[_cells.Count - 1].GetComponent<RectTransform>().anchoredPosition.y < currentContentEndPosition)
// {
// if (firstItemID -1 > 0)
// {
// AppendCellFirst(firstItemID);
// DeleteCellLast(lastItemID);
//
// }
//
// }
//
//
// }
public void ReloadData()
{
if (_cells.Count == 0) return;
var contentPosition = scrollRect.content.anchoredPosition.y;
var firstItemID = _cells[0].GetComponent<Cell>().ItemID;
var lastItemID = _cells[_cells.Count - 1].GetComponent<Cell>().ItemID;
// Content가 위로 이동 (아래로 스크롤)
if (contentPosition > previousContentPosition + threshold)
{
if (lastItemID + 1 < _items.Count)
{
AppendCellLast(lastItemID);
DeleteCellFirst(firstItemID);
}
previousContentPosition = contentPosition;
}
// Content가 아래로 이동 (위로 스크롤)
else if (contentPosition < previousContentPosition - threshold)
{
if (firstItemID - 1 >= 0)
{
AppendCellFirst(firstItemID);
DeleteCellLast(lastItemID);
}
previousContentPosition = contentPosition;
}
}
private void LoadData()
{
_items = new List<Item>();
_cells = new List<GameObject>();
for (int i = 0; i < itemCount; i++)
{
var item = new Item
{
itemFlileName = $"Item{i}",
title = $"Title{i}",
subtitle = $"Subtitle{i}",
itemID = i,
};
_items.Add(item);
}
// 셀까지 구했다.
for (int i = 0; i < 8; i++)
{
var newCell = cellObjectPool.GetPool();
newCell.transform.SetParent(scrollRect.content, false);
newCell.GameObject().GetComponent<Cell>().SetItem(_items[i]);
_cells.Add(newCell.GameObject());
}
previousContentPosition = scrollRect.content.anchoredPosition.y;
ReloadData();
}
// 현재 Content의 위치를 구했음
(float, float) ContentPosition()
{
var contentPosition = scrollRect.content.anchoredPosition;
var viewPortHight = scrollRect.viewport.rect.height;
var endPositionY = contentPosition.y - viewPortHight;
return (contentPosition.y, endPositionY);
}
private void AppendCellFirst(int cellIndex)
{
var newCell = cellObjectPool.GetPool();
newCell.transform.SetParent(scrollRect.content, false);
newCell.transform.SetAsFirstSibling();
newCell.GetComponent<Cell>().SetItem(_items[cellIndex-1]);
_cells.Insert(0, newCell.GameObject());
}
private void AppendCellLast(int cellIndex)
{
var newCell = cellObjectPool.GetPool();
newCell.transform.SetParent(scrollRect.content, false);
newCell.transform.SetAsLastSibling();
newCell.GetComponent<Cell>().SetItem(_items[cellIndex+1]);
_cells.Add(newCell.GameObject());
}
private void DeleteCellFirst(int cellIndex)
{
var cellToRemove = _cells[0];
cellToRemove.transform.SetParent(null, false);
cellObjectPool.ReturnPool(cellToRemove);
_cells.RemoveAt(0);
}
private void DeleteCellLast(int cellIndex)
{
var cellToRemove = _cells.Last();
cellToRemove.transform.SetParent(null, false);
cellObjectPool.ReturnPool(cellToRemove);
_cells.RemoveAt(_cells.Count - 1);
}
}
내가 생각한 로직은 다음과 같았다.
Content의 위치 (Y값) 을 구한다. 이것이 위로 가면 지금 우리는 아래로 스크롤하고 있는 중이다.
Content의 위치값과, 리스트의 제일 첫 번째 있는 Cell의 Position을 비교한다.
만일 Content의 위치값보다 리스트의 제일 첫 번째 있는 Cell의 Position이 커진다면 (위에 있다면)
첫 번째 Cell을 리스트에서 지우고, ReturnPool을 하고, GetPool한 것을 리스트에 추가하고 Parent를 설정해준다.
(Vertical Layout Group에 의한 자동 정렬)
여기서 예외처리만 해 주면 될 줄 알았다.
그런데 올라가는 것이 매우매우 빠르게 ( 1초도 안되어서 99가 된다) 진행되었고, 새롭게 데이터가 추가된다는 느낌보다는 숫자가 바뀐다는 느낌이 강했다. 그리고 반대로 내려가는 것은 잘 동작하지 않았다.
또, 중간에 계속 원래 위치로 돌아가는 버그도 있었다.
여러가지 문제를 하나하나 보자.
문제 1 : Content Size 문제
ContentSizeFilter가 있어서 추가할 때마다 자동으로 Content의 크기가 바뀔줄 알았다. (줄어드는 것도 포함)
그런데 그 조절 기능이 잘 먹히지 않았다. UI 요소의 크기를 자식 요소의 크기에 맞춰 자동으로 조절하는 것으로 알고 있었는데 내가 설정을 잘못했는지 제대로 먹히지 않아서 매우 작은 크기 그대로 남아 있었다.
그래서 스크롤을 해도 자꾸 원래대로 돌아왔다.
문제 2 : anchoredPosition 문제
// if (_cells[0].GetComponent<RectTransform>().anchoredPosition.y > currentContentStartPosition)
이 부분에서 나는 첫 번째 Cell의 anchoredPosition.y와 currentContentStartPosition을 비교한다.
그런데 여기서 cell들은 기본적으로 따로 설정해주지 않으면 부모 오브젝트의 특정 지점에 ( 프리팹에서 지정했던 strech대로) anchoredPosition을 지정하게 되고 , 이는 부모가 움직일 때 변하지 않고 따라서 움직이게 된다.
또 더해서, 한 번 실행하고 나면 _cells[0] 번째는 그 다음 cell이 되는데 이때도 똑같이 부모 오브젝트의 특정 지점에 anchoredPosition을 갖고 있기 때문에 조금이라도 currentContentStartPosition보다 위에 있으면 (조금이라도 당기면)
무시무시하게 빠른 속도로 마지막까지 가는 것을 확인할 수 있다.
반대로, 밑으로 내리는 것은 동작하지 않는다.
// 현재 Content의 위치를 구했음
(float, float) ContentPosition()
{
var contentPosition = scrollRect.content.anchoredPosition;
var viewPortHight = scrollRect.viewport.rect.height;
var endPositionY = contentPosition.y - viewPortHight;
return (contentPosition.y, endPositionY);
}
현재 Content의 위치 중 밑의 값은 contentPosition.y - viewPortHeight로 맨 위의 값에서 보이는 영역을 빼준 것이다.
그렇지만, 우리의 비교 대상인
// if (_cells[_cells.Count - 1].GetComponent<RectTransform>().anchoredPosition.y < currentContentEndPosition)
마지막 셀의 anchoredPosition.y는 첫 번째 셀과 동일하게 특정 부분으로 고정되어 있고, 그저 Vertical LayoutGroup에 의해 정렬될 뿐이다.
그래서 이상한 현상이 나타났던 것이다.
밑의 코드는 다른 분의 코드를 참고해서 새롭게 만든 코드이다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Pool;
using UnityEngine.UI;
[RequireComponent(typeof(ScrollRect))]
[RequireComponent(typeof(RectTransform))]
public class ScrollViewController : MonoBehaviour
{
[SerializeField] private GameObject cellPrefab;
private ScrollRect _scrollRect;
private RectTransform _rectTransform;
private List<Item> _items;
private List<GameObject> _cellObjects;
private ObjectPool _pool;
private int loadedStartIndex = 0;
private int loadedEndIndex = 0;
private float cellSize = 300f;
private void Awake()
{
_scrollRect = GetComponent<ScrollRect>();
_rectTransform = GetComponent<RectTransform>();
_items = new List<Item>();
_pool = GetComponent<ObjectPool>(); }
private void Start()
{
LoadData();
}
/// <summary>
/// _items에 있는 값을 Scroll View에 표시하는 함수
/// </summary>
private void ReloadData()
{
int startIndex = ReturnItemIndex().Item1;
int endIndex = ReturnItemIndex().Item2;
startIndex = Math.Max(startIndex, 0);
endIndex = Math.Min(endIndex, _items.Count-1);
// 스크롤했을 때 맨 위의 인덱스 값이, 원래 있던 cell의 가장 높은 인덱스 값보다 크다 (위로 스크롤 했다)
if (startIndex > loadedStartIndex)
{
DeleteFirst(startIndex);
}
// 스크롤 했을 때 맨 위의 인덱스 값이, 원래 있던 cell의 가장 높은 인덱스 값보다 작다 (아래로 스크롤 했다)
else if (startIndex < loadedStartIndex)
{
AppendFirst(startIndex);
}
// 스크롤 했을 때 맨 아래 인덱스 값이, 원래 맨 아래 인덱스 값보다 크다(위로 스크롤 했다)
if (endIndex > loadedEndIndex)
{
AppendLast(endIndex);
}
//스크롤 했을 때 맨 아래 인덱스 값이, 원래 맨 아래 인덱스 값보다 작다 (아래로 스크롤 했다)
else if (endIndex < loadedEndIndex)
{
DeleteLast(endIndex);
}
}
private void AppendFirst(int startIndex)
{
int count = loadedStartIndex - startIndex;
float CurrentPosY = (loadedStartIndex - 1) * cellSize;
for (int i = 0; i < count; i++)
{
var newCell = _pool.GetObject();
newCell.transform.SetAsFirstSibling();
newCell.GetComponent<RectTransform>().anchoredPosition = new Vector2(0, -CurrentPosY);
CurrentPosY -= cellSize;
newCell.gameObject.GetComponent<Cell>().SetItem(_items[loadedStartIndex - 1 - i]);
}
loadedStartIndex = startIndex;
}
private void AppendLast(int endIndex)
{
int count = endIndex - loadedEndIndex;
float currentPosY = loadedEndIndex * cellSize;
for (int i = 0; i < count; i++)
{
var newCell = _pool.GetObject();
newCell.transform.SetAsLastSibling();
newCell.GetComponent<RectTransform>().anchoredPosition = new Vector2(0, -currentPosY);
currentPosY += cellSize;
newCell.gameObject.GetComponent<Cell>().SetItem(_items[loadedEndIndex + i]);
}
loadedEndIndex = endIndex;
//중요 * 우리 지금까지 content사이즈가 고정되어 있었음, 근데 하나 늘릴떄는 늘려줘야 함.
// 첫줄에 추가하는건 이미 있는걸 쓰는거고, 지금은 COntentSize 부족하니까 늘려줘야함 ( cellSize만큼)
var contentSize = _scrollRect.content.sizeDelta;
_scrollRect.content.sizeDelta = new Vector2(contentSize.x, cellSize*endIndex);
}
private void DeleteFirst(int startIndex)
{
var count = startIndex - loadedStartIndex;
Debug.Log(count);
for (int i = 0; i < count; i++)
{
//이건 좀 놀랍네
_pool.ReturnObject(_scrollRect.content.GetChild(0).gameObject);
}
// 이게 맞나 ?
loadedStartIndex = startIndex;
}
private void DeleteLast(int endIndex)
{
var count = loadedEndIndex - endIndex;
for (int i = 0; i < count; i++)
{
_pool.ReturnObject(_scrollRect.content.GetChild(_scrollRect.content.childCount -1).gameObject);
}
loadedEndIndex = endIndex;
var contentSize = _scrollRect.content.sizeDelta;
_scrollRect.content.sizeDelta = new Vector2(contentSize.x, cellSize * endIndex);
}
private (int, int) ReturnItemIndex()
{
var contentPos = _scrollRect.content.anchoredPosition;
var viewPortHeight = _scrollRect.viewport.rect.height;
var cellSize = 300f;
var startIndex = MathF.Floor(contentPos.y / cellSize);
var endIndex = MathF.Ceiling((contentPos.y + viewPortHeight) / cellSize);
return ((int)startIndex, (int)endIndex);
}
private void LoadData()
{
for (int i = 0; i < 100; i++)
{
var item = new Item()
{
title = $"Item {i}",
subtitle = $"Subtitle {i}",
};
_items.Add(item);
}
ReloadData();
}
#region Scroll Rect Events
public void OnValueChanged(Vector2 value)
{
ReloadData();
}
#endregion
}
중요한 부분은 Content의 Size를 늘려주는 부분이다. 새롭게 생성했으면 그 부분까지 늘려준다.
ContentSize를 일정하게 유지할 수 있을까도 생각해 보았지만 anchoredPositon이 문제를 일으킬 수도,
스크롤이 위쪽으로 올라가지 않는 문제가 발생할 수도 있을것 같아 개조는 하지 않았다.
원래 코드의 방식을 차용해서 ReloadData를 두 개의 경우, 즉 위로 스크롤과 아래로 스크롤만으로 또 바꿔보려 하였지만
여기선 위와 아래의 index를 구하는 방식이 달라서 조심해야 할 것 같았다.
신기했던 방식은 나는 정렬을 Vertical Layout Group에 위임하려고 SetAsFirstSibling()을 사용했었는데
이 방식은 가장 위에 있는 Cell을 구하기 위해서 미리 작업을 해둔 것이었다.
앞으로도 종종 써 먹을 수 있는 방식인 것 같다.
결국 중요한 것은, 디버깅을 조금 더 잘 하는것 같다. 아직 너무 미숙해서 어떤 데이터를 확인해야 하는지.. 까지가 조금 시간이 걸렸다. 다시 시작하는 것을 겁낼 필요도 없는 것 같다. 날려봤자 하루 날리는 것인데 (혹은 더 적은 시간) 그냥 처음부터 다시 시작하자 ! 는 마인드로 다시 해야겠다.
또 UI는 Transform이 혼동을 줄 여지가 있으므로, 조금 더 천천히 생각해야지 .
그림을 먼저 그리고, 일단 코드를 쳐 보자 ! 가 아닌 조금 더 정리된 상태로 했으면 이것보다는 빨리, 그리고 정확하게 했을 듯 하다.
어디서부터 잘못되었는지를 찾는 방식에 대한 고찰도 필요하다. 지금은 뭐가 잘못되었지를 다른 분의 코드를 보고
내 것으로 만드는 과정에서 깨달았다. 많이 구현해 봐야, 어 뭔가 이상한데 ? 를 느낄 수 있을 것 같다.
내일도 화이팅 !
'시행착오' 카테고리의 다른 글
02.25 회고 + Scroll View 응용 (0) | 2025.02.26 |
---|---|
애니메이션 이벤트 + DOTween 회전 실행 오류 // 02/21 수정(Root Motion) (0) | 2025.02.20 |