Animated Vector Drawable for Android with Xamarin.Forms

There are several techniques to animate UI elements in Android applications created with Xamarin.Forms. ViewExtensions and Custom Animations can be used to animate views directly from C# code. Both techniques provide a nice cross-platform solution for animating UI elements.

In many projects, graphics and animations are created by (UI) designers; recreating these elements from code is not always easy or even possible. Luckily there are alternatives such as Lottie. An awesome cross platform library to display animations created with After Effects. To start using Lottie for your Xamarin Forms app, check out the Xamarin bindings created by Martijn van Dijk.

Unfortunately Lottie animations can be quite CPU intensive and they run on the UI thread, which in our case resulted in dropped frames. That’s why we explored yet another technique: Animated Vector Drawable. AVD is not a cross-platform technique, it’s Android only, but in our case that’s all we needed. The two main benefits are:

· Easily human readable XML files for vectors and animations

· Fast performance because animations run on a separate RenderThread

The source that’s used in this sample is available on Github.


Let’s dive in

First of all we need the animation. In the case of Animated Vector Drawable the animation is separated into several XML files. I’ve used After Effects with the Body Movin plugin to export a timeline to an XML file, which I cleaned up a little and named “image_vector.xml”.

<?xml version="1.0" encoding="utf-8" ?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="42dp"
android:width="160dp"
android:viewportHeight="42"
android:viewportWidth="160">
<group android:name="_R_G">
<group android:name="ImageBackgroudGroup" android:translateX="80" android:translateY="21">
<path android:name="Background" android:fillColor="#0099da" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="InvisibleOutline" android:strokeColor="#ff0000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0" android:strokeAlpha="1" android:pathData="..."/>
</group>
<group android:name="TextGroup" android:translateX="10.474" android:translateY="25.433">
<path android:name="T4" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="T4" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="X" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="X" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="E" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="E" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="T3" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="T3" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="N" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="N" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="O" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="O" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="T2" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="T2" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="T1" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="T1" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="U" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="U" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
<path android:name="B" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="0.01" android:strokeAlpha="1" android:pathData="..."/>
<path android:name="B" android:fillColor="#ffffff" android:fillAlpha="1" android:fillType="nonZero" android:pathData="..."/>
</group>
<group android:name="CircleArrow">
<group android:name="Circle" android:translateX="58" android:translateY="21">
<path android:fillColor="#0099da" android:fillAlpha="1" android:fillType="nonZero" android:pathData=" M55 -10 C60.52,-10 65,-5.52 65,0 C65,5.52 60.52,10 55,10 C49.48,10 45,5.52 45,0 C45,-5.52 49.48,-10 55,-10c "/>
<path android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:strokeAlpha="1" android:pathData="..."/>
</group>
<group android:name="Arrow" android:translateX="63.5" android:translateY="-28.5" android:pivotX="50" android:pivotY="50" android:scaleX="0.13" android:scaleY="0.13">
<path android:name="ArrowPath" android:strokeColor="#ffffff" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="4" android:strokeAlpha="1" android:pathData="..."/>
</group>
</group>
</group>
</vector>

I removed the path data in the sample above. What’s important is that this is the base vector file for the animation. This file should be added in your Android project in the /Resources/drawable/ directory, with a Build Action of AndroidResource.

Because this is basically an image, you can load it directly into an Image tag in your Xaml page like this:

<Image Source="image_vector"/>

Note that if you want to scale this image, especially upward, you should change the android:width and android:height properties in the vector XML file in the Android project. Changing the size in Xaml with a WidthRequest and HeightRequest will result in a blurry image. The second button below is resized from Xaml.

Images become blurry when resized from Xaml

I’d also like to mention that it’s generally a best practice to specify the colors in a separate colors.xml file in the /Resources/values directory and likewise specify path data in a strings file.

Adding some animation

The next step is to set things in motion, literally. To do this, we add a few more XML files to our Android project.

The first file will be added to the /Resources/drawable/ directory, I named it animated_image.xml

<?xml version="1.0" encoding="utf-8" ?>
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/image_vector" >
<target
android:name="CircleArrow"
android:animation="@anim/slide_twoway" />
<target
android:name="Arrow"
android:animation="@anim/rotate" />
<target
android:name="ArrowPath"
android:animation="@anim/alpha" />
</animated-vector>

