Vincent
Vincent

Reputation: 1477

Is it possible to nest a VirtualizingStackPanel to make a virtualizing Grid?

For a project we've ran into the need for a Grid which can quickly load thousands of user controls. While google'ing for a way I ran into the many articles on Virtualization with WPF and the VirtualizingStackPanel.

Eventually I got to this stackoverflow post which uses an ItemsControl in combination with a VirtualizingStackPanel to virtualize all items in the vertical direction. For our project, we would need it to virtualize in both directions since the input data can stretch tens of thousands of rows, tens of thousands of columns or both. I tried to adjust the code from the stack post and ended up with this:

<ItemsControl ItemsSource="{Binding Rows}"
    ScrollViewer.HorizontalScrollBarVisibility="Auto"
    ScrollViewer.VerticalScrollBarVisibility="Auto"
    ScrollViewer.CanContentScroll="true"
    ScrollViewer.PanningMode="Both">
    <ItemsControl.Template>
        <ControlTemplate>
            <ScrollViewer>
                <ItemsPresenter/>
            </ScrollViewer>
        </ControlTemplate>
    </ItemsControl.Template>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <ItemsControl ItemsSource="{Binding Items}"
                            ScrollViewer.HorizontalScrollBarVisibility="Auto"
                            ScrollViewer.VerticalScrollBarVisibility="Auto"
                            ScrollViewer.CanContentScroll="true"
                            ScrollViewer.PanningMode="Both">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Label Content="{Binding Text}"
                                    Width="{Binding Width}"
                                    Height="{Binding Height}">
                        </Label>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <VirtualizingStackPanel Orientation="Horizontal" VirtualizationMode="Standard" IsVirtualizing="True"/>
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
            </ItemsControl>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel Orientation="Vertical" VirtualizationMode="Standard" IsVirtualizing="True"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

This doesn't seem to work as all the items in the horizontal direction are loaded every time I scroll down. In other words, items in the horizontal direction are not virtualized.

I also looked at implementing VirtualizingPanel and IScrollInfo, along the line of what's done here, in an attempt to create a VirtualizingGrid. But it seems the ItemContainerGenerator class is not built to work with a 2D source, it only gives out "the next item". Which, to my understanding, means it either only works horizontally or vertically at one time. So I think nesting two VirtualizingPanels is the only option.

My question is, why do the nested VirtualizingPanels from the code snippet above not work? What am I doing wrong? And is there a way to achieve a "fully virtualizing grid" somehow?

P.S. We are not looking for a virtualizing DataGrid since every cell can have one of three different sizes and one of many border colours. In other words, the cells need to be freely style'able. Plus, as mentioned, we need both row as well as column virtualization.

Upvotes: 0

Views: 2087

Answers (3)

Vincent
Vincent

Reputation: 1477

After a lot of searching and head-scratching we found a thing called VirtualCanvas which is a "simple" example of a Canvas combined with an IScrollInfo implementation (and more). This allows for scrolling to be virtualized in both ways. The only downside is that it works based on coordinates instead of rows/columns. But, as it seems to be the only solution, we converted the codebase to work with points and sizes.

It is fairly easy to use VirtualCanvas though as you only need to implement IVirtualChild for the objects you'd like to put on the canvas. You can then add instances of those objects on the canvas by calling AddVirtualChild on the canvas. The example packaged with the VirtualCanvas actually explains most of what you need to know.

Upvotes: 0

Emperor Eto
Emperor Eto

Reputation: 3520

Try this open source project - Virtualizing WrapPanel. It works fairly well out of the box.

Upvotes: 0

VitaliyK
VitaliyK

Reputation: 299

Here's my old implementation of VirtualizingPanel that looks like a grid based on the area size:

class VirtualizingTilePanel : VirtualizingPanel, IScrollInfo,    INotifyPropertyChanged
{
    private readonly Logger _log = LogManager.GetCurrentClassLogger();

    public VirtualizingTilePanel()
    {
        // For use in the IScrollInfo implementation
        this.RenderTransform = _trans;
    }

    // Dependency property that controls the size of the child elements
    public static readonly DependencyProperty ChildSizeProperty
       = DependencyProperty.RegisterAttached("ChildSize", typeof(double), typeof(VirtualizingTilePanel),
          new FrameworkPropertyMetadata(Double.PositiveInfinity, FrameworkPropertyMetadataOptions.AffectsMeasure |
          FrameworkPropertyMetadataOptions.AffectsArrange));

