65.9K
CodeProject 正在变化。 阅读更多。
Home

虚拟化 WrapPanel

starIconstarIconstarIconstarIconstarIcon

5.00/5 (28投票s)

2010 年 4 月 26 日

CPOL

8分钟阅读

viewsIcon

208856

downloadIcon

6463

WPF 的虚拟化 WrapPanel。

本文的最新代码可以在 这里 找到;如果您想为代码做出贡献,请在此处发布一条消息并附上您的 CodePlex 帐户。

引言

这是一个虚拟化 WrapPanel,类似于默认的 WPF WrapPanel,但它应该用在绑定到大量数据的 ListBox/ListView 中,因为它只生成用户可见的元素,从而提高性能。

描述

在网上搜索了一个实现后,我下载了一个付费解决方案的试用版,但它不起作用(至少对我需要的功能不起作用),所以我决定自己写一个。我花了一整天时间来实现它,于是我想:“为什么不分享出去,让其他人不必经历同样的麻烦呢?”

我编写这个面板是为了实现一个自定义的打开文件对话框,该对话框像 Windows XP 默认打开文件对话框一样排列文件(固定高度和垂直方向,以便换行到新列),但您可以使用它来显示任何换行内容(如果数据集很小,它与默认的 WrapPanel 没有区别)。

实现

首先,虚拟化面板必须处理滚动,所以我遵循了 Ben Constable 关于如何实现 IScrollInfo 接口的 教程 中的说明。该接口是 ScrollViewerListBox/ListView 中与您的面板通信的方式;它会向面板询问总区域(可以是固定的,也可以由子项的大小确定),并将可见区域(视口)作为参数传递给面板的 MeasureOverride 方法。它还会告诉面板用户何时以任何方式使用了滚动条。其次,您必须使用 VirtualizingPanelItemContainerGenerator 来处理子项的生成/销毁,如 Dan Crevier 的 教程 中所示。

最大的问题是,在那些示例中,项的大小是预先知道的,要么通过大小计算规则,要么通过从属性获取值。在 WrapPanel 中,只有当项通过 ItemContainerGenerator 实例化后,您才能知道子项的大小,但同时,UI 的虚拟化要求只实例化可见的子项。当您尝试计算面板的范围(extent)时,而大小又由子项决定,这就成了一个问题。

为了解决这个问题,我首先遍历集合中的所有项,当找到一个新类型时,我就为该类型生成容器并将其映射到类型。当用户显式设置 ItemTemplate 或使用 ItemTemplateSelector 时,同样的规则也适用,如下面的方法所示。

private void RealizePossibleDataTemplates()
{
    var virtualFrame = new Rect(new Size(10000, 10000));

    var template = _itemsControl.ItemTemplate;
    if (template != null)
    {
        Type type = template.DataType as Type;
        var realizedTemplate = (FrameworkElement)template.LoadContent();
        realizedTemplate.HorizontalAlignment = HorizontalAlignment.Left;
        realizedTemplate.VerticalAlignment = VerticalAlignment.Top;
        realizedTemplate.Arrange(virtualFrame);
        _realizedDataTemplates.Add(type, (FrameworkElement)realizedTemplate);
        return;
    }

    var templateSelector = _itemsControl.ItemTemplateSelector;
    if (templateSelector != null)
    {
        foreach (var item in _itemsControl.Items)
        {
            var dt = templateSelector.SelectTemplate(item, _itemsControl);
            if (!_loadedDataTemplates.ContainsKey(dt))
            {
                var realizedTemplate = (FrameworkElement)dt.LoadContent();
                realizedTemplate.HorizontalAlignment = HorizontalAlignment.Left;
                realizedTemplate.VerticalAlignment = VerticalAlignment.Top;
                realizedTemplate.Arrange(virtualFrame);
                _loadedDataTemplates.Add(dt, realizedTemplate);
            }
        }
        _useTemplateSelector = true;
        return;
    }

    int count = _itemsControl.Items.Count;

    for (int i = 0; i < count; i++)
    {
        object currentItem = _itemsControl.Items[i];
        Type currentItemDataType = currentItem.GetType();
        if (!_realizedDataTemplates.ContainsKey(currentItemDataType))
        {
            var currentPos = _generator.GeneratorPositionFromIndex(i);
            using (_generator.StartAt(currentPos, GeneratorDirection.Forward, false))
            {
                var child = _generator.GenerateNext() as ListBoxItem;
                child.HorizontalAlignment = HorizontalAlignment.Left;
                child.VerticalAlignment = VerticalAlignment.Top;
                _generator.PrepareItemContainer(child);
                child.Arrange(virtualFrame);
                _realizedDataTemplates.Add(currentItemDataType, 
                   (FrameworkElement)VisualTreeHelper.GetChild(child, 0));
            }
            _generator.Remove(currentPos, 1);
        }
    }
}

当需要计算特定项的大小时,我使用这个

private Size GetItemSize(object item)
{
    FrameworkElement realizedTemplate = null;
    if (_useTemplateSelector)
    {
        var templateSelector = _itemsControl.ItemTemplateSelector;
        var template = templateSelector.SelectTemplate(item, _itemsControl);
        realizedTemplate = _loadedDataTemplates[template];
    }
    else
    {
        Type itemDataType = item.GetType();
        if (!_realizedDataTemplates.ContainsKey(itemDataType))
            throw new ArgumentException("invalid item");
        realizedTemplate = _realizedDataTemplates[itemDataType];
    }
    realizedTemplate.DataContext = item;
    realizedTemplate.UpdateLayout();
    return new Size(realizedTemplate.ActualWidth, realizedTemplate.ActualHeight);
}

之后,一切就相当直接了。所有需要做的就是“虚拟化”所有项,通过存储大小并计算位置(WrapPanel 特定的逻辑)。

使用代码

要使用该控件,只需将其设置为已绑定的 ListBoxListView 上的 ItemsPanel

<ListBox ItemsSource="{StaticResource boundCollection}">
   <ListBox.ItemsPanel>
       <ItemsPanelTemplate>
            <VirtualizingWrapPanel Orientation="Vertical" /> 
       </ItemsPanelTemplate>
    </ListBox.ItemsPanel> 
</ListBox>

关注点

我之所以想使用一个虚拟化的 wrappanel,是因为当我在一个普通的 WrapPanel 中拥有一个大集合时,使用键盘箭头键导航很慢。后来我发现这是因为面板用来查找最近子项的计算是一个 O(n) 算法(n 是面板中的项数),所以编写一个虚拟化面板在这方面并没有起到帮助(尽管它有助于大型数据集的加载时间)。因此,在虚拟化 WrapPanel 中,我处理 KeyPress 事件以使用我自己的导航逻辑。如果您想使用默认面板的键盘导航,只需删除 'OnKeyDown' 方法。

观察

您不能将此面板用作独立的布局控件,它必须用在 ListBox/ListView 中,并且 ItemsSource 属性设置为一个集合。希望有人觉得这有用。

更新

在测试了虚拟化 WrapPanel 的第一个版本后,我发现性能不可接受,因为大小计算使用了 'UIElement.UpdateLayout()' 方法,当元素数量非常大时,这反而导致了比普通 WrapPanel 更大的性能问题。于是我决定检查虚拟化 StackPanel 的内部工作原理,以便了解它是如何如此快速地计算面板范围(extent)的。猜猜怎么着?它对项的大小一无所知。

正如本文第一部分所示,我在生成容器之前预先计算了项的大小,这对于实现基于像素的虚拟化面板是必要的。所谓的基于像素,是指面板的视口和范围都以像素为单位进行测量。

虚拟化 StackPanel 使用了一种不同的方法:它使用基于项的度量单位来测量视口和范围。例如:如果面板的方向是垂直的,并且项的数量是 957,那么范围的高度就设置为 957。在每次测量(滚动垂直偏移量改变)之后,视口的高度动态设置为可见项的数量。有些人可能已经注意到,当您使用向下滚动按钮时,虚拟化 StackPanel 会将其视口移动一个项。这是因为 LineDown() 方法将垂直偏移量增加了 1。因此,无论项的高度(以像素为单位)是多少,它总是会移动到下一个项。当项的高度(或水平方向面板的宽度)发生大值变化时,这可能会导致滚动条出现一些奇怪的效果,因为可见项的数量(以及因此的视口尺寸)会发生变化。除此之外,这种度量方法使得面板的性能极高,与项的数量无关。

在理解了虚拟化 StackPanel 的实现后,我心想:“那么,我只需要使用非基于像素的度量来实现虚拟化 WrapPanel。”这时我发现了一些复杂的问题,让我思考是否真的能用这种方式实现虚拟化 WrapPanel。

  • WrapPanel 可以向两个方向增长,而 StackPanel 只向垂直或水平方向增长。此外,在与面板方向正交的方向上的增长取决于在面板方向上的项的大小,因此,除非我知道最后一行的所有项的大小,否则我无法知道何时应该换行到新的一行/列,但如果我想对两个方向的面板进行虚拟化,这是不可能的。
  • 范围的大小与行/列的数量(我将称之为“段”)相同。在 StackPanel 中,这不是问题:由于每个段只允许一个项,所以范围的宽度/高度就是项的数量。在 WrapPanel 中,段的数量高度依赖于项的大小,由于项的大小不是固定的并且可以在任何方向上改变,因此无法计算 WrapPanel 中的段数。

