Wednesday, October 1, 2014

Xamarin Forms Reusable Custom Content Views

There is a lot (relatively) of information on how to make reusable native controls with renderers and native views. However there is next to nothing about how to make reusable controls which don't require renderers or native views.

[Edit 2014-12-05] The project I developed required the same header on each page, so I figured I would use that as a way to describe how to create resuable controls. Performance-wise, it shouldn't be any worse than loading the information on every page. I needed the NavigationPage functionality, but without the navigation bar. It is probably not ideal, but it is what the design imposed.

Let's assume that we have the following XAML which needs to be replicated on several pages.
<Grid x:Name="SharedHeaderBar" HorizontalOptions="FillAndExpand" Padding="5,10,5,-5" BackgroundColor="#3FB5C1" >
    <Grid.RowDefinitions>
      <RowDefinition Height="45" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="Auto" />
      <ColumnDefinition Width="*" />
      <ColumnDefinition Width="Auto" />
    </Grid.ColumnDefinitions>
    <Image x:Name="homeImageiOS" Source="{local:ImageResource IntPonApp.Resources.IntPon-Logo.png}" Grid.Row="0" Grid.Column="0" />
    <Button x:Name="logoffButtoniOS" Text="{Binding Path=FullName}" TextColor="#FFFFFF" Grid.Row="0" Grid.Column="2" />
  </Grid>
My first foray into this was unsuccessful because I tried using a View. I stumbled upon the ContentView. I couldn't find any examples of people using it. Having had a lot of experience with WPF and knowing what I wanted to achieve, this seemed to be the most promising path.

I created a new Forms Xaml Page and changed the ContentPage tags to ContentView. Then I moved the reused XAML into the ContentView.
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
        xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:IntPonApp;assembly=IntPonApp"
        x:Class="IntPonApp.Controls.SharedHeaderView">
  
  <Grid x:Name="SharedHeaderBar" HorizontalOptions="FillAndExpand" Padding="5,10,5,-5" BackgroundColor="#3FB5C1" >
    <Grid.RowDefinitions>
      <RowDefinition Height="45" />
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="Auto" />
      <ColumnDefinition Width="*" />
      <ColumnDefinition Width="Auto" />
    </Grid.ColumnDefinitions>
    <Image x:Name="homeImageiOS" Source="{local:ImageResource IntPonApp.Resources.IntPon-Logo.png}" Grid.Row="0" Grid.Column="0" />
    <Button x:Name="logoffButtoniOS" Text="{Binding Path=FullName}" TextColor="#FFFFFF" Grid.Row="0" Grid.Column="2" />
  </Grid>

</ContentView>
It is important to understand the state of the binding context. The binding context will be inherited from the parent control where it is used. This is very convenient because I wanted to pull a value from the ContentPage's ViewModel.

I pulled over the event bindings and the needed navigation functions. Part of the functionality that I wanted to move into the ContentView needed to raise a DisplayAlert which, as it turns out, isn't available on the ContentView element. So I flexed my recursive skills and created the following to get the ContentPage element.
public ContentPage FindParentPage(Element el = null)
{
    if (el == null)
        el = this;
    return 
          (el is ContentPage) ? (ContentPage)el
        : (el.Parent != null) ? FindParentPage(el.Parent) 
        : null;
}
Then I was able to display alert messages.

Below is the resulting code behind.
public partial class SharedHeaderView
{
    public SharedHeaderView()
    {
        InitializeComponent();

        string platformName = Device.OS.ToString();

        this.FindByName<Button>("logoffButton" + platformName)
            .Clicked += OnLogoffClicked;
        this.FindByName<Image>("homeImage" + platformName)
            .GestureRecognizers.Add(new TapGestureRecognizer((view, args) =>
        {
            this.Navigation.PopToRootAsync();
        }));
    }

    protected async void OnLogoffClicked(object sender, EventArgs e)
    {
        if (!string.IsNullOrEmpty(App.ApiKey))
        {
            string errorMessage = "";
            try
            {
                if (await FindParentPage()
                    .DisplayAlert("Sign Off", "Are you sure?", "Yes", "No"))
                {
                    App.Logout();
                }
            }
            catch (Exception ex)
            {
                errorMessage = ex.Message;
            }

            if (!string.IsNullOrEmpty(errorMessage))
            {
                await FindParentPage()
                    .DisplayAlert("Help", errorMessage, "OK");
            }
        }
    }

    public ContentPage FindParentPage(Element el = null)
    {
        if (el == null)
            el = this;
        return 
              (el is ContentPage) ? (ContentPage)el
            : (el.Parent != null) ? FindParentPage(el.Parent) 
            : null;
    }
}
Then you add the XML namespace to the ContentPage declaration and add the XML node.
xmlns:localcontrol="clr-namespace:IntPonApp.Controls;assembly=IntPonApp"
<localcontrol:SharedHeaderView />
Getting this to work has really saved me a lot of unneeded duplication.

Edit 2014-12-05: Edited to add comment/response/excuse to a question on StackOverflow: Is it possible to create a custom page layout with Xamarin.Forms?

2 comments:

Unknown said...

Hi,

Does this have an example project on GitHub?

Brock said...

That is a very good idea, but I do not have a sample project to demonstrate this. Basically, you can follow the same patterns that allows you to use custom views in WPF/XAML. It is rather disappointing that Xamarin doesn't have examples showing people how to do this. Sometimes I wonder if Xamarin knows that this is possible.

If I get sometime in the next week or so, I will create a sample project and post it to GitHub.