Xamarin.Forms LazyView: boost your app reactivity and startup time

Xamarin.Forms LazyView: boost your app reactivity and startup time

History behind it

Just a quick post today before the big one of Xamarin UI July :)

When I was coding the Exalt Training app, I had to face the sad reality of android startup time. Of course I fired up the magical AOT (not so magical for apk size), but the layout of the "run" section of the app was really complicated with a lot of views and animations, so I needed more than that.

For performance and reactivity reason, I also had to construct the landscape view when the "run" section was loaded. If I didn't do so, it induced a big lag when rotating the screen.

As you can see these are complex views, and by default the two others sections were loaded too (prepare, and analyze). When the app loaded, it had to build all the views behind those tabs.

Custom tabs

Something that is key in my opinion in Xamarin.Forms development, is to be the master of all your views. I mean, the more you will use existing high level xamarin forms components, the more it will enslave you (I'm not so sure about this master/slave analogy...).

For example, when you are using the built-it navigation bar, you will not be able to animate it easily, to make it transparent, to resize it dynamically, etc...

The same goes with TabbedPage, what if I want to use the well-known SPAM tabs for example ?

So being the master of your views implies more job at the beginning, you'll have to recreate those controls in pure xamarin forms. But it will save you tons of time when your designer will ask you to do this (even if it is a terrible UI anti-pattern, but that's another debate (that I lost, obviously)) :

LazyView

Being the master of your view implies also to control the whole lifecycle of it. I can decide to create, and initialize them when I want. In the Exalt Training case, I needed to delay the creation of all the views in the tabs, so the user wouldn't stare for 10 seconds at the splashscreen.

So I created a simple ContentView that is responsible for the loading of the real view.
Since the construction of the "run" view was really heavy (several seconds, with an empty CarouselView), I added an option to show an ActivityIndicator while constructing it.

Then you decide when you want to construct your inner view by calling the LoadView() method.

Here how I used them in the Exalt app main page:

<Grid ColumnSpacing="0" RowSpacing="0">
    <Grid.RowDefinitions>
        <RowDefinition Height="*" />
        <RowDefinition x:Name="BottomBarRowDefinition" Height="{StaticResource BottomBarHeight}" />
    </Grid.RowDefinitions>

    <tabs:ViewSwitcher x:Name="Switcher"
                       Grid.Row="0"
                       Animate="False"
                       SelectedIndex="{Binding SelectedViewModelIndex, Mode=TwoWay}">

        <!-- without lazy views
        <views:PreLivePage BindingContext="{Binding PreLiveViewModel}" />
        <views:LivePage BindingContext="{Binding LiveViewModel}" />
        <views:AnalysisPage BindingContext="{Binding AnalysisViewModel}" />
        -->

        <customViews:LazyView x:TypeArguments="views:PreLivePage" 
                              BindingContext="{Binding PreLiveViewModel}"
                              AccentColor="{StaticResource Accent}" />
        <customViews:LazyView x:TypeArguments="views:LivePage" 
                              BindingContext="{Binding LiveViewModel}" 
                              AccentColor="{StaticResource Accent}"
                              UseActivityIndicator=true />
        <customViews:LazyView x:TypeArguments="views:AnalysisPage" 
                              BindingContext="{Binding AnalysisViewModel}"
                              AccentColor="{StaticResource Accent}" />

    </tabs:ViewSwitcher>

    <mr:Grid x:Name="BottomBarContainer"
             Grid.Row="0"
             Grid.RowSpan="2"
             BackgroundColor="White"
             ColumnSpacing="0"
             RowSpacing="0"
             Swiped="BottomBarContainerGridOnSwiped">

        <Grid.RowDefinitions>
            <RowDefinition Height="65" />
            <RowDefinition x:Name="StatusBarHeight" Height="0" />
            <RowDefinition Height="40" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <BoxView Grid.Row="0" Style="{StaticResource DividerHorizontalTop}" />

        <tabs:TabHostView x:Name="TabHost"
                          Grid.Row="0"
                          SelectedIndex="{Binding Source={x:Reference Switcher}, Path=SelectedIndex, Mode=TwoWay}">
            <tabs:BottomTabItem IconImageSource="prepare.png" Label="{loc:Translate Tabs_Prepare}" />
            <tabs:BottomTabItem IconImageSource="run.png" Label="{loc:Translate Tabs_Run}" />
            <tabs:BottomTabItem IconImageSource="analysis.png" Label="{loc:Translate Tabs_Analyze}" />
        </tabs:TabHostView>

        <ffi:CachedImage Grid.Row="0"
                         Margin="0,10,0,0"
                         VerticalOptions="Start"
                         Style="{StaticResource CachedImageCenterTop}"
                         Source="m_108_n6_nav_tirette.png" />

        <ffi:CachedImage Grid.Row="2"
                         Style="{StaticResource CachedImageCenter}"
                         Source="m_108_n6_nav_sub_close.png" />

        <ContentView Grid.Row="1"
                     Grid.RowSpan="2"
                     effects:TapCommandEffect.Tap="{Binding CollapseMenuCommand}"
                     effects:ViewEffect.TouchFeedbackColor="{StaticResource ColorAccent}" />

        <views:MenuPage Grid.Row="3" BindingContext="{Binding MenuPageViewModel}" />

    </mr:Grid>

</Grid>

You can see that I'm only using the ActivityIndicator on the LivePage which is the children view of the "run" section in our example.

