In this post I will be looking at the choices currently available for laying out the tiled interface. I want to create a similar effect to the Windows 8 Start Menu, i.e. tiles grouped together under a heading like this:
It’s easy enough to visualise each tile being represented by a Xamarin.Forms Layout View with it’s BackgroundColor set to the colour of the tile, but I will need to find a way to lay these tiles out into a grid.
I think the best place to start looking is in the XLabs Xamarin.Forms Labs, as this OSS project has a number of additional Controls/Views which may meet my needs.
The WrapLayout control looks promising; it lays it’s items out in a column (or row) until it runs out of space, whereupon it will start the next column (or row). This seemed ideal but it does not support data bindings of the items to layout.
The GridView also looks like a strong contender. It renders its data bound items using each platform’s native grid layout. However it doesn’t have a Windows implementation yet. I started looking at the source code but it looked like it would take considerable effort to understand the approach it uses well enough to apply it to the Windows platform.
The RepeaterView also looks interesting as it gives an example of how to data bind an ObservableCollection of items and lay them out into a list. It can also also use a TemplateSelector which determines the DataTemplate to use depending on the type of the item it is binding. An example of the TemplateSelector in use can be seen in the CarouselSample.
So can I use the layout logic from WrapLayout, but the data binding and template selections from RepeaterView? Let’s take a look at creating a control which I will call WrapView.
First of all I need to add the XLabs Xamarin.Forms Lab NuGet package to all the projects. The easiest way to do this is to right-click the solution in Solution Explorer and select Manage NuGet packages for Solution… then search for XLabs in the Online packages. You should see Xlabs – Forms in the list shown, select this and click Install and make sure all the projects are selected before clicking OK:
I’ll also select Enable NuGet Package Restore from the same right-click menu so that packages will get downloaded automatically if necessary. This saves a lot of hassle when restoring the project from source control (e.g. GitHub) on another PC as you will not have to re-add the packages.
I can now create the new control. I’ll create a folder called Controls in the root of the SmartTiles project and then add a new class called WrapView into it.
Like the WrapLayout, the WrapView will inherit from Layout
public class WrapView : Layout
Looking inside the WrapLayout and RepeaterView controls I can see a number of bindable properties I will also need for my control:
- Orientation will specify whether the tiles should be placed horizontally or vertically before wrapping. I’ll be setting this to Vertical.
- Spacing will specify the gap to leave between each item (tile).
ItemTemplatecan be used to specify a single template to be used for every item. I won’t be using this as I want the template used to be determined by the type of the item.ItemSourceis theIEnumerablesource of the items. I will be assigning anObservableCollectionto this so that the control can be updated dynamically.TemplateSelectoris of typeTemplateSelectorwhich is implemented in the Xlabs code and provides the mapping from each item type to a template.
I’ll copy these across to WrapView:
public static readonly BindableProperty OrientationProperty =
BindableProperty.Create<WrapView, StackOrientation>(w => w.Orientation, StackOrientation.Vertical,
propertyChanged: (bindable, oldvalue, newvalue) => ((WrapView)bindable).OnSizeChanged());
public StackOrientation Orientation
{
get { return (StackOrientation)GetValue(OrientationProperty); }
set { SetValue(OrientationProperty, value); }
}
public readonly BindableProperty SpacingProperty =
BindableProperty.Create<WrapView, double>(w => w.Spacing, 6,
propertyChanged: (bindable, oldvalue, newvalue) => ((WrapView)bindable).OnSizeChanged());
public double Spacing
{
get { return (double)GetValue(SpacingProperty); }
set { SetValue(SpacingProperty, value); }
}
public static readonly BindableProperty ItemTemplateProperty =
BindableProperty.Create<WrapView, DataTemplate>(w => w.ItemTemplate, null,
propertyChanged: (bindable, oldvalue, newvalue) => ((WrapView)bindable).OnSizeChanged());
public DataTemplate ItemTemplate
{
get { return (DataTemplate)GetValue(ItemTemplateProperty); }
set { SetValue(ItemTemplateProperty, value); }
}
public static readonly BindableProperty ItemsSourceProperty =
BindableProperty.Create<WrapView, IEnumerable>(w => w.ItemsSource, null,
propertyChanged: (bindable, oldvalue, newvalue) => ((WrapView)bindable).ItemsSource_OnPropertyChanged(bindable, oldvalue, newvalue));
public IEnumerable ItemsSource
{
get { return (IEnumerable)GetValue(ItemsSourceProperty); }
set { SetValue(ItemsSourceProperty, value); }
}
public static readonly BindableProperty TemplateSelectorProperty =
BindableProperty.Create<WrapView, TemplateSelector>(w => w.TemplateSelector, null,
propertyChanged: (bindable, oldvalue, newvalue) => ((WrapView)bindable).OnSizeChanged());
public TemplateSelector TemplateSelector
{
get { return (TemplateSelector)GetValue(TemplateSelectorProperty); }
set { SetValue(TemplateSelectorProperty, value); }
}
Sizing the Control
Many of these properties are configured to call the OnSizeChanged when there value is changed, which in turn calls the base Layout’s ForceLayout method to trigger a redraw of the control:
/// <summary>
/// Called when the spacing or orientation properties are changed - it forces
/// the control to go back through a layout pass.
/// </summary>
private void OnSizeChanged()
{
ForceLayout();
}
This in turn causes the overridden methods OnSizeRequest and LayoutChildren to be called. OnSizeRequest needs to calculate the size that the control would like to be - it’s requested size, and the smallest size it can be - it’s minimum size. LayoutChildren needs to call LayoutChildIntoBoundingRegion for each child, item passing the coordinates and width of the area to place the item in.
In the following snippet OnSizeRequest determines whether to call DoVerticalMeasure or DoHorizontalMeasure depending on the Orientation property, i.e. whether the items are being stacked vertically or horizontally before wrapping. The snippet only includes DoVerticalMeasure because DoHorizontalMeasure is the same logic swapping height for width. The logic is quite straight forward, item.GetSizeRequest is called to get the size of an item, it’s height is added to height and the newHeight to check whether it exceeds the height available to the control, heightConstraint. If it does, columnCount is incremented. When all items have been sized the columnCount is multiplied with the maximum width of all the items.
/// <summary>
/// Called during the measure pass of a layout cycle to get the desired size of an element.
/// </summary>
/// <param name="widthConstraint">The available width for the element to use.</param>
/// <param name="heightConstraint">The available height for the element to use.</param>
protected override SizeRequest OnSizeRequest(double widthConstraint, double heightConstraint)
{
if (WidthRequest > 0)
widthConstraint = Math.Min(widthConstraint, WidthRequest);
if (HeightRequest > 0)
heightConstraint = Math.Min(heightConstraint, HeightRequest);
double internalWidth = double.IsPositiveInfinity(widthConstraint) ? double.PositiveInfinity : Math.Max(0, widthConstraint);
double internalHeight = double.IsPositiveInfinity(heightConstraint) ? double.PositiveInfinity : Math.Max(0, heightConstraint);
return Orientation == StackOrientation.Vertical
? DoVerticalMeasure(internalWidth, internalHeight)
: DoHorizontalMeasure(internalWidth, internalHeight);
}
private SizeRequest DoVerticalMeasure(double widthConstraint, double heightConstraint)
{
int columnCount = 1;
double width = 0;
double height = 0;
double minWidth = 0;
double minHeight = 0;
double heightUsed = 0;
foreach (var item in Children)
{
var size = item.GetSizeRequest(widthConstraint, heightConstraint);
width = Math.Max(width, size.Request.Width);
var newHeight = height + size.Request.Height + Spacing;
if (newHeight > heightConstraint)
{
columnCount++;
heightUsed = Math.Max(height, heightUsed);
height = size.Request.Height;
}
else
height = newHeight;
minHeight = Math.Max(minHeight, size.Minimum.Height);
minWidth = Math.Max(minWidth, size.Minimum.Width);
}
if (columnCount > 1)
{
height = Math.Max(height, heightUsed);
width *= columnCount; // take max width
}
return new SizeRequest(new Size(width, height), new Size(minWidth, minHeight));
}
Laying out the child tiles
Also from WrapLayout is the code for laying out the child items which you will see below:
/// <summary>
/// Positions and sizes the children of a Layout.
/// </summary>
/// <param name="x">A value representing the x coordinate of the child region bounding box.</param>
/// <param name="y">A value representing the y coordinate of the child region bounding box.</param>
/// <param name="width">A value representing the width of the child region bounding box.</param>
/// <param name="height">A value representing the height of the child region bounding box.</param>
protected override void LayoutChildren(double x, double y, double width, double height)
{
if (Orientation == StackOrientation.Vertical)
{
double colWidth = 0;
double yPos = y, xPos = x;
foreach (var child in Children.Where(c => c.IsVisible))
{
var request = child.GetSizeRequest(width, height);
double childWidth = request.Request.Width;
double childHeight = request.Request.Height;
colWidth = Math.Max(colWidth, childWidth);
if (yPos + childHeight > height)
{
yPos = y;
xPos += colWidth + Spacing;
colWidth = 0;
}
var region = new Rectangle(xPos, yPos, childWidth, childHeight);
LayoutChildIntoBoundingRegion(child, region);
yPos += region.Height + Spacing;
}
}
else
{
...
}
}
I have removed the code for the horizontal orientation for the sake of brevity. The foreach loops for each child and the variables yPos and xPos are used for the coordinates of the top left corner of the current child. In the loop the current child is sized and it’s height added to the yPos, if this exceeds the height of the control a new column is started by resetting yPos to the top of the control and adding the maximum width of the children in the current column to xPos (plus spacing).
Testing the WrapView
So now I have the WrapView control’s code in place I want to test it to see it if it will display simple tiles.
I modified MainViewModel to hold the data for the simplest tiles I could think of, a single number:
public class MainViewModel
{
public IEnumerable<int> Tiles { get; private set; }
public MainViewModel()
{
Tiles = Enumerable.Range(1, 8).ToList();
}
}
Now for the View. I modified MainView to reference to reference my WrapView by adding this attribute to the ContentPage element:
xmlns:lc="clr-namespace:SmartTiles.Controls;assembly=SmartTiles"
Then I added the control itself to the page specifying a DataTemplate to use for the tiles. The DataTemplate simply uses a Label control in a StackPanel appropriately sized with a blue background. I bound the WrapPanel ItemsSource to the Tiles property of the MainViewModel and the Label Text property to the items in the Tiles collection (i.e. the integers):
<lc:WrapView x:Name="DevicesPanel" Grid.Row="1" ItemsSource="{Binding Tiles}" Orientation="Vertical">
<lc:WrapView.ItemTemplate>
<DataTemplate>
<StackLayout BackgroundColor="Blue" WidthRequest="108" HeightRequest="108" Padding="6, 6, 6, 6">
<Label Text="{Binding}" FontSize="Large" XAlign="Center" TextColor="White"/>
</StackLayout>
</DataTemplate>
</lc:WrapView.ItemTemplate>
</lc:WrapView>I finished the View by putting this control in a Grid and adding a Label to display a title. The result can be seen here in this Windows Phone Emulator:
Conclusion
I now have a working WrapView control that supports DataTemplates but they are just displaying a boring digit. In my next post I’ll briefly step away from the UI code and start looking at how to connect to the SmartApp I built in my first post so I can get hold of some more interesting data.
The source code for this release can be found at https://github.com/JasonBSteele/SmartTiles/releases/tag/Post3
