Problema con ListView dentro de un CardView en Xamarin Forms

¡Hola!
El otro día me encontré con un pequeño (no tan pequeño) problema utilizando el control CardView donde cada elemento del carrusel era un ListView. Algo parecido a lo siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    <?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:d="http://xamarin.com/schemas/2014/forms/design"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:cards="clr-namespace:PanCardView;assembly=PanCardView"
        mc:Ignorable="d"
        x:Name="this"
        x:Class="FixListViewWithCardsView.MainPage">
 
       <cards:CarouselView
           ItemsSource="{Binding Novels}">
           <cards:CarouselView.ItemTemplate>
               <DataTemplate>
 
                   <ListView
                       IsPullToRefreshEnabled="True"
                       RefreshCommand="{Binding Source={x:Reference this},Path=BindingContext.RefreshNovelCommand}"                   
                       ItemsSource="{Binding Characters}">
                       <ListView.Header>
                           <Label
                               FontSize="Large"
                               HorizontalTextAlignment="Center"
                               VerticalTextAlignment="Center"
                               HeightRequest="150"
                               Text="{Binding Title}"/>
                       </ListView.Header>                  
                   </ListView>
 
               </DataTemplate>
           </cards:CarouselView.ItemTemplate>
       </cards:CarouselView>
 
    </ContentPage>

Visualmente, el resultado sería algo así:

El gif anterior corresponde a iOS, pero, ¿cómo se comporta en Android?

Pues como podemos ver, no funciona demasiado bien.. Por un lado, parece que casi no podemos hacer scroll en el listado, el PullToRefresh del listado tampoco funciona, interfiere el gesto del carrusel con el del listado, vamos, un desastre :S
El carrusel tiene una propiedad llamada VerticalSwipeThresholdDistance, que indica la distancia para reconocer el movimiento como vertical, es decir, le podemos dar un valor alto (por ejemplo, 500) para intentar que no se mezclen los gestos del listado y el carrusel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
   <cards:CarouselView
       VerticalSwipeThresholdDistance="500"       ItemsSource="{Binding Novels}">
       <cards:CarouselView.ItemTemplate>
           <DataTemplate>
 
               <ListView
                   IsPullToRefreshEnabled="True"
                   RefreshCommand="{Binding Source={x:Reference this},Path=BindingContext.RefreshNovelCommand}"                   
                   ItemsSource="{Binding Characters}">
                   <ListView.Header>
                       <Label
                           FontSize="Large"
                           HorizontalTextAlignment="Center"
                           VerticalTextAlignment="Center"
                           HeightRequest="150"
                           Text="{Binding Title}"/>
                   </ListView.Header>                  
               </ListView>
 
           </DataTemplate>
       </cards:CarouselView.ItemTemplate>
   </cards:CarouselView>

Y, efectivamente, funciona más o menos:

Podemos ver que ha mejorado mucho con respecto al principio, pero no parece suficiente. Cuando hacemos un scroll que no sea totalmente vertical, el carrusel se mueve; hay problemas entre el PullToRefresh y el carrusel, de vez en cuando cambia de página el carrusel al hacer scroll en el listado..
En cambio en iOS funciona perfectamente, en ningún momento se mezclan los gestos entre el listado y el carrusel. Vamos a intentar que se comporte de la misma forma en Android. El primer paso es ver exactamente cómo se comporta en iOS y lo hace de la siguiente forma:
* Mientras se está interactuando con el scroll del listado el carrusel está desactivado.
* Mientras el listado está en movimiento, el carrusel está desactivado.
* Si el indicador del PullToRefresh está visible y se está interactuando con el scroll del listado, el carrusel está desactivado.

Corrigiendo comportamiento en Android

Para poder hacer estos tres pasos, vamos a necesitar crear un custom render. Si no sabes cómo crear un renderer, echa un vistazo a este enlace. Vamos a partir de lo siguiente para empezar a crearlo. En nuestro proyecto compartido:

1
2
3
    public class FixParentInteractionListView : ListView
    {
    }

Y en nuestro proyecto Android, el correspondiente renderer:

