.NET MAUI 中的所有列表
使用 .NET MAUI 内置控件和替代控件的列表的各种 UX 示例的集合。
在我的.NET MAUI UI 七月博客文章中阅读更多内容。
本博客是.NET MAUI UI 2024 年 7 月系列的一部分,每月每天都会发布新文章。查看完整时间表了解更多信息。
在任何应用项目中,你不可避免地会列出需要显示的内容,并面临选择最佳控件的问题。在这里,我将以移动应用为例,分享我是如何做出这些决策的。
我调查了手机上的所有应用程序,并收集了各种不同的体验。对于数据,我编写了一个程序MockDataService
来生成有用但随机的内容。对于图片,我结合使用了Lorem Picsum和我用ChatGPT制作的图片。
我认为结果相当不错,尽管我警告说它们还不是经过生产完善且功能齐全的。
跳转到下面的每个示例:
使用 .NET MAUI 内置控件和替代控件的列表的各种 UX 示例的集合。
在我的.NET MAUI UI 七月博客文章中阅读更多内容。
在介绍每个样本之前,我想先说一下一些一般性的想法。
包罗万象的东西终究是做不好的。为了使通用控件足够灵活,能够满足各种需求,其实现过程中必然会做出一些妥协。当它无法满足你的期望时,你可能会感到沮丧。而一个只做你需要的专用控件,才能最好地满足你的场景需求。然而,另一方面,你的知识和技能也需要从通用升级到专用。
扁平比臃肿更快。这是真的。如果速度对您的场景很重要,那么避免大量 UI 和控件嵌套的布局在大规模情况下会表现更好,因为它需要更少的测量和布局调用。当性能至关重要时,请尽量避免测量;尽可能明确地提供 UI 尺寸。
用户体验 > 用户界面我发现很多应用在列表场景下表现不佳,因为它们为了完成任务而塞入了大量用户界面,而不是遵循良好的用户体验原则。你真的需要在列表的每一行都提供完整的聊天体验吗?或者你可以导航到其他页面吗?或许你可以使用模态框体验或底部表单?如果你的移动用户界面包含多个明确的行动号召,那么你就有可能降低用户界面的效率,而不是提高用户的使用效率。先解决用户体验问题,再解决用户界面问题。
在我的示例中,我使用了三个内置控件和两个社区控件,它们都展示了不同的方法,各有优缺点。.NET MAUI 提供了CollectionView
、ListView
和BindableLayout
。从社区中,我选择了VirtualListView
和VirtualizeListView
。还有许多其他选项,我在最后列出了其中一些,供您自行评估。
CollectionView | 列表视图 | 可绑定布局 | 虚拟列表视图 | 虚拟化列表视图 | |
---|---|---|---|---|---|
虚拟化 | 是的 | 是的 | 不 | 是的 | 是的 |
下拉刷新 | 是的 - 使用 RefreshView | 是的 | 是的 - 使用 RefreshView | 是的 | 是的 |
单选 | 是的 | 是的 | 不 | 是的 | 是的 |
多项选择 | 是的 | 不 | 不 | 是的 | 不 |
加载更多(阈值) | 是的 | 不 | 不 | 不 | 是的 |
布局 - 垂直 | 是的 | 是的 | 是的 | 是的 | 是的 |
布局 - 水平 | 是的 | 不 | 是的 | 是的 | |
布局 - 网格 | 是的 | 不 | 是的 | 不 | 不 |
布局 - 自定义 | 是的 | 不 | 是的 | 不 | 不 |
行为 | 特定于平台 | 特定于平台 | 跨平台 | 特定于平台 | 跨平台 |
分组数据 | 是的 | 是的 | 不 | 是的 | 是的 |
上下文菜单项 | 是的 - 使用 SwipeView | 是的 | 是的 - 使用 SwipeView | 不 | 不 |
页眉/页脚 | 是的 | 是的 | 不 | 是的 | 是的 |
预定义模板 | 不 | 是的 | 不 | 不 | 不 |
空视图模板 | 是的 | 不 | 是的 - 使用社区工具包 | 是的 | 不 |
我将主要关注CollectionView
后者ListView
,除非有令人信服的理由选择后者。
如果渲染和滚动的速度对于您的场景至关重要,那么这些注释适合您。
布局生命周期- 当你尝试诊断和提升复杂 UI 的渲染性能时,了解布局的测量和排列过程至关重要。通常,如果你知道某个元素的尺寸,就提供它。
编译绑定将告知编译器正在使用的类型,从而改进 XAML 数据绑定控件的渲染和更新。在任何包含 BindingContext 的 XAML 元素上,请使用 指定类型,例如x:DataType="model:Sample"
。
绑定模式- 可绑定属性的默认绑定模式因控件和属性而异。大多数属性的默认绑定模式OneWay
为View.Rotation
或View.Scale
,而常用于捕获用户输入的属性为TwoWay
和Entry.Text
。ListView.IsRefreshing
在大多数情况下,默认模式将满足您的预期和需求,但请记住,您可以更改这些模式,并使用其他选项,例如OneTime
和OneWayToSource
。文档
ObservableCollection 与 List如果您的数据不会动态更新,并且也许它是一个OneTime
绑定,那么使用List
。
图片- 确保您的图片尺寸适合在屏幕上使用。在运行时缩小图片尺寸可能会对资源造成巨大需求,导致内存不足和崩溃。在几乎所有情况下,光栅图像的渲染速度都比矢量图像快。如果您从远程源加载图片,请确保不会阻塞 UI 的加载。使用 FFImage 之类的控件显示占位符图片并延迟加载远程图片。另外,请注意,您可以在 .NET MAUI 中自定义图片缓存策略。
发布版本 vs 调试版本- 评估性能时,必须使用发布版本。调试版本中有很多操作会降低应用速度,因此判断发布版本毫无意义。请生成发布版本并进行测量。同时,了解 AOT(提前编译)的选项。.NET 9 为 iOS 提供了预览版原生 AOT;然而,它非常严格,大多数库都不兼容。我们在 .NET MAUI 本身上做了很多工作,以使其兼容。Android 有部分(启动跟踪)和完整 AOT 两种版本可供选择。
在设备上测试- 务必在设备上审核发布版本。如果您知道用户的目标设备和操作系统版本,最好在该设备上进行测试。我使用过 iPhone 15 Pro 和 Pixel 5。在 99.9999% 的情况下,iOS 系统不会出现性能问题。
布局压缩(已过时)是 Xamarin.Forms 中的一项运行时优化,旨在从可视化树中移除包装布局。如果布局没有背景色或未通过手势接收用户输入,则可以安全地将其从实际渲染到屏幕的 UI 中移除。这在 Xamarin.Forms 中非常有用,因为几乎所有视图(渲染器)都包裹在视图中。后来,Xamarin.Forms 引入了一组更新的渲染器,巧妙地命名为“快速渲染器”,它们移除了这些包装视图。在 .NET MAUI 中,这种冗余已被消除,并且布局压缩功能也未实现。该 API 保留,但应该弃用,您也应该如此对待它。
这是列表最简单、最常见的用法,所以没什么好说的。所有行的高度和布局都完全相同。为了满足这一需求,虚拟化控件绝对不会出错。它们都能很好地处理这种情况,即使显示 10,000 行也是如此。
<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>
你可能想知道为什么我没有将上面的任何东西绑定到ProductListItem
。BindingContext
在这种情况下(以及大多数情况下),它会自动传播给子级。这里提供的BindingContext
是单个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>
ListView
除了和的示例之外CollectionView
,我还查看了VirtualListView
Redth 和VirtualizeListView
MPowerKit 的示例。后者是一个完全跨平台的虚拟化控件,这是一种很有意思的方法。如果您的目标是跨平台一致性,那么它可能是一个不错的选择。
参考:
PlugShare移动应用中的电动汽车充电站评论列表模拟了下一个示例。虽然模板并不复杂,但它确实包含一个长度可变的字符串,该字符串被包裹在 中Label
。这在 .NET MAUI 的早期版本中存在问题,文本可能会被截断或溢出屏幕。默认情况下,ItemSizingStrategy
仅测量第一个项目,并假设所有其余项目的大小相同。出于显而易见的原因,这样做性能更高。
为了适应可变的尺寸,我需要使用一种策略来测量所有项目或单独测量每个项目。实践中,这种方法效果很好,滚动非常流畅。
<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>
参考:
在本示例中,我的灵感源自一款啤酒爱好者社交应用Untapped。它的“活动动态”会显示好友的啤酒签到记录,包括评分和可选的照片。如果显示照片,模板会略高一些,因此我再次需要处理行间距不均匀的问题。
在这种情况下,CollectionView
具有明显的优势,ListView
因为我能够通过调用来指定项目之间的间距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>
为了适应不同的外观,我可以选择DataTemplateSelectorHasImage
,但我选择向模型添加只读属性,以显示/隐藏Image
控件以及调整内容的 Y 位置。
public class Product
{
///...
public bool HasImage => !string.IsNullOrWhiteSpace(ImageUrl);
}
<Border
Grid.Row="1"
TranslationY="{Binding Product.HasImage, Converter={StaticResource BoolToIntConverter}}"
我之前没用过BoolToObjectConverter
.NET MAUI 社区工具包。真是个惊喜的发现!
<mct:BoolToObjectConverter
x:Key="BoolToIntConverter"
TrueObject="-60"
FalseObject="0"/>
也非常适合翻转颜色。
<mct:BoolToObjectConverter
x:Key="BoolToColorBrushConverter"
TrueObject="#FFFFFF"
FalseObject="#000000"/>
参考:
认识我的人都知道我热爱语言学习。我用过一款叫TEUIDA的应用,它界面简洁,以单元和课时的形式呈现课程。点击一个单元,会展开显示不同的课时,并以目录的形式呈现,就像路线图一样。
最初,我尝试使用CollectionView
和ListView
,但这证实了 iOS 上 .NET MAUI 的一个错误,即运行时调整大小不会触发列表控件的其余部分按预期调整大小。从 8.0.60 版本开始,此功能在 Android 上可以正常使用。
当我评估要显示的内容时,我意识到我的数据并不多。在应用程序的每个页面上,我通常有四个单元,每个单元的章节和课程数量不定,但不会超过 10 个。
出于这些原因,我选择使用BindableLayout
。事实上,这个示例使用了三个嵌套的BindableLayout
。😲 这有问题吗?没有。
BindableLayout
有点奇怪,回想起来,它应该像其他控件一样是一个独立的控件。但它实际上是一个附加属性,可以添加到任何其他布局中。因此,与其像 那样从控件开始并指定布局CollectionView
,不如从您喜欢的布局开始,并在项目源和数据模板上进行标记。很简单。
<ScrollView>
<VerticalStackLayout Spacing="10"
BindableLayout.ItemsSource="{Binding Items}">
<BindableLayout.ItemTemplate>
<DataTemplate>
<v:LearningUnitListItem />
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
</ScrollView>
显示LearningUnitListItem
主框和循环遍历章节和课程的隐藏列表。
为了扩大和缩小章节和课程列表,我只需使用点击处理程序并切换VerticalStackLayout
包含该内容的可见性。
参考:
受 Netflix、Disney+ 以及“插入其他流媒体服务”的启发,我制作了一个“谁在看”的示例。这个非常简单,它FlexLayout
带有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>
参考:
为了重现 iOS 版 Mail 中显示的邮箱 UI,我选择了.NET MAUI 社区工具包中BindableLayout
的Expander
。虽然用户最终可能会拥有多个邮件帐户,这些帐户会从虚拟化中受益,但从这里开始并在CollectionView
必要时逐步扩展似乎是合理的。
由于我已经介绍过 的使用BindableLayout
,现在我将重点介绍Expander
。该控件主要包含两个部分:标题和内容。标题始终可见,而内容则根据用户交互显示/隐藏。
为了切换 V 形指示器的打开/关闭状态,我首先使用了两个Label
控件来显示字体图标,并使用了相对源绑定来监视IsExpanded
父控件的属性。由于我位于控件内部,因此我可以通过这种方式引用它,而不是通过名称。我将其重构为单个控件Label
,并使用了 magnificent BoolToObjectConverter
。如果没有它,我该如何编写代码呢?!
<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>
参考:
回到需要虚拟化、分组和搜索的样本,我重新创建了一个联系人列表。
我的联系人需要出现在列表顶部,并在其他内容之前滚动显示。为此,我在 中添加了一个标题ListView
。注意,它不需要 ,DataTemplate
因为 只能有一个,而且不需要延迟实例化它。
<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>
第一步是准备好可分组和可搜索的数据源。我的做法是,将所有联系人放入一个有序的平面列表中,按姓氏的首字母进行分组,然后将它们添加到已分组联系人列表中。最后一步是将分组联系人添加到一个未经筛选的新列表中,以便进行搜索。
_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);
为了显示分组列表,我ListView
主要选择了这个场景,因为这个场景是它设计的基本场景之一。为了分组,我设置IsGroupingEnabled="True"
并提供了一个组标题的模板。
<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>
就这样,我有了基本的分组列表。
.NET MAUI 提供了一个SearchBar
控件,所以我把它添加到了ListView
页面的上方。当用户输入时,SearchCommand
就会执行。该Text
属性默认为绑定,所以我不需要指定它,但直到我为了写这篇文章阅读了绑定模式的文档TwoWay
后才确定。;)
<SearchBar
x:Name="SearchBar"
Placeholder="Search"
Text="{Binding SearchText, Mode=TwoWay}"
SearchCommand="{Binding SearchCommand}"
VerticalOptions="Start"
BackgroundColor="{AppThemeBinding Light=White, Dark=Black}"
/>
搜索命令过滤未过滤的列表并重新填充ContactsGroups
与 绑定的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();
}
}
但我遇到了一个问题,因为我输入了内容,列表也过滤了,但我还是得到了意想不到的结果。为什么?!
我向 Copilot 解释了我的情况,它(正如我所料)解释说,我只搜索了群组,而不是我预想的群组内的联系人。Copilot 提供了解决方案。
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();
参考:
受阿迪达斯应用的启发,我做这个应用时也挺有意思的。除了标题和用 ChatGPT 制作产品图片外,它的显示模式也很独特。一开始你以为它会是一个两列的网格布局,但过了四行之后,你发现一个产品横跨了两列。好吧,所以先是 4,然后是 1,对吧?错了。从那以后,就变成了 2 和 1。🤯
因为我需要在用户到达列表末尾时批量加载数据,所以我选择了CollectionView
内置此功能的。
因此标题很简单:一组水平滚动的按钮来过滤列表。
<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>
当然,在真正的应用程序中,按钮将来自某个集合,我会对BindableLayout
它们使用。
我该如何实现这种布局模式?我选择修改数据来表示它的显示方式。反正这就是 ViewModel 的用途。在 Copilot 的帮助下,我告诉它我需要实现的模式,然后观察代码的运行!我懂功夫!!!
_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)
});
}
重复看起来GenerateProducts()
就像是在一遍又一遍地重新生成数据,但实际上我返回的是缓存的数据集,一旦它被填充。我承认,这读起来不太顺畅。
现在我有了代表我需要的模式的数据,即 4:1:2:1:2:1:2:1 等,我可以转到数据模板。
默认情况下,它CollectionView
实现了线性项目布局,这很好。使用数据模板选择器,我可以根据需要显示的项目数量选择两个模板:Mono 和 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;
}
}
这个DuoTemplate
更有趣,因为它只是MonoTemplate
并排显示两个。
<?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>
就这样,我得到了我需要的显示,而且我并不觉得它过于复杂。
当用户接近列表末尾时,我需要开始获取更多数据,并向用户显示一个指示。该指示应该显示在列表底部。
具有CollectionView
有助于第一部分操作的属性。RemainingItemsThreshold
当剩余项目数量达到指定数量时,它会通知控件,然后调用事件RemainingItemsThresholdReached
并执行命令RemainingItemsThresholdReachedCommand
。就我而言,我会同时使用后两者,但您可能只需要命令。下文将详细介绍我这样做的原因。
RemainingItemsThreshold="4"
RemainingItemsThresholdReached="CollectionView_RemainingItemsThresholdReached"
RemainingItemsThresholdReachedCommand="{Binding OnThresholdReachedCommand}"
获取OnThresholdReachedCommand
更多数据并将其附加到的末尾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;
}
细心的读者会注意到上一节中数据模板选择器中的一些代码,这些代码现在与上面的命令相连接。一旦调用获取更多数据,就会创建一个空白ProductDisplay
对象,该对象只有一个任务,用于告知用户IsLoading=true
。在数据模板选择器中,我选择显示这个特殊模板并将其添加到列表底部。
if(productDisplay.IsLoading)
{
return LoadingMoreTemplate;
}
我的数据一到达,我就从集合中删除最后一项,并继续添加要显示的真实数据。
布尔IsLoadingMore
值可以防止在方法执行过程中调用该方法。也许有更好的方法,但还是老习惯……
总而言之,为什么我还要用 来处理事件CollectionView_RemainingItemsThresholdReached
?这是为了解决某个平台上命令无法执行的错误。
private void CollectionView_RemainingItemsThresholdReached(object sender, EventArgs e)
{
((ProductDisplaysViewModel)BindingContext).ThresholdReachedCommand.Execute(null);
}
总而言之,在为您的应用场景选择合适的控件时,您有很多选择!请考虑您的具体需求以及列表或布局所需的自定义级别。优先考虑CollectionView
,ListView
不要忽略BindableLayout
!
写到这里,我不断看到有更多事情需要添加和尝试,比如编辑和整理清单。我想这就是明天该做的事情。
我的所有开发工作都是在 .NET 9 预览版上完成的,使用了VS Code Insiders以及 Macbook Pro M1 上VS Code 的 .NET MAUI 扩展的预发布版本。XAML IntelliSense 和 XAML/C# Hot Reload 的加入非常出色。
我最后要分享的建议是,在解决一个场景时,要考虑所有选项。选择控件只是其中之一,调整数据是另一个,调整用户体验模式又是另一个。虽然技术可能不够灵活,有时会对你不利,但与其试图强行取得成功,不如记住,你是灵活的!我发现,无论我使用什么语言或技术,这都是成功的关键。
希望这篇文章读起来有趣,并且您能从中获得一些启发。也许您有更好的方法,或者您不喜欢我的做法。代码本身可能非常个人化。无论您的反应如何,请充满能量地去创造一些精彩的东西,与世界分享。
文章来源:https://dev.to/davidortinau/all-the-lists-in-net-maui-33bd