본문 바로가기

[Unity] UI 재사용 스크롤뷰 제작

앤디가이 2022. 6. 10.

Unity UI에서 스크롤 뷰는 상당히 많은 곳에서 사용한다. 이런 스크롤 뷰를 재사용 가능한 스크롤 뷰로 만드는 방법에 대해 알아보자.

 

Unity ScrollView의 단점은 스크롤되는 데이터가 많아질수록 느려지고 메모리 사용도 많아진다는 점이다. 

따라서, 스크롤뷰에 아이템을 Instantiate를 통해 모든 데이터를 생성하는 것이 아닌 일부 아이템을 가지고 데이터만 교체해주는 오브젝트 풀링 방식으로 개선하게 되면, 속도 향상 및 메모리 확보 효과도 좋다. Instantiate를 통한 오브젝트 생성 및 Destroy를 통한 오브젝트 삭제의 과정을 거치지 않기에 프레임 밀림 현상도 개선이 된다. 

 

해당 기능 구현을 위해 총 2개의 base 클래스를 먼저 제작해보자.

 

1. UIRecycleViewController Class 작성

 - 해당 클래스는 ScrollView 이벤트를 캐치하여, 재사용뷰를 사용할 수 있게 만들어 준다.

 - Cell(아이템)의 생성을 담당한다.

 - Update를 통해 Cell을 활성화하고 보이는 View를 갱신시킨다.

 - 코드에 대한 자세한 내용은 주석을 참고하면 좋다.

 - Assets/Class/UI/RecycleView/Scripts 폴더를 만들고 UIRecycleViewController.cs C# 스크립트 생성 후 아래와 같이 작성해준다.

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

/* 재사용뷰 구현시 해당 클래스 상속받아 사용 */
namespace UI
{
    [RequireComponent(typeof(ScrollRect))]
    [RequireComponent(typeof(RectTransform))]
    public class UIRecycleViewController<T> : MonoBehaviour
    {
        protected List<T> tableData = new List<T>(); // 리스트 항목의 데이터를 저장
        [SerializeField]
        protected GameObject cellBase = null;   // 복사 원본 셀
        [SerializeField]
        private RectOffset padding; // 스크롤할 내용의 패딩
        [SerializeField]
        private float spacingHeight = 4.0f; // 각 셀의 간격
        [SerializeField]
        private RectOffset visibleRectPadding = null; // visibleRect의 패딩

        private LinkedList<UIRecycleViewCell<T>> cells = new LinkedList<UIRecycleViewCell<T>>(); // 셀 저장 리스트

        private Rect visibleRect; // 리스트 항목을 셀의 형태로 표시하는 범위를 나타내는 사각형

        private Vector2 prevScrollPos; // 바로 전의 스크롤 위치를 저장

        public RectTransform CachedRectTransform => GetComponent<RectTransform>();
        public ScrollRect CachedScrollRect => GetComponent<ScrollRect>();

        protected virtual void Start()
        {
            // 복사 원본 셀은 비활성화해둔다
            cellBase.SetActive(false);

            // Scroll Rect 컴포넌트의 On Value Changed 이벤트의 이벤트 리스너를 설정한다
            CachedScrollRect.onValueChanged.AddListener(OnScrollPosChanged);
        }

