Animando transiciones entra páginas en Xamarin.Forms

Una de las cosas que hace que nuestras aplicaciones sean más vistosas y tengan un aspecto más pro, son las animaciones. Hace un tiempo escribí éste post explicando cómo hacer animaciones sencillas en Xamarin.Forms. Lo que quiero explicar aquí va un poco más allá. Os voy a contar cómo consigo animar transiciones entre páginas, moviendo sus elementos para que aparezcan y desaparezcan de pantalla a nuestro antojo.
Nótese que esta es la forma en que yo hago este tipo de animaciones, pero no tiene porque ser la única ni la mejor.
Como siempre, para llevar a cabo la explicación, he creado un ejemplo con dos páginas en las que realizo animaciones tanto al navegar hacía delante como hacía atrás.

Para empezar, lo primero que vamos a hacer es que nuestras páginas hereden de un ContentPage base, que implementará dos métodos para hacer animaciones cuando se navega desde/hacia una página. Estos métodos los he encapsulado en una interfaz, que usaremos más adelante en el servicio de navegación.
public interface IAnimatedPage
{
Task RunDisappearingAnimationAsync();
Task RunAppearingAnimationAsync();
}
public class BasePage : ContentPage, IAnimatedPage
{
protected readonly INavigationService navigationService;
private bool appearingAnimationDone;
public BasePage()
{
navigationService = DependencyService.Get<INavigationService>();
}
protected override async void OnAppearing()
{
if (Device.RuntimePlatform == Device.iOS)
await RuniOSAppearingAnimationAsync();
else
await RunAndroidAppearingAnimationAsync(Width, Height);
}
protected override async void OnSizeAllocated(double width, double height)
{
base.OnSizeAllocated(width, height);
await RunAndroidAppearingAnimationAsync(width, height);
}
private async Task RuniOSAppearingAnimationAsync()
{
if (!appearingAnimationDone)
await RunAppearingAnimationAsync();
}
private async Task RunAndroidAppearingAnimationAsync(double width, double height)
{
if (!appearingAnimationDone && width > 0 && height > 0)
await RunAppearingAnimationAsync();
}
protected override bool OnBackButtonPressed()
{
navigationService.NavigateBack();
return true;
}
public virtual Task RunAppearingAnimationAsync()
{
appearingAnimationDone = true;
return Task.CompletedTask;
}
public virtual Task RunDisappearingAnimationAsync()
{
appearingAnimationDone = false;
return Task.CompletedTask;
}
}
Los métodos importantes de esta clase base, son los que aparecen en la interfaz IAnimatedPage. El método RunAppearingAnimationAsync, se llama cuando la página aparece por primera vez, es decir, cuando se navega a ésta. Al hacerlo solo la primera vez, conseguimos que la animación no se lance cuando la app pasa a segundo plano. Además, hay que destacar que se han incluido condicionales para lanzar la animación en momentos distintos, dependiendo de si estamos en Android o iOS. Estas validaciones las hacemos para asegurar que, en Android, la página está presente en el dispositivo y tiene un tamaño asignado.
El otro método de la interfaz, RunDisappearingAnimationAsync, no se llama desde la propia Page, ya que en OnDisappearing no podemos saber si estamos navegando a otra página o si la app ha pasado a segundo plano. Por lo tanto, a este método se llama desde el servicio de navegación que vamos a crear a continuación.
public interface INavigationService
{
Task NavigateToSecondPage();
Task NavigateBack();
}
public class NavigationService : INavigationService
{
private Page CurrentPage => ((NavigationPage)Application.Current.MainPage).CurrentPage;
private INavigation MainNavigation => ((NavigationPage)Application.Current.MainPage).Navigation;
public async Task NavigateToSecondPage()
{
await ((IAnimatedPage)CurrentPage).RunDisappearingAnimationAsync();
var view = new SecondPage();
NavigationPage.SetHasNavigationBar(view, false);
await MainNavigation.PushAsync(view, false);
}
public async Task NavigateBack()
{
await ((IAnimatedPage)CurrentPage).RunDisappearingAnimationAsync();
await MainNavigation.PopAsync(false);
}
}
Como podéis apreciar, en código anterior, al navegar desde la primera página a la segunda, se lanza la animación ejecutada a través de RunDisappearingAnimationAsync en la primera página. De forma análoga, al navegar atrás, se lanza la animación de desaparición de la página en la que se está.
Las navegaciones realizadas con PushAsync y PopAsync se hacen sin animación, para que ésta no interfiera en las animaciones que hagamos nosotros.
Llegados a este punto, solo falta implementar las animaciones de las dos páginas. No entraré en detalle en estas animaciones, ya que son simplemente un ejemplo de animaciones en paginas sencillas, sin demasiado contenido. En función del contenido de nuestra página y la disposición de sus elementos, nos puede interesar hacer unas animaciones u otras. Imaginación al poder. Simplemente quiero hacer el apunte de que las animaciones las hago con la clase Animation. Asimismo, para esperar a que la animación termine, he utilizado un TaskCompletionSource.
Aunque, como os he dicho antes, no voy a entrar en detalles a explicar las animaciones, aquí os dejo el XAML y .cs de la primera página.
<?xml version="1.0" encoding="UTF-8" ?>
<base:BasePage
xmlns:base="clr-namespace:CustomTransitions.Base"
xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
x:Class="CustomTransitions.Features.FirstPage"
ios:Page.UseSafeArea="true">
<Grid x:Name="gridContent"
RowDefinitions="50,50,100,*" ColumnDefinitions="100,*"
RowSpacing="0" ColumnSpacing="0"
Padding="20,40">
<Frame x:Name="frImage" Grid.RowSpan="2"
CornerRadius="50"
HasShadow="False"
IsClippedToBounds="True"
Padding="0" Opacity="0"
HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand">
<Image Source="https://jorgediegocrespo.wordpress.com/wp-content/uploads/2019/11/iphonesafearea-e1574276852279.jpg"
Aspect="AspectFill"
HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand" />
</Frame>
<Label x:Name="lbName" Grid.Column="1"
Text="JORGE DIEGO CRESPO"
FontAttributes="Bold" FontSize="20"
Margin="10,0,0,0"
VerticalTextAlignment="Center" HorizontalOptions="Start"/>
<Label x:Name="lbJob" Grid.Row="1" Grid.Column="1"
Text="Xamarin developer"
FontSize="16" TextColor="Gray"
Margin="10,0,0,0"
VerticalTextAlignment="Start" HorizontalOptions="Start"/>
<Button x:Name="btDetail" Grid.Row="2" Grid.ColumnSpan="2"
Text="Details"
TextColor="White" BackgroundColor="Black"
CornerRadius="25"
HeightRequest="50" WidthRequest="200"
Scale="0"
HorizontalOptions="Center" VerticalOptions="Center"
Clicked="btDetail_Clicked"/>
</Grid>
</base:BasePage>
public partial class FirstPage : IAnimatedPage
{
private const double X_TRANSLATION = 500;
public FirstPage()
{
InitializeComponent();
lbName.TranslationX = X_TRANSLATION;
lbJob.TranslationX = X_TRANSLATION;
}
public override async Task RunAppearingAnimationAsync()
{
await base.RunAppearingAnimationAsync();
var animation = new Animation();
animation.Add(0, 0.5, new Animation(x => frImage.Opacity = x, frImage.Opacity, 1));
animation.Add(0.2, 0.8, new Animation(x => lbName.TranslationX = x, lbName.TranslationX, 0, Easing.BounceOut));
animation.Add(0.3, 0.9, new Animation(x => lbJob.TranslationX = x, lbJob.TranslationX, 0, Easing.BounceOut));
animation.Add(0.4, 1, new Animation(x => btDetail.Scale = x, btDetail.Scale, 1, Easing.SpringOut));
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
animation.Commit(this, "appearingAnimation", length: 2000, finished: (x, y) =>
{
frImage.Opacity = 1;
lbName.TranslationX = 0;
lbJob.TranslationX = 0;
btDetail.Scale = 1;
tcs.SetResult(true);
});
await tcs.Task;
}
public override async Task RunDisappearingAnimationAsync()
{
await base.RunDisappearingAnimationAsync();
var animation = new Animation();
animation.Add(0.2, 0.8, new Animation(x => lbName.TranslationX = x, lbName.TranslationX, X_TRANSLATION, Easing.SpringOut));
animation.Add(0.3, 0.9, new Animation(x => lbJob.TranslationX = x, lbJob.TranslationX, X_TRANSLATION, Easing.SpringOut));
animation.Add(0.4, 1, new Animation(x => btDetail.Scale = x, btDetail.Scale, 0, Easing.SpringIn));
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
animation.Commit(this, "disappearingAnimation", length: 2000, finished: (System.Action<double, bool>)((x, y) =>
{
//frImage.Opacity = 0;
lbName.TranslationX = FirstPage.X_TRANSLATION;
lbJob.TranslationX = FirstPage.X_TRANSLATION;
btDetail.Scale = 0;
tcs.SetResult(true);
}));
await tcs.Task;
}
private async void btDetail_Clicked(System.Object sender, System.EventArgs e)
{
await navigationService.NavigateToSecondPage();
}
}
Y aquí está el mismo contenido, de la segunda página.
<?xml version="1.0" encoding="UTF-8" ?>
<base:BasePage
xmlns:base="clr-namespace:CustomTransitions.Base"
xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
x:Class="CustomTransitions.Features.SecondPage"
ios:Page.UseSafeArea="true">
<Grid x:Name="gridContent"
RowDefinitions="100,*,100" ColumnDefinitions="0,100,*"
RowSpacing="0" ColumnSpacing="0"
Padding="20,40">
<Frame x:Name="frImage" Grid.Column="1"
CornerRadius="50"
HasShadow="False"
IsClippedToBounds="True"
Padding="0"
HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand">
<Image Source="https://jorgediegocrespo.wordpress.com/wp-content/uploads/2019/11/iphonesafearea-e1574276852279.jpg"
Aspect="AspectFill"
HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand" />
</Frame>
<StackLayout x:Name="slInfo"
Grid.Row="1" Grid.ColumnSpan="3"
Margin="0,20,0,0"
HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand">
<Label x:Name="lbName"
VerticalTextAlignment="Center" HorizontalOptions="FillAndExpand">
<Label.FormattedText>
<FormattedString>
<Span Text="NAME: " FontAttributes="Bold" FontSize="20" TextColor="Black"/>
<Span Text="Jorge Diego Crespo" FontSize="18" TextColor="Gray"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Label x:Name="lbBirth"
VerticalTextAlignment="Center" HorizontalOptions="FillAndExpand">
<Label.FormattedText>
<FormattedString>
<Span Text="DATE OF BIRTH: " FontAttributes="Bold" FontSize="20" TextColor="Black"/>
<Span Text="29/11/1985" FontSize="18" TextColor="Gray"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Label x:Name="lbAddress"
VerticalTextAlignment="Center" HorizontalOptions="FillAndExpand">
<Label.FormattedText>
<FormattedString>
<Span Text="ADDRESS: " FontAttributes="Bold" FontSize="20" TextColor="Black"/>
<Span Text="Xamarin Street, 5" FontSize="18" TextColor="Gray"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Label x:Name="lbCity"
VerticalTextAlignment="Center" HorizontalOptions="FillAndExpand">
<Label.FormattedText>
<FormattedString>
<Span Text="CITY: " FontAttributes="Bold" FontSize="20" TextColor="Black"/>
<Span Text="Madrid" FontSize="18" TextColor="Gray"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Label x:Name="lbCountry"
VerticalTextAlignment="Center" HorizontalOptions="FillAndExpand">
<Label.FormattedText>
<FormattedString>
<Span Text="COUNTRY: " FontAttributes="Bold" FontSize="20" TextColor="Black"/>
<Span Text="Spain" FontSize="18" TextColor="Gray"/>
</FormattedString>
</Label.FormattedText>
</Label>
<Label x:Name="lbJob"
VerticalTextAlignment="Center" HorizontalOptions="FillAndExpand">
<Label.FormattedText>
<FormattedString>
<Span Text="JOB: " FontAttributes="Bold" FontSize="20" TextColor="Black"/>
<Span Text="Xamarin developer" FontSize="18" TextColor="Gray"/>
</FormattedString>
</Label.FormattedText>
</Label>
</StackLayout>
<Button x:Name="btBack" Grid.Row="2" Grid.ColumnSpan="3"
Text="Go back"
TextColor="White" BackgroundColor="Black"
CornerRadius="25"
HeightRequest="50" WidthRequest="200"
Scale="0"
HorizontalOptions="Center" VerticalOptions="Center"
Clicked="btBack_Clicked"/>
</Grid>
</base:BasePage>
public partial class SecondPage : IAnimatedPage
{
private const double Y_TRANSLATION = 1000;
public SecondPage()
{
InitializeComponent();
foreach (View child in slInfo.Children)
child.TranslationY = Y_TRANSLATION;
}
public override async Task RunAppearingAnimationAsync()
{
await base.RunAppearingAnimationAsync();
var animation = new Animation();
double duration = 0.5;
double step = (0.8 - duration) / (slInfo.Children.Count - 1);
for (int i = 0; i < slInfo.Children.Count; i++)
{
View child = slInfo.Children[i];
double beginAt = i * step;
animation.Add(beginAt, beginAt + duration, new Animation(x => child.TranslationY = x, child.TranslationY, 0));
}
double columnWith = (gridContent.Width - 100) / 2;
animation.Add(0, 0.5, new Animation(x => gridContent.ColumnDefinitions[0].Width = new GridLength(x, GridUnitType.Absolute), gridContent.ColumnDefinitions[0].Width.Value, columnWith));
animation.Add(0.8, 1, new Animation(x => btBack.Scale = x, btBack.Scale, 1, Easing.SpringOut));
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
animation.Commit(this, "appearingAnimation", length: 2000, finished: (x, y) =>
{
foreach (View child in slInfo.Children)
child.TranslationY = 0;
btBack.Scale = 1;
gridContent.ColumnDefinitions[0].Width = new GridLength(columnWith, GridUnitType.Absolute);
tcs.SetResult(true);
});
await tcs.Task;
}
public override async Task RunDisappearingAnimationAsync()
{
var animation = new Animation();
double duration = 0.5;
double step = (0.8 - duration) / (slInfo.Children.Count - 1);
for (int i = 0; i < slInfo.Children.Count; i++)
{
View child = slInfo.Children[slInfo.Children.Count - 1 - i];
double beginAt = 0.2 + (i * step);
animation.Add(beginAt, beginAt + duration, new Animation(x => child.TranslationY = x, child.TranslationY, Y_TRANSLATION));
}
animation.Add(0, 0.2, new Animation(x => btBack.Scale = x, btBack.Scale, 0, Easing.SpringOut));
animation.Add(0.5, 1, new Animation(x => gridContent.ColumnDefinitions[0].Width = new GridLength(x, GridUnitType.Absolute), gridContent.ColumnDefinitions[0].Width.Value, 0));
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
animation.Commit(this, "appearingAnimation", length: 2000, finished: (x, y) =>
{
foreach (View child in slInfo.Children)
child.TranslationY = Y_TRANSLATION;
btBack.Scale = 0;
gridContent.ColumnDefinitions[0].Width = new GridLength(0, GridUnitType.Absolute);
tcs.SetResult(true);
});
await tcs.Task;
}
private async void btBack_Clicked(System.Object sender, System.EventArgs e)
{
await navigationService.NavigateBack();
}
}
Como siempre, aquí os dejo un enlace al repositorio del proyecto de ejemplo que he creado, para que podáis descargarlo y jugar con el ejemplo que he creado.