Thursday, January 10, 2019

Xamarin Forms: Lazy load tabs in TabbedPage

In Xamarin Forms, we sometimes need the TabbedPage to display a tab layout. You can checkout the great official documentation here about how to create and use a TabbedPage.

Problem: Every tab is a Page instance


The TabbedPage is a multi page container where every tab is a Page object. No matter how you create the tabs (either by setting the TabbedPage.Children page collection with Page instances or by setting TabbedPage.ItemsSource and TabbedPage.ItemTemplate) the TabbedPage needs a Page instance for every tab and uses the Page.Title property to display the text header on the tab button.

So if we have 2 tabs, we will need to have 2 Page objects. Creating and adding several pages at once can be a problem if some of the pages have a complex layout or do heavier tasks (display several images for example) and it could slow down rendering the TabbedPage. And if the TabbedPage is the start page of the app, the app will appear to start slow:

<TabbedPage>
    <ContentPage Title="First tab">
        <Label Text="First tab layout" />
    </ContentPage>

    <ContentPage Title="Second tab">
         <!--Complex and slow to render layout here -->
          …               
    </ContentPage>
</TabbedPage>
In the example above, assuming the second tab has a complex layout, it can slow down noticeably navigating to this TabbedPage, even if the TabbedPage has the first tab as the selected tab at start.

Solution: Lazy load the tab Page content


A solution is to make the heavy pages load their content in a lazy manner, only when their tab becomes selected. This way, since these pages are now empty when TabbedPage is created, navigating to the TabbedPage suddenly becomes very fast!

To implement this mechanism, we need to:
  1. Detect the tab selection in the TabbedPage
  2. Ask the selected Page to load its content
  3. Have a way for the Page to load its content on demand
Obviously, it makes sense to use this mechanism on any tab Page other than the first one, which is the default selected page. If the first tab page is the heaviest, it might be still worth to apply the mechanism on the other pages.

For the first two tasks, I created a behavior for the TabbedPage page, called ActivePageTabbedPageBehavior. This behavior subscribes to the CurrentPageChanged event of the attached TabbedPage and when it receives the event, if the selected Page implements the custom interface IActiveAware, it sets its IsActive property to true:

class ActivePageTabbedPageBehavior : Behavior<TabbedPage>
{
    protected override void OnAttachedTo(TabbedPage tabbedPage)
    {
        base.OnAttachedTo(tabbedPage);
        tabbedPage.CurrentPageChanged += OnTabbedPageCurrentPageChanged;
    }

    protected override void OnDetachingFrom(TabbedPage tabbedPage)
    {
        base.OnDetachingFrom(tabbedPage);
        tabbedPage.CurrentPageChanged -= OnTabbedPageCurrentPageChanged;
    }

    private void OnTabbedPageCurrentPageChanged(object sender, EventArgs e)
    {
        var tabbedPage = (TabbedPage)sender;

        // Deactivate previously selected page
        IActiveAware prevActiveAwarePage = tabbedPage.Children.OfType<IActiveAware>()
            .FirstOrDefault(c => c.IsActive && tabbedPage.CurrentPage != c);
        if (prevActiveAwarePage != null)
        {
            prevActiveAwarePage.IsActive = false;
        }

        // Activate selected page
        if (tabbedPage.CurrentPage is IActiveAware activeAwarePage)
        {
            activeAwarePage.IsActive = true;
        }
    }
}

The IActiveAware interface is very simple:

interface IActiveAware
{
    bool IsActive { get; set; }
    event EventHandler IsActiveChanged;
}

Behaviors are a great mechanism to reuse functionality in a composable way. The view activation pattern using IActiveAware above can be created for other page containers as well. How exactly the IActiveAware target (a Page in this case) is activated can differ, and this is what the TabbedPage behavior above encapsulates.

Next, we need to make the tab page activation aware and implement the last task, to load content in the selected tab page. For this, I created a base generic abstract class called LoadContentOnActivateBehavior which does two things:
  1. It starts listening to the IsActiveChanged event of the attached IActiveAware instance
  2. When IsActiveChanged event is received, the behavior creates a view from its ContentTemplate property and sets the view as content to the attached VisualElement
