Animaciones Avanzadas en .NET MAUI

Esta entrada forma parte de la iniciativa del calendario de Adviento organizado por mi buen amigo Ricardo Pérez. Te animo a echar un vistazo a las otras publicaciones del calendario.
En una publicación del blog de Telerik, se ha explicado cómo utilizar animaciones básicas en .NET MAUI. En este artículo, iremos un paso más allá y veremos cómo implementar animaciones avanzadas en .NET MAUI, lo que te permitirá tener el máximo control para crear experiencias únicas. ¡Comencemos!
Una Easing Function permite especificar la velocidad de cambio de un valor de una propiedad a lo largo del tiempo, o en otras palabras, la suavidad de una animación. Para comprender esto mejor, imagina cuando dejamos caer en el suelo una pelota de basketball. Esta pelota no cae al suelo y deja de botar inmediatamente, sino que realiza una serie de rebotes antes de quedarse quieta. Esta suavidad es la que podemos aplicar a los objetos en las animaciones que creemos. En .NET MAUI, contamos con las siguientes easing functions por default:
  • Funciones Bounce: Crean un efecto de rebote, añadiendo un toque dinámico y natural a las animaciones:
    • `BounceIn`
    • `BounceOut`
Example of bounce animation
  • Funciones Cubic: Proporcionan un control sobre la aceleración y desaceleración.
    • CubicIn
    • CubicOut
    • CubicInOut
Example of cubic animation
  • Funciones Sin: Basadas en la función matemática Sin, ofrecen transiciones naturales y suaves.
    • SinIn
    • SinOut
    • SinInOut
Example of sin animation
  • Funciones Spring: Proporcionan un efecto de rebote, iniciando con un estiramiento y finalizando con una contracción.
    • SpringIn
    • SpringOut
Example of spring animation
  • Función Lineal: Mantiene una velocidad constante a lo largo de toda la animación, resultando menos natural que las anteriores.
    •   Linear
Example of linear animation

Utilizando Easing Functions en .NET MAUI

En el artículo de animaciones básicas de .NET MAUI, se ha hablado sobre un conjunto de métodos existentes en el framework, que nos permite aplicar animaciones sobre los elementos, por ejemplo:
await image.TranslateToAsync(0, 200, 2000); 
Los métodos TranslateTo, ScaleTo, RotateTo, ScaleTo y TranslateTo permiten especificar un último parámetro para cambiar el easing function utilizado, que por default es Linear. A continuación, te muestro un ejemplo del uso de las diferentes Easing Functions disponibles en .NET MAUI, empezando por el código en una página XAML de ejemplo:
<Grid ColumnDefinitions="*,*,*" RowDefinitions="*,*,*,*">
    <Grid.GestureRecognizers>
        <TapGestureRecognizer Tapped="TapGestureRecognizer_Tapped" />
    </Grid.GestureRecognizers>
    <Grid
        Background="#F5F5F5"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="BounceIn"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Column="1"
        Background="#F0F8FF"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="BounceOut"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse2"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Column="2"
        Background="#F5FFFA"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="CubicIn"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse3"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="1"
        Background="#FFF5EE"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="CubicOut"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse4"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="1"
        Grid.Column="1"
        Background="#FDF5E6"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="CubicInOut"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse5"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="1"
        Grid.Column="2"
        Background="#F0FFF0"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="Linear"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse6"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="2"
        Background="#F8F8FF"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="SinIn"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse7"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="2"
        Grid.Column="1"
        Background="#FAF0E6"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="SinOut"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse8"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="2"
        Grid.Column="2"
        Background="#F0FFFF"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="SinInOut"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse9"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="3"
        Background="#FFF0F5"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="SpringIn"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse10"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
    <Grid
        Grid.Row="3"
        Grid.Column="1"
        Background="#FFFAF0"
        HeightRequest="150"
        WidthRequest="400">
        <Label
            FontSize="45"
            HorizontalOptions="Center"
            Text="SpringOut"
            TextColor="LightSeaGreen"
            VerticalOptions="Center" />
        <Ellipse
            x:Name="Ellipse11"
            Fill="DarkViolet"
            HeightRequest="50"
            HorizontalOptions="Start"
            VerticalOptions="Start"
            WidthRequest="50" />
    </Grid>
</Grid> 
El código que iniciará las animaciones es el siguiente:
public partial class MainPage : ContentPage
{        
    public MainPage()
    {
        InitializeComponent();           
    }

    private void StartAnimations()
    {
        uint lenght = 5000;
        double maxWidth = 350;
        double maxHeight = 100;
        Ellipse.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.BounceIn);
        Ellipse2.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.BounceOut);
        Ellipse3.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.CubicIn);
        Ellipse4.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.CubicOut);
        Ellipse5.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.CubicInOut);
        Ellipse6.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.Linear);
        Ellipse7.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.SinIn);
        Ellipse8.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.SinOut);
        Ellipse9.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.SinInOut);
        Ellipse10.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.SpringIn);
        Ellipse11.TranslateToAsync(maxWidth, maxHeight, lenght, Easing.SpringOut);
    }

    private void TapGestureRecognizer_Tapped(object sender, TappedEventArgs e)
    {
        StartAnimations();
    }
} 
Al ejecutar la aplicación, este es el resultado:
The execution of an application showing all the animations available on .NET MAUI
Aunque las Easing Functions por default serán útiles en la mayoría de los casos, también es posible crear tus propias Easing Functions siguiendo los diferentes métodos descritos en la documentación oficial.

Animaciones personalizadas

En caso de querer crear animaciones personalizadas en nuestras aplicaciones, debemos crear una variable del tipo Animation, cuyo primer constructor no recibe parámetros, mientras el segundo sí. La firma del método que sí recibe parámetros es la siguiente:
public Animation(Action<double> callback, double start = 0.0f, double end = 1.0f, Easing easing = null, Action finished = null) : base(callback, start, end - start, easing, finished) 
Los parámetros anteriores sirven para lo siguiente:
callback: Define un `Action` que será ejecutada con los valores sucesivos de la animación
start: La fracción de la animación actual en la que se iniciará la animación
end: La fracción de la animación actual en la que se terminará la animación
easing: Una Easing Function que será utilizada en la animación
finished: Una acción que se llama cuando la animación ha terminado
 
Comprender cómo utilizar todos estos parámetros con conjunto puede resultar un poco trivial, por lo que es importante iniciar con lo más básico. Iniciemos con la creación de una animación personalizada básica:
var animation = new Animation(v => Debug.WriteLine(v), 0, 1); 
La animación anterior define un callback que será llamado el número de veces calculado de acuerdo a la duración de la animación, y el cual va a mostrar en la consola un conjunto de valores entre el 0 y el 1, que son los valores start y end. Seguramente te estés preguntando cuántos valores van a ser mostrados en la consola, eso depende del tiempo total que se tarde la ejecución de la animación, que vas a poder especificar a través del método Commit, el cual tiene la siguiente firma:
public void Commit(IAnimatable owner, string name, uint rate = 16, uint length = 250, Easing easing = null, Action<double, bool> finished = null, Func<bool> repeat = null) 
Los valores del método anterior significan lo siguiente:

owner: El propietario de la animación
name: Un identificador que se utilizará para identificar a la animación y realizar operaciones sobre ella
rate: El tiempo entre fotogramas (en milisegundos)
length: La duración de la animación (en milisegundos)
easing: Una Easing Function que será utilizada en la animación
finished: Una acción que será llamada cuando la animación haya finalizado
repeat: Una función que devolverá un valor true en caso de querer repetir la animación

En nuestro ejemplo, iniciaremos la animación indicando que el owner es la clase donde está definida la animación, el name es CustomAnimation, un frame rate de 16 y length de 1 segundo:
animation.Commit(this, "CustomAnimation", 16, 1000); 
Al ejecutar la animación anterior, recibiremos en consola un conjunto de valores similares a los siguientes:
0
0.016
0.032
0.063
0.078
...
0.938
0.953
0.969
0.985
1 
En resumen, se han calculado los valores entre el 0 y el 1 en un lapso de 1 segundo, a 16 frames por segundo. Los valores anteriores pueden ser tomados para asignarlos a las propiedades de los elementos visuales. Haciendo algo más práctico, supón que deseamos rotar en el eje Y una imagen 360 grados durante un segundo. Retomando el conocimiento anterior, redefinamos el código de la siguiente forma:
var animation = new Animation(v => Image.RotationY = v, 0, 360);
animation.Commit(this, "CustomAnimation", 16, 5000); 
En el código anterior, puedes notar que en vez de imprimir los valores a la consola, los aplicamos a la propiedad RotationY de un control Image, con lo que obtenemos el siguiente resultado:
A custom animation applied to the RotationY property of an Image control

