In the previous part of the series we created a simple game simulation where the slugs win randomly without moving at all. But in real races, slugs (or, more commonly, horses or greyhounds) do have to move to win, or even to finish the race. Our slugs are no exception to this rule. In this part of the series we’ll implement animations and make the slugs run.
We’re going to create a couple animations. Not only are the slugs going to move from one end of the racetrack to the other, but they’re also going to have some moving parts, like the tentacles with the eyes.
But before we implement any of that, let’s have a look at some theory. Let’s see how animation works in .NET MAUI and what we can do with it. We’ll make use of the TestPage at first, so that we can practice the new stuff. Then we’ll implement the animations in our app.
Table of Contents
Basic Animations
Animations consist in a gradual change of a property between two values over a period of time. The four basic animations supported by .NET MAUI are:
– FadeTo
– TranslateTo
– RotateTo
– ScaleTo
But there are more, like RelScaleTo
, RotateXTo
or ScaleYTo
, to mention just a few. They can be used with VisualElement
objects. The animations listed above are extension methods of the ViewExtensions
class. The methods are asynchronous and return a Task<bool>
object. If an animation completes, the return value is false
. If it’s canceled, the return value is true
.
We can use the await
keyword to combine animations sequentially. These are so-called compound animations. If we don’t use the await
keyword, we can combine the animations to run simultaneously. These are so-called composite animations.
We have to keep in mind, too, that on Android animations can be disabled to save power. If this is the case, the animations immediately jump to their finished state.
Let’s modify the TestPage
to contain just a Label
and a BoxView
and a Button
:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:TestViewModel"
x:Class="Slugrace.Views.TestPage"
Title="TestPage"
Padding="50">
<VerticalStackLayout>
<Label x:Name="label"
Text="Test Page"
HorizontalOptions="Center"
Margin="0, 0, 0, 100"
FontSize="40" />
<BoxView x:Name="box"
Color="Red"
WidthRequest="150"
HeightRequest="100" />
<Button
Text="Start Animation"
Margin="0, 100, 0, 0"
Clicked="Button_Clicked" />
</VerticalStackLayout>
</ContentPage>
We’ll use the button to start our animations. We can now simplify the TestViewModel
class like so:
using CommunityToolkit.Mvvm.ComponentModel;
namespace Slugrace.ViewModels;
public partial class TestViewModel : ObservableObject
{
}
We’re going to use the code-behind file to implement the animations. And now let’s have a look at some of the basic animations one by one.
Fading
Most animation methods take two parameters. The first parameter is the target value and the second parameter is the duration of the animation in milliseconds.
Let’s make the box fade out in three seconds. The method takes the current value of the Opacity
property (which is 1) for the start of the animation and fades out from this value to 0:
using Slugrace.ViewModels;
namespace Slugrace.Views;
public partial class TestPage : ContentPage
{
public TestPage(TestViewModel testViewModel)
{
InitializeComponent();
BindingContext = testViewModel;
}
private async void Button_Clicked(object sender, EventArgs e)
{
await box.FadeTo(0, 3000);
}
}
If you now run the app and press the button, the box will start fading out:
Let’s move on to the next basic animation.
Translation
To move an element from one location to another we use the TranslateTo
method. It takes three parameters. The first two are for the TranslationX
and TranslationY
properties, and the last one is the duration of the animation.
Let’s translate the box 200 device-independent units to the right and 50 units down over a period of 4 seconds:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await box.TranslateTo(200, 50, 4000);
}
}
Press the button and the box’s journey will begin:
Next, let’s see how rotation works.
Rotation and Relative Rotation
We can rotate around any of the three axes: X, Y or Z. The Z axis is the one that goes through the screen, so, if we rotate around it, the element will rotate in the plane of the screen. This is the most common scenario. We use the Rotate
method for this rotation. We pass the target angle and duration as parameters.
So, let’s rotate the label 15 degrees in clockwise direction over a period of 2 seconds:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await label.RotateTo(15, 2000);
}
}
Here’s what it looks like:
In order to rotate around the X and Y axes, we respectively use the RotateXTo
and RotateYTo
methods. Let’s rotate around the X axis first:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await label.RotateXTo(45, 2000);
}
}
Here’s the result:
And now around the Y axis:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await label.RotateYTo(45, 2000);
}
}
Here’s the result:
The methods above rotate from the current value of Rotation
, RotationX
or RotationY
to the target values. Let’s set the Rotation
property in the XAML file to 45:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
<VerticalStackLayout>
<Label ...
Rotation="45" />
...
Let’s use the RotateTo
method and pass the angle of 90 degrees as the first argument:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await label.RotateTo(90, 2000);
}
}
The label will now be rotated from the angle of 45 degrees to the angle of 90 degrees:
If we want to to rotate it by an angle of 90 degrees, so from 45 to 135 degrees, we should use relative rotation. This can be accomplished by using the RelRotateTo method:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await label.RelRotateTo(90, 2000);
}
}
Here’s the result:
Let’s now remove the Rotation property from the XAML code.
Scaling and Relative Scaling
The scaling methods work pretty much the same as the rotating ones. We have the ScaleTo
method to scale the element uniformly in both directions, the ScaleXTo
and ScaleYTo
methods to scale it along just one axis, and RelScaleTo
for relative scaling.
All these methods take the scaling factor as the first parameter and duration as the second.
So, let’s scale down the box over a period of 2 seconds:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await box.ScaleTo(.5, 2000);
}
}
Here’s the result:
Now let’s scale it only along the X axis:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await box.ScaleXTo(2, 2000);
}
}
This time, we’re making it wider:
The ScaleYTo
method works in a similar way, but along the Y axis. The RelScaleTo
method scales the element relative to its current value.
In the rotating and scaling animations above, the transformation is always performed relative to the center of an element. But it doesn’t have to be the case.
Anchors
We can set the center of rotation or scaling by using the AnchorX
and AnchorY
properties. The values should be between 0 (left for AnchorX
and top for AnchorY
) to 1 (right for AnchorX
and bottom for AnchorY
). The default value of either property is 0.5, which corresponds to the center of the visual element.
Let’s rotate the box 45 degrees around its top-left corner:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
box.AnchorX = 0;
box.AnchorY = 0;
await box.RotateTo(45, 2000);
}
}
Here’s the result:
Next, let’s scale the label relative to its bottom-right corner:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
label.AnchorX = 1;
label.AnchorY = 1;
await label.ScaleTo(2, 2000);
}
}
Here’s what we should see when the animation completes:
We know how to create single animations. But we can combine multiple animations to achieve all sorts of effects.
Compound Animations
Multiple animations can be combined sequentially. We just have to use the await keyword. Let’s create a compound animation on the box:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await box.TranslateTo(-100, 0, 1000);
await box.RotateTo(45, 2000);
await box.TranslateTo(0, -50, 1000);
await box.RelRotateTo(45, 2000);
await box.ScaleTo(2, 500);
}
}
The whole compound animation takes 6.5 seconds to complete. First the box moves to the left. When this movement is complete, the rotation starts, and so on.
Composite Animations
Animations can also run simultaneously. This will happen if we omit the await
keyword. Let’s translate, rotate and scale the box all at the same time:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
box.TranslateTo(-100, 0, 3000);
box.RotateTo(45, 3000);
box.ScaleTo(2, 300);
}
}
If you run the app now and press the button, all three animations will start simultaneously:
The whole composite animation will take 3 seconds to complete.
We can also mix and match. Let’s create a composite animation where some single animations are awaited and others are not:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
label.ScaleXTo(3, 4000);
await label.RotateTo(15, 2000);
await label.RotateTo(0, 2000);
}
}
Here the label will be scaled and rotated simultaneously. The two rotation animations will be run sequentially at the same time as the scaling animation. Here’s the result after the whole composite animation completes:
We can also create composite animations where multiple asynchronous methods run concurrently. Let’s have a look at it next.
WhenAll and WhenAny
We can create composite animations using two methods, Task.WhenAny
and Task.WhenAll
. They return a Task
object. We pass to them a collection of methods that also return a Task
object.
The Task.WhenAny
method completes when any of the methods in its collection completes. Have a look:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await Task.WhenAny
(
label.TranslateTo(400, 0, 5000),
label.ScaleTo(2, 2000)
);
await label.ScaleTo(1, 3000);
}
}
Here we have a Task.WhenAny
method with two tasks. The first task starts translating the label to the right, the second task starts scaling it. The second task needs two seconds to complete. When it completes, the Task.WhenAny
method also completes. While the translation is still running (it needs three more seconds to complete), the second ScaleTo
method can start running. The second scaling and the translation will complete at the same time.
Whereas the Task.WhenAny
method completes when any of its tasks completes, the Task.WhenAll
method completes when all the tasks it contains complete. Have a look:
...
public partial class TestPage : ContentPage
{
...
private async void Button_Clicked(object sender, EventArgs e)
{
await Task.WhenAll
(
box.ScaleXTo(3, 3000),
box.RotateTo(360, 500),
box.TranslateTo(100, 0, 7000)
);
await box.ScaleTo(0, 100);
}
}
Here we have three tasks with different durations. The box first completes the rotation, then the scaling on the X axis, and then the translation. Only when the last task completes, the Task.WhenAll
method completes. As last runs the scaling to zero.
Canceling Animations
We can easily cancel animations by calling the CancelAnimations
method. Let’s add another button to the TestPage
:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
<VerticalStackLayout>
...
<Button
Text="Start Animation"
...
<Button
x:Name="cancel"
Text="Cancel Animations"
Margin="0, 100, 0, 0"
Clicked="Cancel_Clicked" />
</VerticalStackLayout>
</ContentPage>
And here’s the code-behind:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
box.TranslateTo(100, 0, 5000);
box.RotateTo(90, 4000);
box.ScaleTo(2, 4000);
}
private void Cancel_Clicked(object sender, EventArgs e)
{
box.CancelAnimations();
}
}
When you press the first button, all three animations start running simultaneously. When you press the second button, they are immediately canceled.
Easing Functions
Animations can run with constant speed or with different speed and acceleration as they proceed. They can start slow and then accelerate. They can start fast and then decelerate. You can create your own patterns or use the ones we get out of the box.
There’s a class that contains easing functions that enable just that. The class is called Easing
and it contains the following easing functions: BounceIn
, BounceOut
, CubicIn
, CubicInOut
, CubicOut
, Linear
, SinIn
, SinInOut
, SinOut
, SpringIn
and SpringOut
. The In
suffix is used if the effect should be visible at the beginning of the animation. The suffix Out
– if at the end. If both suffixes are used, the effect is visible at both the beginning and the end.
To use an easing function, you just add it as the last argument to the animation method. Let’s check it out.
We’ll create a simple translation for our box. By default the Linear
easing function is used. All the animations we’ve created so far use this default value. Let’s bounce the box at the beginning of the animation. To this end, we can use the BounceIn
easing function:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
box.TranslateTo(300, 0, 3000, Easing.BounceIn);
}
...
Run the animation to see how it works.
Or let’s smoothly accelerate the box at the beginning and decelerate it at the end of the animation:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
box.TranslateTo(300, 0, 3000, Easing.SinInOut);
}
...
This effect is often used.
Feel free to check out the other easing functions. They also work with rotation or scaling.
Custom Animations
We can use the Animation
class to create custom animations. We use the Commit
method to run an animation. Let’s create and run an animation in the TestPage
. Let’s first remove the second button and the related code in the code-behind. The code should now look like so:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
var animation = new Animation(v => box.Rotation = v, 0, 45);
animation.Commit(
this,
"RotationAnimation",
16,
3000,
Easing.SinInOut,
(v, c) => box.Rotation = 0,
() => true);
}
}
We pass a callback, the start value and the end value as arguments to the Animation
class’s constructor. As the callback we use a lambda expression that sets the box’s Rotation
property to values between the start value (0 degrees) and the end value (45 degrees).
Then we run the animation. The Commit
method takes a few parameters. The first parameter is the owner of the animation. This is the visual element the animation is set on or another visual element, such as the page. In our case we pass this
for the page.
The second parameter is the name of the animation. The name and the owner are used jointly to uniquely identify the animation.
The third parameter is the rate of the animation. It’s expressed in milliseconds. In our case the rate is 16, so 16 milliseconds must elapse between calls to the callback method we passed to the Animation
object’s constructor.
The fourth parameter is the duration of the animation. In our case it’s 3000 milliseconds, so 3 seconds.
The fifth parameter is the easing function we want to use for the animation.
The sixth parameter is a callback that will be executed when the animation completes. It takes two arguments, v
and c
. The v
argument is the final value. The c argument, of type bool, is set to true
if the animation is canceled.
Finally, the last parameter is a callback that we use to repeat the animation. In our case it returns true
, so the animation will be repeated.
Now run the app and hit the button. You’ll see the box rotate, then go to the state with Rotation
equal to zero. And this pattern will repeat:
Child Animations
We can combine and synchronize multiple animations in a parent-child relationship. To do that, we create a parent animation and add child animations to it. For the child animations we specify the time frame in which they are supposed to run relative to the duration of the parent animation. Let’s create an example to demonstrate how it works.
We’ll create a parent animation using the parameterless constructor. Next, we’ll create three child animations using the constructor with parameters. By the way, this time we’ll define the easing functions in the constructor, which is an alternative way of doing it. Finally, we’ll add the child animations to the parent animation. Here’s the code:
using Slugrace.ViewModels;
namespace Slugrace.Views;
public partial class TestPage : ContentPage
{
public TestPage(TestViewModel testViewModel)
{
InitializeComponent();
BindingContext = testViewModel;
}
private void Button_Clicked(object sender, EventArgs e)
{
var parentAnimation = new Animation();
var childAnimation1 = new Animation(v => label.ScaleY = v, 1, 2, Easing.CubicIn);
var childAnimation2 = new Animation(v => label.Rotation = v, 0, 30, Easing.SpringIn);
var childAnimation3 = new Animation(v => label.Rotation = v, 30, 0, Easing.SpringOut);
parentAnimation.Add(0, 1, childAnimation1);
parentAnimation.Add(0, .5, childAnimation2);
parentAnimation.Add(.5, 1, childAnimation3);
parentAnimation.Commit(this, "LabelAnimation", 16, 5000, null, (v, c) => label.Text += " |", () => true);
}
}
The first two arguments passed to the Add
method are the time frames for the child animations. They must be between 0 and 1. So, the first animation will run for the entire duration of the parent animation. The second child animation will run during the first half of the parent animation, and the third child animation will run during the second half.
If you now run the app, you’ll see the label scale up for 5 seconds. During the first 2.5 seconds it will rotate in clockwise direction, during the next 2.5 seconds it will rotate back. Additionally, we change the Text
property of the label after the animation is finished (before the next repetition):
The code above can be simplified:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
new Animation
{
{ 0, 1, new Animation(v => label.ScaleY = v, 1, 2, Easing.CubicIn) },
{ 0, .5, new Animation(v => label.Rotation = v, 0, 30, Easing.SpringIn) },
{ .5, 1, new Animation(v => label.Rotation = v, 30, 0, Easing.SpringOut) }
}.Commit(this, "LabelAnimation", 16, 5000, null, (v, c) => label.Text += " |", () => true);
}
}
Animating Properties
Visual elements implement the IAnimatable
interface, which contains the Animate
method. We can use this method to create and start an animation.
For example, we can animate the label’s FontSize
property like so:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
label.Animate("Move", new Animation(x => label.FontSize = x, 5, 50), 16, 5000);
}
}
The Animate
method takes a couple arguments. The first argument is the name, which is used as a unique key. The second argument is the animation that we want to run. Here we’re animating the FontSize
property of the label from 5 to 50. The two remaining arguments are the rate and the duration of the animation. There are also other overloaded versions of the Animate
method.
We can even animate the whole ContentPage
. Let’s rotate it:
...
public partial class TestPage : ContentPage
{
...
private void Button_Clicked(object sender, EventArgs e)
{
this.Animate("Rotate", new Animation(x => Rotation = x, 0, 360), 16, 3000);
}
}
This animation looks like this:
Animations in MVVM
Our project uses the MVVM pattern. Let’s have a look at how to create animations in an app that uses that pattern. First, make sure your TestViewModel
class looks like so:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Slugrace.ViewModels;
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
private bool isHidden;
[ObservableProperty]
private bool isRunning;
[RelayCommand]
async Task Animate()
{
IsRunning = true;
await Task.Delay(5000);
IsRunning = false;
}
}
Here we have two properties and a method. The first property will indicate whether an element (like the box in our case) should be visible or hidden. The second property will be used to indicate whether a simulated task is running.
The method will just set the IsRunning
property to true
, wait for 5 seconds and then set it to false
. This method will be used as a command.
Let’s now have a look at the XAML file:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
<VerticalStackLayout>
<Label x:Name="label"
Text="0"
...
<BoxView x:Name="box"
...
<Button
Text="Start Animation"
Margin="0, 100, 0, 0"
Command="{Binding AnimateCommand}" />
</VerticalStackLayout>
</ContentPage>
It hasn’t changed much. The label’s Text
is now initially set to “0” and we don’t have the Clicked
event anymore. The button’s Command
property now binds to the method we created in the view model.
Finally, let’s have a look at the code-behind:
using Slugrace.ViewModels;
namespace Slugrace.Views;
public partial class TestPage : ContentPage
{
TestViewModel vm;
readonly Animation rotation;
public TestPage(TestViewModel testViewModel)
{
InitializeComponent();
rotation = new Animation(HandleTransformations, 0, 360);
BindingContext = testViewModel;
vm = (TestViewModel)BindingContext;
vm.PropertyChanged += Vm_PropertyChanged;
}
void HandleTransformations(double value)
{
box.Rotation = value;
label.Text = ((int)value).ToString();
if ((int)value % 20 == 0)
{
if (vm.IsHidden)
{
ShowBox();
}
else
{
HideBox();
}
}
}
void HideBox()
{
box.Opacity = 0;
vm.IsHidden = true;
}
void ShowBox()
{
box.Opacity = 1;
vm.IsHidden = false;
}
private void Vm_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(vm.IsRunning))
{
if (vm.IsRunning)
{
rotation.Commit(this, "rotate", 16, 5000, Easing.Linear,
(v, c) => box.Rotation = 0, () => false);
}
else
{
this.AbortAnimation("rotate");
}
}
}
}
Here we have an instance of our view model. In the constructor we create an animation. We define a callback and the start and end values.
We create a method, HandleTransformations
, and pass it as the callback. In the method we change the rotation of the box, modify the text on the label and hide or show the box.
We also have the PropertyChanged
event handler in the constructor. In the Vm_PropertyChanged
method we start the animation when the value of IsRunning
in the view model changes to true
. We cancel the animation when it changes to false
.
If we now run the app and press the button, we’ll see the box rotate and the label text change. Every 20 degrees the box will disappear or reappear:
Fine. We know enough about animations to implement them in our project.
Animations in the Slugrace App
We’re going to create several custom animations in our project. The most important one is the translation of the slug images from left to right. This is a racing game after all, so the slugs must be able to run.
We’re also going to animate the slugs’ tentacles. Rotating them will definitely bring some life to the slugs.
Finally, we’re going to implement some animations related to accidents that may happen to the slugs.
The Running Animation
So, let’s start with the running animation. The racetrack image and the slug images are inside the TrackImage
control. Let’s give them names so that we can reference them later:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...>
<AbsoluteLayout>
<!--Racetrack-->
<Image
x:Name="track"
...
<!--Speedster-->
<controls:SlugImage
x:Name="speedster"
...
<!--Trusty-->
<controls:SlugImage
x:Name="trusty"
...
<!--Iffy-->
<controls:SlugImage
x:Name="iffy"
...
<!--Slowpoke-->
<controls:SlugImage
x:Name="slowpoke"
...
The slug running animations will be implemented in the code behind because they transform the graphics. But we’ll need a link to the GameViewModel
in order to correlate the states of the slugs and of the game with the animated graphics.
Let’s create that link by adding an instance of GameViewModel
in the code-behind:
using Slugrace.ViewModels;
using System.ComponentModel;
namespace Slugrace.Controls;
public partial class TrackImage : ContentView
{
GameViewModel vm;
public TrackImage()
{
InitializeComponent();
}
}
We’ll create four animations, one for each slug. The slugs will run along the racetrack. This is why we need to know the length of the racetrack. However, the Width
property of the track is unavailable in the constructor because at the time when the constructor is run, the children are not yet laid out.
To make sure that the Width
property is set, we have to override the LayoutChildren
method. We create a trackLength
variable to hold the distance the slugs should cover.
Also in the LayoutChildren
method we instantiate the four animations. As the first argument, we pass methods to them that we define below. These methods just take care of translating the SlugImage
objects. As the start and end values we pass 0 and trackLength
respectively:
...
namespace Slugrace.Controls;
public partial class TrackImage : ContentView
{
GameViewModel vm;
double trackLength;
Animation speedsterMovement;
Animation trustyMovement;
Animation iffyMovement;
Animation slowpokeMovement;
public TrackImage()
{
InitializeComponent();
}
protected override void LayoutChildren(double x, double y, double width, double height)
{
base.LayoutChildren(x, y, width, height);
trackLength = track.Width * .79;
speedsterMovement = new Animation(SpeedsterMoveForward, 0, trackLength);
trustyMovement = new Animation(TrustyMoveForward, 0, trackLength);
iffyMovement = new Animation(IffyMoveForward, 0, trackLength);
slowpokeMovement = new Animation(SlowpokeMoveForward, 0, trackLength);
}
private void SpeedsterMoveForward(double value)
{
speedster.TranslationX = value;
}
private void TrustyMoveForward(double value)
{
trusty.TranslationX = value;
}
private void IffyMoveForward(double value)
{
iffy.TranslationX = value;
}
private void SlowpokeMoveForward(double value)
{
slowpoke.TranslationX = value;
}
}
We have the animations, but we haven’t run them yet. When do we want them to run? Well, they should start when the race begins. And this happens when the race status changes to Started
.
So, we need a way to tell when the status changes. To this end, we’ll use the PropertyChanged
event handler. Let’s add a BindingContextChanged
to the TrackImage
class:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...
x:DataType="viewmodels:GameViewModel"
BindingContextChanged="ContentView_BindingContextChanged"
x:Class="Slugrace.Controls.TrackImage">
...
In the code-behind, let’s assign the binding context to the view model instance and let’s assign a Vm_PropertyChanged
method to the event handler:
...
namespace Slugrace.Controls;
public partial class TrackImage : ContentView
{
...
protected override void LayoutChildren(double x, double y, double width, double height)
...
private void ContentView_BindingContextChanged(object sender, EventArgs e)
{
vm = (GameViewModel)BindingContext;
vm.PropertyChanged += Vm_PropertyChanged;
}
private void SpeedsterMoveForward(double value)
...
}
Before we proceed, let’s add a RunningTime
property to the SlugViewModel
class:
...
public partial class SlugViewModel : ObservableObject
{
...
public double PreviousOdds
...
[ObservableProperty]
private uint runningTime;
public SlugViewModel()
...
}
We’ll also need three time-related properties in the GameViewModel
:
...
public partial class GameViewModel : ObservableObject
{
...
public int RaceNumber
...
[ObservableProperty]
private uint raceTime;
[ObservableProperty]
private uint minTime;
[ObservableProperty]
private uint finishTime;
[ObservableProperty]
private bool isShowingFinalResults;
...
What do they refer to?
The first property, RaceTime
, is the time of the entire race, so until the last slug finishes the race.
The second property, MinTime
, is the time the fastest slug needs to finish the race.
The FinishTime
property is the time when the race should be considered resolved because the winner is already known, but the slugs are still running.
And now let’s take care of all the different scenarios as far as the change of the race status is concerned.
So, if the status changes to Started
, we first define the running times of all the slugs. The shorter the time, the faster the slug will run. The first slug, for example, will have a random running time between 3000 and 7000 milliseconds. This is going to be the statistically fastest slug. No other slug will be able to cover the distance in 3 seconds.
Then we create an array of the running times. Next, we set the three time properties we just added to the GameViewModel
.
The RaceTime
is set to the running time of the slowest slug.
The MinTime
is set to the running time of the fastest slug.
The FinishTime
is set to 79% of MinTime
.
We’re going to use these properties in the RunRace
method in the view model. With this in place, we start the animations.
If the race status changes to NotYetStarted
, which happens after each race when we hit the Next Race button, the slugs’ TranslationX
property is reset and the images move to where they started off.
If the status changes to Finished
, the animations are canceled and the slugs stop running. Here you can see all three cases:
...
namespace Slugrace.Controls;
public partial class TrackImage : ContentView
{
...
private void SlowpokeMoveForward(double value)
...
private void Vm_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(vm.RaceStatus))
{
if (vm.RaceStatus == RaceStatus.Started)
{
vm.Slugs[0].RunningTime = (uint)new Random().Next(3000, 7000);
vm.Slugs[1].RunningTime = (uint)new Random().Next(4000, 7000);
vm.Slugs[2].RunningTime = (uint)new Random().Next(4000, 6000);
vm.Slugs[3].RunningTime = (uint)new Random().Next(5000, 8000);
uint[] runningTimes = [
vm.Slugs[0].RunningTime,
vm.Slugs[1].RunningTime,
vm.Slugs[2].RunningTime,
vm.Slugs[3].RunningTime
];
vm.RaceTime = runningTimes.Max();
vm.MinTime = runningTimes.Min();
vm.FinishTime = (uint)(.79 * vm.MinTime);
speedsterMovement.Commit(this, "moveSpeedster", 16, vm.Slugs[0].RunningTime, Easing.Linear,
null, () => false);
trustyMovement.Commit(this, "moveTrusty", 16, vm.Slugs[1].RunningTime, Easing.Linear,
null, () => false);
iffyMovement.Commit(this, "moveIffy", 16, vm.Slugs[2].RunningTime, Easing.Linear,
null, () => false);
slowpokeMovement.Commit(this, "moveSlowpoke", 16, vm.Slugs[3].RunningTime, Easing.Linear,
null, () => false);
}
else if (vm.RaceStatus == RaceStatus.NotYetStarted)
{
speedster.TranslationX = 0;
trusty.TranslationX = 0;
iffy.TranslationX = 0;
slowpoke.TranslationX = 0;
}
else
{
this.AbortAnimation("moveSpeedster");
this.AbortAnimation("moveTrusty");
this.AbortAnimation("moveIffy");
this.AbortAnimation("moveSlowpoke");
}
}
}
}
Now, let’s have a look at the RunRace
method in the GameViewModel
:
...
public partial class GameViewModel : ObservableObject
{
...
private async Task RunRace()
{
await Task.Delay((int)FinishTime);
RaceWinnerSlug = Slugs.Where(s => s.RunningTime == MinTime).FirstOrDefault();
await Task.Delay((int)(RaceTime - FinishTime));
RaceWinnerSlug.IsRaceWinner = true;
HandleSlugsAfterRace();
HandlePlayersAfterRace();
await FinishRace();
}
private void HandleSlugsAfterRace()
...
We introduce a delay of a couple seconds. During this time, the race status is Started
and the slugs are running. The RaceWinnerSlug
is now set to the fastest slug. We have the winner, but the slugs are still running until the slowest one finishes. Then we continue like before.
There is also one change in the WinnerInfo
class. We want the winner info to be visible not when all slugs finish the race, but rather when the winner is known, so after the fastest slug crosses the finish line.
So, we bind the IsVisible
property to RaceWinnerSlug
. We also use the IsNotNullConverter
from the Community Toolkit, so that the WinnerInfo
view should be visible only when there is a RaceWinnerSlug
:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...>
<ContentView.Resources>
...
<toolkit:IsNotNullConverter x:Key="raceResolvedConverter" />
</ContentView.Resources>
<Grid
IsVisible="{Binding RaceWinnerSlug, Converter={StaticResource raceResolvedConverter}}"
...
As you can see, we don’t need the EnumToBoolConverter
anymore.
We also must make sure that the RaceWinnerSlug
is set to null
in the NextRace
method in GameViewModel
:
...
public partial class GameViewModel : ObservableObject
{
...
void NextRace()
{
RaceStatus = RaceStatus.NotYetStarted;
RaceNumber++;
RaceWinnerSlug = null;
foreach (var player in Players)
...
}
...
This way the WinnerInfo
view will be invisible when the next race begins.
If we now run the app and then start the first race, the slugs start running:
The moment the first slug crosses the finish line, the WinnerInfo
is displayed:
The slugs continue running until all of them have completed the race:
Here’s what you should see if you ran the app on Android:
Now the race is finished and we can hit the Next Race button. This will move the slugs to the beginning of the track.
Rotating the Tentacles
The tentacle rotation (or eye rotation, it really doesn’t matter what you call it) is pretty simple. The two tentacles will constantly rotate at a random speed, different for each slug.
We’re going to implemenet this animation using child animations. Each slug has two tentacles. Let’s give them names so that we can reference them in code. Here’s the SlugImage
class in XAML:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...>
<AbsoluteLayout>
<Image
x:Name="leftEye"
Source="{Binding EyeImageUrl}"
...
<Image
x:Name="rightEye"
Source="{Binding EyeImageUrl}"
...
<Image
Source="{Binding BodyImageUrl}"
...
And now let’s implement the rotation in the code-behind:
namespace Slugrace.Controls;
public partial class SlugImage : ContentView
{
public SlugImage()
{
InitializeComponent();
uint rotationSpeed = (uint)new Random().Next(2000, 4000);
new Animation()
{
{0, .5, new Animation(v => leftEye.Rotation = v, 0, -30) },
{0, .5, new Animation(v => rightEye.Rotation = v, 0, 30) },
{.5, 1, new Animation(v => leftEye.Rotation = v, -30, 0) },
{.5, 1, new Animation(v => rightEye.Rotation = v, 30, 0) }
}.Commit(this, "eyeRotation", 16, rotationSpeed, null, null, () => true);
}
}
Here we have four child animations, two for the left eye and two for the right eye. Each eye rotates in one direction during the first half of the animation and then back. This animation is repeated.
Now, as soon as the slugs appear on the screen, they rotate their tentacles:
Great, we have animations in our app now. But how cool would it be if we had some background music and sound effects as well? Well, this is what we’re going to see to in the next part of the series.