TaskLoaderView 2.0: Let's burn IsBusy=true!

TaskLoaderView 2.0: Let's burn IsBusy=true!

This post is the natural follow-up to my Free Yourself From IsBusy=true from the XamExpertDay in Cologne:

https://twitter.com/Piskariov/status/1188825195831857153

The TaskLoaderView component is freeing itself from the Sharpnado.Presentation.Forms repo and is receiving a lot of new features!

  • User custom views
  • Skeleton loading
  • ErrorNotificationView
  • Loading on demand
https://github.com/roubachof/Sharpnado.TaskLoaderView

It has been tested on Android, iOS and UWP platforms through the Retronado app.

It now uses the Sharpnado's TaskMonitor instead of a modified version of the NotifyTask of Stephen Cleary.

The ViewModelLoader is changing its name to TaskLoaderNotifier. Cause now you can use it in any UI component or view model. And it describes better what it actually does: runs a task and raises properties according to the Task state. You can see it as a NotifyTask on steroids.

Introducing the Retronado app

The sample highlighting the possibilities of the TaskLoaderView is a tribute to the TOS of the Atari ST and its famous "busy bee".

It includes a random collection of retro games provided by the IGDB v3 API.

Android iOS UWP

What's new?

User custom views

You can now override any state views to implement your own:

LoadingView (busy bee) Result
ErrorView (atari st bombs) ErrorNotificationView (retro alert)
<sharpnado:TaskLoaderView x:Name="TaskLoaderView"
                            Grid.Row="3"
                            Style="{StaticResource TaskLoaderStyle}"
                            TaskLoaderNotifier="{Binding Loader}">

    <sharpnado:TaskLoaderView.LoadingView>
        <Image x:Name="BusyImage"
                AbsoluteLayout.LayoutFlags="PositionProportional"
                AbsoluteLayout.LayoutBounds="0.5, 0.5, 60, 60"
                Aspect="AspectFit"
                Source="{img:ImageResource Sample.Images.busy_bee_white_bg.png}" />
    </sharpnado:TaskLoaderView.LoadingView>

    <sharpnado:TaskLoaderView.ErrorView>
        <Grid AbsoluteLayout.LayoutFlags="PositionProportional"
                AbsoluteLayout.LayoutBounds="0, 0.5, 150, 90"
                Padding="15,0,0,0"
                BackgroundColor="White">
            <Grid.RowDefinitions>
                <RowDefinition Height="60" />
                <RowDefinition Height="30" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <Image Grid.Row="0"
                    Grid.Column="0"
                    Style="{StaticResource ErrorBombStyle}" />
            <Image Grid.Row="0"
                    Grid.Column="1"
                    Style="{StaticResource ErrorBombStyle}" />
            <Image Grid.Row="0"
                    Grid.Column="2"
                    Style="{StaticResource ErrorBombStyle}" />
            <Label Grid.Row="1"
                    Grid.Column="0"
                    Grid.ColumnSpan="3"
                    Style="{StaticResource TextBody}"
                    Text="{Binding Loader.Error, Converter={StaticResource ExceptionToErrorMessageConverter}}" />
        </Grid>
    </sharpnado:TaskLoaderView.ErrorView>

    <sharpnado:TaskLoaderView.ErrorNotificationView>
        <Grid x:Name="ErrorNotificationView"
                AbsoluteLayout.LayoutFlags="PositionProportional"
                AbsoluteLayout.LayoutBounds="0.5, 0.5, 300, 150"
                Scale="0">
            <Grid.Behaviors>
                <behaviors:TimedVisibilityBehavior VisibilityInSeconds="4" />
            </Grid.Behaviors>
            <Image Aspect="Fill" Source="{img:ImageResource Sample.Images.window_border.png}" />
            <Label Style="{StaticResource TextBody}"
                    Margin="{StaticResource ThicknessLarge}"
                    VerticalOptions="Center"
                    HorizontalTextAlignment="Center"
                    Text="{Binding Loader.Error, Converter={StaticResource ExceptionToErrorMessageConverter}}" />
        </Grid>
    </sharpnado:TaskLoaderView.ErrorNotificationView>

    <RefreshView Command="{Binding Loader.RefreshCommand}"
                    IsRefreshing="{Binding Loader.ShowRefresher}"
                    RefreshColor="{StaticResource AccentColor}">
        <ListView BackgroundColor="Transparent"
                    CachingStrategy="RecycleElementAndDataTemplate"
                    Header=""
                    ItemTemplate="{StaticResource GameDataTemplate}"
                    ItemsSource="{Binding Loader.Result}"
                    RowHeight="140"
                    SelectionMode="None"
                    SeparatorVisibility="None" />
    </RefreshView>
</sharpnado:TaskLoaderView>

You can see that the TaskLoaderView uses an AbsoluteLayout internally. So you can use AbsoluteLayout bounds and flags to position your views.

Support for Xamarin.Forms.Skeleton

Have you tried the Skeleton loading properties from Horus?

https://github.com/HorusSoftwareUY/Xamarin.Forms.Skeleton

It's brilliant! The TaskLoaderView is supporting a simpler use case of the properties by binding directly to the TaskLoaderNotifier. With this method you don't have to create fake item view models in your page view model.

In case of a list: you just have to create a static array of item view models.

<customViews:TaskLoaderView x:Name="GamesTaskLoader"
                            Grid.Row="2"
                            Style="{StaticResource TaskLoaderStyle}"
                            TaskLoaderNotifier="{Binding Loader}">
    <customViews:TaskLoaderView.LoadingView>
        <ListView Style="{StaticResource ListGameStyle}"
                    sk:Skeleton.Animation="Fade"
                    sk:Skeleton.IsBusy="{Binding Loader.ShowLoader}"
                    sk:Skeleton.IsParent="True"
                    ItemTemplate="{StaticResource GameSkeletonViewCell}"
                    ItemsSource="{x:Static views:Skeletons.Games}"
                    VerticalScrollBarVisibility="Never" />
    </customViews:TaskLoaderView.LoadingView>


    <RefreshView Command="{Binding Loader.RefreshCommand}"
                    IsRefreshing="{Binding Loader.ShowRefresher}"
                    RefreshColor="{StaticResource AccentColor}">
        <ListView Style="{StaticResource ListGameStyle}"
                    CachingStrategy="RecycleElementAndDataTemplate"
                    ItemTemplate="{StaticResource GameSkeletonViewCell}"
                    ItemsSource="{Binding Loader.Result}" />
    </RefreshView>
</customViews:TaskLoaderView>
public static class Skeletons
{
    public static Game[] Games = new[]
        {
            new Game(
                0,
                null,
                null,
                DateTime.Now,
                new List<Genre> { new Genre(1, "Genre genre") },
                new List<Company> { new Company(1, "The Company") },
                "Name name name",
                null),
            new Game(
                0,
                null,
                null,
                DateTime.Now,
                new List<Genre> { new Genre(1, "Genre genre") },
                new List<Company> { new Company(1, "The Company") },
                "Name name name",
                null),
            new Game(
                0,
                null,
                null,
                DateTime.Now,
                new List<Genre> { new Genre(1, "Genre genre") },
                new List<Company> { new Company(1, "The Company") },
                "Name name name",
                null),
        }
}

If you are not loading a list but a simple object, you don't even have to use a custom LoadingView, you can just use the TaskLoaderType="ResultAsLoadingView" property.

<sharpnado:TaskLoaderView Grid.Row="2"
                            Grid.Column="0"
                            Grid.ColumnSpan="2"
                            AccentColor="{StaticResource AccentColor}"
                            ErrorImageConverter="{StaticResource ExceptionToImageSourceConverter}"
                            ErrorMessageConverter="{StaticResource ExceptionToErrorMessageConverter}"
                            FontFamily="{StaticResource FontAtariSt}"
                            TaskLoaderNotifier="{Binding RandomGameLoader}"
                            TaskLoaderType="ResultAsLoadingView"
                            TextColor="Black">

    <Frame Style="{StaticResource CardStyle}"
            Margin="-15,0,-15,-15"
            Padding="0"
            skeleton:Skeleton.Animation="Beat"
            skeleton:Skeleton.IsBusy="{Binding RandomGameLoader.ShowLoader}"
            skeleton:Skeleton.IsParent="True"
            BackgroundColor="{DynamicResource CellBackgroundColor}"
            CornerRadius="10"
            IsClippedToBounds="True">
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="160" />
                <RowDefinition Height="40" />
                <RowDefinition Height="20" />
                <RowDefinition Height="20" />
            </Grid.RowDefinitions>
            <Image Grid.Row="0"
                    skeleton:Skeleton.BackgroundColor="{StaticResource GreyBackground}"
                    skeleton:Skeleton.IsBusy="{Binding RandomGameLoader.ShowLoader}"
                    Aspect="AspectFill"
                    Source="{Binding RandomGameLoader.Result.ScreenshotUrl}" />

            <Label Grid.Row="1"
                    Style="{StaticResource GameName}"
                    Margin="15,0"
                    skeleton:Skeleton.BackgroundColor="{StaticResource GreyBackground}"
                    skeleton:Skeleton.IsBusy="{Binding RandomGameLoader.ShowLoader}"
                    Text="{Binding RandomGameLoader.Result.Name}" />

            <Label Grid.Row="2"
                    Style="{StaticResource GameCompany}"
                    Margin="15,0"
                    skeleton:Skeleton.BackgroundColor="{StaticResource GreyBackground}"
                    skeleton:Skeleton.IsBusy="{Binding RandomGameLoader.ShowLoader}"
                    Text="{Binding RandomGameLoader.Result.MajorCompany}" />

            <Label Grid.Row="3"
                    Style="{StaticResource GameGenre}"
                    Margin="15,0"
                    Text="{Binding RandomGameLoader.Result.MajorGenre}" />
        </Grid>
    </Frame>

</sharpnado:TaskLoaderView>

Loading task on demand: NotStartedView

A new NotStartedView has been added so you can display a view before loading the Task.
It is quite useful for load-on-demand.

Here TaskLoaderType="ResultAsLoadingView" is set cause we are using the skeleton loading for just one object.

<sharpnado:TaskLoaderView Grid.Row="2"
                            Grid.Column="0"
                            Grid.ColumnSpan="2"
                            AccentColor="{StaticResource AccentColor}"
                            ErrorImageConverter="{StaticResource ExceptionToImageSourceConverter}"
                            ErrorMessageConverter="{StaticResource ExceptionToErrorMessageConverter}"
                            FontFamily="{StaticResource FontAtariSt}"
                            TaskLoaderNotifier="{Binding RandomGameLoader}"
                            TaskLoaderType="ResultAsLoadingView"
                            TextColor="Black">
    <sharpnado:TaskLoaderView.NotStartedView>
        <Button AbsoluteLayout.LayoutFlags="PositionProportional"
                AbsoluteLayout.LayoutBounds="0.5, 0.5, 120, 50"
                Style="{StaticResource ButtonTextIt}"
                Command="{Binding LoadRandomGameCommand}" />
    </sharpnado:TaskLoaderView.NotStartedView>

    <Frame Style="{StaticResource CardStyle}"
            Margin="-15,0,-15,-15"
            Padding="0"
            skeleton:Skeleton.Animation="Beat"
            skeleton:Skeleton.IsBusy="{Binding RandomGameLoader.ShowLoader}"
            skeleton:Skeleton.IsParent="True"
            BackgroundColor="{DynamicResource CellBackgroundColor}"
            CornerRadius="10"
            IsClippedToBounds="True">
        ...
    </Frame>
</sharpnado:TaskLoaderView
public class LoadOnDemandViewModel : Bindable
{
    private readonly IRetroGamingService _retroGamingService;

    public LoadOnDemandViewModel(IRetroGamingService retroGamingService)
    {
        _retroGamingService = retroGamingService;

        RandomGameLoader = new TaskLoaderNotifier<Game>();

        LoadRandomGameCommand = new Command(
            () => { RandomGameLoader.Load(GetRandomGame); });
    }

    public TaskLoaderNotifier<Game> RandomGameLoader { get; }

    public ICommand LoadRandomGameCommand { get; }

    private async Task<Game> GetRandomGame()
    {
        await Task.Delay(TimeSpan.FromSeconds(4));

        if (DateTime.Now.Millisecond % 2 == 0)
        {
            throw new NetworkException();
        }

        return await _retroGamingService.GetRandomGame();
    }
}

ErrorNotificationView

We tend to forget a state in our Task loading cycle: the notification view.

Consider this scenario:

  1. we are loading a list of retro game
  2. loading is successfull: the list is displayed
  3. we are refreshing the list
  4. oops an error occurs
  5. do we want to see the error view although the items were correctly loaded before?

NO! We just want to see a nice snackbar warning the user about it.

The ErrorNotificationView is also customizable if you like. It's brought to you with a nice TimedVisibilityBehavior so that you can specify how much time it needs to be shown to the user.

Default view User custom view
<sharpnado:TaskLoaderView.ErrorNotificationView>
    <Grid x:Name="ErrorNotificationView"
            AbsoluteLayout.LayoutFlags="PositionProportional"
            AbsoluteLayout.LayoutBounds="0.5, 0.5, 300, 150"
            Scale="0">
        <Grid.Behaviors>
            <behaviors:TimedVisibilityBehavior VisibilityInSeconds="4" />
        </Grid.Behaviors>
        <Image Aspect="Fill" Source="{img:ImageResource Sample.Images.window_border.png}" />
        <Label Style="{StaticResource TextBody}"
                Margin="{StaticResource ThicknessLarge}"
                VerticalOptions="Center"
                HorizontalTextAlignment="Center"
                Text="{Binding Loader.Error, Converter={StaticResource ExceptionToErrorMessageConverter}}" />
    </Grid>
</sharpnado:TaskLoaderView.ErrorNotificationView>

What's old?

Default state views

Of course you can still use the default views.
You can even mix user custom views and default views.

LoadingView Result
ErrorView ErrorNotificationView
<ContentPage.Resources>
    <ResourceDictionary>
        <Style x:Key="TaskLoaderStyle" TargetType="customViews:TaskLoaderView">
            <Setter Property="AccentColor" Value="{StaticResource AccentColor}" />
            <Setter Property="FontFamily" Value="{StaticResource FontAtariSt}" />
            <Setter Property="EmptyStateMessage" Value="{loc:Translate Empty_Screen}" />
            <Setter Property="EmptyStateImageSource" Value="{inf:ImageResource Sample.Images.dougal.png}" />
            <Setter Property="RetryButtonText" Value="{loc:Translate ErrorButton_Retry}" />
            <Setter Property="TextColor" Value="{StaticResource OnDarkColor}" />
            <Setter Property="ErrorImageConverter" Value="{StaticResource ExceptionToImageSourceConverter}" />
            <Setter Property="ErrorMessageConverter" Value="{StaticResource ExceptionToErrorMessageConverter}" />
            <Setter Property="BackgroundColor" Value="{StaticResource LightGreyBackground}" />
            <Setter Property="NotificationBackgroundColor" Value="{StaticResource TosWindows}" />
            <Setter Property="NotificationTextColor" Value="{StaticResource TextPrimaryColor}" />
        </Style>
    </ResourceDictionary>
</ContentPage.Resources>

...