        /// <summary>
        /// 테이블뷰를 초기화 하는 함수
        /// </summary>
        protected void InitializeTableView()
        {
            UpdateScrollViewSize(); // 스크롤할 내용의 크기를 갱신한다
            UpdateVisibleRect();    // visibleRect를 갱신한다

            if (cells.Count < 1)
            {
                // 셀이 하나도 없을 때는 visibleRect의 범위에 들어가는 첫 번째 리스트 항목을 찾아서
                // 그에 대응하는 셀을 작성한다
                Vector2 cellTop = new Vector2(0.0f, -padding.top);
                for (int i = 0; i < tableData.Count; i++)
                {
                    float cellHeight = GetCellHeightAtIndex(i);
                    Vector2 cellBottom = cellTop + new Vector2(0.0f, -cellHeight);
                    if ((cellTop.y <= visibleRect.y && cellTop.y >= visibleRect.y - visibleRect.height) ||
                       (cellBottom.y <= visibleRect.y && cellBottom.y >= visibleRect.y - visibleRect.height))
                    {
                        UIRecycleViewCell<T> cell = CreateCellForIndex(i);
                        cell.Top = cellTop;
                        break;
                    }
                    cellTop = cellBottom + new Vector2(0.0f, spacingHeight);
                }

                // visibleRect의 범위에 빈 곳이 있으면 셀을 작성한다
                SetFillVisibleRectWithCells();
            }
            else
            {
                // 이미 셀이 있을 때는 첫 번째 셀부터 순서대로 대응하는 리스트 항목의
                // 인덱스를 다시 설정하고 위치와 내용을 갱신한다
                LinkedListNode<UIRecycleViewCell<T>> node = cells.First;
                UpdateCellForIndex(node.Value, node.Value.Index);
                node = node.Next;

                while (node != null)
                {
                    UpdateCellForIndex(node.Value, node.Previous.Value.Index + 1);
                    node.Value.Top = node.Previous.Value.Bottom + new Vector2(0.0f, -spacingHeight);
                    node = node.Next;
                }

                // visibleRect의 범위에 빈 곳이 있으면 셀을 작성한다
                SetFillVisibleRectWithCells();
            }
        }

        /// <summary>
        /// 셀의 높이값을 리턴하는 함수
        /// </summary>
        /// <returns>The cell height at index.</returns>
        /// <param name="index">Index.</param>
        protected virtual float GetCellHeightAtIndex(int index)
        {
            // 실제 값을 반환하는 처리는 상속한 클래스에서 구현한다
            // 셀마다 크기가 다를 경우 상속받은 클래스에서 재 구현한다
            return cellBase.GetComponent<RectTransform>().sizeDelta.y;
        }

        /// <summary>
        /// 스크롤할 내용 전체의 높이를 갱신하는 함수
        /// </summary>
        protected void UpdateScrollViewSize()
        {
            // 스크롤할 내용 전체의 높이를 계산한다
            float contentHeight = 0.0f;
            for (int i = 0; i < tableData.Count; i++)
            {
                contentHeight += GetCellHeightAtIndex(i);

                if (i > 0)
                {
                    contentHeight += spacingHeight;
                }
            }

            // 스크롤할 내용의 높이를 설정한다
            Vector2 sizeDelta = CachedScrollRect.content.sizeDelta;
            sizeDelta.y = padding.top + contentHeight + padding.bottom;
            CachedScrollRect.content.sizeDelta = sizeDelta;
        }


        /// <summary>
        /// 셀을 생성하는 함수
        /// </summary>
        /// <returns>The cell for index.</returns>
        /// <param name="index">Index.</param>
        private UIRecycleViewCell<T> CreateCellForIndex(int index)
        {
            // 복사 원본 셀을 이용해 새로운 셀을 생성한다
            GameObject obj = Instantiate(cellBase) as GameObject;
            obj.SetActive(true);
            UIRecycleViewCell<T> cell = obj.GetComponent<UIRecycleViewCell<T>>();

            // 부모 요소를 바꾸면 스케일이나 크기를 잃어버리므로 변수에 저장해둔다
            Vector3 scale = cell.transform.localScale;
            Vector2 sizeDelta = cell.CachedRectTransform.sizeDelta;
            Vector2 offsetMin = cell.CachedRectTransform.offsetMin;
            Vector2 offsetMax = cell.CachedRectTransform.offsetMax;

            cell.transform.SetParent(cellBase.transform.parent);

            // 셀의 스케일과 크기를 설정한다
            cell.transform.localScale = scale;
            cell.CachedRectTransform.sizeDelta = sizeDelta;
            cell.CachedRectTransform.offsetMin = offsetMin;
            cell.CachedRectTransform.offsetMax = offsetMax;

            // 지정된 인덱스가 붙은 리스트 항목에 대응하는 셀로 내용을 갱신한다
            UpdateCellForIndex(cell, index);

            cells.AddLast(cell);

            return cell;
        }

        /// <summary>
        /// 셀의 내용을 갱신하는 함수
        /// </summary>
        /// <param name="cell">Cell.</param>
        /// <param name="index">Index.</param>
        private void UpdateCellForIndex(UIRecycleViewCell<T> cell, int index)
        {
            // 셀에 대응하는 리스트 항목의 인덱스를 설정한다
            cell.Index = index;

            if (cell.Index >= 0 && cell.Index <= tableData.Count - 1)
            {
                // 셀에 대응하는 리스트 항목이 있다면 셀을 활성화해서 내용을 갱신하고 높이를 설정한다
                cell.gameObject.SetActive(true);
                cell.UpdateContent(tableData[cell.Index]);
                cell.Height = GetCellHeightAtIndex(cell.Index);
            }
            else
            {
                // 셀에 대응하는 리스트 항목이 없다면 셀을 비활성화시켜 표시되지 않게 한다
                cell.gameObject.SetActive(false);
            }
        }

        /// <summary>
        /// VisibleRect를 갱신하기 위한 함수
        /// </summary>
        private void UpdateVisibleRect()
        {
            // visibleRect의 위치는 스크롤할 내용의 기준으로부터 상대적인 위치다
            visibleRect.x = CachedScrollRect.content.anchoredPosition.x + visibleRectPadding.left;
            visibleRect.y = -CachedScrollRect.content.anchoredPosition.y + visibleRectPadding.top;

            // visibleRect의 크기는 스크롤 뷰의 크기 + 패딩
            visibleRect.width = CachedRectTransform.rect.width + visibleRectPadding.left + visibleRectPadding.right;
            visibleRect.height = CachedRectTransform.rect.height + visibleRectPadding.top + visibleRectPadding.bottom;
        }




        /// <summary>
        /// VisubleRect 범위에 표시될 만큼의 셀을 생성하여 배치하는 함수
        /// </summary>
        private void SetFillVisibleRectWithCells()
        {
            // 셀이 없다면 아무 일도 하지 않는다
            if (cells.Count < 1)
            {
                return;
            }

            // 표시된 마지막 셀에 대응하는 리스트 항목의 다음 리스트 항목이 있고
            // 또한 그 셀이 visibleRect의 범위에 들어온다면 대응하는 셀을 작성한다
            UIRecycleViewCell<T> lastCell = cells.Last.Value;
            int nextCellDataIndex = lastCell.Index + 1;
            Vector2 nextCellTop = lastCell.Bottom + new Vector2(0.0f, -spacingHeight);

            while (nextCellDataIndex < tableData.Count && nextCellTop.y >= visibleRect.y - visibleRect.height)
            {
                UIRecycleViewCell<T> cell = CreateCellForIndex(nextCellDataIndex);
                cell.Top = nextCellTop;

                lastCell = cell;
                nextCellDataIndex = lastCell.Index + 1;
                nextCellTop = lastCell.Bottom + new Vector2(0.0f, -spacingHeight);
            }
        }


        /// <summary>
        /// 스크롤뷰가 움직였을 때 호출되는 함수
        /// </summary>
        /// <param name="scrollPos">Scroll position.</param>
        public void OnScrollPosChanged(Vector2 scrollPos)
        {
            // visibleRect를 갱신한다
            UpdateVisibleRect();
            // 셀을 재사용한다
            UpdateCells((scrollPos.y < prevScrollPos.y) ? 1 : -1);

            prevScrollPos = scrollPos;
        }

