Introduction
It all started out pretty usual. With Windows 8 on it's way out I was asked (or rather assigned a task) to create a newspaper app for the company I work for. After sorting out all the interesting stuff (like connecting to API, serialization/deserialization of articles, session management) I've got to the point of presenting the data to the user.
My basic concept was to show the article as a collection of article elements (images, paragraphs, videos, graphs, etc.) in multiple columns of same width. Something similiar to this (random googled image):
A WrapPanel would be great to get the job done, but as I found out there isn't any in WinRT (Thanks MACrosoft, as if Vista wasn't enough.
After googling around I've found one Silverlight WrapPanel control ported to WinRT, but it had some issues. For example it couldn't autosize to it's content, so i decided to roll out my own. And since you are reading this even share it out to hopefully save somebody some time.
Creating the WrapPanel
My first idea was to make a custom panel control which would contain multiple vertical stack panels inside horizontal stack panel, updating content and count of inner panels upon adding and removing items to the control. Final solution became a lot simpler without any need for additional inner panels.
So let's get started.
First of all we need to create a new panel control as a panels descendant and declare some useful properties:
public class WrapPanel : Panel
{
public Orientation Orientation { get; set; }
public double BlockSize { get; set; }
public double BlockSpacing { get; set; }
}
We will turn these "standard" properties into DependencyProperties later on. That way they can be set in XAML. But first let's take care of sizing and laying out controls in our new panel.
For this we need to override two methods of parent Panel control. Namely MeasureOverride
and ArrangeOverride
.
I won't go into detail of describing these methods /*that's what we have msdn for*/
http://msdn.microsoft.com/en-us/library/system.windows.frameworkelement.measureoverride.aspx
http://msdn.microsoft.com/en-us/library/system.windows.frameworkelement.arrangeoverride.aspx
What microsoft is basically trying to tell us is:
Use measure override to report back (to the parent control) the amount of space that you are going to use. If the parent control is autosizing to its content it will autosize to this reported size (plus size of other possible controls). Do note that the size you report back will be passed back to you in ArrangeOverride method.
Use arrange override to actually layout child controls within the panel.
For the sake of simplicity I've splitted the Measure/Arrange override methods into separate methods for Vertical and Horizontal orientation. You can merge them together if you like, but i find it more readable this way. Since the code is very much identical, I'll only show the vertical version.
Measure override
protected override Size MeasureOverride(Size availableSize)
{
switch (Orientation)
{
case Orientation.Horizontal:
return MeasureOverrideHorizontal(availableSize);
case Orientation.Vertical:
default:
return MeasureOverrideVertical(availableSize);
}
}
And the vertical MeasureOverride
:
private Size MeasureOverrideVertical(Size availableSize)
{
Size childAvailableSize = new Size(BlockSize, double.PositiveInfinity);
int blockCount = 0;
if (Children.Count > 0)
blockCount = 1;
var remainingSpace = availableSize.Height;
foreach (var item in Children)
{
item.Measure(childAvailableSize);
if (item.DesiredSize.Height > remainingSpace)
{
if (remainingSpace != availableSize.Height)
{
remainingSpace = availableSize.Height;
blockCount++;
}
}
remainingSpace -= item.DesiredSize.Height;
}
Size desiredSize = new Size();
if (blockCount > 0)
desiredSize.Width = (blockCount * BlockSize) + ((blockCount - 1) * BlockSpacing);
else desiredSize.Width = 0;
desiredSize.Height = availableSize.Height;
return desiredSize;
}
Arrange Override
protected override Size ArrangeOverride(Size finalSize)
{
switch (Orientation)
{
case Orientation.Horizontal:
return ArrangeOverrideHorizontal(finalSize);
case Orientation.Vertical:
default:
return ArrangeOverrideVertical(finalSize);
}
}
And once again the Arrange
vertical method:
private Size ArrangeOverrideVertical(Size finalSize)
{
double offsetX = 0;
double offsetY = 0;
foreach (var item in Children)
{
if ((finalSize.Height - offsetY) < item.DesiredSize.Height)
{
if (offsetY != 0)
{
offsetX += BlockSpacing;
offsetX += BlockSize;
offsetY = 0;
}
}
Rect rect = new Rect(new Point(offsetX, offsetY), new Size(BlockSize, item.DesiredSize.Height));
item.Arrange(rect);
offsetY += item.DesiredSize.Height;
}
return base.ArrangeOverride(finalSize);
}
Making our properties XAML friendly
After this, all that is left is turning our properties into dependency properties. Do note we need to update our layout after changing them. For this purpose we cannot just put update logic into property setters (that would work in runtime only) but we need to register OnChangeHandler
in property metadata during property registration.
Orientation
public Orientation Orientation
{
get { return (Orientation)GetValue(OrientationProperty); }
set { SetValue(OrientationProperty, value); }
}
public static readonly DependencyProperty OrientationProperty =
DependencyProperty.Register("Orientation", typeof(Orientation), typeof(WrapPanel), new PropertyMetadata(Orientation.Vertical, OnOrientationPropertyChanged));
private static void OnOrientationPropertyChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
(source as WrapPanel).InvalidateMeasure();
}
BlockSize
public double BlockSize
{
get { return (double)GetValue(BlockSizeProperty); }
set { SetValue(BlockSizeProperty, value); }
}
public static readonly DependencyProperty BlockSizeProperty =
DependencyProperty.Register("BlockSize", typeof(double), typeof(WrapPanel), new PropertyMetadata(100.0, OnBlockSizePropertyChanged));
private static void OnBlockSizePropertyChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
(source as WrapPanel).InvalidateMeasure();
}
BlockSpacing
public double BlockSpacing
{
get { return (double)GetValue(BlockSpacingProperty); }
set { SetValue(BlockSpacingProperty, value); }
}
public static readonly DependencyProperty BlockSpacingProperty =
DependencyProperty.Register("BlockSpacing", typeof(double), typeof(WrapPanel), new PropertyMetadata(0.0, OnBlockSpacingPropertyChanged));
private static void OnBlockSpacingPropertyChanged(DependencyObject source, DependencyPropertyChangedEventArgs e)
{
(source as WrapPanel).InvalidateMeasure();
}
Note: Use invalidate measure for invalidation, since changing for example block size will result in change of wrap panels dimensions. Calling invalidate arrange will result in rearranging of controls but size of wrap panel will remain the same. Controls will be cropped or there will be free space left (whether the size was increased or decreased)
Using the control
After building your project, you should be able to drag and drop this control onto designer surface and use it as any other control.
Most basic usage is for homogeneous controls (for this WrapGrid
would suffice) example Xaml and image:
<Grid Name="grid" Width="600" Height="160" Background="{StaticResource ApplicationPageBackgroundThemeBrush}" HorizontalAlignment="Left">
<local:WrapPanel Background="Brown" VerticalAlignment="Stretch" HorizontalAlignment="Left" MinWidth="100" BlockSize="250" BlockSpacing="10" Orientation="Vertical">
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 1" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 2" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 3" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 4" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 5" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 6" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 7" />
</local:WrapPanel>
</Grid>
But works nicely with diffrent sizes of controls:
<Grid Name="grid" Width="550" Height="200" Background="{StaticResource ApplicationPageBackgroundThemeBrush}" HorizontalAlignment="Left">
<local:WrapPanel Background="Brown" VerticalAlignment="Stretch" HorizontalAlignment="Left" MinWidth="100" BlockSize="150" BlockSpacing="10" Orientation="Vertical">
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 1" />
<TextBlock FontSize="18" TextWrapping="Wrap" TextAlignment="Justify">Hello world, this is a test of long textblock</TextBlock>
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Height="90" Content="Test 2 - Bigger" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 3" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 4" />
<Button HorizontalAlignment="Stretch" Margin="0,22" VerticalAlignment="Stretch" Content="Test 5 - Margin" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 6" />
<Button HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Content="Test 7" />
</local:WrapPanel>
</Grid>
Note: The black border you are seeing is from underlying grid to make the wrap panels size visible, remove the grids fixed width and it will autosize to our panel.
Further improvements hints
Well I can think of only one, if you would like to add controls with fixed sizes and make the block autosize to the biggest of them, all you should have to do is remember the biggest width found during the measure pass and make it your BlockSize
. But that is not what I needed, so it's up to you.