Search

'UI Virtualization'에 해당되는 글 2건

  1. 2011.03.14 WPF UI Virtualization -2, SeeMoreStackPanel 만들기
  2. 2011.03.01 WPF UI Virtualization -1

가상화 지원 Panel을 구현하기 위해 가장 어려운 부분은 보여지지 않는 영역의 크기를 추정하는 것이라고 말한 바 있다.이 "추정"에 따른 한계에 의해 WPF에 포함된 VirtualizingStackPanel은 바인딩된 개별 Item의 크기가 각각 다르고 총 Item의 수가 적을 때 스크롤바의 Thumb크기가 일정하지 않은 문제가 발생한다.

이를 확인하기 위해 아래의 코드로 실험해 보았다.

public class DataItem
{
    public string Content { get; set; }
    public double Height { get; set; }
}
 
public partial class SeeMoreStackPanelTest : UserControl
{
    public SeeMoreStackPanelTest()
    {
        InitializeComponent();
 
        this.DataContext = GetDataItems(50);
    }
    private IEnumerable<DataItem> GetDataItems(int count)
    {
        Random random = new Random((int)DateTime.Now.Ticks);
        //높이가 20~ 250인 DataItem을 반환한다.
        for (int i = 0; i < count; i++)
        {
            yield return new DataItem { Content = i.ToString(), Height = random.Next(230) + 20};
        }
    }
}

<ListBox ItemsSource="{Binding}">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <Border Width="100"
                Height="{Binding Height}" BorderThickness="1" BorderBrush="Black" CornerRadius="5"> 
                <StackPanel>
                    <TextBlock Text="{Binding Content}"/>
                    <TextBlock Text="{Binding Height}"/>
                </StackPanel>
            </Border>
        </DataTemplate>
    </ListBox.ItemTemplate>

위 코드로 동작되는 ListBox는 화면에 보이는 ListBoxItem의 크기에 따라 우측 ScrollViewer의 Thumb 크기가 들쑥날쑥 변하게 된다.

또 위 프로그램을 실행해보면 한가지 문제가 더 있음을 발견하게 되는데 그것은 "Smooth Scroll"을 사용할 수 없다는 것이다. ScrollViewer는 항상 ItemContainer의 크기 단위로 Offset이 변하게 되는데 이것을 원하지 않아 ScrollViewer.CanContentScroll을 false로 준다면 당장 VirualizingStackPanel의 가상화 동작을 지원받을 수 없게 된다.

  1. ItemContainer의 크기가 가변적일 때 ScrollViewer의 Thumb가 일정하지 않다.
  2. 가상화 기능과 Smooth Scroll을 동시에 사용할 수 없다.

이 두가지 문제를 해결해 보자. 1번 문제는 ScrollViewer의 Extent 크기를 "추정"에서 얻어오기에 발생하는 문제이므로 기존의 시나리오로는 완벽히 해결할 수 없다. 이럴 땐 돌아가야 한다. Google Reader와 같은 Scroll방식을 사용하면 어떨까?

*GoogleReader의 ScrollViewer의 모습. 한번에 모든 Data를 Query하는 것이 아니라 Scroll Thumb가 가장 하단에 위치했을 때 일부 Data를 추가로 조회해 오고 있다. 살펴보니 GoogleReader는 UI Virtualization과 Data Virtualization을 매우 적절히 활용하고 있음을 확인할 수 있었다. *

GoogleReader와 같은 Scroll방식을 적용하면 얻어진 ItemContainer에 대해선 정확한 사이즈를 얻어낼 수 있으므로 1번의 문제가 해소된다. 2번의 문제는 가상화 Panel을 직접 구현하면 매우 쉽게 해결 할 수 있다. 좋다. 시작해보자.

1단계. 이름 정하기, IScrollInfo 인터페이스 구현하기

고민끝에 이름을 SeeMoreStackPanel로 하기로 했다. Naming은 어렵다. ㅜ.ㅜ 그리고 IScrollInfo 인터페이스를 구현해야 한다.

public double VerticalOffset
{
    get;
    private set;
}
 
public void SetHorizontalOffset(double offset)
{
    if (offset < 0 || viewportSize.Height >= ExtentHeight)
    {
        offset = 0;
    }
    else
    {
        if (offset + viewportSize.Height >= ExtentHeight)
        {
            offset = ExtentHeight - viewportSize.Height;
        }
    }
    VerticalOffset = offset;
 
    ScrollOwner.InvalidateScrollInfo();
    InvalidateMeasure();
}
 
public void SetVerticalOffset(double offset)
{
    throw new NotImplementedException();
}
 
public void LineDown()
{
    SetVerticalOffset(VerticalOffset + 5);
}
 
public void LineUp()
{
    SetVerticalOffset(VerticalOffset - 5);
}
//이것은 IScrollInfo Interface의 일부 구현이다.

LineUp, LineDown이 불렸을 때 VerticalOffset의 차이를 5로 두었다. 이것으로 Smooth Scrolling이 가능해진다. 그 어떤 값을 두어도 상관 없을 것이다. 나의 편의를 위해 Left, Right등의 Horizontal 동작의 기능은 구현하지 않았다.

2단계. ItemContainer들을 생성해 표시해보기

일단 VerticalOffset이 0인 경우 만을 생각하기로 하자. 2단계를 위해 ItemContainer를 만들고, ScrollViewer정보를 업데이트하고 ItemContainer들을 배치해야 한다. 이를 위해 MeasureOverride, ArrangeOverride 메소드를 override해 구현한다.

MeasureOverride메소드를 보자. 사실 가상화 지원 패널의 반은 이 메소드의 구현에 있다고 해도 과언이 아니다. 이 메소드 내에서 우리는 ItemContainer를 생성하고, ScrollViewer의 정보를 업데이트 하고, 더 이상 필요하지 않은 ItemContainer를 삭제해야 한다. 이 중 ItemContainer를 삭제하는 것은 선택사항이므로 당연히(?) 여기선 구현하지 않는다. 아래 코드에서 Assertion을 주목해 읽어주면 되겠다.

protected override Size MeasureOverride(Size availableSize)
{
    if (VerticalOffset != 0)
        throw new InvalidOperationException("2단계에서는 VerticalOffset이 0인 경우만 고려한다.");
 
    ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
    if (itemsControl == null)
    {
        throw new InvalidOperationException("단독으로 사용될 수 없는 Panel입니다.");
    }
    double remainHeight = availableSize.Height;
    UIElementCollection children = this.InternalChildren;
    GeneratorPosition position = new GeneratorPosition(-1,0);
    IItemContainerGenerator generator = ItemContainerGenerator as IItemContainerGenerator;
    using (generator.StartAt(position, GeneratorDirection.Forward, true))
    {
        while (remainHeight > 0)
        {
            bool isRealized;
            UIElement element = generator.GenerateNext(out isRealized) as UIElement;
            if (element == null) break;
            if (isRealized) //새로 생성된 ItemContainer
            {
                Debug.Assert(InternalChildren.Contains(element) == false,
                                         "새로 얻은 element이므로 InternalChildren은 이 element를 포함하지 않는다.");
                this.AddInternalChild(element);
                //이 PrepareItemContainer메소드가 어떤 역할을 하는지 알고 싶다면 이 PrepareItemContainer메소드 호출을 제거하고 
                //프로그램을 실행시키면 된다. 
                //PrepareItemContainer메소드 호출을 제거하면 Measure 호출 후
                // element.DesiredSize 값이 정상적으로 얻어지지 않는다. 
                //추측하건데 PrepareItemContainer에 ItemTemplate으로 VisualTree를 얻어 
                //ListBoxItem의 Child로 설정하는 부분이 있지 않을까 싶다.
                ItemContainerGenerator.PrepareItemContainer(element);
                element.Measure(availableSize);
            }
            else
            {
                Debug.Assert(InternalChildren.Contains(element));
            }
            remainHeight -= element.DesiredSize.Height;
        }
    }
    this.extentSize = availableSize;
    if (remainHeight < 0)
    {
        this.extentSize.Height += remainHeight * -1;
    }
    this.viewportSize = availableSize;
    this.ScrollOwner.InvalidateScrollInfo();
    return availableSize;
}

VerticalOffset이 0인 경우, 즉 ItemsControl이 처음 로드 되었을 때의 경우만 고려했으므로 코딩이 이정도로 끝나게 되었다. 이것으로 ItemsControl이 로드되었을 때 화면내에 보여질 만큼의 ListBoxItem만 생성되게 된다. 생성된 ItemContainer를 배치하는 ArrangeOverride메소드를 확인해보자.

