Animating the TabbedView

It’s been a while since my last post as, amongst other things, I’ve been busy working on my mountain weather app. So I thought it was about time I shared my progress with an update on my TabbedView.

Screen Shot 2015-04-19 at 11.30.14

In part 10 of my “Creating a Xamarin Forms App” series I introduced my TabbedView, which provides a really nice customized tabbed view that allows the tabs to scroll horizontally. I wanted to improve the transitions between the views when changing tabs because I was getting a small delay when tapping on the tab, which was quite evident on Android.

The crux of the problem is the TemplateContentView, taken from Xamarin Forms Labs, which I use to bind to the selected item. As the selected item changes the BindingContext of the TemplateContentView is updated. The problem with this is the control needs to rebind the template to the updated BindingContext and this seems to cause the lag. I also wanted to have a nice fading transition between the views when switching tabs and display a background image for each tab. This clearly wasn’t going to work with just a single TemplateContentView. I needed to rethink the TabbedView.

My first thought was to have two TemplateContentViews and switch between them as the selected item changed. This is quite a common technique used for animation transitions because it allows you to fade one view out whilst fading in the other. I found this problematic however as it didn’t really fix the lag issue and the fading transitions, particularly on Android, where not smooth. After much trail and error I found a better approach using the Grid control.

The Grid control can be much more useful than you might think. Not only can you use it to layout rows and columns, it is a great control for overlaying content. You can place any number of controls in any row/column and create really great effects using opacity. You don’t even need to define any rows or columns; you can use it as a single container to overlay content. This technique is quite commonly used in WPF and works equally well with XF.

You can get a similar effect using the AbsoluteLayout control but I find this tricky to work with because you have to define the positioning of every control, relative or absolute, and the syntax for this is both very verbose and difficult to follow.

The Grid however allows us to maintain a much better flow layout and used together with other flow layouts, like StackLayout, we can produce some really nice overlay effects in a very simple way. Not only that but it provides me with a nice simple solution I was looking for.

Let’s take a look at the Xaml for my original TabbedView.

<Grid 
  xmlns="http://xamarin.com/schemas/2014/forms" 
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
  x:Class="Silkweb.Mobile.Core.Views.TabbedView"
  xmlns:cv="clr-namespace:Silkweb.Mobile.Core.Views;assembly=Silkweb.Mobile.Core">

	<Grid.RowDefinitions>
		<RowDefinition Height="*" />
		<RowDefinition Height="Auto" />
	</Grid.RowDefinitions>

	<cv:TemplateContentView Grid.Row="0" x:Name="content" />
	<cv:ItemsView Grid.Row="1" x:Name="items" />
</Grid>

Here is my updated version replacing the TemplateContentView with a Grid and introducing another Grid for the background images.

<Grid
  xmlns="http://xamarin.com/schemas/2014/forms"
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  x:Class="Silkweb.Mobile.Core.Views.TabbedGridView"
  xmlns:cv="clr-namespace:Silkweb.Mobile.Core.Views;assembly=Silkweb.Mobile.Core">

  <Grid.RowDefinitions>
    <RowDefinition Height="*" />
    <RowDefinition Height="Auto" />
  </Grid.RowDefinitions>

  <Grid x:Name="backgroundGrid" Grid.Row="0" Grid.RowSpan="2" />
  <Grid x:Name="contentGrid" Grid.Row="0" />
  <cv:ItemsView x:Name="itemsView" Grid.Row="1" />

</Grid>

As you can see they are similar in that both have an outer Grid that defines two rows. The first being for the content and the second containing my ItemsView for the Tabs.

You can find more about ItemsView in my previous post here, but essentially it allows me to provide an ItemsSource and a DataTemplate which I can hook up to a StackLayout.

I have replaced the TemplateContentView with a Grid called contentGrid and added another Grid called backgroundGrid that will contain content for the background. Notice that this Grid has a RowSpan of 2 so that the background spans the entire control. What’s interesting is that both the contentGrid and the itemsView will be overlaid over the top of the background. Provided they have some transparency we will still see the background behind them.