<customViews:TaskLoaderView Grid.Row="2"
                            Style="{StaticResource TaskLoaderStyle}"
                            TaskLoaderNotifier="{Binding Loader}">
    <RefreshView Command="{Binding Loader.RefreshCommand}"
                    IsRefreshing="{Binding Loader.ShowRefresher}"
                    RefreshColor="{StaticResource AccentColor}">
        <ListView BackgroundColor="Transparent"
                    CachingStrategy="RecycleElementAndDataTemplate"
                    Header=""
                    ItemTemplate="{StaticResource GameDataTemplate}"
                    ItemsSource="{Binding Loader.Result}"
                    RowHeight="140"
                    SelectionMode="None"
                    SeparatorVisibility="None" />
    </RefreshView>
</customViews:TaskLoaderView>

RefreshCommand

Just bind the RefreshCommand to the RefreshView and IsRefreshing to the ShowRefresher property.

<RefreshView Command="{Binding Loader.RefreshCommand}"
                IsRefreshing="{Binding Loader.ShowRefresher}"
                RefreshColor="{StaticResource AccentColor}">
    <ListView Style="{StaticResource ListGameStyle}"
                CachingStrategy="RecycleElementAndDataTemplate"
                ItemTemplate="{StaticResource GameSkeletonViewCell}"
                ItemsSource="{Binding Loader.Result}" />
</RefreshView>

Reminder

For those who don't even know the TaskLoaderView and its TaskLoaderNotifier.

The TaskLoaderNotifier is a loading component for your tasks, and is commonly used in your view models.

public class RetroGamesViewModel : ANavigableViewModel
{
    private readonly IRetroGamingService _retroGamingService;

    public RetroGamesViewModel(
        INavigationService navigationService, 
        IRetroGamingService retroGamingService)
        : base(navigationService)
    {
        _retroGamingService = retroGamingService;

        RefreshCommand = new Command(() => Load(null));
        Loader = new TaskLoaderNotifier<List<Game>>();
    }

    public TaskLoaderNotifier<List<Game>> Loader { get; }

    public ICommand RefreshCommand { get; }

    public override void Load(object parameter)
    {
        // TaskStartMode = Manual (Default mode)
        Loader.Load(InitializeAsync);
    }

    private async Task<List<Game>> InitializeAsync()
    {
        ...
    }
}

And that's all. It wraps all the states of the task (NotStarted, Loading, Fault, Success).
You can just stop worrying about IsBusy, HasErrors, ErrorMessage, IsRefreshing...

You bind your TaskLoaderNotifier to your TaskLoaderView, and the magic happens.

<customViews:TaskLoaderView Grid.Row="2"
                            Style="{StaticResource TaskLoaderStyle}"
                            TaskLoaderNotifier="{Binding Loader}">
    <RefreshView Command="{Binding Loader.RefreshCommand}"
                    IsRefreshing="{Binding Loader.ShowRefresher}"
                    RefreshColor="{StaticResource AccentColor}">
        <ListView BackgroundColor="Transparent"
                    CachingStrategy="RecycleElementAndDataTemplate"
                    Header=""
                    ItemTemplate="{StaticResource GameDataTemplate}"
                    ItemsSource="{Binding Loader.Result}"
                    RowHeight="140"
                    SelectionMode="None"
                    SeparatorVisibility="None" />
    </RefreshView>
</customViews:TaskLoaderView>

And just with those 2 chunks of code you are now handling all the loading states of your view model :)

Why retro games?

Cause I'm old you disrespectful young animal!

Bubble Bobble Le manoir de Mortevielle Dungeon Master

I was trying to find an original way to represent loading, and then I remember the "busy bee" from the Atari TOS.
I was raised with an Atari 2600 then an Atari 520 ST.

Bombs represented loading bugs. They appeared a lot when you were copying games of a friend :)

Those bombs always frightened the shit out of me. First time I saw them I thought I started a nuclear war against USSR.