abstract class LoadContentOnActivateBehavior<TActivateAwareElement> : Behavior<TActivateAwareElement>
    where TActivateAwareElement : VisualElement
{
    public DataTemplate ContentTemplate { get; set; }

    protected override void OnAttachedTo(TActivateAwareElement element)
    {
        base.OnAttachedTo(element);
        (element as IActiveAware).IsActiveChanged += OnIsActiveChanged;
    }

    protected override void OnDetachingFrom(TActivateAwareElement element)
    {
        (element as IActiveAware).IsActiveChanged -= OnIsActiveChanged;
        base.OnDetachingFrom(element);
    }

    void OnIsActiveChanged(object sender, EventArgs e)
    {
        var element = (TActivateAwareElement)sender;
        element.Behaviors.Remove(this);
        SetContent(element, (View)ContentTemplate.CreateContent());
    }

    protected abstract void SetContent(TActivateAwareElement element, View contentView);
}

The specialized LazyContentPageBehavior below knows how to actually set the content on the ContentPage:

class LazyContentPageBehavior : LoadContentOnActivateBehavior<ContentView>
{
    protected override void SetContent(ContentView element, View contentView)
    {
        element.Content = contentView;
    }
}

Having all the above, we can now go from the initial TabbedPage setup:

<TabbedPage ...>
    <ContentPage Title="First tab">
        <Label Text="First tab layout" />
    </ContentPage>

    <ContentPage Title="Second tab">
         <!—-Complex and slow to render layout here –->
          …               
    </ContentPage>
</TabbedPage>

to this setup:

<TabbedPage.Behaviors>
     <local:ActivePageTabbedPageBehavior />
</TabbedPage.Behaviors>

<ContentPage Title="First tab">
     <Label Text="First tab layout" />
</ContentPage>

<local:LazyLoadedContentPage Title="Second tab">
     <ContentPage.Behaviors>
         <local:LazyContentPageBehavior ContentTemplate="{StaticResource ContentTemplate}" />
     </ContentPage.Behaviors>

     <ContentPage.Resources>
         <ResourceDictionary>
             <DataTemplate x:Key="ContentTemplate">
                 <!-- Complex and slow to render layout –>
                 …
             </DataTemplate>
         </ResourceDictionary>
     </ContentPage.Resources>
</local:LazyLoadedContentPage>

What happened is we moved the ContentPage complex layout to become a DataTemplate.  Here's the custom LazyLoadedContentPage page which is activation aware:

class LazyLoadedContentPage : ContentPage, IActiveAware
{
    public event EventHandler IsActiveChanged;

    bool _isActive;
    public bool IsActive
    {
        get => _isActive;
        set
        {
            if (_isActive != value)
            {
                _isActive = value;
                IsActiveChanged?.Invoke(this, EventArgs.Empty);
            }
        }
    }
}

When the LazyLoadedContentPage is activated (it becomes the current page selected in the TabbedPage), the LazyContentPageBehavior behavior (which is attached to the page) creates the page content from the DataTemplate instance.

Does view activation sound familiar?


If you’re using Prism for Xamarin Forms, Prism already has a similar mechanism, it has the IActiveAware interface and a TabbedPage behavior similar to the ActivePageTabbedPageBehavior above in order to ”activate” an activation aware page when it becomes selected in the TabbedPage. 

However, Prism doesn’t have a similar LazyContentPageBehavior behavior, so you can use my implementation.

Demo app


You can find a demo app with this mechanism and complete source code in my repo here: https://github.com/andreinitescu/TabbedPageLazyLoadApp

The app mimics a complex layout by blocking the main thread for few seconds, something you should NEVER do in a real app. Also, hopefully, you will never have a layout which takes 5 seconds to load 😃

    public partial class SlowContentView : ContentView
    {
        public SlowContentView()
        {
            InitializeComponent();

            // Simulating a complex view
            // NEVER do this in real code
            Task.Delay(TimeSpan.FromSeconds(5)).Wait();
        }
    }

No comments: