Variable height expand/collapse rows in a ListView with HasUnevenRows in Xamarin Forms

Hi!

Recently I was asked to make a ListView in Xamarin Forms (on iOS at the moment) where you could expand and collapse rows by clicking on them. This is quite a common paradigm but is actually quite hard to do in Xamarin Forms. The problem is, the ListView doesn't re-layout the items when you expand and collapse them, so they overlap and it looks horrible. Here's my ListView:

 <ListView x:Name="listView" ItemsSource="{Binding List, Mode=OneWay}" HasUnevenRows="True" SeparatorVisibility="None" Grid.Row="3">
  <ListView.ItemTemplate>
    <DataTemplate>
       <ViewCell>
         <StackLayout Orientation="Vertical">
             <Grid ColumnSpacing="0" RowSpacing="0" BackgroundColor="White" Margin="10,0" Padding="10">
               <Grid.RowDefinitions>
                 <RowDefinition Height="Auto"/>
                   <RowDefinition Height="Auto"/>
                   <RowDefinition Height="Auto"/>
                 </Grid.RowDefinitions>
                 <Grid.ColumnDefinitions>
                   <ColumnDefinition Width="*"/>
                   <ColumnDefinition Width="*"/>
                   <ColumnDefinition Width="*"/>
                   <ColumnDefinition Width="30"/>
                 </Grid.ColumnDefinitions>
                 <Grid.GestureRecognizers>
                   <TapGestureRecognizer Command="{Binding BindingContext.ExpandCommand, Source={x:Reference thisPage}}" CommandParameter="{Binding .}"/>
                 </Grid.GestureRecognizers>
                 <Label Grid.Row="0" Grid.Column="0" Text="{Binding Name}"/>
                 <Label Grid.Row="0" Grid.Column="1" Text="{Binding Created}"/>
                 <StackLayout Grid.Row="0" Grid.Column="3" HorizontalOptions="End" VerticalOptions="Start" Margin="0,0,10,0" WidthRequest="20" HeightRequest="20">
                   <Image Source="{Binding IsExpanded, Converter={StaticResource BoolToStringConverter}, ConverterParameter='chevronup.png:chevrondown.png'}" WidthRequest="20" HeightRequest="20"/>
                 </StackLayout>
                 <Label Grid.Row="1" Grid.Column="1" Text="Style" HorizontalOptions="Start" />
                 <Label Grid.Row="1" Grid.Column="2" Text="{Binding Style}" HorizontalOptions="Start"/>
                 <Grid Grid.Row="2" ColumnSpacing="0" RowSpacing="0" BackgroundColor="White" Margin="0,10,0,0" Grid.ColumnSpan="4" HorizontalOptions="Start" IsVisible="{Binding IsExpanded}">
                   <Grid.RowDefinitions>
                     <RowDefinition Height="Auto"/>
                     <RowDefinition Height="Auto"/>
                   </Grid.RowDefinitions>
                   <Grid.ColumnDefinitions>
                     <ColumnDefinition Width="*"/>
                     <ColumnDefinition Width="*"/>
                     <ColumnDefinition Width="*"/>
                     <ColumnDefinition Width="30"/>
                   </Grid.ColumnDefinitions>
                   <StackLayout Orientation="Horizontal" Grid.Row="2" Grid.Column="1">
                     <Label Text="Weight" />
                     <Label Text="{Binding Weight}" />
                   </StackLayout>
                   <Label Grid.Row="2" Grid.Column="2" Text="{Binding ActualWeight}" />
                 </Grid>
               </Grid>
             </StackLayout>
           </ViewCell>
         </DataTemplate>
       </ListView.ItemTemplate>
     </ListView>

Now setting the IsExpanded flag on the item will expand the second Grid, but it overlaps the items below it. I read somewhere that the ListView only does layout when the ItemsSource changes, so I first tried setting the ItemsSource to null, waiting for 50 msecs, then setting it back to its original value. This worked okay although the UI flashed a bit, and I thought I was home and dry. Then I tried a list that had a lot of items and realised that the scroll view position was getting reset to the top. I then monkeyed around with scrolling the ListView back to where it should be but it was all pretty ugly.

Then I found someone who was trying to do the opposite to me - he was trying to set the scroll position to the top when he loaded new items in the list, and I realised that the scroll view must not move when you reset the ItemsSource. So here's my final solution:

 ExpandCommand = new DelegateCommand<ListItem>(DoExpand);

       private void DoExpand(ListItem obj)
        {
            var data = new List<ListItem>(this.List);
            var expanded = obj.IsExpanded;
            foreach (var item in data)
                item.IsExpanded = false;
            obj.IsExpanded = !expanded;
            this.List = data;
        }

We change the ItemsSource to a collection that contains exactly the same items, but with some expanded or collapsed. The ListView dutifully re-lays out the items and recalculates the heights, but the scroll view stays in the same place (another bug in my opinion).

Works a treat! The items expand and collapse in place with hardly any flickering.

Enjoy!

Paul Tallett, App Dev Architect, UX Global Practice, Microsoft, July 2017

Disclaimer: The information on this site is provided AS IS? with no warranties, confers no rights, and is not supported by the authors or Microsoft Corporation. Use of included script samples are subject to the terms specified in the Terms of Use.