This blog is part of the .NET MAUI UI July 2024 series with a new post every day of the month. See the full schedule for more.
In any app project, you will inevitably have a list of things to display and be faced with choosing the best control to use. Here I will muse on how I have approached these decisions, focusing on mobile applications.
I surveyed the apps on my phone and snagged a cross-section of different experiences. For the data, I wrote a MockDataService
to generate useful yet random content. For images, I used a combination of Lorem Picsum and images I crafted with ChatGPT.
I think the results are pretty nice, although I warn they are not production polished and feature complete.
Jump to each of the samples below:
- Basic list - even rows
- Reviews - uneven rows
- Social check-in - complex layout
- Learning course - expanding rows
- Who's Watching - flex layout
- Mailboxes - expanding rows
- Contacts - grouping and search
- Shopping - header, multiple data templates, infinite scroll
davidortinau / AllTheLists
Collection of UX samples for lists
All The Lists in .NET MAUI
A collection of various UX samples for lists using .NET MAUI built-in controls and alternative controls.
Read more in my .NET MAUI UI July blog post.
Before I get into each sample, I want to get out of the way some general thoughts.
Anything that does everything does nothing well. In order for a generalized control to be flexible enough to meet a wide variety of needs, compromises will be made in its implementation. This may lead you to be frustrated when it doesn't meet your expectations. A specialized control that only does what you need will best meet the need of that scenario. The other side of that sharp edge is your knowledge and skill also need to level up from general to specialized.
Flat is faster than fat. It's true. If speed is important to your scenario, then a layout that avoids a lot of UI and nesting of controls will perform better at scale because it requires fewer measure and layout calls. Avoid measuring at all costs when performance is critical; give your UI explicit size anytime you can.
UX > UI I see a lot of apps struggling with list scenarios because they jam a ton of UI into them to get the job done, rather than leaning on good UX principles. Do you really need a whole chat experience in every row of the list, or could you navigate to another page? Perhaps you could use a modal experience or a bottom sheet? Anytime your mobile UI has more than one clear call to action, then you're in danger of the UI being less efficient instead of more efficient for your user. Solve problems with UX before UI.
Overview of .NET MAUI List Controls
In my sample, I've used three built-in controls and two community controls that all demonstrate different approaches with strengths and weaknesses. .NET MAUI provides CollectionView
, ListView
, and BindableLayout
. From the community, I chose VirtualListView
and VirtualizeListView
. There are many other options, a few of which I list at the end for you to evaluate yourself.
CollectionView | ListView | BindableLayout | VirtualListView | VirtualizeListView | |
---|---|---|---|---|---|
Virtualized | Yes | Yes | No | Yes | Yes |
Pull-to-Refresh | Yes - with RefreshView | Yes | Yes - with RefreshView | Yes | Yes |
Single Selection | Yes | Yes | No | Yes | Yes |
Multiple Selection | Yes | No | No | Yes | No |
Load More (Threshold) | Yes | No | No | No | Yes |
Layout - Vertical | Yes | Yes | Yes | Yes | Yes |
Layout - Horizontal | Yes | No | Yes | Yes | |
Layout - Grid | Yes | No | Yes | No | No |
Layout - Custom | Yes | No | Yes | No | No |
Behavior | Platform specific | Platform specific | Cross-platform | Platform specific | Cross-platform |
Grouped Data | Yes | Yes | No | Yes | Yes |
Context Menu Items | Yes - with SwipeView | Yes | Yes - with SwipeView | No | No |
Header / Footer | Yes | Yes | No | Yes | Yes |
Predefined Templates | No | Yes | No | No | No |
Empty View Template | Yes | No | Yes - with Community Toolkit | Yes | No |
I will mostly focus on CollectionView
over ListView
unless there is a compelling reason to prefer the latter.
Additional Performance Notes
If the speed of rendering and scroll is of utmost importance for your scenario, then these notes are for you.
Layout Lifecycle - understanding the layout measure and arrange process is essential when you're trying to diagnose and improve the rendering performance of a complex UI. In general, if you know the size of something, then provide it.
Compiled Bindings will improve the rendering and updating of your XAML data-bound controls by telling the compiler the type that is being used. On any enclosing XAML element with a BindingContext specify the type with, for example,
x:DataType="model:Sample"
.Binding Modes - the default binding mode for bindable properties differs from control to control, and property to property. Most are
OneWay
such asView.Rotation
orView.Scale
, while properties often used to capture user input areTwoWay
such asEntry.Text
andListView.IsRefreshing
. In most cases, the default will be what you expect and need, but keep in mind you can change these and have other options such asOneTime
andOneWayToSource
. DocumentationObservableCollection vs List if your data won't be updating dynamically, and perhaps it's a
OneTime
binding, then useList
.Images - make sure your images are appropriately sized for their use on screen. Scaling down images at runtime can be a massive demand on resources, quickly sending you into memory and crash issues. Raster images render faster than vector images in almost every situation. AND if you're loading images from a remote source, be sure you're not blocking the UI loading them. Use a control like FFImage to show a placeholder image and lazy load the remote image. Also, be aware you can customize the image caching policy in .NET MAUI.
Release vs Debug - when evaluating performance, you must be using a release build. There are just so many things going on in a debug build that slow the app down that it's not at all useful to judge. Produce a release build and measure that. And know your options for AOT (Ahead of Time) compilation. .NET 9 has a preview Native AOT for iOS; however, it's extremely strict, and most libraries are not compatible. We did a lot of work in .NET MAUI itself to make it compatible. Android has partial (startup tracing) and full AOT to choose from.
Test on Device - be sure to review release builds on the device. If you know the target device and OS version of your users, then ideally test on that. I've used my iPhone 15 Pro, and a Pixel 5. In 99.9999% of cases, iOS isn't going to be where you see performance concerns.
Layout compression (obsolete) was a run-time optimization in Xamarin.Forms what would remove wrapping layouts from the visual tree. If the layout had no background color or received no user input via gestures, then it could safely be eliminated from the actual UI rendered to the screen. This was useful in Xamarin.Forms where nearly all views (renderers) were wrapped in views. Later in Xamarin.Forms, a set of updated renderers was introduced aptly named "fast renderers" which removed those wrapping views. In .NET MAUI, this redundancy was eliminated, and Layout Compression was not implemented. The API remains, but should be deprecated, and you should treat it so.
Layout 1: Basic List
This is the most simple and common use of a list, so there's not much to say about it. All the rows are exactly the same height and layout. For this need, you cannot go wrong between the virtualized controls. They all perform this scenario very well, even when displaying 10,000 rows.
<CollectionView ItemsSource="{Binding Products}">
<CollectionView.ItemTemplate>
<DataTemplate>
<v:ProductListItem />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<ListView ItemsSource="{Binding Products}">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<v:ProductListItem />
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
You may be wondering why I'm not binding anything above to the ProductListItem
. BindingContext
automatically propagates in this (and most) cases to the children. Here the provided BindingContext
is the single Product
.
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:ffimageloading="clr-namespace:FFImageLoading.Maui;assembly=FFImageLoading.Maui"
xmlns:m="clr-namespace:AllTheLists.Models"
xmlns:vm="clr-namespace:AllTheLists.ViewModels"
x:DataType="m:Product"
x:Class="AllTheLists.Views.ProductListItem">
<Grid Padding="16" ColumnDefinitions="80,*,40" ColumnSpacing="16">
<ffimageloading:CachedImage
Source="{Binding ImageUrl}"
HeightRequest="80"
WidthRequest="80"
LoadingPlaceholder="https://via.placeholder.com/80"
ErrorPlaceholder="error.png">
</ffimageloading:CachedImage>
<VerticalStackLayout Grid.Column="1" Padding="10">
<Label Text="{Binding Name}" FontSize="16" />
<Label Text="{Binding Price, StringFormat='Price: {0:C}'}" FontSize="14" />
<Label Text="{Binding Description}" FontSize="12" LineBreakMode="TailTruncation" />
</VerticalStackLayout>
<CheckBox Grid.Column="2" VerticalOptions="Center" />
</Grid>
</ContentView>
In addition to samples for ListView
and CollectionView
, I checked out VirtualListView
by Redth and VirtualizeListView
by MPowerKit. The latter is a completely cross-platform virtualized control, which is an interesting approach. If consistency across platforms is your goal, then that might be a great option for you.
References:
Layout 2: Reviews [Uneven rows]
The list of EV charging station reviews in the PlugShare mobile app modeled the next sample. While the template is not very complex, it does have a variable-length string that wraps in a Label
. This was problematic in early releases of .NET MAUI, where the text would be clipped or flow offscreen. By default, the ItemSizingStrategy
is to measure only the first item and assume all the rest of the items are the same size. This is much more performant for obvious reasons.
To accommodate the variable sizing, I need to use a strategy that measures all items or each item individually. In practice, this performs well and scrolls very smoothly.
<CollectionView ItemsSource="{Binding Reviews}" ItemSizingStrategy="MeasureAllItems">
<CollectionView.ItemTemplate>
<DataTemplate>
<v:ReviewListItem />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Grid ColumnDefinitions="40,*"
RowDefinitions="Auto,Auto"
ColumnSpacing="8"
Margin="16">
<Image
Source="{Binding StatusImage}"
Grid.Column="0"
Grid.RowSpan="2"
HeightRequest="20"
WidthRequest="20"
VerticalOptions="Start"
HorizontalOptions="Center"/>
<VerticalStackLayout Grid.Column="1" Spacing="8">
<Label
Text="{Binding Author}"
FontSize="18"
FontAttributes="Bold" />
<Label Text="{Binding Comment}" MaxLines="5" Margin="0,0,0,8" />
<Label Text="{Binding Car}" TextColor="Gray"/>
<Label Text="{Binding ChargerType}" TextColor="Gray"/>
</VerticalStackLayout>
<Label Text="{Binding CreatedAt, StringFormat='{0:MM/dd/yyyy}'}"
Grid.Row="0"
Grid.Column="1"
FontSize="10"
TextColor="Gray"
HorizontalOptions="End"
VerticalOptions="Start" />
<BoxView
HeightRequest="1"
BackgroundColor="LightGray"
VerticalOptions="End"
Grid.Column="1"
TranslationY="16" />
</Grid>
References:
Layout 3: Social Check-in [Uneven rows, Complex Layout]
For this sample, I took inspiration from Untapped, a social beer enthusiast app. The Activity feed shows the beer check-ins of your friends, including a rating and an optional photo. When the photo is present, the template is a bit taller, so I again need to handle uneven rows.
In this scenario, CollectionView
has a clear advantage over ListView
because I'm able to specify spacing between the items by calling up the LinearItemsLayout
.
<CollectionView
ItemSizingStrategy="MeasureAllItems"
ItemsSource="{Binding CheckIns}">
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="10" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate>
<v:CheckInListItem />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
To accommodate the different looks, I could have opted for a DataTemplateSelector, but I chose instead to add a HasImage
read-only property to the model in order to show/hide the Image
control as well as adjust the Y position of the content.
public class Product
{
///...
public bool HasImage => !string.IsNullOrWhiteSpace(ImageUrl);
}
<Border
Grid.Row="1"
TranslationY="{Binding Product.HasImage, Converter={StaticResource BoolToIntConverter}}"
I had not previously used the BoolToObjectConverter
from the .NET MAUI Community Toolkit. What a tasty discovery!
<mct:BoolToObjectConverter
x:Key="BoolToIntConverter"
TrueObject="-60"
FalseObject="0"/>
Also great for flip-flopping colors.
<mct:BoolToObjectConverter
x:Key="BoolToColorBrushConverter"
TrueObject="#FFFFFF"
FalseObject="#000000"/>
References:
Layout 4: Learning Course [Expand and Contract]
Those of you who know me are aware I enjoy language learning. One of the apps I've used called TEUIDA has a nice UI that presents courses in units and lessons. Tapping a unit expands to display the different lessons with chapters in a table of contents, roadmap fashion.
Originally, I tried this with CollectionView
and ListView
, but this confirmed a bug in .NET MAUI on iOS where resizing at runtime doesn't trigger the rest of the list control to resize as you would expect. As of version 8.0.60, this works great on Android.
As I evaluated the content to be displayed, I recognized I don't have a LOT of data. On each page of the app, I usually have four units, each with a variable number of chapters and lessons that never exceeds 10.
For these reasons, I chose to use BindableLayout
. In fact, this sample uses three nested BindableLayout
. 😲 Did this become a problem? Nope.
BindableLayout
is a bit of an odd duck, and perhaps in retrospect it should have been a standalone control like the others. Instead it's an attached property that you can add to any other layout. So rather than starting with the control and specifying a layout like with CollectionView
, you start with the layout you prefer and tag on the items source and data template. Simple enough.
<ScrollView>
<VerticalStackLayout Spacing="10"
BindableLayout.ItemsSource="{Binding Items}">
<BindableLayout.ItemTemplate>
<DataTemplate>
<v:LearningUnitListItem />
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
</ScrollView>
The LearningUnitListItem
displays the primary box and a hidden list that is a loop over the chapters and lessons.
To expand and contract the list of chapters and lessons, I'm simply using a click handler and toggling the visibility of the VerticalStackLayout
that contains that content.
References:
Layout 5: Who's Watching [Flex layout]
Inspired by Netflix, and Disney+, and "insert other streaming service," I made a "Who's Watching" sample. This one is very simple. It's a FlexLayout
with BindableLayout
.
<FlexLayout
Direction="Row"
JustifyContent="Center"
Wrap="Wrap"
BindableLayout.ItemsSource="{Binding WhoIsWatching}"
VerticalOptions="Center">
<BindableLayout.ItemTemplate>
<DataTemplate x:DataType="m:Contact">
<VerticalStackLayout
HorizontalOptions="Center"
Spacing="8"
FlexLayout.Basis="40%"
FlexLayout.AlignSelf="Start">
<Image
Source="{Binding ProfilePicture}"
WidthRequest="80"
HeightRequest="80"
Aspect="AspectFill"
BackgroundColor="Transparent">
<Image.Clip>
<EllipseGeometry Center="40, 40" RadiusX="40" RadiusY="40" />
</Image.Clip>
</Image>
<Label
Text="{Binding FirstName}"
HorizontalOptions="Center" />
</VerticalStackLayout>
</DataTemplate>
</BindableLayout.ItemTemplate>
</FlexLayout>
References:
Layout 6: Mailboxes [Expand and Contract]
To reproduce the Mailboxes UI as seen in Mail on iOS, I chose BindableLayout
and Expander
from the .NET MAUI Community Toolkit. While a user could end up with a lot of mail accounts that would then benefit from some virtualization, it seems reasonable to start here and grow up into a CollectionView
when necessary.
Since I've covered the use of BindableLayout
already, I'll focus now on the Expander
. The control has two main parts, the header and the content. The header is always visible, and the content is what is shown/hidden based on the user interaction.
In order to toggle the chevron indicator for open/closed, I started with two Label
controls to display the font icons and used a relative source binding to watch the IsExpanded
property of the parent control. Since I'm within the control, I can reference it this way rather than by name. I refactored this to a single Label
and used the magnificent BoolToObjectConverter
. How did I ever code without that?!
<mct:Expander>
<mct:Expander.Header>
<Grid ColumnDefinitions="*,100,50" RowDefinitions="40">
<Label
Text="Ortinau"
Grid.Column="0"
FontSize="Subtitle"
VerticalOptions="Center" />
<Label Text="38386" Grid.Column="1"
Style="{StaticResource SecondaryLabel}"
HorizontalOptions="End" HorizontalTextAlignment="End"
IsVisible="{Binding Path=IsExpanded, Source={RelativeSource AncestorType={x:Type mct:Expander}}, Converter={StaticResource InvertedBoolConverter}}" />
<Label
Text="{Binding Path=IsExpanded, Source={RelativeSource AncestorType={x:Type mct:Expander}},Converter={StaticResource BoolToChevronConverter}}"
FontSize="14"
FontFamily="FluentUI"
Style="{StaticResource SecondaryLabel}"
TextColor="{StaticResource ActionColor}"
Grid.Column="2"
VerticalOptions="Center"
HorizontalOptions="Center" />
</Grid>
</mct:Expander.Header>
<mct:Expander.Content>
<Border>
<VerticalStackLayout>
<BindableLayout.ItemsSource>
...
</BindableLayout.ItemsSource>
<BindableLayout.ItemTemplate>
<DataTemplate x:DataType="m:Mailbox">
<Grid
ColumnDefinitions="60,*,100,50"
RowDefinitions="40,1">
<Image
Aspect="Center"
HorizontalOptions="Center"
VerticalOptions="Center">
<Image.Source>
<FontImageSource
Glyph="{Binding Icon}"
FontFamily="FluentUI"
Size="18"
Color="{StaticResource ActionColor}" />
</Image.Source>
</Image>
<Label
Text="{Binding Name}"
Grid.Column="1"
FontSize="14"
VerticalOptions="Center" />
<Label
Text="{Binding UnreadCount}"
Grid.Column="2"
Style="{StaticResource SecondaryLabel}"
HorizontalOptions="End"
HorizontalTextAlignment="End" />
<Label
Text="{x:Static f:FluentUI.chevron_right_12_regular}"
Grid.Column="3"
Style="{StaticResource SecondaryLabel}"
VerticalOptions="Center"
FontSize="14"
FontFamily="FluentUI"
HorizontalOptions="Center" />
<BoxView
Grid.ColumnSpan="4"
Grid.Row="1"
Margin="16,0,0,0"
HeightRequest="1"
Color="{AppThemeBinding Light=#f3f3f4, Dark=#333333}" />
</Grid>
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
</Border>
</mct:Expander.Content>
</mct:Expander>
References:
Layout 7: Contacts [Grouping, Search]
Getting back into a sample with the need for virtualization, grouping, and search, I reproduced a Contacts list.
Header
My contact needed to appear at the top of the list and scroll away before the rest of the content. For that, I added a header to the ListView
. Notice it does NOT take a DataTemplate
since there can be only one of these and there's no need to instantiate it lazily.
<ListView.Header>
<HorizontalStackLayout Spacing="16" Padding="16">
<Border StrokeShape="RoundRectangle 40"
StrokeThickness="0">
<Image Source="avatar_01.png"
WidthRequest="80"
HeightRequest="80"
Aspect="AspectFill"
VerticalOptions="Center"
/>
</Border>
<Label Text="David Ortinau"
FontSize="20"
FontAttributes="Bold"
VerticalOptions="Center" />
</HorizontalStackLayout>
</ListView.Header>
Grouping
Preparing your data sources to be grouped and searchable is the first step. In my approach, I get all my contacts in an ordered flat list, group them by the first initial of the last name, and then add them to a list of grouped contacts. The final piece is setting that aside to a new list that is unfiltered on which I can perform searches.
_contacts = MockDataService.GenerateContacts().OrderBy(c => c.LastName).ThenBy(c => c.FirstName).ToList();
ContactsGroups = new List<ContactsGroup>();
var groupedContacts = _contacts.GroupBy(c => c.LastName[0]).OrderBy(g => g.Key);
foreach (var group in groupedContacts)
{
var contactsGroup = new ContactsGroup(group.Key.ToString(), group.ToList());
ContactsGroups.Add(contactsGroup);
}
_unfilteredContactsGroups = new List<ContactsGroup>(ContactsGroups);
To display the grouped list, I went with ListView
primarily because this scenario is one of the fundamental scenarios it was made for. To group, I set IsGroupingEnabled="True"
and provide a template for the group header.
<ListView.GroupHeaderTemplate>
<DataTemplate>
<ViewCell>
<Label Text="{Binding GroupName}"
FontSize="18"
FontAttributes="Bold"
Padding="12,0,0,0"
VerticalOptions="Center"
Background="Transparent" />
</ViewCell>
</DataTemplate>
</ListView.GroupHeaderTemplate>
And just like that I have the basic grouped list.
Search
.NET MAUI provides a SearchBar
control, so I added that above the ListView
on the page. As the user types, the SearchCommand
is executed. The Text
property does default to a TwoWay
binding, so I didn't need to specify that, but I wasn't sure until reading the documentation about binding modes for writing this post. ;)
<SearchBar
x:Name="SearchBar"
Placeholder="Search"
Text="{Binding SearchText, Mode=TwoWay}"
SearchCommand="{Binding SearchCommand}"
VerticalOptions="Start"
BackgroundColor="{AppThemeBinding Light=White, Dark=Black}"
/>
The search command filters down the unfiltered list and repopulates the ContactsGroups
that is bound to the ListView
.
[RelayCommand]
void Search()
{
if (string.IsNullOrWhiteSpace(SearchText))
{
// If the search text is empty, show all contacts
ContactsGroups = _unfilteredContactsGroups;
}
else
{
// If the search text is not empty, show only contacts that contain the search text
ContactsGroups = _unfilteredContactsGroups
.Where(g => g.Any(c =>
c.FirstName.Contains(SearchText, StringComparison.InvariantCultureIgnoreCase)
|| c.LastName.Contains(SearchText, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
}
}
BUT I was having a problem because I would type, and the list would filter, but I was also getting results I didn't expect. Why?!
I explained my situation to Copilot, and it explained (as I suspected) that I was only searching on the group and not the contacts within the group as I expected. Copilot provided the solution.
ContactsGroups = _unfilteredContactsGroups
.Select(g => new ContactsGroup(g.GroupName, g.Where(c =>
c.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| c.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToList()))
.Where(g => g.Any())
.ToList();
References:
Layout 8: Shopping [Header, Data template selector, infinite scroll]
Inspired by the Adidas app, I had a bit of fun making this one. In addition to a header and making product images with ChatGPT, the display pattern is unique. You begin thinking it's going to be a grid layout with two columns, but then after four rows, you hit a product that spans both columns. Ok, so 4 and then 1, right? Wrong. From there on out it's 2 and 1. 🤯
Because I need to load data in batches as the user reaches the end of the list, I chose CollectionView
, which has this feature built-in.
Filter Header
So the header is simple: a horizontal scrolling set of buttons to filter the list.
<CollectionView.Header>
<v:FilterView />
</CollectionView.Header>
FilterView.xaml
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="16" Margin="16,16,-16,16">
<Image
HeightRequest="24"
WidthRequest="24"
Aspect="Center"
Background="Transparent">
<Image.Source>
<FontImageSource FontFamily="FontAwesome"
Glyph="{x:Static f:FontAwesome.Filter}"
Size="14"
Color="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray300}}"/>
</Image.Source>
</Image>
<ScrollView Orientation="Horizontal" Grid.Column="1" HorizontalScrollBarVisibility="Never">
<HorizontalStackLayout Spacing="8">
<Button Text="705" Style="{StaticResource FilterButtonStyle}" />
<Button Text="SAMBA" Style="{StaticResource FilterButtonStyle}" />
<Button Text="GAZELLE" Style="{StaticResource FilterButtonStyle}" />
<Button Text="ULTRABOOST" Style="{StaticResource FilterButtonStyle}" />
<Button Text="ADIZERO" Style="{StaticResource FilterButtonStyle}" />
<Button Text="FORUM" Style="{StaticResource FilterButtonStyle}" />
<Button Text="SUPERSTAR" Style="{StaticResource FilterButtonStyle}" />
<Button Text="CAMPUS" Style="{StaticResource FilterButtonStyle}" />
<Button Text="LITE RACER" Style="{StaticResource FilterButtonStyle}" />
<Button Text="2000S" Style="{StaticResource FilterButtonStyle}" />
</HorizontalStackLayout>
</ScrollView>
</Grid>
Of course in a real app the buttons would be sourced from some collection and I would use a BindableLayout
for them.
Funky Layout Pattern
How could I achieve this layout pattern? I chose to massage the data to represent how it would be displayed. That's what a ViewModel is for anyway. With more help from Copilot, I told it the pattern I needed to achieve and watched the code flow! I KNOW KUNG FU!!!
_productDisplays = new List<ProductDisplay>();
for (int i = 0; i < count; i++)
{
if (i < 4)
{
_productDisplays.Add(new ProductDisplay
{
Products = GenerateProducts().GetRange(i * 2, 2)
});
}
else if (i % 3 == 1)
{
_productDisplays.Add(new ProductDisplay
{
Products = GenerateProducts().GetRange(i * 2 - 1, 1)
});
}
else
{
_productDisplays.Add(new ProductDisplay
{
Products = GenerateProducts().GetRange(i * 2 - 2, 2)
});
}
Seeing GenerateProducts()
repeated may look like it's regenerating data over and over, but I'm actually returning the cached data set once it's populated. It doesn't read well, I admit.
Now that I have the data representing the pattern I need of 4:1:2:1:2:1:2:1 etc., I can move on to the data template.
The CollectionView
implements a linear items layout by default, and that's just fine. Using a data template selector, I can have two templates based on how many items I need to display: Mono and Duo.
public class ShopTemplateSelector : DataTemplateSelector
{
public DataTemplate MonoTemplate { get; set; }
public DataTemplate DuoTemplate { get; set; }
public DataTemplate LoadingMoreTemplate { get; set; }
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
{
ProductDisplay productDisplay = (ProductDisplay)item;
if(productDisplay.IsLoading)
{
return LoadingMoreTemplate;
}
return ((ProductDisplay)item).Products.Count < 2 ? MonoTemplate : DuoTemplate;
}
}
The DuoTemplate
is the more interesting one, as it just displays two MonoTemplate
s side by side.
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:v="clr-namespace:AllTheLists.Views"
xmlns:m="clr-namespace:AllTheLists.Models"
x:DataType="m:ProductDisplay"
x:Class="AllTheLists.Views.DuoProductListItem">
<Grid ColumnDefinitions="*,*" ColumnSpacing="4">
<v:MonoProductListItem Grid.Column="0" BindingContext="{Binding Products[0]}" />
<v:MonoProductListItem Grid.Column="1" BindingContext="{Binding Products[1]}" />
</Grid>
</ContentView>
And just like that, I have the display I need, and I don't feel like it's overly complex.
Infinite Scrolling
As the user reaches near the end of the list, I need to start fetching more data and display an indicator to the user that this is happening. The indicator is meant to appear at the bottom of the list.
The CollectionView
has properties to help with the first part. RemainingItemsThreshold
tells the control when that many items remain to be displayed, then call the event RemainingItemsThresholdReached
and execute the command RemainingItemsThresholdReachedCommand
. In my case, I use both of the latter, but you may only need the command. More on why I do this below.
RemainingItemsThreshold="4"
RemainingItemsThresholdReached="CollectionView_RemainingItemsThresholdReached"
RemainingItemsThresholdReachedCommand="{Binding OnThresholdReachedCommand}"
The OnThresholdReachedCommand
fetches more data and appends it to the end of the ObservableCollection
.
[RelayCommand]
async Task OnThresholdReached()
{
if(IsLoadingMore) return;
IsLoadingMore = true;
VisibleProducts.Add(new ProductDisplay { IsLoading = true });
await Task.Delay(4000); // fake a server call delay, allows the loading template to show
VisibleProducts.Remove(VisibleProducts.Last());
var newProducts = Products.Skip(VisibleProducts.Count).Take(16);
foreach (var product in newProducts)
{
VisibleProducts.Add(product);
}
await Task.Delay(200); // tiny delay for a ui refresh
IsLoadingMore = false;
}
The attentive reader will have noticed some code in the data template selector in from the previous section, which connects now with the command above. As soon as the call is made to get more data, create a blank ProductDisplay
object which has one job, to tell the user IsLoading=true
. In the data template selector, I opt to display this special template and add it to the bottom of the list.
if(productDisplay.IsLoading)
{
return LoadingMoreTemplate;
}
As soon as my data arrives, I remove the last item from the collection and resume adding real data to be displayed.
The IsLoadingMore
boolean protects from calling this method while it's already in progress. Maybe there's a better way to do this, but old habits...
To wrap this up, why am I also handling the event with CollectionView_RemainingItemsThresholdReached
? It's to work around a bug on one of the platforms where the command is not being executed.
private void CollectionView_RemainingItemsThresholdReached(object sender, EventArgs e)
{
((ProductDisplaysViewModel)BindingContext).ThresholdReachedCommand.Execute(null);
}
Conclusion
In conclusion, when choosing the right control for your app scenario, you have options! Consider your specific requirements and the level of customization you need for your list or layout. Prefer CollectionView
over ListView
, and don't ignore BindableLayout
!
As I was writing this, I kept seeing more things to add and try, such as editing and ordering a list. I suppose that's what tomorrow is for.
All of my development here was done on .NET 9 previews using VS Code Insiders and pre-release bits of the .NET MAUI extension for VS Code on a Macbook Pro M1. The addition of XAML IntelliSense and XAML/C# Hot Reload has been great.
One final piece of advice I have to share is to consider all options when solving for a scenario. Choosing a control is only one element. Shaping your data is another. Adapting UX patterns is yet another. While technology may be inflexible and at times will work against you, rather than trying to brute force your way to success remember that you are flexible! I have found this to be a key to success no matter what language or technology I've used.
I hope this has been a fun read and you have found a takeaway or two. Maybe you have a better way to do something, or you hate how I did it. Code can be a very personal thing. Whatever your reaction, be energized to go make something amazing to share with the world.
Top comments (10)
I'm curious how you'd handle a case that's giving me fits. The scenario is a bit like a data grid: each row has some input controls (Entries) and the user can add new rows. In particular, rows are inserted at the top rather than added at the bottom.
My experience is scary. Having more than 10 or 15 rows can make the app unusable. I've done a lot of poking and prodding and it feels like everything's fine until you add an Entry to an ItemTemplate, then everything goes nuts.
I've filed an issue but haven't really found any resolution.
Thanks Owen, I reviewed your project and commented on the issue. The release build seems fine, and I didn't observe the same slowness that I did in the debug build.
I really appreciate that you took time looking at it. I'm really embarrassed it seems I didn't try a Release build.
This at least unsticks me. Something is making our app have a lot of issues with this scenario in release, but this tells me to dig more into what we're actually doing. Thanks again!
Hi David. Excellent blog post!
Well.. it would be if you tested Windows and highlighted the horrible state of lists on Windows or MacCatalyst. I took your AllTheLists repository, cloned it, ran with .NET 9 Preview 6 (after fixing a bad nuget reference).
Barely any of the lists even work.
This blog post is shortsighted and is not representative of the actual experience of MAUI. It's so disappointing to see the desktop platforms get shortsighted. Why is MacCatalyst, Tizen, or Windows even supported if this is the level of support they get?
**This blog post is not indicative of the state of MAUI. **If all you care about is Android/iOS, go for it. Otherwise you will be bit by the many desktop focused bugs, and CV race conditions.
Something that isn't shown here is "dynamic" content in CV. MAUI to this day has issues when items inside of a CollectionView get resized.
incrivel, parabens pelo artigo!!!
I'm a bit disappointed to see tests only on 2 OS'es.
I've recently shipped an app to production with MAUI for more platforms, and ran into various large issues with different list views on one platform or the other.
It was mostly solved by using BindableLayout on StackLayout in almost all cases. Granted, it was always small lists.
question .net maui it's the same to blazor hibrid; can use the same sintaxis?
If you want native controls but the Blazor syntax you could use learn.microsoft.com/en-us/mobile-b..., otherwise any Razor is going to use HTML and depend on the BlazorWebView.
Hi David Ortinau,
Top, very nice and helpful !
Thanks for sharing.
Valuable tips. Thank you, David!