가상화 지원 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파일로 각 단계의 소스코드를 첨부해놓았다. 


신고


 

티스토리 툴바