    public static readonly DependencyProperty ItemHeightProperty = DependencyProperty.Register("ItemHeight", typeof(double), typeof(VirtualizingTilePanel), new FrameworkPropertyMetadata(double.PositiveInfinity));
    public static readonly DependencyProperty ItemWidthProperty = DependencyProperty.Register("ItemWidth", typeof(double), typeof(VirtualizingTilePanel), new FrameworkPropertyMetadata(double.PositiveInfinity));
    public static readonly DependencyProperty OrientationProperty = StackPanel.OrientationProperty.AddOwner(typeof(VirtualizingTilePanel), new FrameworkPropertyMetadata(Orientation.Horizontal));
    public static readonly DependencyProperty HorizontalContentAlignmentProperty = ListBox.HorizontalContentAlignmentProperty.AddOwner(typeof(VirtualizingTilePanel), new FrameworkPropertyMetadata(HorizontalAlignment.Center));

    // Accessor for the child size dependency property
    public double ChildSize
    {
        get { return (double)GetValue(ChildSizeProperty); }
        set { SetValue(ChildSizeProperty, value); }
    }

    // Accessor for the child size dependency property
    private double _childDesiredWidth = double.PositiveInfinity;
    public double ChildDesiredWidth
    {
        get { return _childDesiredWidth; }
        set { _childDesiredWidth = value; }
    }

    // Accessor for the child size dependency property
    private double _childDesiredHeigth = double.PositiveInfinity;
    public double ChildDesiredHeigth
    {
        get { return _childDesiredHeigth; }
        set { _childDesiredHeigth = value; }
    }

    // Current First visible item
    private int _firstVisibleItem = 0;
    public int FirstVisibleItem
    {
        get { return _firstVisibleItem; }
        set
        {
            _firstVisibleItem = value;
            OnPropertyChanged("FirstVisibleItem");
        }
    }

    // Current Last visible item
    private int _lastVisibleItem = 0;
    public int LastVisibleItem
    {
        get { return _lastVisibleItem; }
        set
        {
            _lastVisibleItem = value;
            OnPropertyChanged("LastVisibleItem");
        }
    }

    /// <summary>
    /// Measure the children
    /// </summary>
    /// <param name="availableSize">Size available</param>
    /// <returns>Size desired</returns>
    protected override Size MeasureOverride(Size availableSize)
    {
        UpdateScrollInfo(availableSize);

        // Figure out range that's visible based on layout algorithm
        int firstVisibleItemIndex, lastVisibleItemIndex;
        GetVisibleRange(out firstVisibleItemIndex, out lastVisibleItemIndex);
        FirstVisibleItem = firstVisibleItemIndex;
        LastVisibleItem = lastVisibleItemIndex;

        // We need to access InternalChildren before the generator to work around a bug
        UIElementCollection children = this.InternalChildren;            
        IItemContainerGenerator generator = this.ItemContainerGenerator;
        if (children == null)
            throw new ArgumentNullException("InternalChildren");
        else if (generator == null)
            throw new ArgumentNullException("ItemContainerGenerator");

        // Get the generator position of the first visible data item
        GeneratorPosition startPos = generator.GeneratorPositionFromIndex(firstVisibleItemIndex);
        if (startPos == null)
            throw new ArgumentNullException("GeneratorPositionFromIndex");
        // Get index where we'd insert the child for this position. If the item is realized
        // (position.Offset == 0), it's just position.Index, otherwise we have to add one to
        // insert after the corresponding child
        int childIndex = (startPos.Offset == 0) ? startPos.Index : startPos.Index + 1;

        this.IsItemsHost = true;
        ItemsControl itemsControl = ListBox.GetItemsOwner(this);
        if (itemsControl == null)
        {
            _log.Error("class VirtualizingTilePanel, method MeasureOverride ->ListBox.GetItemsOwner(this) returned null");
            return availableSize;
        }

        int itemCount = itemsControl.HasItems ? itemsControl.Items.Count : 0;

        int current = firstVisibleItemIndex;

        using (generator.StartAt(startPos, GeneratorDirection.Forward, true))
        {
            bool stop = false;

            double currentX = 0;
            double currentY = 0;
            double maxItemSize = 0;

            while (current < itemCount)
            {
                bool newlyRealized;

                // Get or create the child
                UIElement child = generator.GenerateNext(out newlyRealized) as UIElement;
                if (newlyRealized)
                {
                    // Figure out if we need to insert the child at the end or somewhere in the middle
                    if (childIndex >= children.Count)
                    {
                        base.AddInternalChild(child);
                    }
                    else
                    {
                        base.InsertInternalChild(childIndex, child);
                    }
                    generator.PrepareItemContainer(child);
                }
                else
                {
                    // The child has already been created, let's be sure it's in the right spot
                    Debug.Assert(child == children[childIndex], "Wrong child was generated");
                    //_log.Warn("Wrong child was generated: {0}", childIndex);
                }

                // Measurements will depend on layout algorithm
                child.Measure(GetChildSize());
                var childDesiredSize = child.DesiredSize;
                ChildDesiredHeigth = childDesiredSize.Height;
                ChildDesiredWidth = childDesiredSize.Width;
                Rect childRect = new Rect(new Point(currentX, currentY), childDesiredSize);
                maxItemSize = Math.Max(maxItemSize, childRect.Height);
                if (childRect.Right > availableSize.Width) //wrap to a new line
                {
                    currentY = currentY + maxItemSize;
                    currentX = 0;
                    maxItemSize = childRect.Height;
                    childRect.X = currentX;
                    childRect.Y = currentY;
                }
                if (currentY > (availableSize.Height + ChildDesiredHeigth))
                    stop = true;
                currentX = childRect.Right;

                if (stop)
                    break;
                current++;
                childIndex++;
            }
        }

        // Note: this could be deferred to idle time for efficiency
        CleanUpItems(firstVisibleItemIndex, current - 1);
        return availableSize;
    }

