WPF ListView虚拟化分组

7

有没有人知道一个支持启用分组时UI虚拟化的ListView实现?默认情况下,当设置分组时,VirtualizingStackPanel被禁用。

似乎微软不打算在.NET Framework 4.0中实现这一点,因此我正在寻找替代解决方案。

4个回答

6

你有没有找到如何切换分组项可见性的方法?让它们像展开器一样,将分组项作为内容呈现? - bigfoot
抱歉,我在这方面没有取得任何进展。 - Luke
2
提供的示例代码链接已经失效,因为代码库已经移动。这是移动后的分组和虚拟化MSDN代码示例的链接。 - XAMlMAX

4

现在,wpf/.net 4.5支持IsVirtualizingWhenGrouping。如果您仍然使用4.0版本,您可以通过反射来设置它,这样至少一些用户可以受益。


0

希望这不算太离题,但我最近遇到了类似的问题。如上所述,这只是.NET 4.0的问题。我甚至同意,在大多数情况下,对于组合框,您通常不需要虚拟化,因为它不应该有那么多项,如果需要分组,则应实现某种主细节解决方案。但可能存在一些灰色地带。

Luke提供的关于MSDN上的分组和虚拟化的链接对我很有帮助。在我的情况下,这是我能想到或找到的唯一方法,符合我需要的方向。它不支持ListViewCollection的所有功能。我不得不覆盖一些方法,否则项目的选择将无法正常工作。显然还有更多的工作要做。

因此,这里是来自这里的FlatGroupListCollectionView的更新解决方案:

/// <summary>
///     Provides a view that flattens groups into a list
///     This is used to avoid limitation that ListCollectionView has in .NET 4.0, if grouping is used then Virtialuzation would not work
///     It assumes some appropriate impelmentation in view(XAML) in order to support this way of grouping
///     Note: As implemented, it does not support nested grouping
///     Note: Only overriden properties and method behaves correctly, some of methods and properties related to selection of item might not work as expected and would require new implementation 
/// </summary>
public class FlatGroupListCollectionView : ListCollectionView
{
    /// <summary>
    /// Initializes a new instance of the <see cref="FlatGroupListCollectionView"/> class.
    /// </summary>
    /// <param name="list">A list used in this collection</param>
    public FlatGroupListCollectionView(IList list)
        : base(list)
    {
    }

    /// <summary>
    ///     This currently only supports one level of grouping
    ///     Returns CollectionViewGroups if the index matches a header
    ///     Otherwise, maps the index into the base range to get the actual item
    /// </summary>
    /// <param name="index">Index from which get an item</param>
    /// <returns>Item that was found on given index</returns>
    public override object GetItemAt(int index)
    {
        int delta = 0;
        ReadOnlyObservableCollection<object> groups = this.BaseGroups;
        if (groups != null)
        {
            int totalCount = 0;
            for (int i = 0; i < groups.Count; i++)
            {
                CollectionViewGroup group = groups[i] as CollectionViewGroup;
                if (group != null)
                {
                    if (index == totalCount)
                    {
                        return group;
                    }

                    delta++;
                    int numInGroup = group.ItemCount;
                    totalCount += numInGroup + 1;

                    if (index < totalCount)
                    {
                        break;
                    }
                }
            }
        }

        object item = base.GetItemAt(index - delta);
        return item;
    }

    /// <summary>
    ///     In the flat list, the base count is incremented by the number of groups since there are that many headers
    ///     To support nested groups, the nested groups must also be counted and added to the count
    /// </summary>
    public override int Count
    {
        get
        {
            int count = base.Count;

            if (this.BaseGroups != null)
            {
                count += this.BaseGroups.Count;
            }

            return count;
        }
    }

    /// <summary>
    ///     By returning null, we trick the generator into thinking  that we are not grouping
    ///     Thus, we avoid the default grouping code
    /// </summary>
    public override ReadOnlyObservableCollection<object> Groups
    {
        get
        {
            return null;
        }
    }

    /// <summary>
    ///     Gets the Groups collection from the base class
    /// </summary>
    private ReadOnlyObservableCollection<object> BaseGroups
    {
        get
        {
            return base.Groups;
        }
    }

    /// <summary>
    ///     DetectGroupHeaders is a way to get access to the containers by setting the value to true in the container style 
    ///     That way, the change handler can hook up to the container and provide a value for IsHeader
    /// </summary>
    public static readonly DependencyProperty DetectGroupHeadersProperty =
        DependencyProperty.RegisterAttached("DetectGroupHeaders", typeof(bool), typeof(FlatGroupListCollectionView), new FrameworkPropertyMetadata(false, OnDetectGroupHeaders));

    /// <summary>
    /// Gets the Detect Group Headers property
    /// </summary>
    /// <param name="obj">Dependency Object from which the property is get</param>
    /// <returns>Value of Detect Group Headers property</returns>
    public static bool GetDetectGroupHeaders(DependencyObject obj)
    {
        return (bool)obj.GetValue(DetectGroupHeadersProperty);
    }