The root is an animated-vector which has a reference to the drawable we created in the previous step. There should be at least one target, the name is a reference to an element (group or path) in the vector drawable. The animation is a reference to an XML file in the /Resources/anim/ directory.

Here’s the slide_twoway.xml file:

<?xml version="1.0" encoding="utf-8" ?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<objectAnimator
android:duration="1000"
android:propertyName="translateX"
android:valueFrom="1.0"
android:valueTo="20.0"
android:valueType="floatType"
android:repeatMode="reverse"
android:repeatCount="-1" />
</set>

These properties are pretty self-explanatory. A translate on the X-axis is a horizontal movement. The repeatMode is set to reverse, otherwise the animation would immediately jump back to the original position and start over. The repeatCount has a value of -1 to repeat the animation forever.

If you want to animate multiple properties at the same time, you can add several objectAnimators to the <set>. A set has an ordering property with a default value of “together”, resulting in all objectAnimators within a set running at the same time. If you want each objectAnimator to run when the previous animation has finished, set the ordering property to “sequentially”.

It’s also possible to nest <set> elements, to create more advanced compositions.

Loading the animation

In order to display the animation in our App, we need a control that can handle visual updates. If we use an image control we can display static images, but we wouldn’t see the actual animation. The easiest way to solve this is by using a ProgressBar. In our Android project we create a custom ViewRenderer, which inherits from ProgressBarRenderer.

[assembly: ExportRenderer(typeof(AnimatedImageView), typeof(AnimatedImageViewRenderer))]
namespace XFAVDSample.Droid
{
class AnimatedImageViewRenderer : ProgressBarRenderer
{
public AnimatedImageViewRenderer(Context context) : base(context)
{ }
    protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.ProgressBar> e)
{
base.OnElementChanged(e);
CreateAnimatedImageView((AnimatedImageView)e.NewElement);
}
    protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
}
    private void CreateAnimatedImageView(AnimatedImageView animatedImageView)
{
if (Control != null)
{
var progressBar = Control as Android.Widget.ProgressBar;
progressBar.IndeterminateDrawable = Context.GetDrawable(animatedImageView.SourceUrl);
progressBar.Indeterminate = true;
}
}
    protected override void OnLayout(bool changed, int l, int t, int r,  int b)
{
base.OnLayout(changed, l, t, r, b);
}
}
}

I want to point out a few things. In the OnElementChanged we cast e.NewElement to an AnimatedImageView object. This is the custom view in our .NET Standard library, we will get to this later. It is however important to notice that this object has a SourceUrl property, which we use to retrieve the drawable and set it to the InderterminateDrawable property of the progressBar. We could have set a static path to animated_image, but this way we create a reusable control, because we can now set the path to the animation file from Xaml.

Displaying the animation

The final step before we can run our app and view the results is to create the custom view and consume it in our Xaml page.

public class AnimatedImageView : ProgressBar
{
public static readonly BindableProperty SourceUrlProperty = BindableProperty.Create(
propertyName: nameof(SourceUrl),
returnType: typeof(string),
declaringType: typeof(AnimatedImageView));

public string SourceUrl
{
get => (string)GetValue(SourceUrlProperty);
set => SetValue(SourceUrlProperty, value);
}
}

The custom control is called AnimatedImageView and it derives from Xamarin.Forms.ProgressBar. It has one bindable property called SourceUrl, which (as you have seen above) will be used to set the Android.Widget.ProgressBar.IndeterminateDrawable property.

Finally we can use the control in our Xaml page with two simple steps. First we need to add a reference to the XFAVDSample.Views namespace and secondly we need to define the view with its properties.

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:customcontrols="clr-namespace:XFAVDSample.Views;assembly=XFAVDSample"
xmlns:local="clr-namespace:XFAVDSample"
x:Class="XFAVDSample.MainPage">
<StackLayout>
<customcontrols:AnimatedImageView
SourceUrl="animated_image"
HeightRequest="42"
WidthRequest="160"
HorizontalOptions="Center"
VerticalOptions="CenterAndExpand"/>
</StackLayout>
</ContentPage>

Get the sample app from Github, run it on an emulator or device and the result should look like this (but better since the image below if a recording saved as animated gif).