    /// <summary>
    /// Arrange the children
    /// </summary>
    /// <param name="finalSize">Size available</param>
    /// <returns>Size used</returns>
    protected override Size ArrangeOverride(Size finalSize)
    {
        IItemContainerGenerator generator = this.ItemContainerGenerator;

        UpdateScrollInfo(finalSize);

        for (int i = 0; i < this.Children.Count; i++)
        {
            UIElement child = this.Children[i];


            // Map the child offset to an item offset
            int itemIndex = generator.IndexFromGeneratorPosition(new GeneratorPosition(i, 0));

            ArrangeChild(itemIndex, child, finalSize);
        }

        return finalSize;
    }

    /// <summary>
    /// Revirtualize items that are no longer visible
    /// </summary>
    /// <param name="minDesiredGenerated">first item index that should be visible</param>
    /// <param name="maxDesiredGenerated">last item index that should be visible</param>
    private void CleanUpItems(int minDesiredGenerated, int maxDesiredGenerated)
    {
        UIElementCollection children = this.InternalChildren;
        IItemContainerGenerator generator = this.ItemContainerGenerator;

        for (int i = children.Count - 1; i >= 0; i--)
        {
            GeneratorPosition childGeneratorPos = new GeneratorPosition(i, 0);
            int itemIndex = generator.IndexFromGeneratorPosition(childGeneratorPos);
            if (itemIndex < minDesiredGenerated || itemIndex > maxDesiredGenerated)
            {
                generator.Remove(childGeneratorPos, 1);
                RemoveInternalChildRange(i, 1);
            }
        }
    }

    /// <summary>
    /// When items are removed, remove the corresponding UI if necessary
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="args"></param>
    protected override void OnItemsChanged(object sender, ItemsChangedEventArgs args)
    {
        switch (args.Action)
        {
            case NotifyCollectionChangedAction.Remove:
            case NotifyCollectionChangedAction.Replace:
            case NotifyCollectionChangedAction.Move:
                RemoveInternalChildRange(args.Position.Index, args.ItemUICount);
                break;
        }
    }

    #region Layout specific code
    // I've isolated the layout specific code to this region. If you want to do something other than tiling, this is
    // where you'll make your changes

    /// <summary>
    /// Calculate the extent of the view based on the available size
    /// </summary>
    /// <param name="availableSize">available size</param>
    /// <param name="itemCount">number of data items</param>
    /// <returns></returns>
    private Size CalculateExtent(Size availableSize, int itemCount)
    {
        int childrenPerRow = CalculateChildrenPerRow(availableSize);

        // See how big we are
        return new Size(childrenPerRow * ChildDesiredWidth,
            ChildDesiredHeigth * Math.Ceiling((double)itemCount / childrenPerRow));
    }

