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

用 ListBox 替换 TreeView

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (18投票s)

2011年10月5日

BSD

4分钟阅读

viewsIcon

72980

downloadIcon

5232

TreeView 无法充分支持数百万个节点。通过 ListBox 进行模拟可能有所帮助。

引言

TreeView 控件在处理数千个节点时的性能不佳。如果单个节点包含数千个子节点,它也不会进行 UI 虚拟化。因此,我使用支持虚拟化的 ListBox 来模拟 TreeView

背景

我正在开发一个名为 Media Assistant 的开源项目,该项目管理您的所有媒体(音乐和电影)文件,并提供来自 IMDb 的信息以及来自 TasteKid 的推荐和其他许多功能。但是,当我向应用程序添加了数千部电影后,它收集了大约 24,000 个艺术家/流派和其他信息。我在 TreeView 中显示 Media Library。我发现 TreeView 无法处理如此巨大的数据。加载和展开节点需要很长时间。尤其当单个节点有数千个子节点时。我在网上搜索了这个问题,发现了一个支持虚拟化的自定义 TreeView 控件。但性能仍然不令人满意。因此,我需要某种数据虚拟化和 UI 虚拟化来解决我的问题。我找到了一个关于 数据虚拟化 的精彩文章。但文章中解释的数据虚拟化仅适用于扁平数据,而不适用于层次数据。所以,我最终找到了一个解决方案,即使用 ListBox,我可以在其中进行数据虚拟化,而 ListBox 默认支持 UI 虚拟化。这是解决我问题的完整方案。

Media_Library.png

如何使用 ListBox 模拟 TreeView 控件

为了解释我如何实现一个看起来像 TreeView 并支持数据虚拟化的 ListBox,我将问题分为两部分。第一部分将解释如何对 ListBox 进行模板化,使其看起来像 TreeView,第二部分将解释我如何将层次数据转换为扁平列表,以便实现数据虚拟化。

模板化 ListBox 以使其看起来像 TreeView

<ListBox Name="Tree" DockPanel.Dock="Top" 
       ItemsSource="{Binding DataSource.OrderedLibraryItems}" 
       Width="230" HorizontalAlignment="Left"
       BorderThickness="0"
       Background="Transparent"
       VirtualizingStackPanel.IsVirtualizing="True"
       VirtualizingStackPanel.VirtualizationMode="Standard"
       ScrollViewer.IsDeferredScrollingEnabled="True"               
       ItemTemplate="{StaticResource ListLibraryItemTemplate}"
       SelectionMode="Single"
       MouseDoubleClick="HandleMouseDoubleClick"
/>

默认情况下,ListBox 使用 VirtualizingStackPanel。我使用了延迟滚动,这样在滚动时,ListBox 不会被更新。在 ItemsSource 中,我绑定了扁平列表,这是我层次数据的扁平表示。ListLibraryItemTemplate 代表列表项,使其看起来像 TreeView 的节点。

<DataTemplate x:Key="ListLibraryItemTemplate" >
    <StackPanel x:Name="listItemPanel" Orientation="Horizontal" 
          Margin="{Binding Converter={StaticResource LibraryItemMarginConverter}}">
        <ToggleButton x:Name="expandCollapseButton"
          IsChecked="{Binding IsExpanded, Mode=TwoWay}" 
          Visibility="{Binding HasChildren, 
                      Converter={StaticResource HasChildreenVisibilityConverter}}"
          Command="{Binding RelativeSource={RelativeSource 
                   Mode=FindAncestor,AncestorType={x:Type UserControl}}, 
                   Path=DataContext.ToggleExpandCollapseCommand}"
          Style="{StaticResource ExpandCollapseToggleStyle}"/>
        <Border Name="iconBorder" Width="20" Height="20">
            <ContentControl x:Name="icon"/>
        </Border>
        <TextBlock TextAlignment="Left" HorizontalAlignment="Center" 
          VerticalAlignment="Center" Text="{Binding Title}" 
          ToolTip="{Binding Title}" Width="150" 
          TextTrimming="CharacterEllipsis"></TextBlock>
    </StackPanel>
    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding Type}" 
                  Value="{x:Static Constants:LibraryItemType.MovieLibrary}">
            <Setter TargetName="icon" Property="Content" 
                  Value="{StaticResource MovieLibraryImage}"/>
            <Setter TargetName="iconBorder" 
                    Property="Width" Value="25"/>
            <Setter TargetName="iconBorder" 
                    Property="Height" Value="25"/>
        </DataTrigger>
        <DataTrigger Binding="{Binding Type}" 
                  Value="{x:Static Constants:LibraryItemType.UnreadMovieLibrary}">
            <Setter TargetName="icon" Property="Content" 
                   Value="{StaticResource NewImage}"/>
        </DataTrigger>
    //some other triggers
    </DataTemplate.Triggers>
</DataTemplate>

在数据模板中,我使用了一个切换按钮来显示展开/折叠状态(+/-),一个图标来表示库项,以及一个 TextBlock 来表示我的库项的文本。

为了让切换按钮看起来像展开/折叠按钮,我使用了以下样式

<Style x:Key="ExpandCollapseToggleStyle" TargetType="{x:Type ToggleButton}">
    <Setter Property="Focusable" Value="False"/>
    <Setter Property="Width" Value="19"/>
    <Setter Property="Height" Value="13"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type ToggleButton}">
                <Border Width="19" Height="13" Background="Transparent">
                    <Border SnapsToDevicePixels="true" Width="9" Height="9" 
                           Background="{DynamicResource {x:Static SystemColors.WindowBrushKey}}" 
                           BorderBrush="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" 
                           BorderThickness="1">
                        <Path Margin="1,1,1,1" x:Name="ExpandPath" 
                            Fill="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}" 
                            Data="M 0 2 L 0 3 L 2 3 L 2 5 L 3 5 L 3 3 L 5 3 L 5 2 L 3 2 L 3 0 L 2 0 L 2 2 Z"/>
                    </Border>
                </Border>
                <ControlTemplate.Triggers>
                    <Trigger Property="IsChecked" Value="True">
                        <Setter Property="Data" TargetName="ExpandPath" 
                                 Value="M 0 2 L 0 3 L 5 3 L 5 2 Z"/>
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style> 

我使用了我的库项的 IsExpanded 属性来更改切换按钮的展开/折叠状态。我在我的数据结构中引入了一个属性来表示它是展开还是折叠。

为了在 ListBoxItem 上显示缩进,使其看起来像树节点,我使用了 LibraryItemMarginConverter 将库项的 Lavel 属性转换为用于缩进的边距。

public class LibraryItemMarginConverter:IValueConverter
{
    public object Convert(object value, Type targetType, 
           object parameter, CultureInfo culture)
    {
        var libraryItem = (LibraryItem)value;
        return new Thickness(libraryItem.Lavel * 10, 1, 0, 1);
    }
    public object ConvertBack(object value, Type targetType, 
           object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

我在我的 LibraryItem 中引入了一个 Lavel 属性。

如果节点没有子节点,则树视图不显示任何展开/折叠按钮。为了模拟这一点,我使用了 HasChildreenVisibilityConverter 转换器,它将 HasChildren 布尔属性转换为可见性。

public class HasChildreenVisibilityConverter:IValueConverter
{
    public object Convert(object value, Type targetType, 
                  object parameter, CultureInfo culture)
    {
        if (value == null)
            return Visibility.Visible;
        var hasChildren = (bool)value;
        return hasChildren ? Visibility.Visible : Visibility.Hidden;
    }
    public object ConvertBack(object value, Type targetType, 
                  object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

我在我的 LibraryItem 中引入了一个 HasChildren 属性。我可以在需要时找出库项是否有子项。但当您使用 Entity Framework 时,这会非常昂贵。因为每次访问导航属性时,都需要花费大量时间来处理。我使用了一个数据触发器来更改我的库项的图标。这就是将 ListBox 模板化以使其看起来像 TreeView 的全部内容。

将层次数据转换为扁平列表以支持数据虚拟化

要了解数据虚拟化,请阅读文章 WPF 数据虚拟化

var orderedLibraryItems = 
  new VirtualizingCollection<LibraryItem>(new LibraryItemProvider(rootItems));

VirtualizingCollection 接受 IItemsProvider 的实现,该接口有两个方法:FetchCountFetchRange。我的 IItemsProvider 实现是 LibraryItemProvider,它以根项作为构造函数参数。VirtualizingCollection 首先访问 FetchCount 方法来确定集合中有多少项可用。

计算计数

private void CalculateCount()
{
    _count = 0;
    foreach (var rootItem in _rootItems)
    {
        _count++;
        rootItem.Lavel = 0;
        _startIndexDictionary.Add(_count - 1, rootItem);
        GetChildrenCount(rootItem, 0);
    }
}
private void GetChildrenCount(LibraryItem item, int label)
{
    if (!item.IsExpanded)
    {
        return;
    }
    if (LibraryItemType.CanHaveChildren(item.Type) == false)
    {
        return;
    }
    if (LibraryItemType.IsLastParent(item.Type) == false)
    {
        foreach (var child in item.OrderedChildren)
        {
            _count++;
            child.Lavel = label + 1;
            _startIndexDictionary.Add(_count - 1, child);
            GetChildrenCount(child, label + 1);
        }
    }
    else
    {
        _count += item.Children.Count;
    }
}

我使用递归方法来计算列表中可见项的数量。如果项已展开并且有子项,那么我将通过增加 Lavel(我用来像 TreeView 节点一样进行缩进)来继续递归。同时,我有一个字典,其中索引是键,该索引位置的项是值。此字典将在 FetchRange 方法中使用,以查找要为虚拟化 StackPanel 提供服务的数据范围。

FetchRange

public IList<LibraryItem> FetchRange(int startIndex, int count)
{
    lock (DatabaseManager.LockObject)
    {
        var skippedItems = 0;
        var items = new List<LibraryItem>();
        foreach (var kv in _startIndexDictionary.OrderBy(kv => kv.Key))
        {
            var endeIndex = kv.Key;
            if (kv.Value.IsExpanded)
            {
                endeIndex += kv.Value.Children.Count;
            }
            if (endeIndex < startIndex)
            {
                skippedItems = endeIndex + 1;
                continue;
            }
            if (skippedItems < startIndex)
            {
                skippedItems++;
            }
            else
            {
                items.Add(kv.Value);
                if (items.Count == count)
                    return items;
            }
            if (kv.Value.IsExpanded && LibraryItemType.IsLastParent(kv.Value.Type))
            {
                foreach (var item in kv.Value.OrderedChildren)
                {
                    if (skippedItems < startIndex)
                    {
                        skippedItems++;
                    }
                    else
                    {
                        item.Lavel = kv.Value.Lavel + 1;
                        items.Add(item);
                        if (items.Count == count)
                            return items;
                    }
                }
            }
        }
        return items;
    }
}

FetchRange 方法中,我跳过了项目数量以将位置移动到 startIndex。我使用我构建的字典来执行此遍历,使代码运行得更快。一旦我到达 startIndex 的项,我将尝试返回 count 参数表示的项目数量。virtualizingCollection 类使用 100 的页面大小,这可以更改以获取项目。

关注点

在开发 Media Assistant 时,我发现许多地方性能是主要问题,并且需要以不同的方式使用标准的 WPF 控件来解决问题。我将尝试发布我为提高应用程序性能所做的所有工作。

© . All rights reserved.