Sunday, 5 July 2015

Xamarin.Forms Tiled UI - Part 3

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:

image

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:

image

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 so that it can override the necessary methods to support laying out the items:

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).
  • ItemTemplate can 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.
  • ItemSource is the IEnumerable source of the items. I will be assigning an ObservableCollection to this so that the control can be updated dynamically.
  • TemplateSelector is of type TemplateSelector which 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:

WinPhone

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

4 comments:

  1. What an awesome and very nice post. I just stumbled upon your weblog and wanted to say that I’ve really enjoyed browsing your blog posts.

    In any case I will be subscribing to your rss feed and I hope you write again very soon!


    pave tile - official website

    ReplyDelete
    Replies
    1. Many thanks for your encouraging words! Unfortunately I wrote this some time ago meaning to keep them up, but circumstances changed and I ended up focusing on other things.

      But your comment has made me consider taking up Blogging again - it probably won't be more in this series, but with my day job about to change, I may start something new. Thanks again.

      Delete
    2. hi jason..

      that nice to hear.. hoping to read more blog post..



      site

      Delete
  2. nice blog has been shared by you.before i read this blog i didn't have any knowledge about this but now i got some knowledge. so keep on sharing such kind of an interesting blog.
    xamarin development company
    hire xamarin developer india

    ReplyDelete