Moviendo un carousel en Xamarin Forms

  • xamarin-forms

Hace unos días, tuve la necesidad de crear un carousel view, por lo que no dude y descargué el Nuget CardsView de @Andrik_Just4Fun La complicación que tenía es que mi control no solo tenía que comportarse como un carousel, permitiendo visualizar distintos items haciendo scroll vertical, sino que también tenía que permitir mover todo el control de izquierda a derecha y viceversa (tal y como se muestra a continuación), ya que estos movimientos lanzarían cierta funcionalidad (esta funcionalidad no es el objetivo del post).

La primera opción que me planteé fue la de incluir un PanGestureRecognizer para detectar el movimiento horizontal y mover la tarjeta. Lo cual funcionó bien en iOS.

<Grid x:Name="mainGrid">
    <Grid RowDefinitions="*,500,*"
          ColumnDefinitions="*,250,*">
        <Frame x:Name="frCarousel" Grid.Row="1" Grid.Column="1"
               CornerRadius="20"
               HasShadow="False"
               IsClippedToBounds="True"
               Padding="0"
               BorderColor="Black"
               HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand" >
            <controls:CarouselView x:Name="cvImages"
                                   IsCyclical="False"
                                   IsHorizontalOrientation="False"
                                   HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand">
                <controls:CarouselView.ItemTemplate>
                    <DataTemplate>
                        <Image Source="{Binding .}"
                               Aspect="AspectFill"
                               BackgroundColor="Transparent"
                               HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand"/>
                    </DataTemplate>
                </controls:CarouselView.ItemTemplate>
                <controls:CardsView.GestureRecognizers>
                    <PanGestureRecognizer PanUpdated="MoveCard" />
                </controls:CardsView.GestureRecognizers>
            </controls:CarouselView>
        </Frame>
    </Grid>
</Grid>
private List<string> imageSourceList;
private bool? panVertically = null;
private bool isMovingCard = false;

public MainPage()
{
    InitializeComponent();
    imageSourceList = new List<string>
    {
        "https://jorgediegocrespo.wordpress.com/wp-content/uploads/2019/11/iphonesafearea-e1574276852279.jpg",
        "https://jorgediegocrespo.wordpress.com/wp-content/uploads/2020/11/alertdialogmain-e1605684413393.jpg",
        "https://jorgediegocrespo.wordpress.com/wp-content/uploads/2020/11/chromecast-e1604991843219.jpg"
    };
    cvImages.ItemsSource = imageSourceList;

}

private async void MoveCard(object sender, PanUpdatedEventArgs e)
{
    switch (e.StatusType)
    {
        case GestureStatus.Started:
        case GestureStatus.Running:
            await DoCardAnimationAsync(e.TotalX);
            break;
        case GestureStatus.Completed:
        case GestureStatus.Canceled:
            await FinishCardMovementAsync();
            break;
    }
}

private async Task DoCardAnimationAsync(double totalX)
{
    double absTotalX = Math.Abs(totalX);
    double safeMargin = 20;

    if (absTotalX <= safeMargin)
        return;

    double x = absTotalX - safeMargin;

    double factor = totalX < 0 ? -1 : 1;
    double tanslationX = (factor * x);

    await frCarousel.TranslateTo(tanslationX, 0, 1);
}

private async Task FinishCardMovementAsync()
{
    await frCarousel.TranslateTo(0, 0);
}

Con el bloque de código anterior lo que he conseguido es mover el carousel lateralmente siempre que el StatusType del PanGesture sea Started o Running, lo que significa que el usuario todavía está moviendo el dedo sobre la pantalla del dispositivo. Cuando el usuario finaliza el movimiento (StatusType igual a Completed o Canceled) realizo una animación para dejar el carousel en su posición original.

Con todo el código anterior, haciendo pruebas en iOS, se puede producir un efecto incomodo. Que mientras una tarjeta se esté moviendo verticalmente, el carousel entero se mueva de forma horizontal, tal y como se muestra a continuación.

Para corregir este efecto, lo primero que hacemos es detectar si el movimiento que se está realizando es horizontal y vertical. En caso de ser horizontal, deshabilitamos el swipe del carousel, así impedimos que se muevan las tarjetas mientras estamos moviendo el carousel lateralmente. De forma análoga, si el movimiento es vertical, no realizará un movimiento lateral del carousel.

Con todo lo anterior, el código quedaría de la siguiente manera.

private List<string> imageSourceList;
private bool? panVertically = null;
private bool isMovingCard = false;

