[UMG] ListView 최적화

suyoung·2025년 12월 4일

UE5

목록 보기
9/12
  • 언리얼 엔진에서 리스트 뷰의 장점은 '가상화' 이다.
    이때 말하는 '가상화'는 기본적으로 화면에 보이는 항목만 생성하고 관리하므로, 항목이 수천 개에 달해도 성능 저하를 최소화할 수 있다는 걸 의미한다.

목표

  • ListView 내부구조에 대해 이해한다.
    - Entry 삽입 부분
  • UI 가상화에 대해 이해한다.

ListView의 Entry Widget 생성

  • OnGenerateEntryWidgetInternal
  • GenerateTypedEntry 함수를 호출함을 볼 수 있다.
    - GenerateTypedEntry 함수 내부에서 EntryWidgetPool 객체를 사용해 내부 풀링이 존재하는가 여부를 확인함을 알 수 있다. 이말은 즉슨, 리스트 뷰 내부에 생성되는 엔트리 위젯 또한 내부 풀링에 의해 생성될지 말지가 결정된다.
    - GetOrCreateInstance 함수를 타고가면, 실제 내부에서 캐싱된 슬레이트 위젯이 있는지 여부를 확인하고, 있을 경우에 해당 UUserWidget의 TakeDerivedWidget 함수를 호출하여 SlateWidget을 생성함을 알 수 있다. 그리고 아래 Lamda 함수가 실제 위젯이 생성되거나 캐싱될 때 호출될 함수를 연결하는 바인딩 함수이다.
  • FinishGeneratingEntry
    - 현재 위젯이 에디터 화면에 표시되고 있다면 그 여부를 반환한다.
    • 해당 시점에는 행이 생성되었다고 알리기에 이르다. 왜냐하면, 내부 리스트 관점에서는 아직 행이 완전히 생성되었지 않기 때문이다.
      따라서, 이때 행-아이템 쌍을 캐싱해두고, 다음 틱에 그들이 생성되었음을 알린다.
  • Entry Widget 관점에서 InitializeObjectRow 호출 -> 각 테이블 내부에 있는 Row값이 초기화 될 때 호출 SObjectTableRow<UObject>::InitObjectRowInternal
    - 내부에서 구현하고 있다면, 즉 상속 받고 있다면
    - IUserObjectListEntry::SetListItemObject(
    WidgetObject, ListItemObject) 호출
    ⇒ NativeOnListItemObjectSet 호출

UI 가상화

⇒ 모든 아이템들을 미리 생성하고, 실제 필요한 범위만 보이도록 관리 (가상화 정책)
1. 갱신 필요성 및 초기화
- 이전 지오메트리를 저장하여, 다음 프레임에 변경되었는지 여부를 확인
- 아이템 데이터가 변경되었는가를 확인
2. 스크롤 목표 및 애니메이션
- 목표 오프셋 계산
- 애니메이션을 적용
3. 위젯 재생성
- ReGenerateItems 함수 호출하여 뷰포트 내부에서 보이는 위젯만 생성/업데이트 실행ㅇ
4. 시각적 요소 및 정리
- 첫 줄 오프셋 정리
- 오버 스크롤 처리
- 선택 상태를 정리 → 현재 아이템 리스트 내부에서 선택된 아이템이 없음 정리하도록 함.
- 스크롤 바 업데이트 → 현재 위치에 대한 스크롤 바 업데이트
5. 다음 프레임 예약
- 갱신 예약 → 아직 모든 업데이트가 되었지 않다면, 다음 프레임에 틱을 다시 사용하도록 예약
- 스크롤 완료 알림을 진행

