TaskLoaderView & Lottie Best Friends Forever
You should all know Lottie by now!
If you don't let me just tell you that it's an amazing way to give an identity to your app.
Lottie is a cross platform library created by Twitter, then wrapped in a cool Xamarin.Forms
view by Martijn van Dijk. Thanks to Lottie, you can import as json the coolest animations created by your favorite designer.
For this, your best friend designer needs to create the animation in Adobe After Effects, and export it with the Bodymovin plugin.
Now if you don't have a designer "under the elbow" (popular french saying), you can browse the Lottie Files website and find amazing animations.
I chose the "Nostalgia Pack" by Elemental Concept, cause it matched the retro spirit of my retronado
app.
You can even edit the colors of the lottie files thanks to the Lottie Editor.
This is what I did here and changed the animation color to match my primary and background color.
The identity of your app
App identity should be a major phase in your app creation process.
My favorite kind of branding is the subtle one, the minimal one.
With one color and very few cool animations or icons (maybe a font), you could achieve great identity.
This is material design philosophy. Modern apps tend to follow that principle: icons are no longer shown in the application toolbar for example.
So where could we put our subtle animations?
- Loading screen!
- Error Screen!
- Empty Screen!
Fortunately, those views are all handled by the TaskLoaderView
making it super easy to implement.
At the same time Lottie
really shines at providing simple animations.
Your designer can create several animations with the Bodymovin plugin
and you can embed them so easily in your Xamarin.Forms
app. All you have to do is add the json files in your Assets
folder in Android, and Resources
folder in iOS.
remark: you can notice I didn't mention the Splash screen. If you can, it should be avoided to have a smoother ux.
Android Assets
iOS Resources
So let's start the fusion between the TaskLoaderView
and Lottie
!
TaskLoaderView and Lottie views
The TaskLoaderView
is a loading state container.
It provides you with views for each state of your ViewModel
:
- Loading
- Error
- ErrorOnRefresh
- Empty
- NotStarted
If you use the TaskLoaderView
in your views and the TaskLoaderNotifier
in your view models, you can say goodbye to all the properties like IsBusy, HasErrors, IsRefreshing, ErrorMessage
, ... Everything is handled gracefully thanks to composition.
You can find the github repo and package here: https://github.com/roubachof/Sharpnado.TaskLoaderView
The blog post about version 2.0 is here: https://www.sharpnado.com/taskloaderview-2-0-lets-burn-isbusy-true/
Now since version 2.0 of the TaskLoaderView
you can provide your own custom views for the loading states. We will use them to display the Lottie animations and level-up our retronado identity \o/
Now let's have a look at our Retronado
sample app and our Lottie page:
<customViews:TaskLoaderView Grid.Row="2"
Style="{StaticResource TaskLoaderStyle}"
TaskLoaderNotifier="{Binding Loader}">
<customViews:TaskLoaderView.LoadingView>
<lottie:AnimationView x:Name="LoadingLottie"
AbsoluteLayout.LayoutFlags="PositionProportional"
AbsoluteLayout.LayoutBounds="0.5, 0.4, 120, 120"
HorizontalOptions="FillAndExpand"
VerticalOptions="FillAndExpand"
Animation="{Binding Loader.ShowLoader, Converter={StaticResource CyclicLoadingLottieConverter}}"
AutoPlay="True"
Loop="True" />
</customViews:TaskLoaderView.LoadingView>
<customViews:TaskLoaderView.EmptyView>
<StackLayout AbsoluteLayout.LayoutFlags="PositionProportional"
AbsoluteLayout.LayoutBounds="0.5, 0.4, 300, 180">
<lottie:AnimationView HorizontalOptions="FillAndExpand"
VerticalOptions="FillAndExpand"
Animation="empty_state.json"
AutoPlay="True"
Loop="True" />
<Label Style="{StaticResource TextBody}"
HorizontalOptions="Center"
VerticalOptions="Center"
Text="{loc:Translate Empty_Screen}"
TextColor="White" />
<Button Style="{StaticResource TextBody}"
HeightRequest="40"
Margin="0,20,0,0"
Padding="25,0"
HorizontalOptions="Center"
BackgroundColor="{StaticResource TopElementBackground}"
Command="{Binding Loader.ReloadCommand}"
Text="{loc:Translate ErrorButton_Retry}"
TextColor="White" />
</StackLayout>
</customViews:TaskLoaderView.EmptyView>
<customViews:TaskLoaderView.ErrorView>
<StackLayout AbsoluteLayout.LayoutFlags="PositionProportional"
AbsoluteLayout.LayoutBounds="0.5, 0.4, 300, 180">
<lottie:AnimationView HorizontalOptions="FillAndExpand"
VerticalOptions="FillAndExpand"
Animation="{Binding Loader.Error, Converter={StaticResource ExceptionToLottieConverter}}"
AutoPlay="True"
Loop="True" />
<Label Style="{StaticResource TextBody}"
HorizontalOptions="Center"
VerticalOptions="Center"
Text="{Binding Loader.Error, Converter={StaticResource ExceptionToErrorMessageConverter}}"
TextColor="White" />
<Button Style="{StaticResource TextBody}"
HeightRequest="40"
Margin="0,20,0,0"
Padding="25,0"
HorizontalOptions="Center"
BackgroundColor="{StaticResource TopElementBackground}"
Command="{Binding Loader.ReloadCommand}"
Text="{loc:Translate ErrorButton_Retry}"
TextColor="White" />
</StackLayout>
</customViews:TaskLoaderView.ErrorView>
...
</customViews:TaskLoaderView>
Thanks to the TaskLoaderView
AbsoluteLayout
, we can position the views very easily.
To have a better experience, we're selecting the animation according to the Exception
type:
namespace Sample.Converters
{
public class ExceptionToLottieConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null)
{
return null;
}
string imageName;
switch (value)
{
case ServerException serverException:
imageName = "server_error.json";
break;
case NetworkException networkException:
imageName = "connection_grey.json";
break;
default:
imageName = "sketch_grey.json";
break;
}
return imageName;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
// One-Way converter only
throw new NotImplementedException();
}
}
}
And for the loading animation we're cycling between the 3 different ones:
namespace Sample.Converters
{
public class CyclicLoadingLottieConverter : IValueConverter
{
private int _counter = -1;
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null || value is bool showLoader && !showLoader)
{
return null;
}
_counter = ++_counter > 2 ? 0 : _counter;
switch (_counter)
{
case 0:
return "delorean_grey.json";
case 1:
return "joystick_grey.json";
case 2:
return "cassette_grey.json";
}
return "delorean_grey.json";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
// One-Way converter only
throw new NotImplementedException();
}
}
}
For the view model part, it's exactly the same than the others cases. The TaskLoaderNotifier
is doing wonders by handling all the properties and notify change events:
namespace Sample.ViewModels
{
public class RetroGamesViewModel : ANavigableViewModel
{
private readonly IRetroGamingService _retroGamingService;
private readonly ErrorEmulator _errorEmulator;
private GamePlatform _platform;
public RetroGamesViewModel(
INavigationService navigationService,
IRetroGamingService retroGamingService,
ErrorEmulator errorEmulator)
: base(navigationService)
{
_retroGamingService = retroGamingService;
_errorEmulator = errorEmulator;
ErrorEmulatorViewModel =
new ErrorEmulatorViewModel(errorEmulator, () => Loader.Load(InitializeAsync));
Loader = new TaskLoaderNotifier<List<Game>>();
}
public TaskLoaderNotifier<List<Game>> Loader { get; }
public ErrorEmulatorViewModel ErrorEmulatorViewModel { get; }
public override void Load(object parameter)
{
_platform = (GamePlatform)parameter;
Loader.Load(InitializeAsync);
}
private async Task<List<Game>> InitializeAsync()
{
Stopwatch watch = new Stopwatch();
watch.Start();
var result = _platform == GamePlatform.Computer
? await _retroGamingService.GetAtariAndAmigaGames()
: await _retroGamingService.GetNesAndSmsGames();
watch.Stop();
var remainingWaitingTime = TimeSpan.FromSeconds(4) - watch.Elapsed;
if (remainingWaitingTime > TimeSpan.Zero)
{
// Sometimes the api is too good x)
await Task.Delay(remainingWaitingTime);
}
switch (_errorEmulator.ErrorType)
{
case ErrorType.Unknown:
throw new InvalidOperationException();
case ErrorType.Network:
throw new NetworkException();
case ErrorType.Server:
throw new ServerException();
case ErrorType.NoData:
return new List<Game>();
case ErrorType.ErrorOnRefresh:
if (DateTime.Now.Second % 2 == 0)
{
throw new NetworkException();
}
throw new ServerException();
}
return result;
}
}
}