public MainPage()
{
    InitializeComponent();
    imageSourceList = new List<string>
    {
        "https://jorgediegocrespo.wordpress.com/wp-content/uploads/2019/11/iphonesafearea-e1574276852279.jpg",
        "https://jorgediegocrespo.wordpress.com/wp-content/uploads/2020/11/alertdialogmain-e1605684413393.jpg",
        "https://jorgediegocrespo.wordpress.com/wp-content/uploads/2020/11/chromecast-e1604991843219.jpg"
    };
    cvImages.ItemsSource = imageSourceList;

}

private async void MoveCard(object sender, PanUpdatedEventArgs e)
{
    switch (e.StatusType)
    {
        case GestureStatus.Started:
        case GestureStatus.Running:
            if (panVertically == null && Math.Abs(e.TotalY) > Math.Abs(e.TotalX))
                panVertically = true;
            else if (panVertically == null && Math.Abs(e.TotalY) < Math.Abs(e.TotalX))
                panVertically = false;

            if (panVertically == false)
            {
                if (!isMovingCard && Device.RuntimePlatform == Device.iOS)
                {
                    isMovingCard = true;
                    cvImages.OnPanUpdated(new PanUpdatedEventArgs(GestureStatus.Completed, 0, e.TotalX, 0));
                }
                await DoCardAnimationAsync(e.TotalX);
            }
            break;
        case GestureStatus.Completed:
        case GestureStatus.Canceled:
            await FinishCardMovementAsync();
            panVertically = null;
            isMovingCard = false;
            break;
    }
}

private async Task DoCardAnimationAsync(double totalX)
{
    double absTotalX = Math.Abs(totalX);
    double safeMargin = 20;

    if (absTotalX <= safeMargin)
        return;

    BlockAnimation(true);
    double x = absTotalX - safeMargin;

    double factor = totalX < 0 ? -1 : 1;
    double tanslationX = factor * x;

    await frCarousel.TranslateTo(tanslationX, 0, 1);
}

private void BlockAnimation(bool block)
{
    if (Device.RuntimePlatform == Device.iOS)
        Device.BeginInvokeOnMainThread(() =>
        {
            cvImages.IsVerticalSwipeEnabled = !block;
            cvImages.IsPanInteractionEnabled = !block;
        });
}

private async Task FinishCardMovementAsync()
{
    await frCarousel.TranslateTo(0, 0);
    BlockAnimation(false);
}

Nótese, que para llegar a este punto, no ha sido necesario modificar el XAML.

Llegados a este punto, en iOS todo funciona correctamente, pero en Android tenemos un problema. Si lanzamos la app tal y como está ahora mismo, veremos que en Android, el PanGestureRecognizer, que hemos añadido para mover el carousel horizontalmente, no funciona. Se debe a que el control de CardsView también usa un PanGestureRecognizer, que entra en conflicto con el nuestro. Para solucionar este problema, tenemos que remangarnos un poco, ya que tenemos que extender el control de CardsView para hacerlo compatible con esta funcionalidad.

En nuestro control, vamos a añadir la propiedad IsCustomizingPanGesture que indica si estamos gestionando el PanGesture o dejamos que el control haga su propia gestión. Además, añadimos un evento que se lanza cuando los valores del PanGesture se actualizan.

public class CustomCarouselView : PanCardView.CarouselView
{
    public event EventHandler<PanUpdatedEventArgs> CustomPanUpdated;
    public bool IsCustomizingPanGesture { get; set; }

    public void OnCustomPanUpdated(PanUpdatedEventArgs args)
    {
        CustomPanUpdated?.Invoke(this, args);
    }
}

El siguiente paso, es crear un renderer para este control, heredando de CardsViewRenderer. En ese renderer utilizaremos IsCustomizingPanGesture para decidir si el PanGesture realiza el movimiento del control original o no hace nada. En ambos casos, se llama al evento que hemos creado para poder controlar el PanGesture como deseemos.

public class CustomCarouselViewRenderer : CardsViewRenderer
{
    private float? startX;
    private float? startY;

    public CustomCarouselViewRenderer(Context context) : base(context)
    { }

    public override bool OnTouchEvent(MotionEvent e)
    {
        var args = GetPanUpdateEventArgs(e);
        if (args != null)
            (Element as CustomCarouselView)?.OnCustomPanUpdated(args);

        if ((Element as CustomCarouselView)?.IsCustomizingPanGesture ?? false)
            return true;
        else
            return base.OnTouchEvent(e);
    }

    private PanUpdatedEventArgs GetPanUpdateEventArgs(MotionEvent e)
    {
        var gesture = GetGestureStatus(e.ActionMasked);
        if (gesture == null)
            return null;

        if (gesture.Value == GestureStatus.Started)
        {
            startX = e.GetX();
            startY = e.GetY();
        }

        if (gesture.Value == GestureStatus.Completed)
        {
            startX = null;
            startY = null;
        }

        return new PanUpdatedEventArgs(gesture.Value, 0, GetTotalX(e) + Element.TranslationX, GetTotalY(e) + Element.TranslationY);
    }