The code source is pretty simple and you can find it in the Sharpnado.Tabs library on github.

Here is the whole source:

using System;
using Xamarin.Forms;

namespace Sharpnado.Presentation.Forms.CustomViews
{
    public interface ILazyView
    {
        View Content { get; set; }

        Color AccentColor { get; }

        bool IsLoaded { get; }

        void LoadView();
    }

    public abstract class ALazyView : ContentView, ILazyView, IDisposable, IAnimatableReveal
    {
        public static readonly BindableProperty AccentColorProperty = BindableProperty.Create(
            nameof(AccentColor),
            typeof(Color),
            typeof(ILazyView),
            Color.Accent,
            propertyChanged: AccentColorChanged);

        public static readonly BindableProperty UseActivityIndicatorProperty = BindableProperty.Create(
            nameof(UseActivityIndicator),
            typeof(bool),
            typeof(ILazyView),
            false,
            propertyChanged: UseActivityIndicatorChanged);

        public static readonly BindableProperty AnimateProperty = BindableProperty.Create(
            nameof(Animate),
            typeof(bool),
            typeof(ILazyView),
            false);

        public Color AccentColor
        {
            get => (Color)GetValue(AccentColorProperty);
            set => SetValue(AccentColorProperty, value);
        }

        public bool UseActivityIndicator
        {
            get => (bool)GetValue(UseActivityIndicatorProperty);
            set => SetValue(UseActivityIndicatorProperty, value);
        }

        public bool Animate
        {
            get => (bool)GetValue(AnimateProperty);
            set => SetValue(AnimateProperty, value);
        }

        public bool IsLoaded { get; protected set; }

        public abstract void LoadView();

        public void Dispose()
        {
            if (Content is IDisposable disposable)
            {
                disposable.Dispose();
            }
        }

        protected override void OnBindingContextChanged()
        {
            if (Content != null && !(Content is ActivityIndicator))
            {
                Content.BindingContext = BindingContext;
            }
        }

        private static void AccentColorChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            var lazyView = (ILazyView)bindable;
            if (lazyView.Content is ActivityIndicator activityIndicator)
            {
                activityIndicator.Color = (Color)newvalue;
            }
        }

        private static void UseActivityIndicatorChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            var lazyView = (ILazyView)bindable;
            bool useActivityIndicator = (bool)newvalue;

            if (useActivityIndicator)
            {
                lazyView.Content = new ActivityIndicator
                {
                    Color = lazyView.AccentColor,
                    HorizontalOptions = LayoutOptions.Center,
                    VerticalOptions = LayoutOptions.Center,
                    IsRunning = true,
                };
            }
        }
    }

    public class LazyView<TView> : ALazyView
        where TView : View, new()
    {
        public override void LoadView()
        {
            IsLoaded = true;

            View view = new TView { BindingContext = BindingContext };

            Content = view;
        }
    }
}

Of course, to make it work with my custom tabs, I had to make the ViewSwitcher aware of the lazy view.
The following method is called when the SelectedIndex changed.

private void ShowView(View view, int viewIndex)
{
    var lazyView = view as ILazyView;
    if (lazyView != null)
    {
        if (!lazyView.IsLoaded)
        {
            lazyView.LoadView();
        }
    }

    view.IsVisible = true;

    if (Animate && view is IAnimatableReveal animatable && animatable.Animate && view.Opacity == 0)
    {
        var localView = view;
        NotifyTask.Create(
            async () =>
            {
                Task fadeTask = localView.FadeTo(1, 500);
                Task translateTask = localView.TranslateTo(0, 0, 250, Easing.CubicOut);

                await Task.WhenAll(fadeTask, translateTask);
                localView.TranslationY = 0;
                localView.Opacity = 1;
            });
    }

    if (lazyView != null)
    {
        view = lazyView.Content;
    }

    _activeView = view;
}

The ViewSwitcher and the TabHost are parts of the Sharpnado.Tabs nuget: Nuget

By using LazyView, the platform only needed to render the container view first, and all the heavy views where loaded later: when selected by the user. This resulted in a drastic improvement in startup time on android (4/5 seconds).

Be efficient, Be lazy!

Bonus

Did you know that you can do every Xamarin.Forms layout construction on a non-UI thread ?

You just have to make sure that you assign you view tree to your content root in the UI thread.

Here is the code of my LivePage code-behind:

public override void OnAppearing()
{
    base.OnAppearing();

    // BuildPage();
    if (!IsBuilt)
    {
        NotifyTask.Create(BuildPageIfNeededAsync);
        return;
    }

    OnAppearingInternal();
}

private async Task BuildPageIfNeededAsync()
{
    if (IsBuilt)
    {
        return;
    }

    await Task.Factory.StartNew(
        () =>
        {
            _landscapeView = new LiveLandscapeView();
            _landscapeView.SetBinding(BindingContextProperty, new Binding(nameof(ComparaisonViewModel)));

            _portraitView = new LivePortraitView();

            _rootLayout = new Grid { RowSpacing = 0, ColumnSpacing = 0 };

            _rootLayout.Children.Add(_landscapeView);
            _rootLayout.Children.Add(_portraitView);
        });

    if (ViewModel != null)
    {
        await ViewModel.InitializationTask.TaskCompleted;
        _rootLayout.BindingContext = ViewModel;
    }

    Content = _rootLayout;
    OnAppearingInternal();
}