    /// <summary>
    /// Get the range of children that are visible
    /// </summary>
    /// <param name="firstVisibleItemIndex">The item index of the first visible item</param>
    /// <param name="lastVisibleItemIndex">The item index of the last visible item</param>
    private void GetVisibleRange(out int firstVisibleItemIndex, out int lastVisibleItemIndex)
    {
        int childrenPerRow = CalculateChildrenPerRow(_extent);
        if (ChildDesiredWidth == double.PositiveInfinity)
            firstVisibleItemIndex = 0;
        else
            firstVisibleItemIndex = (int)Math.Floor(_offset.Y / ChildDesiredHeigth) * childrenPerRow;
        lastVisibleItemIndex = (int)Math.Ceiling((_offset.Y + _viewport.Height) / ChildDesiredHeigth) * childrenPerRow - 1;

        ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
        int itemCount = itemsControl.HasItems ? itemsControl.Items.Count : 0;
        if (lastVisibleItemIndex >= itemCount)
            lastVisibleItemIndex = itemCount - 1;

    }

    /// <summary>
    /// Get the size of the children. We assume they are all the same
    /// </summary>
    /// <returns>The size</returns>
    private Size GetChildSize()
    {
        return new Size(ChildDesiredWidth, ChildDesiredHeigth);
    }

    /// <summary>
    /// Position a child
    /// </summary>
    /// <param name="itemIndex">The data item index of the child</param>
    /// <param name="child">The element to position</param>
    /// <param name="finalSize">The size of the panel</param>
    private void ArrangeChild(int itemIndex, UIElement child, Size finalSize)
    {
        int childrenPerRow = CalculateChildrenPerRow(finalSize);

        int row = itemIndex / childrenPerRow;
        int column = itemIndex % childrenPerRow;

        //coorrect horizontal content
        var itemRect = new Rect(column * ChildDesiredWidth, row * ChildDesiredHeigth, ChildDesiredWidth, ChildDesiredHeigth);

        ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
        int itemCount = itemsControl.HasItems ? itemsControl.Items.Count : 0;
        if(itemCount > 1)
        {
            var correction = Math.Ceiling((finalSize.Width - childrenPerRow * ChildDesiredWidth) / 2);
            if (correction < child.DesiredSize.Width)
            {
                itemRect.X += correction;
            }
        }  
        child.Arrange(itemRect);
    }

    /// <summary>
    /// Helper function for tiling layout
    /// </summary>
    /// <param name="availableSize">Size available</param>
    /// <returns></returns>
    private int CalculateChildrenPerRow(Size availableSize)
    {
        // Figure out how many children fit on each row
        //int childrenPerRow;
        //if (availableSize.Width == Double.PositiveInfinity)
        //    childrenPerRow = 1;
        //else
        //    childrenPerRow = Math.Max(1, (int)Math.Floor(availableSize.Width / ChildDesiredWidth));
        return Math.Max(1, (int)Math.Floor(availableSize.Width / ChildDesiredWidth)); ;
    }

    #endregion

    #region IScrollInfo implementation
    // See Ben Constable's series of posts at http://blogs.msdn.com/bencon/


    private void UpdateScrollInfo(Size availableSize)
    {
        // See how many items there are
        ItemsControl itemsControl = ItemsControl.GetItemsOwner(this);
        int itemCount = itemsControl.HasItems ? itemsControl.Items.Count : 0;
        Size extent = CalculateExtent(availableSize, itemCount);

        // Update extent
        if (extent != _extent)
        {
            _extent = extent;
        }

        //// Update viewport
        if (availableSize != _viewport)
        {
            _viewport = availableSize;

        }
        if (_owner != null)
        {
            _owner.InvalidateScrollInfo();
        }
    }

    public ScrollViewer ScrollOwner
    {
        get { return _owner; }
        set { _owner = value; }
    }

    public bool CanHorizontallyScroll
    {
        get { return _canHScroll; }
        set { _canHScroll = value; }
    }

    public bool CanVerticallyScroll
    {
        get { return _canVScroll; }
        set { _canVScroll = value; }
    }

    public double HorizontalOffset
    {
        get { return _offset.X; }
    }

    public double VerticalOffset
    {
        get { return _offset.Y; }
    }