Animaciones hijas

Es bueno saber que también podemos lograr un comportamiento tipo “Storyboard” con animaciones en .NET MAUI. Esto significa, poder sincronizar múltiples animaciones para determinar en qué momento iniciará la ejecución de cada una de ellas. Por ejemplo, supón que queremos que una animación sobre una imagen que dure 6 segundos, durante los cuales:

1. La imagen debe rotar 360 grados en `RotationX`
    – Inicio: Al segundo 1
    – Fin: Al segundo 6
    – Duración: 6 segundos
2. La imagen debe escalar el doble de su tamaño
   – Inicio: Al segundo 1
    – Fin: Al segundo 3
    – Duración: 3 segundos
3. La imagen debe difuminarse al 50%
   – Inicio: Al segundo 1
    – Fin: Al segundo 3
    – Duración: 3 segundos
4. La imagen debe aclararse al 100%
   – Inicio: Al segundo 4
    – Fin: Al segundo 6
    – Duración: 3 segundos
4. La imagen debe volver a su tamaño normal
   – Inicio: Al segundo 4
    – Fin: Al segundo 6
    – Duración: 3 segundos

Lo anterior involucra 5 animaciones que sucederán en diferentes tiempos. Para lograr la sincronización, vamos definir una primer animación  que será la animación padre. A continuación, definiremos una serie de animaciones personalizadas modificando los valores de acuerdo a los requerimientos, como en el siguiente ejemplo:
var parentAnimation = new Animation();
var rotateXAnimation = new Animation(v => Image.RotationX = v, 0, 360);
var scaleUpAnimation = new Animation(v => Image.Scale = v, 1, 2);
var opacityFadeAnimation = new Animation(v => Image.Opacity = v, 1, 0.5);
var scaleDownAnimation = new Animation(v => Image.Scale = v, 2, 1);
var opacityFadeInAnimation = new Animation(v => Image.Opacity = v, 0.5, 1); 
A continuación, debemos utilizar el método Add de la animación padre para agregar todas las animaciones hijas. La firma del método Add es la siguiente:
public void Add(double beginAt, double finishAt, Animation animation) 
Los parámetros anteriores significan lo siguiente:

beginAt: La fracción dentro de la animación padre en la cual la animación hija empezará a ser reproducida
finishAt: La fracción dentro de la animación padre en la cual la animación hija terminará de ser reproducida
animation: La animación a ser agregada

Los valores begintAt y finishAt, van del 0 al 1, por lo que debemos calcular la fracción de tiempo en valor decimal, que podremos obtener con al fórmula:
1 / # of seconds 
En nuestro caso, como la duración de la animación total es de 6 segundos, el resultado es .1666. Una vez obtenido este valor, podemos agregar las animaciones, determinando el inicio y el fin multiplicando la fracción por el segundo en el cual debe iniciar o finalizar cada animación, además de agregar la animación hija como en el siguiente ejemplo:
var fraction = .1666;

parentAnimation.Add((fraction * 0), (fraction * 6), rotateXAnimation);
parentAnimation.Add((fraction * 0), (fraction * 3), scaleUpAnimation);
parentAnimation.Add((fraction * 0), (fraction * 3), opacityFadeAnimation);
parentAnimation.Add((fraction * 3), (fraction * 6), scaleDownAnimation);
parentAnimation.Add((fraction * 3), (fraction * 6), opacityFadeInAnimation); 
Finalmente, ejecutemos el método Commit de la animación padre para iniciar la animación final que debe durar 6 segundos:
parentAnimation.Commit(this, "childAnimations", 16, 6000); 
El resultado de la ejecución anterior, nos brinda una sincronización exacta de cada animación hija:
Child animations being executed and synchronized through the definition of start and end playback times for each one

Conclusión

A lo largo de este artículo, has podido profundizar en conceptos avanzados en lo que respecta a las animaciones en .NET MAUI. Has comprendido qué son y cómo utilizar las Easing Functions, además de cómo crear animaciones personalizadas. Por último, has visto cómo es posible crear una animación con animaciones hijas, logrando un efecto tipo Storyboard para mantener una sincronización perfecta en su ejecución.

Deja un comentario

Tu dirección de correo electrónico no será publicada.