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:
- we are loading a list of retro game
- loading is successfull: the list is displayed
- we are refreshing the list
- oops an error occurs
- 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.