protected override Size ArrangeOverride(Size finalSize)
{
    if (VerticalOffset != 0)
        throw new InvalidOperationException("2단계에서는 VerticalOffset이 0인 경우만 고려한다.");
    ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
    if (itemsControl== null)
    {
        throw new InvalidOperationException("단독으로 사용될 수 없는 Panel입니다.");
    }
    IItemContainerGenerator generator = itemsControl.ItemContainerGenerator as IItemContainerGenerator;
    double topPosition = 0;
    GeneratorPosition position = new GeneratorPosition(-1, 0);
    using (generator.StartAt(position, GeneratorDirection.Forward, true))
    {
        while (topPosition < finalSize.Height)
        {
            bool isRealized;
            UIElement element = generator.GenerateNext(out isRealized) as UIElement;
            if (element == null) return finalSize;
            Debug.Assert(isRealized == false,
                            "MeasureOverride에 의해 이미 생성된 ItemContainer이어야 하므로 이 값이 false이다.");
 
            Debug.Assert(InternalChildren.Contains(element),
                            "InternalChildren은 이 element를 포함하고 있어야 한다.");
            element.Arrange(new Rect(new Point(0, topPosition), element.DesiredSize));
            topPosition += element.RenderSize.Height; 
        }
    }
    return finalSize;
}

좋다. 이제 3단계에서는 VerticalOffset이 변경되었을 때의 기능을 추가할 것이고, 4단계에서는 Scroll Thumb가 ScrollViewer의 마지막에 닿았을 때 추가적으로 ItemContainer를 생성해 표시하는 기능을 추가할 것이다. 추측하건데, 이 정도로도 머리속에 3,4 단계에 대한 그림이 머리 속에 훤하게 그려진 분이 있을 것이다.

3단계. VerticalOffset이 0보다 큰 값일 경우의 동작 구현

이제 VerticalOffset이 0보다 큰 값일 경우의 동작을 구현해 보자. 먼저 MeasureOverride 메소드를 봐야한다. 다시한번 말하지만 이 메소드는 ItemContainer를 생성, 파괴, ScrollViewer 정보를 업데이트하는 역할을 한다. 그런데 우리의 시나리오에 의하면 이 MeasureOverride가 ItemsContainer를 생성하는 경우는 단 3가지 이다. 첫번째 경우는 ItemsControl이 처음으로 로드되었을 경우이며, 두번째는 ScrollEnd 동작이 수행되었을 때, 즉 VerticalOffset + ViwportHeight = ExtentHeight일 경우이다. 그리고 마지막 경우는 바인딩 된 ItemsSource 자체에 변화가 일어났을 경우이다. 여기서 첫번째, 두번째 경우만 고려해보면 MeasureOverride 메소드는 다음과 같이 될 것이다.

protected override Size MeasureOverride(Size availableSize)
{
    ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
    if (itemsControl == null)
    {
        throw new InvalidOperationException("단독으로 사용될 수 없는 Panel입니다.");
    }
    double remainHeight = availableSize.Height;
    UIElementCollection children = this.InternalChildren;
 
    //새 ItemContainer를 생성할 경우는 다음 두가지 경우 이다. 
    //첫번째는 처음 로딩되었을 경우이며
    //두번째는 ScrollToEnd가 동작되었을 경우이다.
    if ((VerticalOffset == 0 && InternalChildren.Count == 0) || VerticalOffset + ViewportHeight == ExtentHeight)
    {   
        CreateItemContainer(ref availableSize, ref remainHeight);
        this.extentSize = availableSize;
        if (remainHeight < 0)
        {
            this.extentSize.Height += remainHeight * -1;
        }
    }
 
    this.viewportSize = availableSize;
    this.ScrollOwner.InvalidateScrollInfo();
    return availableSize;
}

하나의 If문이 추가되었고 ItemContainer를 생성하는 부분은 CreateItemContainer라는 메소드로 리플랙션을 수행했다. 3단계에서 MeasureOverride메소드의 변경은 이것으로 충분하다. 그럼 이제 생성한 ItemContainer들을 배치할 차례이다. ArrangeOverride메소드는 InternalChildren에 추가된 ItemContainer 들을 VerticalOffset을 고려해 배치하면 된다. 이는 정말 너무 간단하다.