话虽如此,我发现了一种方法,通过使用以下技巧/限制来开发非基于像素的虚拟化 WrapPanel。

  • 面板的范围永远无法确定。相反,我在每次 MeasureOverride 后估算段的数量,使用以下表达式:(项数/每段平均项数)。由于我在每次滚动后更新每段的平均项数,所以面板的范围是动态的。如果面板方向上的项大小是固定的,则范围将是静态的。
  • 项的段/段索引只能按顺序计算,所以如果您正在查看前面的项,然后跳转到一个跳过一个或多个未实现段的段,面板将使用估算来知道哪个项是第一个可见项。如果您返回到一个已实现的段并按顺序返回,这将得到纠正。例如:如果面板知道项 12 在段 1 中,并且面板估算每段 10 个项(段 0 是第一个),如果您跳转到段 9,面板将显示第 100 个项作为第一个可见项(只有当从段 1 到 9 之间恰好有 10 个项/段时,这才是正确的)。但是,如果您返回到段 1 并访问所有段直到达到段 9,那么面板将正确存储所有项的段,因此 up to 段 10 将不再进行估算。
  • 通常,ScrollViewer 中的 WrapPanel 会允许垂直和水平滚动,但由于我只能虚拟化一个方向,这个 WrapPanel 只会在与面板方向正交的方向上滚动。(意思是,您不应该显式设置 VirtualizingPanel 的高度/宽度)。

虽然这个问题可能看起来很复杂,但有了这些数据结构,它就变得简单多了。

class ItemAbstraction
{
    public ItemAbstraction(WrapPanelAbstraction panel, int index)
    {
        _panel = panel;
        _index = index;
    }

    WrapPanelAbstraction _panel;

    public readonly int _index;

    int _sectionIndex = -1;
    public int SectionIndex
    {
        get
        {
            if (_sectionIndex == -1)
            {
                return _index % _panel._averageItemsPerSection - 1;
            }
            return _sectionIndex;
        }
        set
        {
            if (_sectionIndex == -1)
                _sectionIndex = value;
        }
    }

    int _section = -1;
    public int Section
    {
        get
        {
            if (_section == -1)
            {
                return _index / _panel._averageItemsPerSection;
            }
            return _section;
        }
        set
        {
            if (_section == -1)
                _section = value;
        }
    }
}

class WrapPanelAbstraction : IEnumerable<ItemAbstraction>
{
    public WrapPanelAbstraction(int itemCount)
    {
        List<ItemAbstraction> items = new List<ItemAbstraction>(itemCount);
        for (int i = 0; i < itemCount; i++)
        {
            ItemAbstraction item = new ItemAbstraction(this, i);
            items.Add(item);
        }

        Items = new ReadOnlyCollection<ItemAbstraction>(items);
        _averageItemsPerSection = itemCount;
        _itemCount = itemCount;
    }

    public readonly int _itemCount;
    public int _averageItemsPerSection;
    private int _currentSetSection = -1;
    private int _currentSetItemIndex = -1;
    private int _itemsInCurrentSecction = 0;
    private object _syncRoot = new object();

    public int SectionCount
    {
        get
        {
            int ret = _currentSetSection + 1;
            if (_currentSetItemIndex + 1 < Items.Count)
            {
                int itemsLeft = Items.Count - _currentSetItemIndex;
                ret += itemsLeft / _averageItemsPerSection + 1;
            }
            return ret;
        }
    }

    private ReadOnlyCollection<ItemAbstraction> Items { get; set; }

    public void SetItemSection(int index, int section)
    {
        lock (_syncRoot)
        {
            if (section <= _currentSetSection + 1 && index == _currentSetItemIndex + 1)
            {
                _currentSetItemIndex++;
                Items[index].Section = section;
                if (section == _currentSetSection + 1)
                {
                    _currentSetSection = section;
                    if (section > 0)
                    {
                        _averageItemsPerSection = (index) / (section);
                    }
                    _itemsInCurrentSecction = 1;
                }
                else
                    _itemsInCurrentSecction++;
                Items[index].SectionIndex = _itemsInCurrentSecction - 1;
            }
        }
    }

    public ItemAbstraction this[int index]
    {
        get { return Items[index]; }
    }
}

正如您可以想象的,我不得不重写 90% 的代码,虽然我仍然会提供旧面板供下载,但我绝对推荐使用新面板,因为它速度更快,即使有局限性,这个面板对我创建的文件对话框来说也工作得非常完美,我希望它也能帮助其他人。

© . All rights reserved.