안녕하세요? 오늘은 유니티 스크롤뷰에서 그 스크롤이 위아래 이동하는 것을 버튼으로 구현해보도록 하겠습니다.
예를 들어 위 버튼 한번 누르면 컨텐츠가 한 칸씩 아래로 가면서 위에 있는 컨텐츠를 볼 수 있는 식으로요.


저는 미연시 게임을 개발하면서, 백로그 기능을 구현하려는 중이었는데요.
(백로그란, 이미 지나온 대화를 다시 볼 수 있게 하는 기능입니다.)
대화 기록을 스크롤 뷰로 구현했는데, 위와 같은 버튼 4개로 스크롤을 이동하는 기능도 개발해야 했습니다.
using UnityEngine;
using UnityEngine.UI;
public class ItemScrollController : MonoBehaviour
{
[SerializeField] private ScrollRect scrollRect;
[SerializeField] private RectTransform content;
private int currentIndex;
private void Start()
{
Canvas.ForceUpdateCanvases();
if (contentRectTransform.childCount == 0) return;
// 시작 위치를 맨 아래로 설정
currentIndex = contentRectTransform.childCount - 1;
scrollRect.verticalNormalizedPosition = 0f;
}
// 위로 이동 (이전/상단 아이템)
public void OnClickScrollUp()
{
if (content.childCount == 0) return;
// 현재 화면에서 가장 잘 보이는 아이템 인덱스로 갱신
currentIndex = GetCurrentTopVisibleIndex();
currentIndex = Mathf.Max(0, currentIndex - 1);
ScrollToItem(currentIndex);
}
// 아래로 이동 (다음/하단 아이템)
public void OnClickScrollDown()
{
if (content.childCount == 0) return;
// 현재 화면에서 가장 잘 보이는 아이템 인덱스로 갱신
currentIndex = GetCurrentTopVisibleIndex();
currentIndex = Mathf.Min(content.childCount - 1, currentIndex + 1);
ScrollToItem(currentIndex);
}
// 최상단 이동
public void OnClickScrollToTop()
{
if (content.childCount == 0) return;
currentIndex = 0;
scrollRect.verticalNormalizedPosition = 1f;
}
// 최하단 이동
public void OnClickScrollToBottom()
{
if (content.childCount == 0) return;
currentIndex = content.childCount - 1;
scrollRect.verticalNormalizedPosition = 0f;
}
private void ScrollToItem(int index)
{
Canvas.ForceUpdateCanvases();
Transform targetChild = content.GetChild(index);
RectTransform viewport = scrollRect.viewport != null ? scrollRect.viewport : (RectTransform)scrollRect.transform;
Vector2 targetLocalPos = viewport.InverseTransformPoint(targetChild.position);
Vector2 viewportLocalPos = viewport.InverseTransformPoint(viewport.position);
float diffY = viewportLocalPos.y - targetLocalPos.y;
content.anchoredPosition += new Vector2(0, diffY);
}
// 현재 Viewport 상단에 가장 가까운 자식 오브젝트의 인덱스를 찾아 반환
private int GetCurrentTopVisibleIndex()
{
RectTransform viewport = scrollRect.viewport != null ? scrollRect.viewport : (RectTransform)scrollRect.transform;
Vector2 viewportTopPos = viewport.InverseTransformPoint(viewport.position);
int closestIndex = 0;
float minDistance = float.MaxValue;
for (int i = 0; i < content.childCount; i++)
{
Vector2 childLocalPos = viewport.InverseTransformPoint(content.GetChild(i).position);
float distance = Mathf.Abs(viewportTopPos.y - childLocalPos.y);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
}
Canvas.ForceUpdateCanvases();
유니티의 UI 레이아웃(Vertical Layout Group, Content Size Fitter 등)은 기본적으로 프레임이 끝나는 시점에 위치를 재계산합니다. 만약 아이템이 방금 생성되었거나 크기가 변경된 직후라면 targetChild.position 값이 0이나 이전 위치로 잡혀 오작동할 수 있습니다. 이를 방지하기 위해 현재 시점에 UI 위치 계산을 즉시 완료하도록 강제하는 코드입니다.
GetCurrentTopVisibleIndex()
현재 Viewport 상단에 가장 가까운 자식 오브젝트의 인덱스를 찾아 반환하는 함수입니다. 버튼으로만 스크롤을 작동시키는 게 아니라 마우스 휠로도 작동할 수 있기 때문에 OnClickScrollUp(), OnClickScrollDown()을 실행할 때 인덱스를 갱신할 필요가 있습니다. 그를 위해 작성한 코드입니다.
작동 알고리즘은 아래와 같습니다.
ScrollToItem(int index)
지정한 인덱스에 해당하는 자식 아이템이 Viewport 상단에 위치하도록 Content의 Y축 위치를 직접 이동시키는 핵심 함수입니다.
작동 알고리즘은 아래와 같습니다.
OnClickScrollUp(), OnClickScrollDown()
1개만큼의 컨텐츠씩 위, 아래로 이동하게 하는 함수입니다.
OnClickScrollToTop(), OnClickScrollToBottom()
최상단, 최하단으로 이동하게 하는 함수입니다.
scrollRect.verticalNormalizedPosition = 1f;이면 최상단, scrollRect.verticalNormalizedPosition = 0f;이면 최하단으로 이동합니다.
currentIndex = 0;(맨 위 컨텐츠), currentIndex = content.childCount - 1;(맨 아래 컨텐츠)으로 추후 이동 버튼을 누를 것에 대비해 인덱스를 갱신합니다.

컨텐츠의 피벗이 중앙(0.5, 0.5)으로 설정된 경우, 컨텐츠의 맨 위 테두리가 아닌 컨텐츠의 중심(허리) 위치가 화면 상단에 맞춰져 컨텐츠 윗부분이 잘려서 보였습니다. 따라서 원하는 UI가 아니라 절반이 잘린 만큼의 컨텐츠가 보인 것입니다..
ScrollToItem(int index)에서 viewport.position을 기준으로 잡았는데, 이는 뷰포트의 피벗 위치를 나타내므로, 뷰포트의 피벗 설정에 따라 정렬되는 위치가 변할 수 있습니다.
예를 들어
높이가 100px인 컨텐츠: 피벗(중앙)이 기준선에 배치되면서, 피벗 위쪽으로 50px만큼 화면 밖으로 잘려 나갑니다.
높이가 300px인 컨텐츠: 피벗(중앙)이 기준선에 배치되면, 피벗 위쪽으로 150px나 화면 밖으로 크게 잘려 나갑니다.
이런 식으로 작동이 된 것입니다. 그리고 제가 구현한 스크롤뷰의 컨텐츠는 각각의 높이가 다른 프리팹들이 생성되고 있었기 때문에 더욱 기준없이 잘려나가 문제가 되었습니다.
해결 방안
컨텐츠 상단 좌표 계산을 피벗 설정에 상관없는 알고리즘으로 고쳐야 했습니다.
scrollRect.viewport.rect.yMax를 사용해 Viewport의 피벗 설정과 관계없이 항상 Viewport의 실제 맨 위 경계선 Y 좌표를 구하는 방법을 사용하였습니다.
컨텐츠의 상단 좌표 또한 InverseTransformPoint로 아이템의 위치를 Viewport 좌표계로 변환한 뒤, height * (1f - pivot.y) 공식을 적용해 아이템 피벗 위치에 영향받지 않는 실제 아이템 상단 테두리 Y 좌표를 계산하였습니다.
( 1f - pivot.y: 피벗 위치에서 맨 위 테두리까지 남아있는 비율, height에 곱하여 실제 길이를 구함)
다른 게임오브젝트들의 raycast, 렌더링 순서 등 모든 것들을 찾아봤는데도 문제가 될 만한 것이 안 보였습니다.
결국 mcp로 연결된 codex에게 물어보자, x축으로 180도 뒤집어 버린 것이 원인이었다는 것이 밝혀졌습니다.
아트팀에서 윗방향 이미지 리소스만 전달받아서, 하단/최하단 이동 버튼에는 윗방향 이미지를 입히고 x축으로 180도 회전했었습니다. 그러나 180도 회전시켜버린 탓에 그래픽의 앞, 뒷면이 뒤집혀서 화면에는 보여도 raycaster가 무시해버려서 버튼이 작동하지 않은 것이었어요..
사실 유니티 엔진 입장에서 생각해보면 당연히 뒷면에는 raycaster가 작동하지 않도록 하는 것이 납득이 가네요.. 기계란..
해결 방안
따라서 x축이 아닌, z축으로 180도 회전시키면서 문제를 해결하였습니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class BacklogScrollController : MonoBehaviour
{
[SerializeField] private ScrollRect scrollRect;
[SerializeField] private RectTransform contentRectTransform;
private int currentIndex;
private void Start()
{
Canvas.ForceUpdateCanvases();
if (contentRectTransform.childCount == 0) return;
// 시작 위치를 맨 아래로 설정
currentIndex = contentRectTransform.childCount - 1;
scrollRect.verticalNormalizedPosition = 0f;
}
public void OnClickScrollToUp()
{
if (contentRectTransform.childCount == 0) return;
currentIndex = GetCurrentTopVisibleIndex();
currentIndex = Mathf.Max(0, currentIndex - 1);
ScrollToItem(currentIndex);
}
public void OnClickScrollToDown()
{
if (contentRectTransform.childCount == 0) return;
currentIndex = GetCurrentTopVisibleIndex();
currentIndex = Mathf.Min(contentRectTransform.childCount - 1, currentIndex + 1);
ScrollToItem(currentIndex);
}
public void OnClickScrollToBottom()
{
if (contentRectTransform.childCount == 0) return;
scrollRect.verticalNormalizedPosition = 0f;
currentIndex = contentRectTransform.childCount - 1;
}
public void OnClickScrollToTop()
{
if (contentRectTransform.childCount == 0) return;
scrollRect.verticalNormalizedPosition = 1f;
currentIndex = 0;
}
private void ScrollToItem(int index)
{
Canvas.ForceUpdateCanvases();
RectTransform targetChild = contentRectTransform.GetChild(index) as RectTransform;
if (targetChild == null) return;
// 뷰포트 로컬 좌표계 기준 상단 Y 좌표
float viewportTopY = scrollRect.viewport.rect.yMax;
// 뷰포트 로컬 좌표계 기준 아이템 중심 위치
Vector3 childPosInViewport = scrollRect.viewport.InverseTransformPoint(targetChild.position);
// 아이템의 실제 상단 Y 좌표 (아이템 피벗 보정)
float childTopYInViewport = childPosInViewport.y + (targetChild.rect.height * (1f - targetChild.pivot.y));
// 뷰포트 상단과 아이템 상단 간의 오프셋
float diffY = childTopYInViewport - viewportTopY;
// 차이만큼 Content 이동
contentRectTransform.anchoredPosition -= new Vector2(0, diffY);
}
private int GetCurrentTopVisibleIndex()
{
float viewportTopY = scrollRect.viewport.rect.yMax;
int closestIndex = 0;
float minDistance = float.MaxValue;
for (int i = 0; i < contentRectTransform.childCount; i++)
{
RectTransform child = contentRectTransform.GetChild(i) as RectTransform;
if (child == null) continue;
Vector3 childPosInViewport = scrollRect.viewport.InverseTransformPoint(child.position);
float childTopY = childPosInViewport.y + (child.rect.height * (1f - child.pivot.y));
float distance = Mathf.Abs(viewportTopY - childTopY);
if (distance < minDistance)
{
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
}
ScrollToItem(int index)
지정한 인덱스(index) 자식 컨텐츠의 실제 상단 테두리(Top Edge)가 Viewport의 상단 경계선에 정확히 일치하도록 피벗 오차를 보정하여 Content 위치를 이동시키는 정밀한 스크롤 함수입니다.
작동 알고리즘은 아래와 같습니다.
이제 잘됩니다 ㅋ