Matt Goldman revived XamarinUIJuly and renamed it to MAUIUIJuly, where each day in July someone from the .NET MAUI community publishes a blog post or video showing some incredible UI magic in MAUI. In this contribution I will show you how to combine Lottie animations with gestures and scrollable containers to spice up your .NET MAUI App UI!
A Lottie is a JSON-based animation file format that enables designers to ship animations on any platform as easily as shipping static assets. They are small files that work on any device and can scale up or down without pixelation.
Many of you might already be familiar with Lottie animations. For those who are not here is the official guide to Lottie.
Unfortunately, at the time of writing there is no fully functional Lottie SDK wrapper for .NET MAUI. There have been some efforts to migrate LottieXamarin to .NET MAUI, which proved to be quite cumbersome.
An alternative could be to use Skottie, which uses SkiaSharp to render Lottie animations, however I was unable to get the preview to run, so I had to find another solution.
I stumbled upon Csaba8472's repository, where he was building his own Lottie binding for MAUI using the "Slim Binding" approach. When I started working on this post the implementation was incomplete, so that I decided to do my own "Slim Binding" (more on this pattern here).
Exactly one day before the release of this blog post Csaba8472 released his contribution to #MAUIUIJuly, in which he goes over his Slim Binding. You should definitely check it out, since I will not go into more details on the binding in this post. Binding Lottie (or any other Swift framework with UI) in MAUI
Since I wanted to focus on how to combine Lottie animations with gestures and scrollable containers, I will neither dive into the binding nor the creation of the custom handler. I also only provide a working binding for Android. Once there is a stable, fully functional .NET MAUI wrapper for Lottie, I will migrate my samples over to it and release a new blog post. So make sure to follow @CayasSoftware and @f_goncalves_ on twitter.
Let's get started!
This is the classic. You tap on a control which triggers the Lottie animation. Useful for many scenarios, from simple buttons, to a 'like' or 'bookmark' interaction.
Lottie animation triggered by a tap in action
We only need to add the LottieView to our XAML, give it a name and attach a TapGestureRecognizer to it:
<controls:LottieView x:Name="HeartLottieView"
Grid.Column="2"
WidthRequest="100"
HeightRequest="100"
HorizontalOptions="Center"
VerticalOptions="Center"
RepeatCount="0"
MaxFrame="20"
Margin="8,8">
<controls:LottieView.GestureRecognizers>
<TapGestureRecognizer Tapped="TapGestureRecognizer_Tapped" />
</controls:LottieView.GestureRecognizers>
</controls:LottieView>
Then we can implement the Tapped event in our code behind and make the animation play:
void TapGestureRecognizer_Tapped(System.Object sender, System.EventArgs e)
{
HeartLottieView.IsPlaying = true;
}
Since the RepeatCount is set to 0, the animation will only play once and then stop on it's last frame (in this case frame 20, since the full animation includes several similiar frames at the end which I wanted to leave out).
There might be other scenarios where running an animation only for the duration that a user is interacting with our app can enhance the user experience. This can be 'charging up' via tap and hold (shown here), but can also be applicable to advanced scenarios e.g. during fingerprint or face scanning. These scenarios have in common that there is a 'play' trigger and a 'pause' trigger.
Lottie animation triggered by a tap and hold in action
<Grid ColumnDefinitions="*,1,Auto"
RowDefinitions="Auto,Auto">
<Button Grid.Column="0"
Grid.Row="0"
Grid.RowSpan="2"
Text="Tap and hold here!"
Pressed="Button_Pressed"
Released="Button_Released"/>
<BoxView Grid.Column="1"
Grid.Row="0"
Grid.RowSpan="2"
WidthRequest="1"
VerticalOptions="FillAndExpand"
Color="DarkGray" />
<controls:LottieView x:Name="AcceptLottieView"
Grid.Row="0"
Grid.Column="2"
WidthRequest="100"
HeightRequest="100"
HorizontalOptions="Center"
VerticalOptions="Center"
RepeatCount="0"
MinFrame="1"
MaxFrame="45"
Margin="8,8" />
<Label x:Name="AcceptProgress"
Grid.Row="1"
Grid.Column="2"
FontSize="Micro"
Text="0%"
HorizontalTextAlignment="Center"
HorizontalOptions="Fill" />
</Grid>
Here we will use the Pressed and Released events of a Button to control the current rendered Frame of our LottieView and also show the progress of the action in a label. Events are implemented in the code behind:
void InitAccept()
{
_tapAndHoldTimer = new System.Timers.Timer
{
Interval = 1000 / 30 //Animation should have 30FPS
};
_tapAndHoldTimer.Elapsed += _tapAndHoldTimer_Elapsed;
}
void Button_Pressed(System.Object sender, System.EventArgs e)
{
if (AcceptLottieView.Progress >= 1)
AcceptLottieView.Progress = 0;
AcceptProgress.Text = $"{(int)(AcceptLottieView.Progress * 100)}%";
_tapAndHoldTimer.Start();
}
void Button_Released(System.Object sender, System.EventArgs e)
{
_tapAndHoldTimer.Stop();
}
void _tapAndHoldTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
if (AcceptLottieView?.Animation == null)
return;
if (AcceptLottieView.Progress + 100f / AcceptLottieView.MaxFrame / 100f > 1)
{
_tapAndHoldTimer.Stop();
AcceptLottieView.Progress = 1;
}
else
{
AcceptLottieView.Progress += 100f / AcceptLottieView.MaxFrame / 100f;
}
AcceptProgress.Dispatcher.Dispatch(() =>
{
AcceptProgress.Text = $"{(int)(AcceptLottieView.Progress * 100)}%";
});
}
I initialize a timer with an interval that matches 30 frames per second. The timer is started when the button is pressed and stopped when the button is released. When the timer elapses I calculate the new progress of the animation and update the label. If the new progress is greater than 1 (= 100%), the timer is stopped, the progress is set to 1 and the animation is finished.
View the animation on lottiefiles
You can also use IsPlaying on the LottieView to control play and pause of the animation so that you don't need the timer. But to be able to show the progress in the label and also to show another approach to controlling the animation I chose to do it this way.
Everyone is familiar with the standard terms of service page. A scrollable wall of text that you read and accept in a matter of seconds ;-). Occasionally with the twist, that you need to scroll all the way to the bottom, to enable the 'accept' button. I will use this example to display how you can control your Lottie animations based on the current scroll position of a ScrollView.
Lottie animation controlled by scroll position of a ScrollView
In this sample we will use a simple Label for the text, one LottieView for the button that will appear the closer we scroll to the bottom (https://lottiefiles.com/animations/button-reveal-light-theme-3FhFGoLZ3H) and, as an extra, a LottieView prompting the user to scroll down if he didn't already (https://lottiefiles.com/animations/button-scroll-down-iva7BNRqxF).
<Grid RowDefinitions="*,Auto">
<ScrollView x:Name="ToSScrollView"
Grid.Row="0">
<Label x:Name="TermsOfUseLabel"/>
</ScrollView>
<controls:LottieView x:Name="StartLottieView"
Grid.Row="1"
HorizontalOptions="Center"
HeightRequest="100"
RepeatCount="0"
MinFrame="5"
MaxFrame="30"
Margin="8,8"/>
<controls:LottieView x:Name="ScrollDownLottieView"
Grid.Row="0"
HorizontalOptions="Center"
VerticalOptions="End"
RepeatCount="-1"
HeightRequest="50"
WidthRequest="50" />
</Grid>
In the code behind of the page we will subscribe to the Scrolled event of our ScrollView and calculate the current frame of the animation based on the current scroll position.
private void ToSScrollView_Scrolled(object sender, ScrolledEventArgs e)
{
if (_isScolledToBottom)
return;
var heightDifference = ToSScrollView.ContentSize.Height - ToSScrollView.Height;
var tolerance = 50; //50 Pixel tolerance to reaching the bottom of the ScrollView
if (e.ScrollY <= heightDifference - tolerance)
{
ScrollDownLottieView.IsPlaying = true;
ScrollDownLottieView.IsVisible = true;
}
else
{
ScrollDownLottieView.IsPlaying = false;
ScrollDownLottieView.IsVisible = false;
_isScolledToBottom = true;
}
var percentageScrolled = e.ScrollY / heightDifference;
StartLottieView.Frame = (int)(StartLottieView.MinFrame + (StartLottieView.MaxFrame - StartLottieView.MinFrame) * percentageScrolled);
}
We calculate the distance that can be scrolled by using the height of the content and the height of the ScrollView. Based on how much of that distance was scrolled already we calculate the frame that represents that progress. Because the scrolling does not appear to be pixel perfect, we will use a tolerance, when checking if the bottom of the ScrollView is already reached. If the bottom was reached we hide and stop the animation of the arrow that prompts the user to scroll down. To avoid that the button disappears again when scrolling back up, we store and check if the bottom was reached already.
In our timetracking App TimePunch Mobile, which was built with Xamarin.iOS and Xamarin.Android, we got the following animations when swiping between pages in the overview:
Lottie animations in TimePunch Mobile
Since we are considering migrating/rewriting TimePunch Mobile to/with .NET MAUI, I wanted to check if we could also implement above animations in .NET MAUI.
Why shouldn't it work? All we need is a CarouselView which propagates the Scroll events and a working Lottie animation. As a bonus I decided to add a Lottie animation as an indicator, to show that this it is also possible.
<Grid RowDefinitions="*,100">
<CarouselView x:Name="Carousel"
PeekAreaInsets="200"
Loop="False"
ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource AnimationItemTemplateSelector}"
Scrolled="CarouselView_Scrolled"/>
<controls:LottieView x:Name="Indicator"
Grid.Row="1"
HeightRequest="100"
HorizontalOptions="Center" />
</Grid>
In the XAML code you can see that I added the CarouselView, bound the ItemsSource property to the Items property in the code behind and subscribe to the Scrolled event. For the ItemTemplate I use a DataTemplateSelector, we'll see later why. Then in the row below the CarouselView, I added a LottieView which will serve as an indicator.
public ObservableCollection<CarouselAnimation> Items { get; set; } = new ObservableCollection<CarouselAnimation>();
public abstract class CarouselAnimation : INotifyPropertyChanged
{
public abstract event PropertyChangedEventHandler PropertyChanged;
public abstract void UpdateAnimation(double offsetFromCenter);
}
In code I defined an abstract class CarouselAnimation that implements INotifyPropertyChanged and contains an abstract method UpdateAnimation, which will receive a double indicating the offset from center for that item. The Items property in the code behind is an ObservableCollection
Once the page appears Items is filled with instances of subclasses of CarouselAnimation and the indicator animation is loaded.
In the event handler for the Scrolled event, we handle the calculation for the item offsets from the center and pass this into the UpdateAnimation methods of the CarouselAnimation instances. I also adjust the frame of the IndicatorView according to the HorizontalScrollOffset to represent the current index.
void CarouselView_Scrolled(System.Object sender, Microsoft.Maui.Controls.ItemsViewScrolledEventArgs e)
{
if (e.CenterItemIndex < 0 || e.CenterItemIndex >= Items.Count)
return;
var itemWidth = 200d;
var centerItemOffset = e.HorizontalOffset - e.CenterItemIndex * itemWidth;
//centerItemOffset -> centerItemOffsetFactor function
//0 - 100 -> -1 -> 0
//100 - 200 -> 0 -> 1
var centerItemOffsetFactor = (itemWidth / 2 - centerItemOffset) / (itemWidth / 2);
Items[e.CenterItemIndex].UpdateAnimation(centerItemOffsetFactor);
const int indicatorStopFrameDistance = 35; //Frames between each fully colored bubble in the indicator
Indicator.Frame = (int)((e.HorizontalOffset - itemWidth/2) / itemWidth * indicatorStopFrameDistance);
}
The variable centerItemOffsetFactor will be
Of course, it will also take values inbetween the above, so that we will get a fluid animation.
Why do I have subclasses of CarouselAnimation? One of the animations shown in TimePunch is different in that it has 2 animations layered over each other which also move in opposite directions. Therefore, I need to handle the UpdateAnimation method differently. In short there is the SingleLayerCarouselAnimation class and DoubleLayerCarouselAnimation class (I left out standard boiler plate code. For full code, have a look at the repository.)
public class SingleLayerCarouselAnimation : CarouselAnimation
{
public string Animation { get; set; }
public int MinFrame { get; set; }
public int MaxFrame { get; set; }
public int Frame { get; set; }
public SingleLayerCarouselAnimation(bool inverted = false)
{
_inverted = inverted;
}
//offsetFromCenter is
//-1 when fully left from the center
// 0 when in the center
// 1 when fully right from the center
public override void UpdateAnimation(double offsetFromCenter)
{
//In this example we assume the center is exaclty between Min- and MaxFrame
//However, below calculations would also work for uneven animations
var centerFrame = (MaxFrame - MinFrame) / 2;
if (_inverted)
offsetFromCenter *= -1;
if (offsetFromCenter >= 0)
Frame = (int)(centerFrame + (MaxFrame - centerFrame) * offsetFromCenter);
else
Frame = (int)(centerFrame + (centerFrame - MinFrame) * offsetFromCenter);
}
}
The main logic is in UpdateAnimation where the current frame for the animation is calculated based upon offsetFromCenter. The properties will be bound by the DataTemplate.
<DataTemplate x:DataType="demo:SingleLayerCarouselAnimation">
<controls:LottieView WidthRequest="200"
HeightRequest="200"
HorizontalOptions="Center"
VerticalOptions="Center"
Animation="{Binding Animation}"
MinFrame="{Binding MinFrame}"
MaxFrame="{Binding MaxFrame}"
Frame="{Binding Frame}"/>
</DataTemplate>
For the DoubleLayerCarouselAnimation I could just combine two SingleLayerCarouselAnimations and layer the animations on top of eachother in the DataTemplate.
public class DoubleLayerCarouselAnimation : CarouselAnimation
{
public SingleLayerCarouselAnimation Layer1 { get; set; }
public SingleLayerCarouselAnimation Layer2 { get; set; }
public override void UpdateAnimation(double offsetFromCenter)
{
Layer1.UpdateAnimation(offsetFromCenter);
Layer2.UpdateAnimation(offsetFromCenter);
}
}
The final result:
Lottie animations controlled by scroll position in CarouselView
Unfortunately at the time of writing there seems to be a bug with the CarouselView, where first and last item don't snap into position correctly when PeekAreaInsets are set.
As you can see, the possibilities are almost endless. Animations and interactivity are what brings your app to life and enhances the user experience.
Stay tuned for an updated blog post, once there is a fully functional Lottie wrapper with stable API for .NET MAUI.
You can find the source code in our public Bitbucket repository: Cayas Bitbucket.
As Head of Technology, I support my team and our clients with the latest technologies and trends in Android and iOS development. Our projects benefit from my Xamarin and .NET MAUI experience, as well as my penchant for clean code, attractive UIs and intuitive UX. You are welcome to book me for a workshop.
This post is a continuation of the Hackathon topic post, where the technical implementation of voice commands in .NET MAUI is revealed, as well as the challenges the development team faced and how they successfully solved them.
As mobile app developer, we constantly have the need to exchange information between the app and the backend. In most cases, a RESTful-API is the solution. But what if a constant flow of data exchange in both directions is required? In this post we will take a look at MQTT and how to create your own simple chat app in .NET MAUI.
With the end of support for Xamarin approaching in May 2024, developers are busy migrating existing Xamarin.Forms projects to .NET MAUI as its successor. So are we, of course. In this article, I'll show 7 steps we've always had to take during the transition to make your upgrade .NET MAUI easier.