[Unity] Scroll view 상단/하단 이동 버튼 구현

김가은·2026년 9월 8일

Unity

목록 보기
12/12

안녕하세요? 오늘은 유니티 스크롤뷰에서 그 스크롤이 위아래 이동하는 것을 버튼으로 구현해보도록 하겠습니다.

예를 들어 위 버튼 한번 누르면 컨텐츠가 한 칸씩 아래로 가면서 위에 있는 컨텐츠를 볼 수 있는 식으로요.


원하는 것

  • 최상단 (하트 두 개)
    스크롤 뷰의 맨 위 컨텐츠로 이동합니다.
  • 상단 (하트 한 개)
    현재 컨텐츠보다 1개만큼의 컨텐츠 위로 이동합니다.
  • 하단
    현재 컨텐츠보다 1개만큼의 컨텐츠 아래로 이동합니다.
  • 최하단
    스크롤 뷰의 맨 아래 컨텐츠로 이동합니다.

저는 미연시 게임을 개발하면서, 백로그 기능을 구현하려는 중이었는데요.
(백로그란, 이미 지나온 대화를 다시 볼 수 있게 하는 기능입니다.)

대화 기록을 스크롤 뷰로 구현했는데, 위와 같은 버튼 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()을 실행할 때 인덱스를 갱신할 필요가 있습니다. 그를 위해 작성한 코드입니다.

    작동 알고리즘은 아래와 같습니다.

    1. 좌표 변환: content.GetChild(i)로 i번째 자식 아이템의 위치를 가져와 Viewport 기준 로컬 좌표(childLocalPos)로 변환합니다.
    2. 수직 거리 계산: 기준선(viewportTopPos.y)과 자식 아이템(childLocalPos.y) 간의 Y축 거리 차이에 Mathf.Abs를 취해 절댓값(양수) 거리를 구합니다.
    3. 최단 거리 갱신: 계산된 거리가 기존 minDistance보다 작으면, minDistance를 현재 거리로 바꾸고 closestIndex에 해당 아이템의 번호 i를 저장합니다.
  • ScrollToItem(int index)
    지정한 인덱스에 해당하는 자식 아이템이 Viewport 상단에 위치하도록 Content의 Y축 위치를 직접 이동시키는 핵심 함수입니다.

    작동 알고리즘은 아래와 같습니다.

    1. 레이아웃 강제 갱신: Canvas.ForceUpdateCanvases()를 호출하여 UI 연산이 프레임 종료까지 지연되지 않고 현재 시점에 즉시 계산되도록 적용합니다.
    2. 타겟 아이템 및 Viewport 설정: content.GetChild(index)로 대상 아이템을 지정하고, scrollRect.viewport가 지정되지 않은 상황을 대비해 삼항 연산자로 기준 Viewport를 가져옵니다.
    3. 상대 좌표 변환: InverseTransformPoint를 사용하여 타겟 아이템과 Viewport의 위치를 동일한 Viewport 기준 로컬 좌표(targetLocalPos, viewportLocalPos)로 변환합니다.
    4. 수직 거리 계산: Viewport의 Y 위치에서 타겟 아이템의 Y 위치를 빼 두 지점 사이의 수직 거리 차이(diffY)를 구합니다.
    5. Content 위치 조정: 계산된 오프셋(diffY)만큼 content.anchoredPosition의 Y축 값을 조절하여 해당 아이템을 Viewport 위치에 딱 맞게 스크롤시킵니다.
  • OnClickScrollUp(), OnClickScrollDown()

    1개만큼의 컨텐츠씩 위, 아래로 이동하게 하는 함수입니다.

  • OnClickScrollToTop(), OnClickScrollToBottom()

    최상단, 최하단으로 이동하게 하는 함수입니다.
    scrollRect.verticalNormalizedPosition = 1f;이면 최상단, scrollRect.verticalNormalizedPosition = 0f;이면 최하단으로 이동합니다.
    currentIndex = 0;(맨 위 컨텐츠), currentIndex = content.childCount - 1;(맨 아래 컨텐츠)으로 추후 이동 버튼을 누를 것에 대비해 인덱스를 갱신합니다.


문제점

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에 곱하여 실제 길이를 구함)

2. 하단, 최하단 이동 버튼이 안 눌림

다른 게임오브젝트들의 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 위치를 이동시키는 정밀한 스크롤 함수입니다.

    작동 알고리즘은 아래와 같습니다.

    1. 레이아웃 강제 갱신 및 안전성 검사: Canvas.ForceUpdateCanvases()로 UI 연산을 즉시 수행한 후, GetChild(index) as RectTransform을 통해 대상 컨텐츠의 존재 유무를 확인하여 오류를 방지합니다.
    2. Viewport 상단 기준점 확보: scrollRect.viewport.rect.yMax를 사용해 Viewport의 피벗 설정과 관계없이 항상 Viewport의 실제 맨 위 경계선 Y 좌표를 구합니다.
    3. 컨텐츠 상단 좌표 계산 (피벗 보정): InverseTransformPoint로 컨텐츠의 위치를 Viewport 좌표계로 변환한 뒤, height * (1f - pivot.y) 공식을 적용해 컨텐츠 피벗 위치에 영향받지 않는 실제 컨텐츠 상단 테두리 Y 좌표를 계산합니다.
    4. 수직 오프셋 계산: Viewport 상단 경계선과 컨텐츠 상단 테두리 간의 Y축 차이값(diffY)을 구합니다.
    5. Content 위치 정밀 이동: 계산된 오프셋(diffY)만큼 contentRectTransform.anchoredPosition에서 차감(-=)하여 컨텐츠의 최상단이 Viewport 상단에 정확히 밀착되도록 이동시킵니다.

이제 잘됩니다 ㅋ

0개의 댓글