// CurItem : 현재 배열로부터 넘겨받은 아이템 데이터
// ItemIndex : 리스트 뷰 상에서 해당 아이템의 인덱스 번호
// StartIndex : 시작 인덱스 번호
float GenerateWidgetForItem( const ItemType& CurItem, int32 ItemIndex, int32 StartIndex, float LayoutScaleMultiplier )
{
		ensure(TListTypeTraits<ItemType>::IsPtrValid(CurItem));
	
		// 해당 아이템에 대해 이전에 생성된 위젯이 존재한다면, 그 위젯을 찾는다.
		// 내부 풀링에서 확인하는 단계
		// WidgetGenerator : 데이터 아이템으로부터 위젯을 생성하는 역할을 하는 컴포넌트
		// 또한, 현재 생성된 위젯들이 어떤 데이터 아이템을 표현하고 있는지의 매핑도 제공한다.
		// 현재 화면에 생성되어 있는 위젯 <-> 그 위젯이 나타내는 데이터 매핑!
		TSharedPtr<ITableRow> WidgetForItem = WidgetGenerator.GetWidgetForItem( CurItem );
		if ( !WidgetForItem.IsValid() )
		{
			// 만약 존재하는 위젯을 발견하지 못했을 경우,
			// 즉, 이 데이터 아이템은 이전에 화면에 보이지 않았다는 의미로, 따라서 이 아이템을 위한 새로운 위젯을 생성한다.
			WidgetForItem = this->GenerateNewWidget(CurItem); // 생성된 아이템에 대한 위젯
		}

		// 위젯이 어떤 아이템 인덱스로부터 생성되었는지 알고 있는 것이 유용
		// 이는 짝수/홀수 줄 색상 처리에 도움 (아이템의 인덱스 번호를 저장)
		WidgetForItem->SetIndexInList(ItemIndex);
		
		// 현재 아이템과 그에 연결된 위젯을 발견했음을 아이템 생성기에게 알려줌.
		// 즉, WidgetGenerator 는 보이는 아이템에 대해 Widget은 매핑하는 것이기 때문에 알려줌으로써 저장
		WidgetGenerator.OnItemSeen( CurItem, WidgetForItem.ToSharedRef() );
			// 이 과정에서 Entry Widget의 Private_OnEntryInitialized 호출 -> OnEntryInitialized 바인딩된 모든 함수 호출

		// 화면에 몇 개의 위젯이 들어갈 수 있는지 판단하기 위해,
		// 각 위젯의 Desired Size에 의존한다.
		const TSharedRef<SWidget> NewlyGeneratedWidget = WidgetForItem->AsWidget();
		NewlyGeneratedWidget->MarkPrepassAsDirty();
		NewlyGeneratedWidget->SlatePrepass(LayoutScaleMultiplier);
		
		// 이 아이템에 대한 위젯이 이미 존재하므로,
		// 해당 위젯을 패넬에 다시 추가하여 실제 UI 표시한다.
		if (ItemIndex >= StartIndex)
		{
			// 위젯들을 아래 방향으로 생성하는 중
			this->AppendWidget( WidgetForItem.ToSharedRef() );
		}
		else
		{
			// 위젯을 채워 넣는 과정, 즉, 위 방향으로 생성 중
			this->InsertWidget( WidgetForItem.ToSharedRef() );
		}

		const bool bIsVisible = NewlyGeneratedWidget->GetVisibility().IsVisible(); //현재 새롭게 생성된 위젯이 보이는가
		
		// 현재 생성된 위젯의 크기를 사용해서
		// 또는 보이지 않는 경우에는 0 크기를 사용해서 방향에 맞는 위젯치수 정보를 만듬
		FTableViewDimensions GeneratedWidgetDimensions(this->Orientation, bIsVisible ? NewlyGeneratedWidget->GetDesiredSize() : FVector2D::ZeroVector);
		return GeneratedWidgetDimensions.ScrollAxis;
}
  • virtual FReGenerateResults ReGenerateItems( const FGeometry& MyGeometry ) override
    - 필요한 경우 아이템에 대한 위젯을 생성(또는 갱신)하고, 더 이상 필요하지 않은 위젯은 정리한다.
    - 현재 화면에 보여지는 위젯들의 순서를 필요에 따라 재배치한다.
    1. 초기화 준비
    - 현재 그려진 모든 위젯 정리 및 변수 정리
    2. 메인 위젯 생성
    - 실제 아이템 위젯을 생성하는 함수 GenerateWidgetForItem
    - 첫 아이템의 가시성 계산 : 첫 보이는 아이템 위젯이 얼만큼 보이는 가에 대한 계산 수행
    - 뷰포트 채움 감지 : 실제 보이는 위젯 길이가 뷰포트 길이를 초과했는지를 확인
    3. 만약, 다 그려지지 않았는데, 맨 끝이라면 Backfill(되감기)로 위젯 채움
    - 새 오프셋 계산 : NewScrollOffsetForBackfill을 계산하여, 리스트의 끝이 뷰포트 하단에 딱맞도록 스크롤 위치를 재조정
    - 역순 백필 : 시작점이 아닐 때 역순으로 뷰포트 상단에 남아있는 빈공간을 채움
    4. 결과 반환
	/**
	 * - 필요한 경우 아이템에 대한 위젯을 생성(또는 갱신)하고, 더 이상 필요하지 않은 위젯은 정리한다.
   * - 현재 화면에 보여지는 위젯들의 순서를 필요에 따라 재배치한다.
	 */
	virtual FReGenerateResults ReGenerateItems( const FGeometry& MyGeometry ) override
	{
		auto DoubleFractional = [](double Value) -> double
		{
			// 입력된 double 값의 '소수 부분만' 분리해서 반환하는 함수
			return Value - FMath::TruncToDouble(Value);
		};

		// 패널에 존재하는 모든 아이템들을 지운다.
		// 곧 올바른 순서대로 아이템을 다시 추가할 것임.
		this->ClearWidgets();

		// 아이템 생성 패스(단계를 시작하고, 또한 반드시 정리까지 수행되도록 보장
		// 생성 -> 정리를 한 세트로 보장되어야 함.
		FGenerationPassGuard GenerationPassGuard(WidgetGenerator); //RAII Scope

		// 모든 아이템들을 가지고 온다.
		const TArrayView<const ItemType> Items = GetItems();
		if (Items.Num() > 0)
		{
			// 뷰에 보이는 아이템 수(부분적으로 보이는 아이템 포함)
			// 예를 들어, 2.3일 때, 5 6 7(일부분 30%) 보여야함. 이때 사용되는 의미
			float ItemsInView = 0.0f;

			// 지금까지 생성된 위젯들의 전체 길이
			// (세로 리스트일 경우 높이, 가로 리스트일 경우에는 너비)
			float LengthGeneratedSoFar = 0.0f;

			// 뷰(화면 영역) 안에 들어와 있는 생성된 위젯들의 길이
			// 즉, 화면에 실제로 보이는 (또는 일부라도 걸쳐 있는) Row들의 총 길이를 의미
			// 만약 Rect 범위 0px ~ 300px => Row 길이 50px 때, 6개의 Row가 화면에 걸침.
			// 그리고, 300px이 정확하게 계산
			float ViewLengthUsedSoFar = 0.0f;

			// 현재 스크롤된 위치를 기준으로,
			// 생성을 시작해야 하는 아이템의 인덱스
			// 최소한 1개의 아이템은 반드시 생성해야 한다.
			// 현재 스크롤의 Offset(스크롤 위치)일 때, 개수 범위내 제한에서 몇번째 인덱스가 시작 인덱스인지를 확인
			int32 StartIndex = FMath::Clamp( (int32)(FMath::FloorToDouble(CurrentScrollOffset)), 0, Items.Num() - 1 );

			// 생성된 첫 번째 아이템의 길이
			// 이 아이템은 사용자가 스크롤을 요청한 바로 그 위치에 놓이게 된다.
			// 예를 들어, 스크롤 Offset값이 120px일 때 2번 인덱스의 Row위치가 100-150 사이면,
			// 2번 인덱스가 첫 시작점 Offset의 Row로 인식
			float FirstItemLength = 0.0f;

			// 시나리오 a를 가정하는 위젯들을 생성한다
			bool bHasFilledAvailableArea = false;
			bool bAtEndOfList = false;
			
			// 현재 위젯(리스트 뷰)의 지오매트리의 최종 Transform의 Scale값
			// 즉, 화면에 렌더링되어지는 최종 Transform값이 나오는 것임.
			const float LayoutScaleMultiplier = MyGeometry.GetAccumulatedLayoutTransform().GetScale();
			// 스크롤 방향 축/뷰의 물리적크기
			// 테이블(리스트) 레이아웃을 계산할 때 X축인지 Y축인지를 구1분할 필요가 없도록 추상화한 구조체
			FTableViewDimensions MyDimensions(this->Orientation, MyGeometry.GetLocalSize());
			
			// 실제 위젯이 그려지는 부분
			for( int32 ItemIndex = StartIndex; !bHasFilledAvailableArea && ItemIndex < Items.Num(); ++ItemIndex )
			{
				const ItemType& CurItem = Items[ItemIndex];

				// 현재 아이템이 유효한가, ItemType은 데이터 아이템들의 배열을 관찰
				if (!TListTypeTraits<ItemType>::IsPtrValid(CurItem))
				{
					// Don't bother generating widgets for invalid items
					continue;
				}
				// 현재 데이터, 현재 데이터의 리스트뷰 상 인덱스위치(0번부터 시작), 실제 시작 인덱스값, 레이아웃 스케일 크기 
				// 생성된 스크롤 획이 전달되어진다. 해당 아이템을 넣었을 때의 스크롤 축 방향으로 차지하는 길이 위치를 판단
				const float ItemLength = GenerateWidgetForItem(CurItem, ItemIndex, StartIndex, LayoutScaleMultiplier);
				
				const bool bIsFirstItem = ItemIndex == StartIndex;
				// 시작 위치야?
				if (bIsFirstItem)
				{
					// 첫번째 아이템을 삽입하고 나서 전달받은 스크롤 축 방향의 길이를 저장
					FirstItemLength = ItemLength;
				}
				
				// bIsFirstItem = true, 첫번째 아이템이 화면에 얼마나 보이는지 계산하고, 
				// 그 계산한 값을 ItemsInView 더한다. (그 크기만큼 더하는 거임)
				// 뷰에 보이는 아이템의 개수를 추적(부분적으로 보이는 아이템을 포함해서)
				if (bIsFirstItem) //시작위치일 경우에는
				{
					// 첫 번째 아이템은 완전히 보이지 않을 수도 있다.(단, 이 값은 1.0f을 넘을 수 없음)
					// FirstItemFractionScrolledIntoView 는 리스트뷰의 위쪽(또는 왼쪽)으로 스크롤되어 
					// 잘려나간 부분을 고려하여, 그 아이템이 화면에 보이는 비율(= 아이템의 가시 영역 비율)을
					// 나타낸 값 
					// 예를 들어, CurrentScrollOffset = 2.3f -> 0.3f 보임..
					// 그러면, 남아 있는 부분은 0.7f 잘림 => ItemLength * 0.7 => 실제 보이는 영역을 구함
					const float FirstItemFractionScrolledIntoView = 1.0f - (float)FMath::Max(DoubleFractional(CurrentScrollOffset), 0.0);
					
					// FirstItemLengthScrolledIntoView 는 리스트뷰의 위(또는 왼쪽) 밖으로
					// 스크롤되어 잘려 나간 부분을 제외한, 해당 아이템의 '현재화면에 들어와 있는 길이'를 의미
					// 즉, FirstItemFractionScrolledIntoView = 0.2f라면, 30px * 0.2 => 6px 가 된다. 
					const float FirstItemLengthScrolledIntoView = ItemLength * FirstItemFractionScrolledIntoView;
					
					// FirstItemVisibleFraction 값은 두 경우 중 하나가 된다.
					// 1. 아이템 크기가 리스트뷰 가용 공간보다 더 클 경우,
					// 해당 아이템의 보이는 길이를 리스트뷰 길이로 나눈 비율 (이 경우 이 값은 1보다 클 수 있음)
					// 2. 그외의 경우에는 FirstItemLengthScrolledIntoView 을 그대로 사용
					const float FirstItemVisibleFraction = FMath::Min(MyDimensions.ScrollAxis / FirstItemLengthScrolledIntoView, FirstItemFractionScrolledIntoView);
					
					ItemsInView += FirstItemVisibleFraction;
				}
				else if (ViewLengthUsedSoFar + ItemLength > MyDimensions.ScrollAxis)
				{
					// 첫번째가 아니고, 현재 리스트 뷰의 스크롤 축보다 클경우에 (보이는 축을 초과한 경우),
					// 넘어간 부분은 제외하고 보이는 비율만 더한다
					ItemsInView += (MyDimensions.ScrollAxis - ViewLengthUsedSoFar) / ItemLength;
				}
				else
				{
					// 중간에 위치하는 애들은 1씩 더함.
					ItemsInView += 1;
				}
				// 지금까지 생성된 위젯들의 전체 길이
				// (세로 리스트일 경우 높이, 가로 리스트일 경우에는 너비)
				// 생성된 아이템의 위젯의 길이를 더함
				LengthGeneratedSoFar += ItemLength;


			// 뷰(화면 영역) 안에 들어와 있는 생성된 위젯들의 길이
			// 즉, 화면에 실제로 보이는 (또는 일부라도 걸쳐 있는) Row들의 총 길이를 의미
			// 첫번째일 경우에는 현재 아이템에 실제 보이는 아이템 길이를 곱해서 총 얼만큼 보이는지를 확인
			// ItemsInView => 실제 차지하는 비율
				ViewLengthUsedSoFar += (bIsFirstItem)
					? ItemLength * ItemsInView	// For the first item, ItemsInView <= 1.0f
					: ItemLength;
		
				// 현재 보이는 (처리할 아이템)이 마지막에 있는 아이템인지를 확인
				bAtEndOfList = ItemIndex >= Items.Num() - 1;
				
				// 첫번째 아이템이고, 이 첫 번째 아이템의 보이는 길이만으로 뷰의 사용 가능한 공간을
				// 채웠거나 초과했을 경우
				if (bIsFirstItem && ViewLengthUsedSoFar >= MyDimensions.ScrollAxis)
				{
					// 부동 소수점 합산이 없었으므로, 하나의 요소가 공간을 완전히 채우는 경우를
					// 정확하게 감지하도록 함. => 이 반복문을 빠져나옴. 사용 가능영역을 다채웠음 의미
					bHasFilledAvailableArea = true;
				}
				else
				{
					// 참고: 사용된 치수(길이)를 합산하는 과정에서 부동 소수점의 잘림 및 덧셈으로 인해
					// 누적되는 오차를 처리하기 위해, 사용 가능한 공간을 채웠는지 확실하게 확인하기 위해
					// 할당된 축에 약간의 여유분을 더함
					const float FloatPrecisionOffset = 0.001f;
					// 약간의 여유분을 더해서 사용 가능 영역을 다 채웠는지 여부를 확인
					bHasFilledAvailableArea = ViewLengthUsedSoFar >= MyDimensions.ScrollAxis + FloatPrecisionOffset;
				}
			}

			// 시나리오 B를 다룬다.
			// 아이템의 끝에 도달했기 때문에 멈췄을 수도 있지만, 여전히 채워야 할 공간이 남아 있을 수도 있음.
			if (bAtEndOfList && !bHasFilledAvailableArea)
			{
				// 새로운 시작 인덱스를 계산하기 위한 공식
				// 새로운 뷰포트의 시작 인덱스가 어디여야 하는지. 
				// 예를 들어, 현재 시작인덱스가 5이고, 초과된 길이가 아이템 0.5개 길이라면, 
				// 이 5.5는 스크롤이 현재 5번 아이템의 절반 지점에 위치하고 있음을 의미
				double NewScrollOffsetForBackfill = static_cast<double>(StartIndex) + (LengthGeneratedSoFar - MyDimensions.ScrollAxis) / FirstItemLength;
				
				// StartIndex = 0 이여서 ItemIndex가 음수가 되는 경우를 ItemIndex >= 0로 방지
				// 이전껄 채워넣는 방식임. 왜냐면 ItemIndex 가 끝에 도달했기 때문에 음수로 하나씩 줄이면서
				// 새로운 아이템을 삽입 
				// Backfill = 되채움을 의미하며, 역순 순회로 이전걸 채워넣는 방식을 의미
				for (int32 ItemIndex = StartIndex - 1; LengthGeneratedSoFar < MyDimensions.ScrollAxis && ItemIndex >= 0; --ItemIndex)
				{
					const ItemType& CurItem = Items[ItemIndex];
					// 유효한 아이템값
					if (TListTypeTraits<ItemType>::IsPtrValid(CurItem))
					{
					  // 아이템에 대한 위젯을 생성 또는 풀링된 값을 가져옴
						const float ItemLength = GenerateWidgetForItem(CurItem, ItemIndex, StartIndex, LayoutScaleMultiplier);

						if (LengthGeneratedSoFar + ItemLength > MyDimensions.ScrollAxis && ItemLength > 0.f)
						{
							// 뷰포트 길이를 초과하게 만드는 아이템을 생성했다.
							// 리스트 바깥으로 튀어나올 이 아이템의 비율을 계산한다.
							// 부분적으로 보여야 하는 부분을 감지해야함.
							NewScrollOffsetForBackfill = static_cast<double>(ItemIndex) + (LengthGeneratedSoFar + ItemLength - MyDimensions.ScrollAxis) / ItemLength;
						}

						// The widget used up some of the available vertical space.
						LengthGeneratedSoFar += ItemLength;
					}
				}
				
				// 생성된 결과를 전달.
				return FReGenerateResults(NewScrollOffsetForBackfill, LengthGeneratedSoFar, Items.Num() - NewScrollOffsetForBackfill, true);
			}

			return FReGenerateResults(CurrentScrollOffset, LengthGeneratedSoFar, ItemsInView, false);
		}

		return FReGenerateResults(0.0f, 0.0f, 0.0f, false);
	}
  • 실제 가상화를 위해 가장 먼저 호출되어지는 함수 Tick
    - 내부에서 리스트 뷰의 아이템에 변화가 있거나 패널 자체가 변경되지 않는다면, 조건문 아래에 있는 모든 코드 실행되지 않음.