1
2
3
4
5
6
7
8
9
10
11
    [assembly: ExportRenderer(typeof(FixParentInteractionListView), typeof(FixParentInteractionListViewRenderer))]
    namespace FixListViewWithCardsView.Droid
    {
        public class FixParentInteractionListViewRenderer : ListViewRenderer
        {
            public FixParentInteractionListViewRenderer(Context context) : base(context)
            {
            }
        }
 
    }

Vamos a empezar corrigiendo que, en caso de que el listado esté en movimiento, el carrusel debe estar bloqueado. Para ello nos suscribimos al evento del listado que nos indica un cambio de estado y, siempre que no sea Idle, dejamos desactivado el carrusel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
    [assembly: ExportRenderer(typeof(FixParentInteractionListView), typeof(FixParentInteractionListViewRenderer))]
    namespace FixListViewWithCardsView.Droid
    {
        public class FixParentInteractionListViewRenderer : ListViewRenderer
        {
            ScrollState scrollState = ScrollState.Idle;
 
            public FixParentInteractionListViewRenderer(Context context) : base(context)
            {
            }
 
            protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.ListView> e)
            {
                base.OnElementChanged(e);
                if (e.OldElement != null && Control != null)
                {
                    Control.ScrollStateChanged -= OnScrollStateChanged;
                }
 
                if (e.NewElement != null && Control != null)
                {
                    Control.ScrollStateChanged += OnScrollStateChanged;
                }
            }
 
            private void OnScrollStateChanged(object sender, AbsListView.ScrollStateChangedEventArgs e)            {                scrollState = e.ScrollState;                var disabledParentInteraction = scrollState != ScrollState.Idle;                 var carousel = GetCarouselParent(Control);                carousel.RequestDisallowInterceptTouchEvent(disabledParentInteraction);                carousel.Element.ShouldThrottlePanInteraction = disabledParentInteraction;            } 
            private CardsViewRenderer GetCarouselParent(IViewParent view)
            {
                if (view.Parent is CardsViewRenderer carouselView)
                {
                    return carouselView;
                }
 
                return GetCarouselParent(view.Parent);
            }
        }
    }

Seguimos con lo siguiente. Ahora queremos que, si el usuario está interactuando con el listado, el carrusel esté desactivado. Nos vamos a ayudar del evento Scroll del listado para bloquear el carrusel cuando se notifique un cambio de scroll y el listado no esté en estado Idle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
    [assembly: ExportRenderer(typeof(FixParentInteractionListView), typeof(FixParentInteractionListViewRenderer))]
    namespace FixListViewWithCardsView.Droid
    {
        public class FixParentInteractionListViewRenderer : ListViewRenderer
        {
            ScrollState scrollState = ScrollState.Idle;
 
            public FixParentInteractionListViewRenderer(Context context) : base(context)
            {
            }
 
            protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.ListView> e)
            {
                base.OnElementChanged(e);
                if (e.OldElement != null && Control != null)
                {
                    Control.ScrollStateChanged -= OnScrollStateChanged;
                    Control.Scroll -= OnScrollChange;
                }
 
                if (e.NewElement != null && Control != null)
                {
                    Control.ScrollStateChanged += OnScrollStateChanged;
                    Control.Scroll += OnScrollChange;
                }
            }
 
            private void OnScrollStateChanged(object sender, AbsListView.ScrollStateChangedEventArgs e)
            {
                scrollState = e.ScrollState;
                var disabledParentInteraction = scrollState != ScrollState.Idle;
 
                var carousel = GetCarouselParent(Control);
                carousel.RequestDisallowInterceptTouchEvent(disabledParentInteraction);
                carousel.Element.ShouldThrottlePanInteraction = disabledParentInteraction;
            }
 
            private void OnScrollChange(object sender, AbsListView.ScrollEventArgs e)            {                           var disabledParentInteraction = scrollState != ScrollState.Idle;                 var carousel = GetCarouselParent(Control);                carousel.RequestDisallowInterceptTouchEvent(disabledParentInteraction);                carousel.Element.ShouldThrottlePanInteraction = disabledParentInteraction;            } 
 
            private CardsViewRenderer GetCarouselParent(IViewParent view)
            {
                if (view.Parent is CardsViewRenderer carouselView)
                {
                    return carouselView;
                }
 
                return GetCarouselParent(view.Parent);
            }
        }
    }