TranslateTransform translateTransform = new TranslateTransform();
public SeeMoreStackPanel()
{
   this.RenderTransform = translateTransform;
}
protected override Size ArrangeOverride(Size finalSize)
{
    ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
    if (itemsControl== null)
    {
        throw new InvalidOperationException("단독으로 사용될 수 없는 Panel입니다.");
    }
    double topPosition = 0;
    //InnerContainer에 추가된 Item들을 배치한다. 
    foreach(UIElement element in this.InternalChildren)
    {
        element.Arrange(new Rect(new Point(0, topPosition), element.DesiredSize));
        topPosition += element.RenderSize.Height; 
    }
    translateTransform.Y = VerticalOffset * -1;
    return finalSize;
}

정말 쉽지 않은가? 이것으로 3단계가 끝났다. 이제 남은 것은 ScrollBar가 마지막까지 내려갔을 때 Item을 추가시키기만 하면 된다.

4단계. ScrollToEnd가 동작했을 때, VerticalOffset + ViewportHeight = ExtentHeight 일 경우에 Item을 추가

이것 역시 굉장히 쉽다. CreateItemContainer에 다음을 추가하면 되겠다.

if (VerticalOffset == 0 && InternalChildren.Count == 0)
 
   //처음 ItemsControl이 로드되었을 경우의 동작 코드
   // …
   // ...
 
else //ScrollToEnd 명령이 불렸을 경우이다. 이 때는 5개의 Item을 추가적으로 생성하기로 한다.
 
   Debug.Assert(VerticalOffset + viewportSize.Height == ExtentHeight);
 
   //모든 Item들이 추가되었을 경우
   if (InternalChildren.Count == ItemsControl.GetItemsOwner(this).Items.Count) 
       return;
   
   IItemContainerGenerator generator = ItemContainerGenerator as IItemContainerGenerator;
   GeneratorPosition position = generator.GeneratorPositionFromIndex(InternalChildren.Count);
#if DEBUG
   int count = InternalChildren.Count;
#endif
   using (generator.StartAt(position, GeneratorDirection.Forward, true))
   {
       for (int i = 0; i < 5; i++)
       {
           bool isRealized;
           UIElement element = generator.GenerateNext(out isRealized) as UIElement;
           //더 이상 생성할 Item이 없을 경우
           if (element == null) break;
           if (isRealized) //새로 생성된 ItemContainer
           {
               Debug.Assert(InternalChildren.Contains(element) == false, "새로 얻은 element이므로 InternalChildren은 이 element를 포함하지 않는다.");
               this.AddInternalChild(element);
               ItemContainerGenerator.PrepareItemContainer(element);
               element.Measure(availableSize);
               //ScrollViewer의 ExtentSize를 조절한다.
               this.extentSize.Height += element.DesiredSize.Height;
           }
       }
   }
 
#if DEBUG
   Debug.Assert(ItemsControl.GetItemsOwner(this).Items.Count == InternalChildren.Count ||
               count + 5 == InternalChildren.Count);
#endif
 

마치며

어떤가? 금새 가상화 패널을 하나 만들었다. 물론 이것이 완전한 패널의 기능을 하려면 또 다른 여러 경우를 생각해야 한다. 바인딩 된 ItemsSource가 변경되었을 경우에 대한 코드를 추가해야 하며, HorizontalScrollBar의 기능 또한 추가해야 한다. 그리고 추가된 ItemContainer들의 크기가 변경되었을 때의 경우도 고려해보아야한다. 매우 심심하면 한번 직접 구현해 보시라. :)

 

첨부된 파일은 위 SeeMoreStackPanel을 테스트 하기 위한 프로젝트이다. txt파일로 각 단계의 소스코드를 첨부해놓았다. 


신고

WPF UI Virtualization -1

프로그래밍 2011.03.01 14:55 Posted by 아일레프

Virtualization은 Data Virtualization과 UI Virtualization으로 나뉠 수 있다. Data Virtualization이란 필요한 Data만을 데이터 저장소에서 가져와 성능을 향상시키는 방법을 의미하고 UI Virtualization이란 화면에 보여지지 않는 컨트롤 객체를 생성 않는 것으로 속도를 향상시키는 기법을 의미한다. 이 포스트는 UI Virtualization만을 다룬다. 만약 Data Virtualization에 대해 자세히 알고 싶은 분은 놀랍도록 멋진 Data Virtualization in WPF and beyond을 참고하면 되겠다.

UI Virtualization을 좀 더 쉽게 살펴보기 위해 ListBox를 예로 들어보자. 1000개의 int 데이터를 가지는 List<int> 가 ListBox의 ItemsSource에 바인딩 되었다고 가정하면 ListBox의 VisualTree는 다음과 같이 구성될 것이다.

ListBox        
  ScrollViewer      
    ItemsPresenter    
      Panel  
        ListBoxItem
        ListBoxItem
        ListBoxItem
...
...
...

UI 가상화를 사용하지 않는다면 데이터가 1000개 이므로 ListBox가 로드되었을 때 생성될 ListBoxItem들의 숫자는 총 1000개이다. WPF의 UI Virtualization은 "화면에 보여지지 않는 객체를 생성하지 않는 것으로 속도를 향상시키는 기법" 이라고 말했다. 이를 ListBox의 예로 국한하면 화면에 보여지지 않는 불필요한 ListBoxItem을 생성하지 않는 것으로 구체화할 수 있다. ListBox의 예에서 UI Virtualization을 가능하게 하려면 이 ListBoxItem의 생성을 컨트롤 할 수 있어야 한다는 것이다. 그렇다면, ListBoxItem을 생성하게 하는 객체가 무엇인가? 라는 질문을 던져야 한다. 당신은 후보가 다음과 같을 때 어떤 객체가 ListBoxItem을 생성하게 한다고 생각하는가?

  1. ListBox
  2. ItemsPresenter
  3. Panel

 

...

...

...

 

자, 답을 보자. ListBoxItem객체는 ListBox의 GetContainerForItemOverride 메소드를 통해 생성된다. 하지만 위 질문의 답은 ListBox가 아니다. ListBox의 GetContainerForItemOverride메소드는 ListBoxItem을 생성하기 위해 이용당하는 것이다. 잘 살펴보면 GetContainerForItemOverride 메소드를 호출하는 것은 ItemsPresenter내의 ItemContainerGenerator라는 객체이며 이 객체를 이용해 최초에 ListBoxItem을 생성하라는 의지를 발하는 녀석은 "Panel"이다. 만약 StackPanel이 ItemsPanel로 사용되었다면 ItemsSource가 바인딩 된 시점에 다음 코드가 불리게 된다.

public abstract class Panel : FrameworkElement, IAddChild
    internal virtual void GenerateChildren()
    {
        IItemContainerGenerator generator = (IItemContainerGenerator)_itemContainerGenerator; 
        if (generator != null)
        {
            using (generator.StartAt(new GeneratorPosition(-1, 0), GeneratorDirection.Forward))
            { 
                UIElement child;
                while ((child = generator.GenerateNext() as UIElement) != null) 
                { 
                    _uiElementCollection.AddInternal(child);
                    generator.PrepareItemContainer(child); 
                }
            }
        }
} 

StackPanel은 ListBox의 Items가 변경되었을 때 자신의 Panel의 GenerateChildren()메소드를 그대로 사용해 모든 데이터에 대응되는 ItemContainer를 만들어 자신(StackPanel)에 삽입하는 것을 볼 수 있다. 하여간 중요한 것은 ItemsContainer를 생성하게 만드는 객체는 바로 Panel이라는 사실이다. 따라서 UI Virtualization을 구현하려면 바로 이 Panel을 만지작 거려야 한다.

그런데 Panel만을 수정하는 것으로 UI Virtualization을 구현할 수 있을까? 다른 정보가 필요하지 않을까? 가상화 지원 Panel은 일반적인 Panel(Grid, StackPanel, WrapPanel)과 다르게 특정 시점에 화면에 보여야만 하는 컨트롤만을 생성해 자신의 Children에 추가해야한다. 그런데 이것은 Panel 홀로 할 수 없는 일이다. 어떤 것이 추가로 필요할까? 그렇다. 바로 "화면"에 대한 정보, 곧 ScrollViewer정보를 알 수 있어야 하며 나아가 ScrollViewer를 컨트롤 할 수 있어야 한다. UI Virtualization기능은 가상화 지원 Panel과 ScrollViewer를 동시에 필요로 한다. 둘 중 하나라도 만족하지 못하면 UI Virtualization 기능을 구현할 수 없다. UI Virtualization 기능을 위해선 이 ScrollViewer의 역할이 굉장히 중요하다.

이 ScrollViewer를 살펴보자. WPF ScrollViewer Overview 페이지를 보면 Physical Scroll 과 Logical Scroll의 개념이 나온다. Physical Scroll을 사용하면 Pixel, DIU단위의 스크롤링이 가능하다. 반면 Logical Scroll을 사용하면 ScrollViewer의 스크롤 동작을 수행했을 때 pixel단위로 움직이는 것이 아니라 임의의 논리 단위 만큼의 Height 또는 Width만큼 Scroll의 Offset이 이동하게 된다.(ListBox의 동작을 생각해보라!)

일단 Physical Scroll이 어떻게 가능할 지 생각해보자. ScrollViewer는 자신의 가용 크기를 알아야 하며 자식이 요구하는 크기를 알아야 한다. 이는 모두 Measure동작에서 이루어진다. ScrollViewer는 자식에게 Measure(double.PositiveInfinity, double.PositiveInfinity)를 사용해 자식이 요구하는 최대 크기를 알아낼 수 있고 MesureOverride 메소드에서 들어오는 constraint인수로 자신의 가용 크기를 알아 낼 수 있다. 이 때 자식이 요구하는 크기가 ScrollViewer의 Extent Size가 되며, ScrollViewer의 가용 크기, 곧 화면에 노출되는 ScrollViewer의 크기가 Viewport Size가 된다. 따라서 다음이 성립된다.

  • Viewport Size : ExtentSize = Scroll Thumb Size  : ScrollBar Size

그리고 해당 정보를 사용해 VerticalScrollBar, HorizontalScrollBar의 Visual을 결정할 것이다. 그리고 이후 Scroll동작이 일어나면 ㅡ VerticalOffset또는 HorizontalOffset 값이 변경되면 ㅡ ScrollViewer의 Offset을 이용해 Child를 Arrange시키면 되는 것이다.

물론 Logical Scroll은 이와 다르게 동작한다. Logical Scroll을 위해서 ScrollViewer는 자신의 기능(Extent Size 결정, Viewport Size결정, Vertical Offset, Horizontal Offset이 변경되었을 때의 동작 등)을IScrollInfo라는 Interface를 구현하는 객체에게 위임해야 한다. 실제로 Logical Scroll로 동작하는 ScrollViewer는 ScrollBar를 표시하는 일만 하는 것 같다. 대부분의 일들을 ScrollViewer자신이 결정하지 않고 자신의 기능을 위임한 객체에게 맞긴다. UI Virtualization을 위한 Panel은 자식들의 실제 Physical Size를 알 수 없기에 반드시 이 Logical Scroll을 사용해야 한다.

이제 UI Virtualization을 위한 Panel의 동작을 살펴보자. 이 Panel의 동작은 ItemContainer의 생성, 화면에 보여지지 않는 ItemContainer의 파괴(Optional), ScrollViewer의 정보 설정, ItemContainer의 배치로 요약될 수 있다.

  1. ItemContainer 생성
  2. 화면에 보이지 않는 ItemContainer 파괴
  3. ScrollViewer의 정보 설정
  4. ItemContainer의 배치

1,2,3 동작은 Panel의 Measure동작시 이루어진다. Measure 동작시 Panel은 ScrollViewer 정보와 자신의 가용 크기를 바탕으로 화면에 보여져야하는 ItemContainer를 ItemContainerGenerator를 이용해 생성해야 한다. Vertical Orientation의 가상화 지원 StackPanel을 만든다고 한다면 아마 다음과 같은 코드를 사용할 수 있을 것이다.

protected override Size MeasureOverride(Size availableSize)
{
    _viewport = availableSize;
 
    ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
    if (itemsControl.Items.Count == 0)
    {
        return new Size();
    }
 
    double offset;
    GeneratorPosition position = ItemContainerGenerator.
                        GeneratorPositionFromIndex(FindItemIndexFromOffset(VerticalOffset, out offset));
 
    #region 영역의 크기만큼의 ItemContainer을 생성한다.
    using (this.ItemContainerGenerator.StartAt(position, GeneratorDirection.Forward, true))
    {
        double remainHeight = availableSize.Height
 
        while (remainHeight > 0)
        {
            bool isRealized;
            UIElement container = ItemContainerGenerator.GenerateNext(out isRealized) as UIElement;
            if (container != null && isRealized)
            {
                this.AddInternalChild(container);
                ItemContainerGenerator.PrepareItemContainer(container);
            }
            else if (container != null && !isRealized)
            {
                if (!Children.Contains(container))
                {
                    this.AddInternalChild(container);
                }
            }
            else
            {
                break;
            }
            container.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
            remainHeight -= container.DesiredSize.Height;
        }
    }

위 작업이 종료되면 2,3번 동작을 MeasureOverride 메소드내에 포함시켜야 한다. 2번은 Optional할 수 있으므로 지나치더라도 3번 동작을 수행해야하는데 그 중에서도 ScrollViewer의 Extent Size를 결정해야한다. 그런데 이 ItemContainer의 크기가 고정적이라면 이것이 어렵지 않은데 각각의 ItemContainer 크기가 가변적이라면 어쩔 수 없이 "추정"이 들어가야 하므로 굉장히 복잡하고도 어렵다. 따라서 이 "추정"을 가능한 실재와 정확하게 하는 것이 가상화 Panel을 만드는 프로그래머의 주 목표라 할 수 있겠다. "추정"이 들어가는 부분은 이 뿐만이 아니다. 위 코드를 보면 FindItemIndexFromOffset이라는 메소드가 있는데 이 역시 ScrollViewer의 Vertical Offset을 바탕으로 가장 위에 보여질 Item의 Index를 "추정" 해야 한다.

protected override Size MeasureOverride(Size availableSize)
{
       // ...
       // ...
       // ...  
    this.extentSize = GetExtentSize();
    this.ScrollOwner.InvalidateScrollInfo();
    CleanUpItems(firstElement, lastElement);
    return availableSize;
}

현재 회사에서 만들고 있는 가상화 지원 패널은 ItemContainer의 크기를 바인딩 된 Data의 값을 바탕으로 얻을 수있기에 이 부분이 크게 어렵지 않았는데 이런 부가 정보가 없이 정확히 ScrollViewer의 정보를 계산하는 가상화 지원 패널을 만드는 것은 사실상 불가능하다.

이 Measure동작을 완료했다면 4.생성한 ItemContainer들을 배치하는 일이 남았다. 이 일은 ArrangeOverride 메소드가 담당해야 한다.

protected override Size ArrangeOverride(Size finalSize)
{
    double startYlocation =0.0;
    double offset;
    if (Children.Count == 0) return finalSize;
    var index = FindItemIndexFromOffset(VerticalOffset, out offset);
            
    GeneratorPosition position = ItemContainerGenerator.GeneratorPositionFromIndex(index);
            
    using (this.ItemContainerGenerator.StartAt(position, GeneratorDirection.Forward, true))
    {
        while (startYlocation < finalSize.Height)
        {
            bool isRealized;
            UIElement container = ItemContainerGenerator.GenerateNext(out isRealized) as UIElement;
            if (container == null) return finalSize;
            if (!Children.Contains(container))
            {
                throw new InvalidOperationException();
            }
 
            container.Arrange(new Rect(new Point(HorizontalOffset * -1, startYlocation),
                    new Size(finalSize.Width + HorizontalOffset,container.DesiredSize.Height)));
            startYlocation += container.RenderSize.Height;
        }
    }
    return finalSize;
}

여기까지 왔다면 ItemContainer들이 정상적으로 화면에 보이는 것을 확인 할 수 있을 것이다. 하지만 위 Measure와 Arrange코드는 사실 "최소한"의 부분이다. 사실 가장 중요한 부분은 역시 "추정"에 있는데 내가 만든 Panel은 그 동작이 현업의 특수한 상황에 의존적이라 이 코드에 추가하지 않았다. 완전한 코드를 소개하면 좋을 텐데 그러지 못해 조금 아쉽다. 그래서 다음 포스트에는 이 "추정"동작을 완벽 하게 해낼 수 없기에 이를 다른 방식으로 비껴나가는 전략을 취하는 Panel을 소개하기로 한다.

 

 

결론

보기에도 간단해 보이지 않는 이 가상화는 만들어진 소수의 ItemContainer를 바탕으로 보여지지 않는 영역을 예측, 추정해야 하는 것이기에 어려움이 있다. 때로는 "확률"이 쓰일 수도 있고 "평균"에 의지해야 할 수도 있다. 그리고 안타깝게도 이것은 결국 추정이기 때문에 어떤 경우에는 옳았던 구현이 다양한 상황에 의해 어떤 경우에는 틀릴 수 있다. 또한 모든 것을 이해한다고 생각하고 코딩에 뛰어들었지만 이 프로그램 세상은 언제나 그랬듯이 내가 미처 알지 못한 미지의 영역을 비웃으며 소개한다. 물론 그 미지의 영역을 헤집고 나아가는 것이 이 세계를 여행하는 자의 가장 큰 즐거움이라 할 수 있겠지만.

References :

신고


 

티스토리 툴바