/** 위젯 재생성에 대한 결과의 정보 */
struct FReGenerateResults
{
	FReGenerateResults(double InNewScrollOffset, double InLengthGenerated, double InItemsOnScreen, bool AtEndOfList)
		: NewScrollOffset(InNewScrollOffset)
		, LengthOfGeneratedItems(InLengthGenerated)
		, ExactNumLinesOnScreen(InItemsOnScreen)
		, bGeneratedPastLastItem(AtEndOfList)
	{}
		
	// 실제로 사용하는 스크롤 옵셋 값은 사용자가 요청한 것과 다를 수 있음.
	double NewScrollOffset = 0.;
		
	// 아이템들의 보이는 부분집합을 나타내기 위해 우리가 생성한 위젯들의, 스크롤 축을 따른 총 길이
	double LengthOfGeneratedItems = 0.;

	// 소수 점 이하를 포함하여(즉, 일부분) 화면에 들어가는 줄의 수
	double ExactNumLinesOnScreen = 0.;

	// 뷰포트를 채울 만큼 충분한 아이템을 생성할때 true
	bool bGeneratedPastLastItem = false;
};
	
enum class EScrollIntoViewResult
{
    /** 함수가 지정된 아이템을 뷰에 스크롤하는 데 성공했거나, 이미 뷰 안에 있었던 경우 */
    Success,