        /// <summary>
        /// 셀을 재사용하여 표시를 갱신하는 함수
        /// </summary>
        /// <param name="scrollDirection">Scroll direction.</param>
        private void UpdateCells(int scrollDirection)
        {
            if (cells.Count < 1)
            {
                return;
            }

            if (scrollDirection > 0)
            {
                // 위로 스크롤하고 있을 때는 visibleRect에 지정된 범위보다 위에 있는 셀을
                // 아래를 향해 순서대로 이동시켜 내용을 갱신한다
                UIRecycleViewCell<T> firstCell = cells.First.Value;
                while (firstCell.Bottom.y > visibleRect.y)
                {
                    UIRecycleViewCell<T> lastCell = cells.Last.Value;
                    UpdateCellForIndex(firstCell, lastCell.Index + 1);
                    firstCell.Top = lastCell.Bottom + new Vector2(0.0f, -spacingHeight);

                    cells.AddLast(firstCell);
                    cells.RemoveFirst();
                    firstCell = cells.First.Value;
                }

                // visibleRect에 지정된 범위 안에 빈 곳이 있으면 셀을 작성한다
                SetFillVisibleRectWithCells();
            }
            else if (scrollDirection < 0)
            {
                // 아래로 스크롤하고 있을 때는 visibleRect에 지정된 범위보다 아래에 있는 셀을
                // 위를 향해 순서대로 이동시켜 내용을 갱신한다
                UIRecycleViewCell<T> lastCell = cells.Last.Value;
                while (lastCell.Top.y < visibleRect.y - visibleRect.height)
                {
                    UIRecycleViewCell<T> firstCell = cells.First.Value;
                    UpdateCellForIndex(lastCell, firstCell.Index - 1);
                    lastCell.Bottom = firstCell.Top + new Vector2(0.0f, spacingHeight);

                    cells.AddFirst(lastCell);
                    cells.RemoveLast();
                    lastCell = cells.Last.Value;
                }
            }
        }
    }
}

 

2. UIRecycleViewCell Class 제작

 - 해당 클래스는 추상클래스로 스크롤 뷰 안에 들어가는 아이템(Cell)에 붙어야 하는 컴포넌트이다.

 - Cell의 높이 정보 및 Top, Bottom 위치 정보 값을 설정 및 리턴해주는 기능을 담당한다.

 - UpdateContent(T) 제너릭 함수를 통해 아이템 정보를 업데이트해준다.

 - Scripts 폴더에 UIRecycleViewCell.cs C# 스크립트 생성 후 아래와 같이 작성해준다.

using UnityEngine;

namespace UI
{
    [RequireComponent(typeof(RectTransform))]
    public abstract class UIRecycleViewCell<T> : MonoBehaviour
    {
        public RectTransform CachedRectTransform => GetComponent<RectTransform>();

        // 셀에 대응하는 리스트 항목의 인덱스
        public int Index { get; set; }

        // 셀의 높이
        public float Height
        {
            get { return CachedRectTransform.sizeDelta.y; }
            set
            {
                Vector2 sizeDelta = CachedRectTransform.sizeDelta;
                sizeDelta.y = value;
                CachedRectTransform.sizeDelta = sizeDelta;
            }
        }

        // 셀의 내용을 갱신하는 메서드
        // 상속받은 클래스에서 구현
        public abstract void UpdateContent(T itemData);
        
        // 셀의 위쪽 끝의 위치
        public Vector2 Top
        {
            get
            {
                Vector3[] corners = new Vector3[4];
                CachedRectTransform.GetLocalCorners(corners);
                return CachedRectTransform.anchoredPosition + new Vector2(0.0f, corners[1].y);
            }
            set
            {
                Vector3[] corners = new Vector3[4];
                CachedRectTransform.GetLocalCorners(corners);
                CachedRectTransform.anchoredPosition = value - new Vector2(0.0f, corners[1].y);
            }
        }

        // 셀의 아래쪽 끝의 위치
        public Vector2 Bottom
        {
            get
            {
                Vector3[] corners = new Vector3[4];
                CachedRectTransform.GetLocalCorners(corners);
                return CachedRectTransform.anchoredPosition + new Vector2(0.0f, corners[3].y);
            }
            set
            {
                Vector3[] corners = new Vector3[4];
                CachedRectTransform.GetLocalCorners(corners);
                CachedRectTransform.anchoredPosition = value - new Vector2(0.0f, corners[3].y);
            }
        }
    }
}

 

 

3. Test Script 제작

 - 위에서 만든 base 클래스를 상속받는 테스트 클래스 2개를 만들어서 동작 테스트를 해보자.

 - Script 폴더에 UIRecycleViewCellSample.cs 테스트 클래스를 만든 후 아래와 같이 작성해주자.

using UnityEngine;
using UnityEngine.UI;

namespace UI
{
    public class UICellSampleData
    {
        public int index;
        public string name;
    }

    public class UIRecycleViewCellSample : UIRecycleViewCell<UICellSampleData>
    {
        [SerializeField]
        private Text nIndex;
        [SerializeField]
        private Text txtName;

        public override void UpdateContent(UICellSampleData itemData)
        {
            nIndex.text = itemData.index.ToString();
            txtName.text = itemData.name;
        }

        public void onClickedButton()
        {
            Debug.Log(nIndex.text.ToString());
        }
    }
}

 

 - Script 폴더에 UIRecycleViewControllerSample.cs 테스트 클래스를 만든 후 아래와 같이 작성해주자.

 - LoadData() 함수를 보면, 데이터 코드를 임시적으로 하드코딩으로 넣어주었다.

   일반적으로 서버로 받은 데이터나 데이터베이스에서 받은 데이터를 이 부분에서 작성해주면 된다.

   여기서는 테스트를 위해 인덱스 정보와 이름 정보만 가지고 데이터를 구성하였다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace UI
{
    public class UIRecycleViewControllerSample : UIRecycleViewController<UICellSampleData>
    {
        // 리스트 항목의 데이터를 읽어 들이는 메서드
        private void LoadData()
        {
            // 일반적인 데이터는 데이터 소스로부터 가져오는데 여기서는 하드 코드를 사용해하여 정의한다
            tableData = new List<UICellSampleData>() {
            new UICellSampleData { index=1, name="One"},
            new UICellSampleData { index=2, name="Two" },
            new UICellSampleData { index=3, name="Three" },
            new UICellSampleData { index=4, name="Four" },
            new UICellSampleData { index=5, name="Five" },
            new UICellSampleData { index=6, name="Six" },
            new UICellSampleData { index=7, name="Seven" },
            new UICellSampleData { index=8, name="Eight" },
            new UICellSampleData { index=9, name="Nine" },
            new UICellSampleData { index=10,name="Ten" }
        };

            // 스크롤시킬 내용의 크기를 갱신한다
            InitializeTableView();
        }

        // 인스턴스를 로드할 때 Awake 메서드가 처리된 다음에 호출된다
        protected override void Start()
        {
            // 기반 클래스의 Start 메서드를 호출한다
            base.Start();

            // 리스트 항목의 데이터를 읽어 들인다
            LoadData();

        }

        // 셀이 선택됐을 때 호출되는 메서드
        public void OnPressCell(UIRecycleViewCellSample cell)
        {
            Debug.Log("Cell Click");
            Debug.Log(tableData[cell.Index].name);
        }
    }
}

 

4. UI 구성

 - Scene에 Canvas를 만든 후 Unity UI-> ScrollView를 만들어 준다.

 - 이름을 RecycleView로 변경 후 ScrollRect 컴포넌트의 Horizontal을 체크 해제한다.(상하 스크롤만 할 예정)

ScrollView 설정
ScrollView 설정

 - Content 오브젝트에 Image 오브젝트를 만든 후 이름을 Item으로 변경해준다.

 - Item 오브젝트에 HorizontalLayoutGroup 컴포넌트를 붙여준다.(자식 오브젝트 좌우 정렬)

 - Item 오브젝트에 UIRecycleViewCellSample 컴포넌트를 붙여 준 후 UIText를 연결해준다.

 - 인스펙터 설정은 아래 이미지를 참고하자.

Unity RecycleView Item
Unity RecycleView Item

 - 다시 RecycleView 오브젝트를 선택한 후 UIRecycleViewControllerSample 컴포넌트를 붙여준다.

Unity RecycleView 설정
Unity RecycleView 설정

 - Cell Base에 Item 오브젝트를 연결해준다.

 

5. 결과 화면

 - 10개의 데이터를 5개의 Cell이 데이터를 교체만 해주면서 재사용해주는 모습을 볼 수 있다. 

 - 재사용 뷰이기 때문에 몇백 개 몇천 개 데이터를 넣어도 5개의 Cell만 생성한다.(Cell의 개수는 보이는 Item + 1 정도라고 보면 된다)

Unity RecycleView 완성 화면

댓글