Por último, ya sólo nos queda deshabilitar el carrusel mientras se está interactuando con el PullToRefresh. Gracias a que podemos sobrescribir el método CreateNativePullToRefresh, tenemos fácil acceso al elemento SwipeRefreshLayout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
    [assembly: ExportRenderer(typeof(FixParentInteractionListView), typeof(FixParentInteractionListViewRenderer))]
    namespace FixListViewWithCardsView.Droid
    {
        public class FixParentInteractionListViewRenderer : ListViewRenderer
        {
            ScrollState scrollState = ScrollState.Idle;
            SwipeRefreshLayout refreshLayout;
 
            public FixParentInteractionListViewRenderer(Context context) : base(context)
            {
            }
 
            protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.ListView> e)
            {
                base.OnElementChanged(e);
                if (e.OldElement != null && Control != null)
                {
                    Control.ScrollStateChanged -= OnScrollStateChanged;
                    Control.Scroll -= OnScrollChange;
                }
 
                if (e.NewElement != null && Control != null)
                {
                    Control.ScrollStateChanged += OnScrollStateChanged;
                    Control.Scroll += OnScrollChange;
                }
            }
 
            protected override SwipeRefreshLayout CreateNativePullToRefresh(Context context)            {                refreshLayout = base.CreateNativePullToRefresh(context);                return refreshLayout;            }             public override bool OnInterceptTouchEvent(MotionEvent ev)            {                if (ev.Action == MotionEventActions.Down || ev.Action == MotionEventActions.Move)                {                    var isRefreshing = refreshLayout.OnInterceptTouchEvent(ev);                    if (isRefreshing)                    {                        var carousel = GetCarouselParent(Control.Parent);                        carousel.RequestDisallowInterceptTouchEvent(true);                    }                }                return base.OnInterceptTouchEvent(ev);            } 
 
            private void OnScrollStateChanged(object sender, AbsListView.ScrollStateChangedEventArgs e)
            {
                scrollState = e.ScrollState;
                var disabledParentInteraction = scrollState != ScrollState.Idle;
 
                var carousel = GetCarouselParent(Control);
                carousel.RequestDisallowInterceptTouchEvent(disabledParentInteraction);
                carousel.Element.ShouldThrottlePanInteraction = disabledParentInteraction;
            }
 
            private void OnScrollChange(object sender, AbsListView.ScrollEventArgs e)
            {           
                var disabledParentInteraction = scrollState != ScrollState.Idle;
 
                var carousel = GetCarouselParent(Control);
                carousel.RequestDisallowInterceptTouchEvent(disabledParentInteraction);
                carousel.Element.ShouldThrottlePanInteraction = disabledParentInteraction;
            }
 
 
            private CardsViewRenderer GetCarouselParent(IViewParent view)
            {
                if (view.Parent is CardsViewRenderer carouselView)
                {
                    return carouselView;
                }
 
                return GetCarouselParent(view.Parent);
            }
        }
    }

Y con esto tendríamos más o menos funcionando nuestro carrusel de la misma forma que funciona en iOS:

El código es, como siempre, bastante mejorable. Por ejemplo, si tenemos un ListViewRenderer con más funcionalidades, podríamos extraer todo esto a un helper y parametrizar si queremos o no aplicar estas correcciones, de esta manera nos quedaría el código que aplica las correcciones más encapsulado. Además, estamos repitiendo bastante código, accediendo todo el rato a buscar el GetCarouselParent… Pero eso no está dentro del alcance del post 🙂
¡Un saludo!

2 thoughts on “Problema con ListView dentro de un CardView en Xamarin Forms”

    1. Buenas!
      Pues efectivamente *creo* que con el CollectionView+RefreshView funciona bien, pero sólo lo tenemos disponible a partir de versiones 4.3 de Xamarin Forms… Y con respecto al nuevo CarouselView, lo mismo, disponible en las últimas versiones y, además, había ciertas funcionalidades que tiene el CardView y no están todavía disponibles en el CarouselView.

      ¡Un saludo!

Responder a luisnet Cancelar respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *