WPF WrapPanel with some items having a height of *

.net, visual-studio, wpf, wpf-controls

Solution

Update Optimized `RecalcMatrix` so the UI is only rebuilt when needed. It doesn't touch the UI unless necessary so it should be much faster.

Update Again Fixed problem when using `Margin`

Is think what you're looking at is basically a `WrapPanel` with Horizontal Orientation where every element in it is a 1 Column `Grid`. Each element in a Column then has a corresponding `RowDefinition` where the `Height` Property matches an attached property (`"WrapHeight"`) set on its Child. This Panel would have to be in a `Grid` itself, with `Height="*"` and `Width="Auto"` because the Children should be positioned by the available `Height` and not care about the available `Width`.

I made an implementation of this which I called a `WrapGridPanel`. You can use it like this

<local:WrapGridPanel>
    <Button MinHeight="30" local:WrapGridPanel.WrapHeight="Auto">I'm auto-sized.</Button>
    <Button MinHeight="90" local:WrapGridPanel.WrapHeight="*">I'm star-sized.</Button>
    <Button MinHeight="30" local:WrapGridPanel.WrapHeight="Auto">I'm auto-sized.</Button>
    <Button MinHeight="90" local:WrapGridPanel.WrapHeight="*">I'm star-sized, too!</Button>
    <Button MinHeight="30" local:WrapGridPanel.WrapHeight="Auto">I'm auto-sized.</Button>
    <Button MinHeight="30" local:WrapGridPanel.WrapHeight="Auto">I'm auto-sized.</Button>
</local:WrapGridPanel>

WrapGridPanel.cs

[ContentProperty("WrapChildren")] 
public class WrapGridPanel : Grid
{
    private WrapPanel m_wrapPanel = new WrapPanel();
    public WrapGridPanel()
    {
        ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.0, GridUnitType.Auto) } );
        RowDefinitions.Add(new RowDefinition { Height = new GridLength(1.0, GridUnitType.Star) } );
        Children.Add(m_wrapPanel);
        WrapChildren = new ObservableCollection<FrameworkElement>();
        WrapChildren.CollectionChanged += WrapChildren_CollectionChanged;
        DependencyPropertyDescriptor actualHeightDescriptor
            = DependencyPropertyDescriptor.FromProperty(WrapGridPanel.ActualHeightProperty,
                                                        typeof(WrapGridPanel));
        if (actualHeightDescriptor != null)
        {
            actualHeightDescriptor.AddValueChanged(this, ActualHeightChanged);
        }
    }

    public static void SetWrapHeight(DependencyObject element, GridLength value)
    {
        element.SetValue(WrapHeightProperty, value);
    }
    public static GridLength GetWrapHeight(DependencyObject element)
    {
        return (GridLength)element.GetValue(WrapHeightProperty);
    }
    public ObservableCollection<FrameworkElement> WrapChildren
    {
        get { return (ObservableCollection<FrameworkElement>)base.GetValue(WrapChildrenProperty); }
        set { base.SetValue(WrapChildrenProperty, value); }
    }

    void ActualHeightChanged(object sender, EventArgs e)
    {
        RecalcMatrix();
    }
    void WrapChildren_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        RecalcMatrix();
    }

    List<List<FrameworkElement>> m_elementList = null;
    private bool SetupMatrix()
    {
        m_elementList = new List<List<FrameworkElement>>();
        double minHeightSum = 0;
        m_elementList.Add(new List<FrameworkElement>());
        int column = 0;
        if (WrapChildren.Count > 0)
        {
            foreach (FrameworkElement child in WrapChildren)
            {
                double tempMinHeight = 0.0;
                if (WrapGridPanel.GetWrapHeight(child).GridUnitType != GridUnitType.Star)
                {
                    tempMinHeight = Math.Max(child.ActualHeight, child.MinHeight) + child.Margin.Top + child.Margin.Bottom;
                }
                else
                {
                    tempMinHeight = child.MinHeight + child.Margin.Top + child.Margin.Bottom;
                }
                minHeightSum += tempMinHeight;
                if (minHeightSum > ActualHeight)
                {
                    minHeightSum = tempMinHeight;
                    m_elementList.Add(new List<FrameworkElement>());
                    column++;
                }
                m_elementList[column].Add(child);
            }
        }
        if (m_elementList.Count != m_wrapPanel.Children.Count)
        {
            return true;
        }
        for (int i = 0; i < m_elementList.Count; i++)
        {
            List<FrameworkElement> columnList = m_elementList[i];
            Grid wrapGrid = m_wrapPanel.Children[i] as Grid;
            if (columnList.Count != wrapGrid.Children.Count)
            {
                return true;
            }
        }
        return false;
    }
    private void RecalcMatrix()
    {
        if (ActualHeight == 0 || SetupMatrix() == false)
        {
            return;
        }

        Binding heightBinding = new Binding("ActualHeight");
        heightBinding.Source = this;
        while (m_elementList.Count > m_wrapPanel.Children.Count)
        {
            Grid wrapGrid = new Grid();
            wrapGrid.SetBinding(Grid.HeightProperty, heightBinding);
            m_wrapPanel.Children.Add(wrapGrid);
        }
        while (m_elementList.Count < m_wrapPanel.Children.Count)
        {
            Grid wrapGrid = m_wrapPanel.Children[m_wrapPanel.Children.Count - 1] as Grid;
            wrapGrid.Children.Clear();
            m_wrapPanel.Children.Remove(wrapGrid);
        }

        for (int i = 0; i < m_elementList.Count; i++)
        {
            List<FrameworkElement> columnList = m_elementList[i];
            Grid wrapGrid = m_wrapPanel.Children[i] as Grid;
            wrapGrid.RowDefinitions.Clear();
            for (int j = 0; j < columnList.Count; j++)
            {
                FrameworkElement child = columnList[j];
                GridLength wrapHeight = WrapGridPanel.GetWrapHeight(child);
                Grid.SetRow(child, j);
                Grid parentGrid = child.Parent as Grid;
                if (parentGrid != wrapGrid)
                {
                    if (parentGrid != null)
                    {
                        parentGrid.Children.Remove(child);
                    }
                    wrapGrid.Children.Add(child);
                }

                RowDefinition rowDefinition = new RowDefinition();
                rowDefinition.Height = new GridLength(Math.Max(1, child.MinHeight), wrapHeight.GridUnitType);
                wrapGrid.RowDefinitions.Add(rowDefinition); 
            }
        }
    }

    public static readonly DependencyProperty WrapHeightProperty =
            DependencyProperty.RegisterAttached("WrapHeight",
                                                typeof(GridLength),
                                                typeof(WrapGridPanel),
                                                new FrameworkPropertyMetadata(new GridLength(1.0, GridUnitType.Auto)));

    public static readonly DependencyProperty WrapChildrenProperty =
            DependencyProperty.Register("WrapChildren",
                                        typeof(ObservableCollection<FrameworkElement>),
                                        typeof(WrapGridPanel),
                                        new UIPropertyMetadata(null));
}

Update Fixed more than one star-sized column problem. New sample app here: http://www.mediafire.com/?28z4rbd4pp790t2

Update A picture that tries to explain what `WrapGridPanel` does

Problem

How do I make a WrapPanel with some items having a Height of *? A deceptively simple question that I have been trying to solve. I want a control (or some XAML layout magickry) that behaves similar to a Grid that has some rows with a Height of *, but supports wrapping of columns. Hell; call it a WrapGrid. :) Here's a mockup to visualize this. Imagine a grid defined as such: ``` <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Window1" Height="400"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <Button Grid.Row="0" MinHeight="30">I'm auto-sized.</Button> <Button Grid.Row="1" MinHeight="90">I'm star-sized.</Button> <Button Grid.Row="2" MinHeight="30">I'm auto-sized.</Button> <Button Grid.Row="3" MinHeight="90">I'm star-sized, too!</Button> <Button Grid.Row="4" MinHeight="30">I'm auto-sized.</Button> <Button Grid.Row="5" MinHeight="30">I'm auto-sized.</Button> </Grid> </Window> ``` What I want this panel to do is wrap an item into an additional column when the item can not get any smaller than its minHeight. Here is a horrible MSPaint of some mockups I made detailing this process. Recall from the XAML that the auto-sized buttons have minHeights of 30, and the star-sized buttons have minHeights of 90. This mockup is just two grids side by side and I manually moved buttons around in the designer. Conceivably, this could be done programmatically and serve as a sort of convoluted solution to this. How can this be done? I will accept any solution whether it's through xaml or has some code-behind (though I would prefer pure XAML if possible since xaml code behind is tougher to implement in IronPython). Updated with a bounty Meleak's Solution I managed to work out how to use Meleak's solution in my IPy app: 1) I compiled `WrapGridPanel.cs` into a DLL with `csc`: ``` C:\Projects\WrapGridTest\WrapGridTest>csc /target:library "WrapGridPanel.cs" /optimize /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\PresentationFramework.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\PresentationCore.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\WindowsBase.dll" /reference:"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.0\System.Xaml.dll" ``` Update: Added the `/optimize` switch, this nets a small performance increase 2) I added it to my application's xaml with the following line. ``` xmlns:local="clr-namespace:WrapGridTest;assembly=WrapGridPanel.dll" ``` This runs fine, but it breaks the designer. I can't really find a workaround for this yet, it looks to be a bug in VS2010. So as a workaround, in order to be able to use the designer, I just add the WrapGridPanel programmatically at runtime: ``` clr.AddReference("WrapGridPanel.dll") from WrapGridTest import WrapGridPanel wgp = WrapGridPanel() ``` Slow performance when resizing: In my IronPython application, resizing the window containing this WrapGridPanel is slow and hitchy. Could the `RecalcMatrix()` algorithm be optimized? Could it perhaps be called less frequently? Maybe overriding `MeasureOverride` and `ArrangeOverride`, as Nicholas suggested, would perform better? Update: According to the VS2010 Instrumentation Profiler, 97% of the time spent in RecalcMatrix() is spent on Clear() and Add(). Modifying each element in-place would be a huge performance improvement. I'm taking a whack at it myself but it's always tough modifying someone else's code... https://i.stack.imgur.com/tMTWU.png Update: Performance issues have been mostly ironed out. Thanks Meleak! Here is a mockup of part of my actual application's UI, in XAML, if you wish to try it out. http://pastebin.com/2EWY8NS0

Original source