Now let’s take a look at the code for this control.

    public partial class TabbedGridView : Grid
    {
        private readonly IDictionary<ISelectable, View> _views = new Dictionary<ISelectable, View>();

        public TabbedGridView()
        {
            InitializeComponent();
            itemsView.SelectedItemChanged += HandleSelectedItemViewChanged;
        }

        public static readonly BindableProperty ItemsSourceProperty =
            BindableProperty.Create<TabbedGridView, IEnumerable<ISelectable>>(p => p.ItemsSource,
                default(IEnumerable<ISelectable>),
                BindingMode.TwoWay, null, ItemsSourceChanged);

        public IEnumerable<ISelectable> ItemsSource
        {
            get { return (IEnumerable<ISelectable>)GetValue(ItemsSourceProperty); }
            set { SetValue(ItemsSourceProperty, value); }
        }

        public static readonly BindableProperty BackgroundTemplateProperty =
            BindableProperty.Create<TabbedGridView, DataTemplate>(p => p.BackgroundTemplate, default(DataTemplate),
                BindingMode.TwoWay);

        public DataTemplate BackgroundTemplate
        {
            get { return (DataTemplate)GetValue(BackgroundTemplateProperty); }
            set { SetValue(BackgroundTemplateProperty, value); }
        }

        public static readonly BindableProperty TabTemplateProperty =
            BindableProperty.Create<TabbedGridView, DataTemplate>(p => p.TabTemplate, default(DataTemplate),
                BindingMode.TwoWay, null, TabTemplateChanged);

        public DataTemplate TabTemplate
        {
            get { return (DataTemplate)GetValue(TabTemplateProperty); }
            set { SetValue(TabTemplateProperty, value); }
        }

        public static readonly BindableProperty ItemTemplateProperty =
            BindableProperty.Create<TabbedGridView, DataTemplate>(p => p.ItemTemplate, default(DataTemplate),
                BindingMode.TwoWay);

        public DataTemplate ItemTemplate
        {
            get { return (DataTemplate)GetValue(ItemTemplateProperty); }
            set { SetValue(ItemTemplateProperty, value); }
        }

        public static readonly BindableProperty ItemTemplateSelectorProperty =
            BindableProperty.Create<TabbedGridView, TemplateSelector>(p => p.ItemTemplateSelector,
                default(TemplateSelector), BindingMode.Default);

        public TemplateSelector ItemTemplateSelector
        {
            get { return (TemplateSelector)GetValue(ItemTemplateSelectorProperty); }
            set { SetValue(ItemTemplateSelectorProperty, value); }
        }

        public static readonly BindableProperty SelectedItemProperty =
            BindableProperty.Create<TabbedGridView, ISelectable>(p => p.SelectedItem, default(ISelectable),
                BindingMode.TwoWay, null, SelectedItemChanged);

        public ISelectable SelectedItem
        {
            get { return (ISelectable)GetValue(SelectedItemProperty); }
            set { SetValue(SelectedItemProperty, value); }
        }

        private static void ItemsSourceChanged(BindableObject bindable, IEnumerable<ISelectable> oldValue,
            IEnumerable<ISelectable> newValue)
        {
            if (Equals(newValue, oldValue)) return;
            var behavior = (TabbedGridView)bindable;
            behavior.SetItemsSource(newValue);
        }

        private static void SelectedItemChanged(BindableObject bindable, ISelectable oldValue, ISelectable newValue)
        {
            if (Equals(newValue, oldValue)) return;
            var behavior = (TabbedGridView)bindable;
            behavior.AddItemView(newValue);
        }

        private static void TabTemplateChanged(BindableObject bindable, DataTemplate oldValue, DataTemplate newValue)
        {
            var view = (TabbedGridView)bindable;
            view.itemsView.ItemTemplate = newValue;
        }

        private void SetItemsSource(IEnumerable<ISelectable> itemsSource)
        {
            if (itemsSource == null) return;
            var items = itemsSource as IList<ISelectable> ?? itemsSource.ToList();

            itemsView.ItemsSource = items;

            foreach (var selectable in items)
            {
                if (BackgroundTemplate != null)
                {
                    var backgroundView = GetBackgroundView(selectable);
                    backgroundGrid.Children.Add(backgroundView);
                }

                if (Device.OS != TargetPlatform.WinPhone)
                    AddItemView(selectable);
            }
        }

        private void AddItemView(ISelectable item)
        {
            View view;
            if (!_views.TryGetValue(item, out view))
            {
                view = ItemTemplate != null
                    ? ItemTemplate.CreateContent() as View
                    : ItemTemplateSelector != null
                        ? ItemTemplateSelector.ViewFor(item)
                        : null;

                if (view == null) return;

                view.BindingContext = item;

                AddFadeBehavior(view);

                contentGrid.Children.Add(view);
                _views.Add(item, view);
            }
        }

        private View GetBackgroundView(ISelectable item)
        {
            var view = BackgroundTemplate.CreateContent() as View;
            if (view == null) return null;

            view.BindingContext = item;
            AddFadeBehavior(view);
            return view;
        }

        private void AddFadeBehavior(View view)
        {
            var behavior = new FadeBehavior();
            view.Behaviors.Add(behavior);
            behavior.SetBinding(FadeBehavior.IsSelectedProperty, "IsSelected");
        }

        private void HandleSelectedItemViewChanged(object sender, EventArgs e)
        {
            SelectedItem = itemsView.SelectedItem as ISelectable;
        }
    }