    /// <summary>
    /// Sets the Detect Group Headers property
    /// </summary>
    /// <param name="obj">Dependency Object on which the property is set</param>
    /// <param name="value">Value to set to property</param>
    public static void SetDetectGroupHeaders(DependencyObject obj, bool value)
    {
        obj.SetValue(DetectGroupHeadersProperty, value);
    }

    /// <summary>
    ///     IsHeader can be used to style the container differently when it is a header
    ///     For instance, it can be disabled to prevent selection
    /// </summary>
    public static readonly DependencyProperty IsHeaderProperty =
        DependencyProperty.RegisterAttached("IsHeader", typeof(bool), typeof(FlatGroupListCollectionView), new FrameworkPropertyMetadata(false));

    /// <summary>
    /// Gets the Is Header property
    /// </summary>
    /// <param name="obj">Dependency Object from which the property is get</param>
    /// <returns>Value of Is Header property</returns>
    public static bool GetIsHeader(DependencyObject obj)
    {
        return (bool)obj.GetValue(IsHeaderProperty);
    }

    /// <summary>
    /// Sets the Is Header property
    /// </summary>
    /// <param name="obj">Dependency Object on which the property is set</param>
    /// <param name="value">Value to set to property</param>
    public static void SetIsHeader(DependencyObject obj, bool value)
    {
        obj.SetValue(IsHeaderProperty, value);
    }

    /// <summary>
    /// Raises the System.Windows.Data.CollectionView.CollectionChanged event.
    /// </summary>
    /// <param name="args">The System.Collections.Specialized.NotifyCollectionChangedEventArgs object to pass to the event handler</param>
    protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs args)
    {
        switch (args.Action)
        {
            case NotifyCollectionChangedAction.Add:
                {
                    int flatIndex = this.ConvertFromItemToFlat(args.NewStartingIndex, false);
                    int headerIndex = Math.Max(0, flatIndex - 1);
                    object o = this.GetItemAt(headerIndex);
                    CollectionViewGroup group = o as CollectionViewGroup;
                    if ((group != null) && (group.ItemCount == args.NewItems.Count))
                    {
                        // Notify that a header was added
                        base.OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, new object[] { group }, headerIndex));
                    }

                    base.OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, args.NewItems, flatIndex));
                }

                break;

            case NotifyCollectionChangedAction.Remove:
                // TODO: Implement this action
                break;

            case NotifyCollectionChangedAction.Move:
                // TODO: Implement this action
                break;

            case NotifyCollectionChangedAction.Replace:
                // TODO: Implement this action
                break;

            default:
                base.OnCollectionChanged(args);
                break;
        }
    }

    /// <summary>
    /// Sets the specified item to be the System.Windows.Data.CollectionView.CurrentItem in the view
    /// This is an override of base method, an item index is get first and its needed to convert that index to flat version which includes groups
    /// Then adjusted version of MoveCurrentToPosition base method is called
    /// </summary>
    /// <param name="item">The item to set as the System.Windows.Data.CollectionView.CurrentItem</param>
    /// <returns>true if the resulting System.Windows.Data.CollectionView.CurrentItem is within the view; otherwise, false</returns>
    public override bool MoveCurrentTo(object item)
    {
        int index = this.IndexOf(item);

        int newIndex = this.ConvertFromItemToFlat(index, false);

        return this.MoveCurrentToPositionBase(newIndex);
    }

    /// <summary>
    /// Sets the item at the specified index to be the System.Windows.Data.CollectionView.CurrentItem in the view
    /// This is an override of base method, Its called when user selects new item from this collection
    /// A delta is get of which is the possition shifted because of groups and we shift this position by this delta and then base method is called
    /// </summary>
    /// <param name="position">The index to set the System.Windows.Data.CollectionView.CurrentItem to</param>
    /// <returns>true if the resulting System.Windows.Data.CollectionView.CurrentItem is an item within the view; otherwise, false</returns>
    public override bool MoveCurrentToPosition(int position)
    {
        int delta = this.GetDelta(position);

        int newPosition = position - delta;

        return base.MoveCurrentToPosition(newPosition);
    }

    private static void OnDetectGroupHeaders(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        // This assumes that a container will not change between being a header and not
        // If using ContainerRecycling this may not be the case
        ((FrameworkElement)d).Loaded += OnContainerLoaded;
    }

    private static void OnContainerLoaded(object sender, RoutedEventArgs e)
    {
        FrameworkElement element = (FrameworkElement)sender;
        element.Loaded -= OnContainerLoaded; // If recycling, remove this line

        // CollectionViewGroup is the type of the header in this sample
        // Add more types or change the type as necessary
        if (element.DataContext is CollectionViewGroup)
        {
            SetIsHeader(element, true);
        }
    }

    private int ConvertFromItemToFlat(int index, bool removed)
    {
        ReadOnlyObservableCollection<object> groups = this.BaseGroups;
        if (groups != null)
        {
            int start = 1;
            for (int i = 0; i < groups.Count; i++)
            {
                CollectionViewGroup group = groups[i] as CollectionViewGroup;
                if (group != null)
                {
                    index++;
                    int end = start + group.ItemCount;

                    if ((start <= index) && ((!removed && index < end) || (removed && index <= end)))
                    {
                        break;
                    }

                    start = end + 1;
                }
            }
        }

        return index;
    }

    /// <summary>
    /// Move <seealso cref="CollectionView.CurrentItem"/> to the item at the given index.
    /// This is a replacement for base method
    /// </summary>
    /// <param name="position">Move CurrentItem to this index</param>
    /// <returns>true if <seealso cref="CollectionView.CurrentItem"/> points to an item within the view.</returns>
    private bool MoveCurrentToPositionBase(int position)
    {
        // VerifyRefreshNotDeferred was removed
        bool result = false;

        // Instead of property InternalCount we use Count property
        if (position < -1 || position > this.Count)
        {
            throw new ArgumentOutOfRangeException("position");
        }

        if (position != this.CurrentPosition || !this.IsCurrentInSync)
        {
            // Instead of property InternalCount we use Count property from this class
            // Instead of InternalItemAt we use GetItemAt from this class
            object proposedCurrentItem = (0 <= position && position < this.Count) ? this.GetItemAt(position) : null;

            // ignore moves to the placeholder
            if (proposedCurrentItem != CollectionView.NewItemPlaceholder)
            {
                if (this.OKToChangeCurrent())
                {
                    bool oldIsCurrentAfterLast = this.IsCurrentAfterLast;
                    bool oldIsCurrentBeforeFirst = this.IsCurrentBeforeFirst;

                    this.SetCurrent(proposedCurrentItem, position);

                    this.OnCurrentChanged();

                    // notify that the properties have changed.
                    if (this.IsCurrentAfterLast != oldIsCurrentAfterLast)
                    {
                        this.OnPropertyChanged(PropertySupport.ExtractPropertyName(() => this.IsCurrentAfterLast));
                    }

                    if (this.IsCurrentBeforeFirst != oldIsCurrentBeforeFirst)
                    {
                        this.OnPropertyChanged(PropertySupport.ExtractPropertyName(() => this.IsCurrentBeforeFirst));
                    }

                    this.OnPropertyChanged(PropertySupport.ExtractPropertyName(() => this.CurrentPosition));
                    this.OnPropertyChanged(PropertySupport.ExtractPropertyName(() => this.CurrentItem));

                    result = true;
                }
            }
        }

        // Instead of IsCurrentInView we return result 
        return result;
    }

    private int GetDelta(int index)
    {
        int delta = 0;
        ReadOnlyObservableCollection<object> groups = this.BaseGroups;
        if (groups != null)
        {
            int totalCount = 0;
            for (int i = 0; i < groups.Count; i++)
            {
                CollectionViewGroup group = groups[i] as CollectionViewGroup;
                if (group != null)
                {
                    if (index == totalCount)
                    {
                        break;
                    }

                    delta++;
                    int numInGroup = group.ItemCount;
                    totalCount += numInGroup + 1;

                    if (index < totalCount)
                    {
                        break;
                    }
                }
            }
        }

        return delta;
    }

    /// <summary>
    /// Helper to raise a PropertyChanged event
    /// </summary>
    /// <param name="propertyName">Name of the property</param>
    private void OnPropertyChanged(string propertyName)
    {
        base.OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
    }
}

XAML部分保持在示例代码中不变。视图模型也保持不变,这意味着使用FlatGroupListCollectionView并设置GroupDescriptions。

我更喜欢这个解决方案,因为它将分组逻辑与我的视图模型中的数据列表分开。另一种解决方案是在视图模型中实现对原始项目列表的分组支持,这意味着需要某种方式来识别标题。对于一次性使用,这应该没问题,但集合可能需要为不同或无分组的目的重新创建,这不太好。


0
一个选择是看一下Bea Stollniz关于TreeView性能改进的系列文章: 第1部分, 第2部分第3部分。虽然她所做的更适用于TreeView,因为它们默认会进行分组,所以没有任何虚拟化,但所学到的经验教训绝对可以应用于具有虚拟化组的自定义ListView。事实上,在第3部分中,她使用ListBox作为基础来创建虚拟化树,这也是虚拟化分组的良好起点。显然,像在TreeView中显示项这样的操作有一些不同之处,例如从带有分组的ListView选择组节点,但可以通过捕获SelectionChanged来解决。

谢谢!我看了第三部分的示例代码。我遇到的主要困难是如何使用GroupDescriptions添加分组项。 - Luke

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接