    private GestureStatus? GetGestureStatus(MotionEventActions actionMasked)
    {
        switch (actionMasked)
        {
            case MotionEventActions.Down:
                return GestureStatus.Started;
            case MotionEventActions.Move:
                return GestureStatus.Running;
            case MotionEventActions.Up:
                return GestureStatus.Completed;
            default:
                return null;
        }
    }

    private float GetTotalX(MotionEvent ev) => (ev.GetX() - startX.GetValueOrDefault()) / Context.Resources.DisplayMetrics.Density;
    private float GetTotalY(MotionEvent ev) => (ev.GetY() - startY.GetValueOrDefault()) / Context.Resources.DisplayMetrics.Density;
}

Ahora que tenemos este control creado, tenemos que utilizarlo en nuestra Page y tener en cuenta que para Android vamos a usar el evento CustomPanUpdated del control, mientras que para iOS seguiremos usando el PanGestureRecognizer que habíamos creado, ya que iOS ya funcionaba perfectamente.

<Grid x:Name="mainGrid">
    <Grid RowDefinitions="*,500,*"
            ColumnDefinitions="*,250,*">
        <Frame x:Name="frCarousel" Grid.Row="1" Grid.Column="1"
                CornerRadius="20"
                HasShadow="False"
                IsClippedToBounds="True"
                Padding="0"
                BorderColor="Black"
                HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand" >

            <local:CustomCarouselView x:Name="cvImages"
                                        IsCyclical="False"
                                        IsHorizontalOrientation="False"
                                        HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand">
                <local:CustomCarouselView.ItemTemplate>
                    <DataTemplate>
                        <Image Source="{Binding .}"
                                Aspect="AspectFill"
                                BackgroundColor="Transparent"
                                HorizontalOptions="FillAndExpand" VerticalOptions="FillAndExpand"/>
                    </DataTemplate>
                </local:CustomCarouselView.ItemTemplate>
            </local:CustomCarouselView>
        </Frame>
    </Grid>
</Grid>
private PanGestureRecognizer iosPanGesture;
...
protected override void OnAppearing()
{
    base.OnAppearing();
    if (Device.RuntimePlatform == Device.iOS)
    {
        iosPanGesture = new PanGestureRecognizer();
        cvImages.GestureRecognizers.Add(iosPanGesture);
        iosPanGesture.PanUpdated += MoveCard;
    }
    else
    {
        cvImages.CustomPanUpdated += MoveCard;
    }
}

protected override void OnDisappearing()
{
    if (Device.RuntimePlatform == Device.iOS)
    {
        iosPanGesture.PanUpdated -= MoveCard;
    }
    else
    {
        cvImages.CustomPanUpdated -= MoveCard;
    }
    base.OnDisappearing();
}

Ya que estamos usando nuestro control, solo faltaría asignar el valor correcto a IsCustomizingPanGesture. Esto es, si se está moviendo el carousel horizontalmente, asignamos un true, en caso contrario lo dejamos a false

private async Task DoCardAnimationAsync(double totalX)
{
    ...
    BlockAnimation(true);
    cvImages.IsCustomizingPanGesture = true;
    double x = absTotalX - safeMargin;
    ...
}

private async Task FinishCardMovementAsync()
{
    cvImages.IsCustomizingPanGesture = false;
    await frCarousel.TranslateTo(0, 0);

    BlockAnimation(false);
}

Tras estos cambios, ya podemos probar nuestro carousel en Android, pero vamos a ver que se produce un parpadeo, como el que muestro a continuación.

Es debido a que al valor totalX hay que sumarle la translación del frame, por lo que tenemos que modificar el método DoCardAnimationAsync

private async Task DoCardAnimationAsync(double totalX)
{
    ...
    double factor = totalX < 0 ? -1 : 1;
    double tanslationX = Device.RuntimePlatform == Device.iOS ?
                            (factor * x) :
                            (factor * x) + frCarousel.TranslationX;

    await frCarousel.TranslateTo(tanslationX, 0, 1);
}

Ahora sí que hemos cumplido nuestro objetivo de tener un carousel, con tarjeta que se mueven verticalmente, y con desplazamiento horizontal.

Como siempre, aquí os dejo un enlace al repositorio del proyecto de ejemplo que he creado, para que podáis descargarlo y jugar con este control https://github.com/jorgediegocrespo/MovingCardsView

Además, quiero hacer una mención especial a @Andrik_Just4Fun no solo por la calidad del nuget que ha implementado, sino por la ayuda que me ha brindado, resolviéndome las dudas que tenía sobre su control y, así, ayudarme a conseguir el funcionamiento que os he expuesto en este post.