Most of the code simply defines the following BindableProperties:

ItemsSource – The list of Selectable View Models we want to bind to.
BackgroundTemplate – The DataTemplate for the background.
TabTemplate – The DataTemplate for each Tab
ItemTemplate – The DataTemplate for the item content
ItemTemplateSelector – Optional Template Selector for the item content
SelectedItem – The currently selected item.

It’s also worth noting that the view models for each item must implement the ISelectable interface for this control, which is defined as:

    public interface ISelectable
    {
        bool IsSelected { get; set; }

        ICommand SelectCommand { get; set; }
    }

I have provided value changed handers for ItemsSource, SelectedItem and TabTemplate. The most significant of these is ItemsSourceChanged, which calls SetItemsSource. This wires up the ItemsSource of the itemsView for the Tabs and then iterates through the items. For each item a background is created from the BackgroundTemplate and is added to the backgroundGrid, then the content is created and added to the contentGrid using the AddItemView method. This creates the content either from the ItemTemplate or the ItemTemplateSelector, sets the BindingContext and adds a Fade Behavior before adding the control to the contentGrid. Also notice I am caching these views in a dictionary keyed by the item, more on this in a moment. But what is this Fade Behavior?

I have created a FadeBehavior using Behavior that allows me to apply a Fade animation whenever the IsSelected property changes. Let’s take a look at this.

    public class FadeBehavior : BindableBehavior<VisualElement>
    {
        public FadeBehavior()
        {
            FadeInAnimationLength = 250;
            FadeOutAnimationLength = 350;
        }
        public static readonly BindableProperty IsSelectedProperty =
            BindableProperty.Create<FadeBehavior, bool>(p => p.IsSelected, false, BindingMode.Default, null, IsSelectedChanged);

        private static void IsSelectedChanged(BindableObject bindable, bool oldvalue, bool newvalue)
        {
            FadeBehavior behavior = bindable as FadeBehavior;
            if (behavior == null || behavior.AssociatedObject == null) return;
            behavior.Animate();
        }

        private void Animate()
        {
            if (IsSelected)
                AssociatedObject.IsVisible = true;

            AssociatedObject.FadeTo(
                IsSelected ? 1 : 0, 
                IsSelected ? FadeInAnimationLength : FadeOutAnimationLength, 
                Easing.Linear)
                .ContinueWith(x =>
                {
                    if (!IsSelected)
                        AssociatedObject.IsVisible = false;
                }, TaskScheduler.FromCurrentSynchronizationContext());
        }

        public bool IsSelected
        {
            get { return (bool)GetValue(IsSelectedProperty); }
            set { SetValue(IsSelectedProperty, value); }
        }

        public uint FadeInAnimationLength { get; set; }

        public uint FadeOutAnimationLength { get; set; }

        protected override void OnAttachedTo(VisualElement visualElement)
        {
            base.OnAttachedTo(visualElement);
            visualElement.Opacity = 0;
            visualElement.IsVisible = false;
        }
    }

This defines an IsSelected BindableProperty with a change handler that applies a FadeTo animation when this property changes. FadeTo is part of a number of animation extension methods available with XF. Notice also that I am initialising the attached view with an opacity of 0 and setting it’s IsVisible property to false in the OnAttachedTo override. In the change handler IsVisible is set to true when IsSelected is true. The FadeTo animation is then applied. This either animates the Opacity to 1 or 0 depending on the newValue of IsSelected. Effectively fading in if selected or fading out if unselected. I have also provide properties to set the FadeIn and FadeOut Animation Length which I set defaults for. Interestingly FadeTo, along with the other animation extension methods, returns a Task. This means that we can provide a continuation when the animation is complete. In this case I want to set the IsVisible property to false when IsSelected equals false. But why bother setting the IsVisible property at all if we are already setting Opacity? The reason for this is two fold. It means that the view won’t get rendered in the visual tree but it will still get bound to it’s BindingContext. This provides a much smoother animation transition. And secondly it means that overlaid controls won’t obscure any gestures for the currently selected view. I discovered this to be true on both Android and Windows Phone where tap gestures didn’t work property even though an overlay had zero opacity. So better to ensure that any views which are not selected are not visible once the animation sequence has completed.

Back in the TabbedGridView I apply theFadeBahavior as follows:

    private void AddFadeBehavior(View view)
    {
        var behavior = new FadeBehavior();
        view.Behaviors.Add(behavior);
        behavior.SetBinding(FadeBehavior.IsSelectedProperty, "IsSelected");
    }

Notice here that I am binding the IsSelected property of the behavior to the IsSelected property of the view it is attached to. I apply the FadeBehavior to both the item content and also to the background content before they are added to the Grids. This means that initially these controls will have IsVisible set to false and have opacity set to zero, and both will Invoke the FadeTo animation when IsSelected changes.

Notice that in SetItemsSource I have applied a platform tweak for WinPhone. This does not add the item content for each item when the Item Source is set. I was getting some strange behavior doing this with Windows Phone. It caused no content to be displayed at all even when the FadeBehavior made the selected item visible. As an alternative the SelectedItemChanged handler also calls AddItemView which checks to see if the view has already been added by checking the views dictionary. First time the item is selected it will get created and added to the content grid. I could just do this for the other platforms but I get a smoother transition if I preload the views. I may take a further look at this later but for now this WinPhone platform tweak works ok and highlights how we can include these platform optimizations in our code.

Now all I need to do is replace my previous TabbedView with my new TabbedGridView. Here is the Xaml for my AreaForecastReportView in my Mountain Weather App.

<?xml version="1.0" encoding="utf-8"?>
<ContentPage
	xmlns="http://xamarin.com/schemas/2014/forms"
	xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
	x:Class="Silkweb.Mobile.MountainWeather.Views.AreaForecastReportView"
	xmlns:cv="clr-namespace:Silkweb.Mobile.Core.Views;assembly=Silkweb.Mobile.Core"
	xmlns:ex="clr-namespace:Silkweb.Mobile.Core.Extensions;assembly=Silkweb.Mobile.Core"
  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  xmlns:viewModels="clr-namespace:Silkweb.Mobile.MountainWeather.ViewModels;assembly=Silkweb.Mobile.MountainWeather"
  mc:Ignorable="d"
  d:DataContext="{d:DesignInstance Type=viewModels:AreaForecastReportViewModel}"
	Title="{Binding Title}" BackgroundColor="{StaticResource backgroundColor}">
  <ContentPage.Resources>
    <ResourceDictionary>
      
      <Style x:Key="tabGridStyle" TargetType="Grid">
        <Setter Property="BackgroundColor" Value="Transparent" />
        <Style.Triggers>
          <DataTrigger Binding="{Binding IsSelected}" TargetType="Grid" Value="False">
            <Setter Property="BackgroundColor" Value="{StaticResource highlightColor}" />
          </DataTrigger>
        </Style.Triggers>
      </Style>

      <DataTemplate x:Key="tabTemplate">
        <Grid WidthRequest="100" HeightRequest="100" Padding="1"
              d:DataContext="{d:DesignInstance Type=viewModels:WeatherDayViewModel}">
          <Grid Style="{StaticResource tabGridStyle}">
            <StackLayout Padding="5" Spacing="0">
              <Label Text="{Binding Date, StringFormat='{0:ddd}'}" Font="Mirco" VerticalOptions="Start" HorizontalOptions="Start"
                     TextColor="{ex:ApplicationResource navigationBarTextColor}" />
              <Label Text="{Binding Date, Converter={StaticResource dateTimeConverter}, ConverterParameter='d{0} MMM'}" Font="Mirco"
                     VerticalOptions="Start" HorizontalOptions="Start" TextColor="{ex:ApplicationResource navigationBarTextColor}" />
              <Image Source="{Binding Icon}" VerticalOptions="Start" HorizontalOptions="Center" WidthRequest="48" HeightRequest="48" />
            </StackLayout>
          </Grid>
        </Grid>
      </DataTemplate>

      <DataTemplate x:Key="itemTemplate">
        <cv:ViewLocatorControl />
      </DataTemplate>

      <DataTemplate x:Key="backgroundImageTemplate">
        <Grid d:DataContext="{d:DesignInstance Type=viewModels:WeatherDayViewModel}">
          <Image Source="{Binding BackgroundImage}" Aspect="AspectFill" />
          <BoxView BackgroundColor="{Binding Tint, Converter={StaticResource stringToColorConverter}}" />
        </Grid>
      </DataTemplate>

    </ResourceDictionary>
  </ContentPage.Resources>

  <ContentPage.ToolbarItems>
    <ToolbarItem Name="Hazards" Command="{Binding ShowHazardsCommand}" />
  </ContentPage.ToolbarItems>

  <Grid>
    <cv:TabbedGridView x:Name="tabbedGridView" ItemsSource="{Binding Items}"
                       TabTemplate="{StaticResource tabTemplate}" ItemTemplate="{StaticResource itemTemplate}"
                       BackgroundTemplate="{StaticResource backgroundImageTemplate}" />

    <ActivityIndicator IsRunning="{Binding IsBusy}" HorizontalOptions="Center" VerticalOptions="Center" />
    <cv:GradientBoxView StartColor="{StaticResource highlightColor}" EndColor="Transparent" HeightRequest="75" VerticalOptions="Start" />
  </Grid>