    public double ExtentHeight
    {
        get { return _extent.Height; }
    }

    public double ExtentWidth
    {
        get { return _extent.Width; }
    }

    public double ViewportHeight
    {
        get { return _viewport.Height; }
    }

    public double ViewportWidth
    {
        get { return _viewport.Width; }
    }

    public void LineUp()
    {
        SetVerticalOffset(this.VerticalOffset - SCROLL_STEP);
    }

    public void LineDown()
    {
        SetVerticalOffset(this.VerticalOffset + SCROLL_STEP);
    }

    public void PageUp()
    {
        SetVerticalOffset(this.VerticalOffset - _viewport.Height);
    }

    public void PageDown()
    {
        SetVerticalOffset(this.VerticalOffset + _viewport.Height);
    }

    public void MouseWheelUp()
    {
        SetVerticalOffset(this.VerticalOffset - SCROLL_STEP);
    }

    public void MouseWheelDown()
    {
        SetVerticalOffset(this.VerticalOffset + SCROLL_STEP);
    }

    public void LineLeft()
    {
        throw new InvalidOperationException();
    }

    public void LineRight()
    {
        throw new InvalidOperationException();
    }

    public Rect MakeVisible(Visual visual, Rect rectangle)
    {
        return new Rect();
    }

    public void MouseWheelLeft()
    {
        throw new InvalidOperationException();
    }

    public void MouseWheelRight()
    {
        throw new InvalidOperationException();
    }

    public void PageLeft()
    {
        throw new InvalidOperationException();
    }

    public void PageRight()
    {
        throw new InvalidOperationException();
    }

    public void SetHorizontalOffset(double offset)
    {
        throw new InvalidOperationException();
    }

    public void SetVerticalOffset(double offset)
    {
        if (offset < 0 || _viewport.Height >= _extent.Height)
        {
            offset = 0;
        }
        else
        {
            if (offset + _viewport.Height >= _extent.Height)
            {
                offset = _extent.Height - _viewport.Height;
            }
        }

        _offset.Y = offset;

        if (_owner != null)
            _owner.InvalidateScrollInfo();

        _trans.Y = -offset;

        // Force us to realize the correct children           
        InvalidateMeasure();
    }

    private TranslateTransform _trans = new TranslateTransform();
    private ScrollViewer _owner;
    private bool _canHScroll = false;
    private bool _canVScroll = false;
    private Size _extent = new Size(0, 0);
    private Size _viewport = new Size(0, 0);
    private Point _offset;
    private const int SCROLL_STEP = 60;

    #endregion


    public event PropertyChangedEventHandler PropertyChanged;

    public void OnPropertyChanged(string name)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(name));
        }
    }
}

Thats designed for square-like elements of the list box - you can modify it to work with rectangles.

The usage is:

 <Style x:Key="ListBoxStyleVirtualize" BasedOn="{StaticResource ListBoxMainStyle}" TargetType="{x:Type ListBox}">
        <Setter Property="ScrollViewer.CanContentScroll" Value="True"/>
        <Setter Property="ItemsPanel">
            <Setter.Value>
                <ItemsPanelTemplate>
                    <custom:VirtualizingTilePanel Name="VirtHost" IsItemsHost="True" HorizontalAlignment="Center" VirtualizingPanel.VirtualizationMode="Recycling" Margin="0 0 0 23"/>
                </ItemsPanelTemplate>
            </Setter.Value>
        </Setter>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type ListBox}">
                    <Grid>
                        <Border CornerRadius="0" x:Name="Bd" BorderBrush="Transparent" BorderThickness="0" SnapsToDevicePixels="true">
                            <ScrollViewer Focusable="false" MouseLeftButtonDown="ScrollViewer_MouseLeftButtonDown" Padding="{TemplateBinding Padding}" Template="{DynamicResource ScrollViewerControlTemplate1}">
                                <ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
                            </ScrollViewer>
                        </Border>
                    </Grid>
                    <ControlTemplate.Triggers>
                        <Trigger Property="IsEnabled" Value="false">
                            <Setter Property="Background" TargetName="Bd" Value="Transparent"/>
                        </Trigger>
                        <Trigger Property="IsGrouping" Value="true">
                            <Setter Property="ScrollViewer.CanContentScroll" Value="false"/>
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>

Upvotes: 1

Related Questions