    /** 주어진 아이템을 뷰로 스크롤하기 위한 충분한 정보가 아직 없어,
        다음 Tick까지 스크롤을 지연(Defer)해야 하는 경우 */
    Deferred,

    /** 함수가 지정된 아이템으로 스크롤하는 데 실패한 경우 */
    Failure
};

// 리스트뷰 가상화 핵심 부분 
// STableViewBase <- SListView 
void STableViewBase::Tick( const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime )
{
	if (ItemsPanel.IsValid())
	{
		FGeometry PanelGeometry = FindChildGeometry( AllottedGeometry, ItemsPanel.ToSharedRef() );

		// 이전 패넬 범위가 현재 패넬 범위와 다르다면,
		bool bPanelGeometryChanged = PanelGeometryLastTick.GetLocalSize() != PanelGeometry.GetLocalSize();
		
		// 아이템이 변화가 있거나, 패넬 자체가 변경되었다면
		if ( bItemsNeedRefresh || bPanelGeometryChanged)
		{
		  // 업데이트된 페넬에 대해 FGeometry 저장 -> 패넬이 변경되었을 때에만 다시 그리기 위해서임.
		  // 가상화 부분을 의미
			PanelGeometryLastTick = PanelGeometry;
			
			// 뷰에서 스크롤 축에 직교하는 축 기준으로, 한 줄에 몇 개의 아이템을 배치하는가를 반환
			// Listview -> 한 줄에 1개, Tileview -> 한 줄에 여러 개
			const int32 NumItemsPerLine = GetNumItemsPerLine();
			// 스크롤 결과 여부를 의미함. 현재 지오메트리에서 스크롤을 했는가(안했으면 항상 성공)
			const EScrollIntoViewResult ScrollIntoViewResult = ScrollIntoView(PanelGeometry);
			// 어디까지 스크롤해야 하는가를 의미하며, 스크롤한 속도값을 계산해서 어떤 행을 보여줄지 결정
			// 스크롤이 최종적으로 도달해야 하는 목표 위치를 계산하는 함수
			// 스크롤의 속도, 고정 라인 정렬, 스크롤 모드 등에 따라서 조정되며, 어떤 Row(Line)을 보여줄 지 결정
			double TargetScrollOffset = GetTargetScrollOffset();
			
			// 곧 멈출 예정인가
			if (InertialScrollManager.GetShouldStopScrollNow())
			{
			  // 멈출 때 목표 위치를 동일하게 지정 (미세한 차이를 방지)
				TargetScrollOffset = DesiredScrollOffset = CurrentScrollOffset;
				InertialScrollManager.ResetShouldStopScrollNow(); //멈췄다고 정지.
			}

			// 첫 터치 인터랙션 && 터치에 대한 스크롤 애니메이팅이 가능하며
			// bStartedTouchInteraction : 사용자가 이 리스트 내에서 상호작용을 시작했는가
			// bEnableAnimatedScrolling : 원하는 스크롤 오프셋이 변경될때 오프셋들 사이를 부드럽게 선형 보간하기 위해 true 설정
			// bEnableTouchAnimatedScrolling : 터치로 인해 원하는 스크롤 오프셋이 변경될 때, 오프셋들 사이를 부드럽게 lerp 하기 위해 true로 설정
			if((bStartedTouchInteraction && bEnableTouchAnimatedScrolling) || (!bStartedTouchInteraction && bEnableAnimatedScrolling))
			{
				// 사용자의 상호작용 방식에 따라 스크롤 움직임에 애니메이션을 적용할지 여부를 결정
				CurrentScrollOffset = FMath::FInterpTo(CurrentScrollOffset, TargetScrollOffset, (double)InDeltaTime, ScrollingAnimationInterpolationSpeed);
				if (FMath::IsNearlyEqual(CurrentScrollOffset, TargetScrollOffset, 0.01))
				{
					CurrentScrollOffset = TargetScrollOffset;
				}
			}
			else
			{
				CurrentScrollOffset = TargetScrollOffset;
			}
			
			// 이 부분이 해당 페넬에 대해 아이템을 다시 생성하는 부분
			// 즉, 가상화 위치로, 해당 위치에서 실제 패넬 보이는 부분에 아이템을 생성함
			const FReGenerateResults ReGenerateResults = ReGenerateItems( PanelGeometry );
			LastGenerateResults = ReGenerateResults;
			// 실제로 다시 생성된 아이템 위젯에 대한 결과물을 이전 결과물로 캐싱
			
			// 실제 아이템의 개수 (TArray 내의 총 아이템 개수)
			const int32 NumItemsBeingObserved = GetNumItemsBeingObserved();
			// NumItemsPerLine: 한 줄에 들어가는 아이템 개수
			// 즉 세로 스크롤 시, X축에 아이템 몇 개 배치할건가. ListView일 때 1개 TileView일 때는 N개
			const int32 NumItemLines = NumItemsBeingObserved / NumItemsPerLine;
			// 실제 화면에 들어간 총 줄의 개수가 출력

			const double InitialDesiredOffset = DesiredScrollOffset;
			const bool bEnoughRoomForAllItems = ReGenerateResults.ExactNumLinesOnScreen >= NumItemLines;
			if (bEnoughRoomForAllItems)
			{
				// 모든 아이템들이 보일 수 있다, 그래서 스크롤링을 없앰.
				SetScrollOffset(0.0);
				CurrentScrollOffset = TargetScrollOffset = DesiredScrollOffset;
			}
			else if (ReGenerateResults.bGeneratedPastLastItem) // Backfill 일경우에만 true
			{
				// 현재 스크롤 옵셋을 NewScrollOffset지정 (즉, 새롭게 계산된 스크롤 위치)
				// BackFill을 고려해 시작 위치가 변경
				SetScrollOffset(FMath::Max(0.0, ReGenerateResults.NewScrollOffset));
				CurrentScrollOffset = TargetScrollOffset = DesiredScrollOffset;
			}
			
			// 현재 스크롤 위치 / 아이템 개수 나눠, 첫번째 줄 스크롤 위치를 분석
			// 정수 값을 뺌으로서 소수점을 가져옴. 즉 첫번째 줄에서 얼만큼 부분적으로 보이는지 비율 계산
			// 0.0 - 1.0 사이의 비율 계산
			ItemsPanel->SetFirstLineScrollOffset(GetFirstLineScrollOffset());

			// 오버 스크롤이 가능하다. 즉, 아이템 보다 더 아래로 내리거나 위로 올릴 수 있음
			if (AllowOverscroll == EAllowOverscroll::Yes)
			{
				// 얼마나 오버 스크롤 했는지 양을 확인한다.
				const float OverscrollAmount = Overscroll.GetOverscroll(GetTickSpaceGeometry());
				ItemsPanel->SetOverscrollAmount( OverscrollAmount );
			}
			
			// 리스트에서 사라진 아이템 제거
			// 현재 리스트에서 더이상 존재하지 않는 항목들은 선택된 항목 목록에서 제거
				// 리스트 뷰 내부에서는 유효한 아이템이 아닐 경우에 삭제
				// 유효한 아이템일 경우에는 SelectedItems 포함되어 있으면, 새로운 NewSelectedItem 판정
				// 새로운 아이템과 원본 아이템간의 차이점이 있는지 여부를 확인 한 후 있음 업데이트
			// 더이상 리스트에 보이지 않는데, 선택된 아이템 값이 유효하면 안되기 때문에 업데이트를 진행하는 것
			UpdateSelectionSet();

			// 스크롤바를 업데이트한다.
			// 현재 실제 위젯의 위치에 대해서 스크롤 바 상태를 업데이트하는 로직이 구현
			if (NumItemsBeingObserved > 0)
			{
				if (ReGenerateResults.ExactNumLinesOnScreen < 1.0f)
				{
					// 가용 가시 영역보다 더 큰 단일 행을 관찰하고 있으므로,
					// 이를 기반으로 엄지(스크롤 바 Thumb) 크기를 계산해야 함. 
					const double VisibleSizeFraction = AllottedGeometry.GetLocalSize().Y / ReGenerateResults.LengthOfGeneratedItems;
					const double ThumbSizeFraction = FMath::Min(VisibleSizeFraction, 1.0);
					const double OffsetFraction = CurrentScrollOffset / NumItemsBeingObserved;
					ScrollBar->SetState( OffsetFraction, ThumbSizeFraction );
				}
				else
				{
					// 엄지 크기는 우리가 현재 보고 있는 아이템들의 분수(비율)만큼이다. (부분적으로 보이는 아이템 포함)
					// 예를들어, 첫번째 생성된 위젯의 0.5와 마지막 위젯의 0.75가 보인다면, 이는 1.25개의 위젯이 보이는 것
					const double ThumbSizeFraction = ReGenerateResults.ExactNumLinesOnScreen / NumItemLines;
					const double OffsetFraction = CurrentScrollOffset / NumItemsBeingObserved;
					ScrollBar->SetState( OffsetFraction, ThumbSizeFraction );
				}
			}
			else
			{
				const double ThumbSizeFraction = 1;
				const double OffsetFraction = 0;
				ScrollBar->SetState( OffsetFraction, ThumbSizeFraction );
			}
			
			// 바닥으로부터의 길이가 SMALL_NUMBER 작으면, 끝에 있음을 의미
			bWasAtEndOfList = (ScrollBar->DistanceFromBottom() < SMALL_NUMBER);
			
			// 아이템을 리프래쉬할 필요가 있는가?
			bItemsNeedRefresh = false;
			ItemsPanel->SetRefreshPending(false);
			
			Invalidate(EInvalidateWidget::ChildOrder);
			
			if (ScrollIntoViewResult == EScrollIntoViewResult::Success)
			{
				// 스크롤해야 할 작업이 남아 있더라도, 아이템에 대한 위젯을 만들자마자 즉시 알림.
				NotifyItemScrolledIntoView();
			}
			if (ScrollIntoViewResult == EScrollIntoViewResult::Deferred || CurrentScrollOffset != TargetScrollOffset)
			{
				// 아이템 위젯을 아직 만들지 못했거나,
				// 스크롤 작업이 남아있기 때문에 다음 프레임에 또 다른 갱신이 필요할 것.
				// bItemsNeedRefresh를 단순히 true로 남겨놓는 대신에 이 함수를 호출하는 것은 
				// STableViewBase::EnsureTickToRefresh가 확실하게 등록되도록 보장하기 위함.
				// 리스트 뷰의 갱신 로직을 현재 함수에서 즉시 실행하지 않고, 다음 프레임의 특정 시점까지 지연
				// 한 프레임에 두 번 갱신이 발생하는 것을 막고, 갱신을 틱 단계로 통합하여 단 한번만 수행
				// 하도록 보장함으로써 렌더링 부하를 줄이고 효율성을 높임.
				RequestLayoutRefresh();
			}
			else if (CurrentScrollOffset == TargetScrollOffset)
			{
				// 현재 옵셋값이 목표로 한 옵셋값과 같으면 스크롤링을 멈춤.
				NotifyFinishedScrolling();
			}

			// 아이템이 다시 그려졌음을 의미
			OnItemsRebuilt.ExecuteIfBound();
		}
	}
}

결론

리스트 뷰와 타일 뷰의 장점인 가상화가 어떻게 일어나는지를 코드로 확인해 보았다.

결론적으로,

  1. 내부 틱에서 변화가 있을 때에만 갱신하도록 하는 코드가 존재한다.
    1. 이때 말하는 변화는 아이템이 갱신되었거나 스크롤의 이동으로 패널이 변경되었거나 이다.
  2. 갱신 코드 내부에서 실제 위젯을 그리는 코드는 ReGenerateItems 코드이다.
    1. 가상화의 핵심 코드도 ReGenerateItems 함수이다.
    2. 화면에 보이는 아이템만 그려낸다.
  3. 리스트 뷰 내부에서 아이템 데이터, 위젯을 쌍으로 미리 풀링하고 있다.
    1. 그렇기에 실제 보이는가? 확인하고 풀링 내부에 있는 위젯이라면 가지고 와서 그린다.
    2. 그렇지 않다면, 새로 생성해 해당 데이터와 쌍을 이룬다.
    3. 이때 멤버 변수는 WidgetGenerator 이다.
      1. 배치 정보(지오메트리), 내부 속성 등..
  4. 생성된 위젯에 대해서 스크롤 옵셋 값을 조정하는 등 다양한 규칙들이 내부 코드에서 이루어진다.

소스코드를 하나 하나 읽어보면서 주석으로 정리했고, 실제로 틀린 부분이 있을 수도 있습니다! 만약, 틀린 부분이 있으면 꼭 알려주시길 바랍니다. (같이 정보 알아요!!!)

profile
게임 클라이언트 프로그래머

1개의 댓글

comment-user-thumbnail
2025년 12월 4일

긍정이 언니님 멋지다 !!!

답글 달기