</ContentPage>

I have defined 3 Data Template’s for the TabTemplate, ItemTemplate and BackgroundTemplate in the resources. The content defines the TabbedGridView and sets the Templates accordingly. I also bind the ItemsSource to an Items property in my view model.

Note the use of d:DataContext which gives me design time intellisense support for my bindings. More on this in my previous post here.

The TabTemplate is the same as the previous version. The new Background Template defines a grid containing an Image bound to the BackgroundImage property on my ViewModel and a BoxView with a BackgroundColor with a Tint colour which is bound to a Tint property on my view model. This allows me to apply differing Tints to the background. This is a popular technique that darkens the image so that white text overlaid over the background is easier to read.

The ItemTemplate is of particular interest here. Let’s take a closer look at this.

      <DataTemplate x:Key="itemTemplate">
        <cv:ViewLocatorControl />
      </DataTemplate>

This contains just one control called ViewLocatorControl. This clever little control is responsible for resolving the correct view that has been registered with my ViewFactory from the bound view model. If you are not familiar with my view factory then it’s worth taking a look at my previous post on View Model First Navigation.

Let’s have a quick look at the code for the ViewLocatorControl.

    public class ViewLocatorControl : ContentView
    {
        private readonly IViewFactory _viewFactory;

        public ViewLocatorControl()
        {
            _viewFactory = ViewFactory.Instance;
            BindingContextChanged += OnBindingContextChanged;
        }

        private void OnBindingContextChanged(object sender, EventArgs eventArgs)
        {
            View view = _viewFactory.Resolve(BindingContext as IViewModel) as View;
            Content = view;

        }
    }

As you can see all this does is use the ViewFactory to resolve the view when the BindingContext changes and sets the content to the resolved view.

This required a slight modification to the ViewFactory to register and resolve a VisualElement instead of Page. This allow me to register and resolve both Views and Pages, which is much more flexible. In this case there are two different views registered with two different view models. This are registered like this.

    viewFactory.Register<AreaForecastViewModel, AreaForecastView>();
    viewFactory.Register<OutlookViewModel, OutlookView>();

This is really a nice alternative to defining a Template Selector using the TemplateSelector class from Xamarin Forms Labs which I was using previously.

The resolved view will then be overlaid over the background image to produce a really nice effect. The views also add some additional opacity effects over the content to produce a further effect.

Now lets see it in action with the following videos showing the animation transition effects as I switch tabs on all 3 platforms.

I think the result looks really nice and shows what is possible using Grid overlays and just a few simple controls.

I really like the results and hope you do to. This post shows some really nice things that are possible with Xamarin Forms without resorting to Custom Renderers.

9 thoughts on “Animating the TabbedView

  1. Looks very nice, but I’ve tried it also and can’t find the base class “BindableBehavior” for the FadeBehavior. It seems that Xamarin.Forms only has Behavior as a class (and that one then doesn’t have the AssociatedObject property)…where to find it ?

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s