C--开发者的--NET-MAUI-指南-全-

C# 开发者的 .NET MAUI 指南(全)

原文:zh.annas-archive.org/md5/5ba72982a6bbf2e43cc7bf3618e2448c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

.NET MAUI 是微软的跨平台开发框架,用于构建 iOS、Android、Windows、Mac 和 Tizen 应用程序。使用.NET MAUI,您只需构建一次 UI 和逻辑,并为每个平台生成本地代码。它是Xamarin.Forms的后续技术,并增加了强大的新功能和能力。

.NET MAUI 社区工具包补充.NET MAUI,提供源代码生成器、行为等更多功能,极大地增强并扩展.NET MAUI,使其成为构建应用程序的首选开发框架。

本书面向对象

本书面向任何对 C#有基本理解并希望编写跨平台应用程序的人。如果您不是 C#程序员,但具有其他面向对象程序的经验,您应该能够轻松地跟随示例。

本书涵盖内容

第一章组装您的工具和创建您的第一个应用程序,向您展示如何下载您需要的(免费)软件,并教会您如何创建一个“即用型”应用程序,以了解提供的功能,帮助您开始。

第二章我们将构建什么 – 忘不了,介绍了我们的非平凡、真实世界的应用程序——“忘不了”。我们将介绍它的功能,然后我们将浏览各个页面,以查看整个完成的项目,这是我们将在本书的剩余部分工作的。

第三章XAML 和 Fluent C#,探讨了用于创建.NET MAUI 应用程序 UI 的标记语言。我们还将检查如果您更喜欢,如何使用 C#编写 UI。

第四章MVVM 和控件,探讨了构建.NET MAUI 应用程序最流行和最强大的架构——模型-视图-视图模型MVVM)。我们还将查看用于创建 UI 的许多核心控件。

第五章高级控件,在前一章的基础上,添加了更多高级控件,以创建更强大和健壮的用户界面。

第六章布局,专注于在视图中排列控件并创建专业外观的技术。

第七章理解导航,展示了如何从一个页面跳转到另一个页面,以及如何在导航过程中传递数据。任何严肃的.NET MAUI 应用程序都不应只有一个页面。

第八章存储和检索数据,探讨了持久化数据的两种方式。第一种适用于存储用户对程序的偏好。第二种涉及使用 SQLite 构建关系型数据库。

第九章单元测试,展示了如何使用 xUnit 和模拟工具nSubstitute来创建强大的单元测试。没有一套广泛的单元测试,任何.NET MAUI 程序都不完整,以确保程序正确运行。

第十章消费 REST 服务,探讨了如何验证用户的登录并从 Azure 获取他们的数据。许多现代应用程序从云中获取数据,而最流行的方式是通过使用 REST 服务。

第十一章探索高级主题,将进入专家技术,例如管理视觉状态、使用行为和触发器以及在运行时选择数据模板。

为了充分利用这本书

您至少需要具备面向对象语言(特别是 C#)的基本经验。您不需要了解 C#的最新进展,示例代码将进行深入解释。您需要 Visual Studio 的最新版本;社区版是免费的,并且可以很好地工作。如果您使用 Mac(或 Linux),示例应该可以在 Visual Studio for Mac 上正常工作,尽管它们不是在 Mac 上开发的。

本书中涵盖的软件/硬件 操作系统要求
.NET MAUI Windows, macOS, 或 Linux
.NET MAUI 社区工具包 Windows, macOS, 或 Linux

visualstudio.com安装 Visual Studio(或 Visual Studio for Mac)。不要将其与 Visual Studio Code 混淆,它是一个不同的编辑器。

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取它。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

请注意,只有一个仓库,每个章节都有一个分支。分支代表已完成章节的代码。如果您想跟随,请从上一章的分支开始。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份 PDF 文件,其中包含本书中使用的截图和图表的颜色图像。您可以从这里下载:https://packt.link/z75ye。

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

<VerticalStackLayout x:Name="LoginStackLayout">
    <HorizontalStackLayout WidthRequest="300">
        <Label
            Style="{StaticResource LargeLabel}"
            Text="User Name" />

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

<Entry
            HorizontalOptions="End"
            Placeholder="User Name"
            Text="{Binding Name}"
            WidthRequest="150" />

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“要开始,请安装sqlite-net-pcl的最新版本 NuGet 包,如图图 8.2所示。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们欢迎读者的反馈。

customercare@packtpub.com 并在邮件主题中提及书籍标题。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你向我们报告。请访问www.packtpub.com/support/errata并填写表格。

copyright@packt.com 并附上材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com

分享你的想法

一旦你阅读了.Net MAUI for C# Developers,我们很乐意听听你的想法!请点击此处直接转到该书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

你喜欢在路上阅读,但又无法携带你的印刷书籍到处走?

你的电子书购买是否与你的选择设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠不会就此结束,你还可以获得独家折扣、时事通讯和每日免费内容的每日访问权限。

按照以下简单步骤获取好处:

  1. 扫描二维码或访问以下链接

https://packt.link/free-ebook/9781837631698

  1. 提交你的购买证明

  2. 就这些!我们将直接将你的免费 PDF 和其他好处发送到你的电子邮件。

第一部分 – 入门

在这部分,我们将帮助你设置所需的软件,并检查我们将要构建的项目。然后,我们将继续探讨 XAML——.NET MAUI 的标记语言。接下来,我们将深入研究 MVVM,它是它的架构以及大多数.NET MAUI 应用的架构,我们将对用于构建用户界面的控件和结构进行广泛审查。

这部分包含以下章节:

  • 第一章组装你的工具和创建你的第一个应用

  • 第二章我们将构建什么 – 忘不了

  • 第三章XAML 和 Fluent C#

  • 第四章, MVVM 和控件

  • 第五章, 高级控件

  • 第六章, 布局

第一章:组装你的工具和创建你的第一个应用程序

在本书中,我们将使用一个共同的代码库构建 iOS、Android、Windows 和 Mac 应用程序。除非你想为 iOS 和 Mac 构建应用程序,在这种情况下你需要一台 Mac 电脑,否则你需要的一切都是免费的。我将假设你有一台 Mac,但如果你没有,变化非常小;你只是可以部署的平台更有限。

如果你没有 Mac 的替代方案

微软的 James Montemagno 有一个针对没有 Mac 的解决方案的视频。虽然存在严重的限制,但需求必须。我个人的建议是,如果你没有 Mac,请使用 Android 进行开发。以下是视频:www.youtube.com/watch?v=snQ1C6Cppr8

在接下来的章节中,你将看到我们将逐步构建的一个非平凡的 .NET MAUI 项目。在这个过程中,我们将检查如何使用 XAML(一种标记语言)和 C# 创建 用户界面UI)。

MAUI Blazor

一种替代方案,本书未涉及,是使用 MAUI Blazor,它允许你利用 Blazor 技能创建跨平台应用程序。你可以在 bit.ly/MauiBlazor 了解更多关于 MAUI Blazor 的信息。

在本书的第一部分,我们将讨论 .NET MAUI 的主要架构:模型-视图-视图模型MVVM)。然后我们将深入了解用于创建强大 UI 的各种控件,接着将有一章专门讨论如何在页面上布局这些控件。

我们将继续讨论 Shell 导航架构以及如何从一个页面移动到另一个页面,并在需要时传递数据。我们将查看数据持久化,然后停下来讨论测试代码这个至关重要的主题。

虽然 .NET MAUI 提供了丰富的控件,但有时你需要 Microsoft 没有预料到的东西,因此我们将专门用一章来创建自定义控件。(一旦你有了自定义控件,你就可以在后续的任何 .NET MAUI 项目中使用它。)

在本书的最后部分,我们将探讨消费 REST API 并创建用于移动和桌面应用程序的相同 REST API 的网络前端,这次使用 Blazor

在本章中,你将学习如何获取和安装 Visual Studio 以编写程序,以及 Git 以管理和保护你的代码。每个章节的最终代码将位于一个专门的分支中,最终产品位于主分支。

在本章中,你将找到以下内容:

  • Visual Studio 的描述,以及安装说明

  • Git 的描述,以及安装说明

  • 如何创建你的第一个、现成的程序,以及该项目文件的浏览

应用程序与应用程序的区别

由于我们将为 iOS 和 Android(指应用程序)以及 Windows 和 Mac(指应用程序)构建,我将交替使用这两个术语。

让我们获取所需的软件,并设置 Visual Studio。

技术要求

要跟随本章和本书的内容,您需要获取并安装 Visual Studio 和 Git。为此,您需要一个 Windows 机器(Windows 10 或更高版本)。此外,如果您想为 iOS 和/或 Mac 编写代码,您需要一台与您的 Windows 电脑在同一网络上的 Apple 电脑。

本书中的所有代码都可在 github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers 上找到。每个章节的代码将有自己的分支,并在该章节的 技术要求 部分中注明。请注意,第 1第 2 章没有代码。

关于 Visual Studio Mac 的说明

应该可以使用 Visual Studio Mac 跟随本书的内容,但一些菜单和当然许多快捷键将不同。根据我的经验,Visual Studio Mac 在新功能实现上会稍后跟随 Visual Studio。如果您只有 Mac,那么当然可以在那里进行开发。如果您两者都有,或者只有 Windows 机器,您会发现使用 Visual Studio(Windows 版本)进行跟随会更容易一些。

在此期间,我想提到,我正在使用一台装有 64 GB 内存和 1 TB 硬盘的台式电脑运行 Windows 11。这本书不需要这些。您至少需要 16 GB 的内存来进行 .NET MAUI 编程。

获取和安装 Visual Studio

本书最首要的软件是来自微软的最新版本的 Visual Studio。当然,您可以使用任何数量的编辑器或 集成开发环境IDE)来编写 .NET 和 .NET MAUI 应用程序,但在这本书中,我将使用 Visual Studio,因为它是我认为最适合这项工作的最强大的 IDE。所有示例都将使用 Visual Studio,如果您也这样做,至少在阅读本书的过程中,您的工作将会更加容易。

要获取 Visual Studio,请打开浏览器并导航到 visualstudio.microsoft.com/。微软经常更改此页面的外观,但您应该会看到下载 Visual StudioVisual Studio for MacVisual Studio Code 的机会。

安装 Visual Studio

在网站上,点击 下载 Visual Studio。您可以下载您喜欢的任何版本。请注意,社区版本是免费的。如果您已经安装了 Visual Studio 2022 或更高版本,您不需要添加另一个副本,尽管如果您有足够的磁盘空间,它们可以并行运行。安装时,请按照以下步骤操作:

  1. 一旦下载了 Visual Studio,点击可执行文件以开始安装过程。安装程序将更新,并会显示如图 图 1 所示的 工作负载 对话框。

![图 1.1 – Visual Studio 工作负载

![img/Figure_1.1_B19723.jpg]

图 1.1 – Visual Studio 工作负载

  1. Visual Studio 允许你选择和选择你需要的工作负载,以便它的大小不会超过必要。务必检查Azure 开发.NET 多平台应用 UI 开发.NET 桌面开发,如图图 1**.2所示。

图 1.2 – 选择工作负载

图 1.2 – 选择工作负载

  1. 接下来,点击安装并为自己泡一杯咖啡;这可能需要几分钟。你应该能在 Visual Studio 安装程序中看到进度,如图图 1**.3所示。

图 1.3 – Visual Studio 安装程序

图 1.3 – Visual Studio 安装程序

安装完成后,Visual Studio 将打开。

关于 ReSharper 的一些建议

ReSharper是一个非常强大的工具,它极大地提高了.NET 开发者的生产力。然而,它不是免费的,尽管我在自己的工作中每天都在使用它,但在这本书中我们不会使用它。由于我们可以在 Visual Studio 中完成所有操作而不需要 ReSharper(尽管可能需要更多的按键),所以没有关系。

安装了 Visual Studio 后,唯一剩下的问题是存储、保护和检索源代码。为此,我们将使用Git – 行业标准。最终应用程序将在主分支中,每个章节的代码将在一个专门的分支中。

Git

你接下来需要的软件是 Git。要下载 Git,请导航到git-scm.com/download并选择你的操作系统。我会选择 Windows。接下来,我会在独立安装程序下选择64 位 Git for Windows 安装。这将立即开始下载。双击下载的文件进行安装。如果你已经安装了 Git,这将更新它。

你不需要任何 Git GUI,因为我们将通过 Visual Studio 与 Git 交互。

安装完成后,你会看到以下选项:启动 Git Bash查看发行说明。取消选中两者并点击完成

让我们继续探索 Visual Studio。

打开 Visual Studio

你需要从 Visual Studio 内部获取其余的软件,所以让我们先打开它。如果安装按预期进行,启动对话框将显示在屏幕上:

  1. 点击创建新项目。(如果你直接进入 Visual Studio,跳过了启动对话框,只需点击文件 | 新建项目。)

  2. 现在是时候选择我们想要的模板了。模板可以让开始变得更容易。在MAUI中,将展示几个选择;你需要选择.NET MAUI App,如图所示:

图 1.4 – 创建新项目

图 1.4 – 创建新项目

  1. 点击ForgetMeNotDemo(你将在本书中构建的项目),而不是一个只是为了快速浏览的示例项目。给它起一个有创意的名字,比如SampleApp,并将其放置在你稍后能轻松找到的磁盘位置。在点击下一步之前,确保你的对话框看起来类似于图 1**.5

图 1.5 – 命名你的项目

图 1.5 – 命名你的项目

  1. 点击下一步,然后使用下拉菜单选择.NET 的最新版本。在撰写本文时,那就是.NET 7。最后,点击创建

注意

由于微软一直在更新 Visual Studio,你的屏幕或步骤可能会有所不同。不要为此担心。我使用的是 Visual Studio 2022,版本 17.4.3。只要你的版本相同或更高,你就准备好了。但为了保险起见,让我们启动示例应用(F5)。你应该能看到类似图 1**.6的东西。

图 1.6 – 运行你的应用

图 1.6 – 运行你的应用

  1. 在前一个图中看到的屏幕上,点击按钮几次以确保它正在工作。

通常情况下,我不会详细介绍如何在 Visual Studio 上做简单的事情。假设你是 C#程序员,因此你很可能熟悉 Visual Studio。另一方面,万一你不熟悉,我会描述如何做任何不是立即直观的事情。接下来,让我们更详细地探索随盒装应用。

应用快速浏览

让我们快速浏览一下随盒装应用附带的内容。首先,通过按菜单栏中的红色方块按钮停止应用。确保解决方案资源管理器已打开(如果没有,请转到视图 | 解决方案资源管理器)。注意,有三个文件夹和四个文件,如图图 1**.7所示:

图 1.7 – 三个文件夹和四个文件

图 1.7 – 三个文件夹和四个文件

扩展名为.xaml的文件是 XAML 文件——也就是说,它们使用 XAML 标记语言。我不会假设你知道 XAML,实际上,在整个本书中,我将提供 XAML 和流畅 C#的布局和其他代码,但这将在下一章中介绍。

现在,让我们打开this out of the box project

这是程序的入口点。正如你所见,它是一个静态类,包含一个静态方法,负责创建应用本身。我们将在后续章节中回到这个文件。

当你打开MainPage.xaml时,你会看到一个布局,其中包含我们刚才查看的页面(带有那个古怪的 MAUI 人物挥手并计数我们的按钮点击)。再次强调,我们还将回到布局和控制,但扫描这个页面,看看你是否能猜出发生了什么。你可能发现它并不像第一眼看起来那么陌生。如果你有这个动力,你可以通过仔细阅读这个页面来学习很多关于 XAML 的知识。

点击MainPage.xaml旁边的三角形以显示代码隐藏文件。代码隐藏文件总是命名为<PageName>.xaml.cs – 在这个例子中,MainPage.xaml.cs。这些文件总是使用 C#编写的。在这里,我们看到构造函数然后是一个事件处理器。当用户点击按钮时,这个事件处理器(OnCounterClicked)会被调用。

通过在 XAML 和代码隐藏文件之间来回切换,你可能能够弄清楚按钮是如何工作的以及点击次数是如何显示的。然而,没有必要这样做,因为我们在接下来的章节中会涵盖所有这些细节。

目前,其他大多数文件几乎是空的,不值得花时间去检查。

为了好玩,展开Resources文件夹。你会看到有应用程序图标、字体、图像等文件夹。所有平台的所有资源都保存在这里。

然后是一个Platforms文件夹,它包含每个平台所需的文件。例如,iOS 应用程序需要一个info.plist文件,你可以在Platforms | iOS中找到它。

在 Forget Me Not™应用程序中,还有很多东西可以探索,但我们将随着构建过程逐步解决每个部分。

摘要

在本章中,你看到了如何找到、下载和安装 Visual Studio 和 Git,这两样工具你将在整本书中都需要。你还创建了你的第一个.NET MAUI 应用程序,并且我们快速浏览了一些文件。

在下一章中,我们将深入探讨 XAML:页面布局和控制创建的标记语言。然后我们将探讨如何在 C#中创建布局和控制,以及一个允许我们使用 Fluent C#创建布局和控制的库。

问答

通过回答以下问题来测试你对本章内容的理解:

  1. 你如何创建一个新的项目?

  2. 如果Solution Explorer不可见,你如何找到它?

  3. .xaml扩展名表示什么?

  4. 我们称与.xaml文件关联的.cs文件为什么?

  5. .NET MAUI 应用程序的入口点在哪里?

你试试看

大多数章节都会有一个你试试看部分,其中你会被鼓励承担与章节内容相关的任务。遗憾的是,本章没有为你设置任务。

第二章:我们将构建的内容:勿忘我

在本书的整个过程中,我们将构建一个名为Forget Me Not的完整非平凡应用程序的核心。当你知道你要去哪里时,你最容易到达某个地方,因此本章将从用户的角度回顾最终产品。也就是说,在本章中,我们将回顾功能,在随后的章节中,我们将深入到实现中。

技术要求

本章不会回顾代码(尽管所有随后的章节都会),因此本章没有特定的技术要求。

Forget Me Not 是什么?

Forget Me Not™是一个应用程序,旨在帮助您为您的朋友购买礼物,并允许他们轻松为您购买礼物。

Forget Me Not 的核心是您的偏好列表,如图图 2.1所示:

图 2.1 – 预设

图 2.1 – 预设

你可以用你喜欢的任何方式和信息填写每个字段。更重要的是,你可以更改左侧的提示,然后在右侧填写你的新偏好!这让你在指定你的偏好和你在各种潜在礼物类别中的品味方面具有最大的灵活性。

当你有了朋友后,你就能看到这种力量的体现。

朋友

如果一个朋友或亲戚有这个应用,你可以邀请他们成为你的朋友。一旦你在云数据库中建立了关系,你就可以看到你朋友的偏好,他们也可以看到你的(尽管你不能编辑彼此的偏好)。

邀请朋友

要邀请一个朋友,你使用标签导航到朋友列表,点击添加朋友,这将显示分享页面,如图图 2.2所示。

图 2.2 – 分享页面

图 2.2 – 分享页面

您现在可以使用任何平台特定的分享选项,包括复制邀请。让我们将其作为电子邮件消息发送,如图图 2.3所示。

图 2.3 – 邮件邀请

图 2.3 – 邮件邀请

邀请函的文本是预先设定的,并自动提供,但用户可以随意编辑它。魔法链接将由服务器提供,用于一次性访问登录页面,以回应邀请好友的连接。请注意,本书不会涉及这一点,因为这项工作是由 API 完成的。

那些是关键页面,但还有一些其他页面。

其他页面

除了这些主要页面之外,还有一个关于页面,如图所示:

图 2.4 – 关于页面

图 2.4 – 关于页面

点击这里的预设将显示用户预设页面,如图图 2.5所示。

图 2.5 – 用户预设

图 2.5 – 用户预设

这个应用并没有太多其他内容。虽然只有六七页,但这些页面做了很多工作,将为学习基础知识提供肥沃的土壤,并在此基础上深入到中级和高级主题。

你将学到什么

即使只有这几页,也将为我们提供一个机会来讨论 .NET MAUI 的几乎所有方面,包括以下内容:

  • Shell 导航

  • 布局

  • XAML

  • 控件

  • 显示集合

  • MVVM 模式

  • 数据绑定

  • 数据持久化

  • 消费 REST 服务

  • 管理 UI 的高级主题

  • 使用社区工具包

  • 行为

  • 触发器

当你完成这本书的阅读时,你将拥有两样东西:一个可工作的应用程序和构建 .NET MAUI 应用程序的专业知识!

摘要

在接下来的章节中,我们将构建本章中描述的“忘不了”应用。这个看似简单的应用实现将使我们能够探索 .NET MAUI 的核心方面,然后继续到中级,最终到高级主题。

如同在第一章中提到的,每个章节(除了这个)都将以一个测验结束,以确保你对所涵盖的内容感到舒适,并有一个你试试看部分,鼓励你将新技能付诸实践。

我迫不及待地想要开始。让我们从检查标记语言(XAML)和使用 C# 为程序逻辑开始。

第三章:XAML 和 Fluent C#

在本章中,我们将探讨如何使用标记语言创建用户界面和 C# 逻辑来创建 .NET MAUI 应用程序。

.NET MAUI 程序通常用两种语言编写。一种是 C#,用于所有逻辑,另一种是 XAML(发音为 zamel,与 camel 同韵),用于布局和控制创建。正如你将看到的,使用 XAML 是可选的。你可以完全用 C# 创建布局和控制,但大多数人不会这样做。然而,这种情况可能正在改变(越来越多的 Microsoft Learn 文档展示了两种方法)。

你何时会选择使用 C#?

使用 C# 而不是 XAML 的原因有很多,最不重要的是你熟悉 C#,不想学习 XAML。然而,如果你这样做,你会发现阅读其他人的代码很困难,因为大多数现有的 Xamarin.Forms(.NET MAUI 的前身)应用程序都是用 XAML 编写的。

使用 C# 可以帮助在需要根据某些条件(例如运行时获取的数据类型)更改设计的情况下。但正如在 第十一章 中解释的,还有其他方法可以使用 XAML 来处理这种情况,高级主题

本书将展示一些页面的 C#,但重点将放在 XAML 上。

所有 Microsoft 文档至少包含 XAML;只有部分是 C# 编写的,还有一些是 Fluent C#(我们将在本章后面讨论的话题)。几乎所有的 Xamarin.Forms 应用和示例都使用 XAML,仅此一点就值得学习。更重要的是,XAML 是一种高度表达性的声明性标记语言,它使得创建布局和控制变得即便不是容易,至少也是可管理的。

本章将涵盖以下主题:

  • 理解 XAML 的结构

  • 代码隐藏和事件处理器

  • 探索布局选项

  • 使用 C# 创建 UI

技术要求

要跟随本章内容,你需要以下条件:

理解 XAML 的结构

XAML 文件具有 .xaml 扩展名,例如,MainPage.xaml,在默认程序中,如 第一章 所示。让我们检查这个文件,以探索 XAML 的布局和控件声明。

简要概述

本章将仅对创建 XAML 布局和控制进行初步介绍。第四章第五章 将分别详细介绍控制和布局的细节。

用 XAML 编写的 .NET MAUI 页面将具有 MyName.xaml 的格式,并且与该页面相关联的后置代码页面(稍后解释)将具有 MyName.xaml.cs 的格式。

在 XAML 页面的顶部是一个声明,即该文件实际上是一个 XML 类型的文件。该声明必须位于每个 .xaml 文件的顶部。

有不同类型的页面(也称为视图)。最常见的是 ContentPage,在这里 MainPage 被创建为 ContentPage,如下所示:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/
  dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       x:Class="ForgetMeNotDemo.View.MainPage">

作为 ContentPage 声明的一部分,我们识别了两个命名空间(xmlns)。第一个命名空间是无名的,用于 .NET MAUI 本身。第二个命名空间,名为 x,用于 XAML。

最后,这个 XAML 文件所属的类是 ForgetMeNotDemo.View.MainPage

第一部分 (ForgetMeNotDemo.View) 是命名空间,而 MainPage 是类的名称。View 部分被添加以表示项目下的文件夹。

如前所述,每个 .xaml 页面都有一个相关的后置代码页面。您可以在后置代码中放置程序的逻辑,这就是我们将在本章中做的事情(在下一章中,我们将查看一个更适合测试的替代方案)。无论如何,后置代码中必须包含一些内容,您将在下一节中看到。

后置代码文件

每个 XAML 文件都有一个相关的后置代码页面,其名称为 <pagename>.xaml.cs。因此,这个页面的后置代码是 MainPage.xaml.cs

后置代码文件通常(并且正确地)以类的名称命名(即,MainPage 类将在 MainPage.xamlMainPage.xaml.cs 中)。命名空间在文件中明确声明,并应遵循文件夹结构。因此,如果命名空间是 ForgetMeNotDemo/MainPage,那么我们预计 MainPage.cs 将具有 ForGetMeNotDemo 命名空间:

namespace ForgetMeNotDemo.View;
public partial class MainPage : ContentPage

注意,这是一个部分类。在 .NET MAUI 中,所有 UI 类都被标记为部分。注意,该类表明它是 ContentPage,这与我们在 XAML 中看到的一致。

除了定义类之外,您还可以使用 XAML 来创建页面的布局,即元素之间相互关系的位置。XAML 提供了一系列布局选项,如下所述。

探索布局选项

返回到 MainPage.xaml,我们看到 ContentPage 下方的是 ScrollView 布局元素。这是一个布局,本质上表示 ScrollView 中包含的任何内容都可以(惊喜!)滚动。

布局包含其他布局和控制元素。它就像在 XML 中做的那样做,即使用开闭标签。以下是语法:

<ScrollView>
   // … layouts and controls
</ScrollView>

一个页面只能有一个元素。通常,这个元素是一个布局,由于布局可以包含其他布局和控制元素,因此可以创建整个层次结构。

MainPage.xaml 中的前四个元素(默认情况下)如下:

<ScrollView>  [1]
     <VerticalStackLayout  [2]
         Spacing="25"
         Padding="30,0"
         VerticalOptions="Center">
         <Image [3]
             Source="dotnet_bot.png"
             SemanticProperties.Description="Cute dot net
               bot waving hi to you!"
             HeightRequest="200"
             HorizontalOptions="Center" />
         <Label [4]
             Text="Hello, World!"
             SemanticProperties.HeadingLevel="Level1"
             FontSize="32"
             HorizontalOptions="Center" />
</ScrollView>

为什么这段代码与仓库中的代码不同?

之前的代码就是您从盒子里得到的内容。在本章中,我们将增强这段代码,仓库反映了章节结束时的最终版本。

让我们一次处理一个元素。我们已经讨论了第一个元素,ScrollView,所以让我们从下一个开始,也就是VerticalStackLayout

VerticalStackLayout

ScrollView内部是VerticalStackLayout。正如其名所示,这个布局将东西堆叠在一起。在这里,我们为VerticalStackLayout定义了三个属性:SpacingPaddingVerticalOptions。现在,VerticalStackLayout有数十个属性和方法。我们将在第五章**,布局中了解更多。

Spacing决定了包含的每个元素之间的垂直空间量。在这里,Spacing设置为25 设备无关单位DIPs)。使用 DIPs 意味着您可以定义一个设备(手机、Windows 等)的大小,并且它可以在所有其他设备上看起来如您所愿。至少理论上是这样的。正如一位好朋友曾经说过:“在理论上,理论和实践是相同的。但在实践中,它们永远不是。”

第二个属性是Padding。这是您控制控件位置和对齐方式的一种方法。第二种主要方式是使用Margins。这告诉您从最近的另一个元素(或从页面边缘)的距离,而Padding告诉您当前元素周围缓冲区的大小,如图图 3.1所示。1:

图 3.1 – Padding 和 Margin

图 3.1 – Padding 和 Margin

Padding的格式是。一个Padding值为(20,10,5,0)将在左边有20 DIPs 的填充,顶部有10,右边有5,底部没有 DIPs。如果顶部和底部相同,它们可以合并。同样,对于左右也是如此。因此,正如我们在这里所看到的,Padding = "30,0",这意味着左右将有30的填充,但顶部和底部没有填充。

VerticalStackLayout中的最后一个属性是VerticalOptions,它表示相对于其容器(在这种情况下,ScrollView)放置VerticalStackLayout的位置。这个选项基于一个枚举:

  • 居中

  • 结束

  • 填充

  • 开始

这个枚举与许多不同的布局和控制一起使用。目前,只需知道Start对于垂直布局意味着顶部,对于水平布局意味着最左边。同样,End对于水平布局意味着最右边,对于垂直布局意味着底部。我们将在本书的后面部分回到这些值。

Image

页面的第三个元素是一个Image元素,在这种情况下,它有四个属性。第一个是源(在哪里找到图像)。第二个属性称为SemanticProperties.Description语义属性被添加以帮助使用屏幕阅读器的用户。

您不能直接设置高度(它是只读的),但您可以设置HeightRequest,我们在这里将其设置为200 DIPs。.NET MAUI 将尝试根据您在页面上的其他设置和可用空间提供该高度。最后,我们将HorizontalOptions设置为Center,以便在水平轴上居中图像。

标签

接下来,我们看到Label。在这种情况下,它也有四个属性。第一个是标签上要显示的文本。第二个属性,同样是为了屏幕阅读器,表示组织结构(在这里标签位于顶层)。第三个属性是FontSize。设置FontSize有几种方法,我们将在第四章中看到,但在这里我们使用 DIPs。最后,再次设置HorizontalOptionsCenter

如果您向下滚动页面,您会看到另一个Label和一个Button控件(它几乎可以做您所猜测的一切)。

在底部,您将看到VerticalStackLayout的关闭,然后是ScrollView的关闭,最后是ContentPage本身的关闭。

因此,XAML 提供了一种高度结构化的方法来描述布局。以下是完整的 XAML 页面:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="ForgetMeNotDemo.MainPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <ScrollView>
        <VerticalStackLayout
            Padding="30,0"
            Spacing="25"
            VerticalOptions="Center">
            <Image
                HeightRequest="200"
                HorizontalOptions="Center"
                SemanticProperties.Description="Cute dot
                  net bot waving hi to you!"
                Source="dotnet_bot.png" />
            <Label
                FontSize="32"
                HorizontalOptions="Center"
                SemanticProperties.HeadingLevel="Level1"
                Text="Hello, World!" />
            <Label
                FontSize="18"
                HorizontalOptions="Center"
                SemanticProperties.Description="
                      Welcome to dot net Multi platform
                        App U I"
                SemanticProperties.HeadingLevel="Level2"
                Text="Welcome to .NET Multi-platform
                  App UI" />
            <Button
                x:Name="CounterBtn"
                Clicked="OnCounterClicked"
                HorizontalOptions="Center"
                SemanticProperties.Hint="Counts the number
                  of times you click"
                Text="Click me" />
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

此页面通过声明ContentPage(最常见的页面类型)并定义页面的命名空间和名称(这将在代码后也反映出来)来打开。然后声明两个标准命名空间(使用xmlns),第一个用于.NET MAUI,第二个用于 XAML 标记。

我们看到ScrollView,在其中我们看到VerticalStackLayout,它被设置为使用填充和间距,并垂直居中。我们将随着我们的进展回顾这些属性。

VerticalStackout包含四个控件:一个图像,两个标签和一个按钮。每个控件都有自己的属性。您现在不必担心这些属性;它们将在稍后解释。这里的要点是布局可以包含布局和控件。它们像乌克兰娃娃一样堆叠,一个套一个(尽管控件不包含控件)。

备注

每个ContentPage只能有一个布局,但该布局可以包含其他布局(正如我们在这里看到的),因此这不是一个问题。

此外,请注意,由于打印页面的尺寸限制,一些文本将换行到下一行。

您甚至可以在 Visual Studio 中这样做,方法是转到工具 | 选项 | C# | 常规,并勾选自动换行复选框。如果您这样做,我建议您还勾选显示自动换行视觉符号,这会使阅读代码更容易。当您在那里时,您可能还想检查行号,这在追踪编译错误时非常有用。这些选项在图 3.2中显示。

图 3.2 – 设置自动换行和行号

图 3.2 – 设置自动换行和行号

现在,当你的代码行太宽而无法显示时,它将自动换行,你会在右侧看到一个小的箭头,表示该行已继续,如图 3.3所示。3*.

图 3.3 – Visual Studio 自动换行

图 3.3 – Visual Studio 自动换行

当发生某些事件,例如用户点击按钮时,会引发一个事件。该事件在代码背后被处理,我们将在下一节中回顾。

事件与命令

从下一章开始,我们将停止使用事件,转而使用命令。命令在 ViewModel 中处理,这使得它们更容易测试。现在,为了方便起见,我们将处理事件,并在代码背后进行处理。

代码背后和事件处理器

我们在下面的Button控件列表中看到有一个Clicked属性,它指向OnCounterClicked方法:

<Button
  x:Name="CounterBtn"
  Clicked="OnCounterClicked"
  HorizontalOptions="Center"
  SemanticProperties.Hint="Counts the number of times you
    click"
  Text="Click me" />

此方法(onCounterClicked)位于代码背后文件,MainPage.xaml.cs中。所有EventArgs(或从EventArgs派生的类)。按照惯例,EventArgs参数被命名为e

在我们的情况(以及大多数情况)中,我们并不关心发送者以及简单的EventArgs(如这里所用的),它是空的,仅作为提供额外信息的派生类的事件处理器的基类(因此你可能有一个从EventArgs派生的类型,它提供了事件处理器所需的信息):

private void OnCounterClicked(object sender, EventArgs e)
{
    count++;
    if (count == 1)
        CounterBtn.Text = $"Clicked {count} time";
    else
        CounterBtn.Text = $"Clicked {count} times";
    SemanticScreenReader.Announce(CounterBtn.Text);
}

事件处理器名称与 XAML 中识别的事件处理器匹配。

<Button
    Clicked="OnCounterClicked"

此处理器的任务仅在每个按钮被点击时更新按钮上的文本。最后,它使用SemanticScreenReader Announce方法再次显示该文本,以便屏幕阅读器:

计数实例字段在类的顶部声明:

public partial class MainPage : ContentPage
{
    private int count = 0;
    public MainPage()
    {
        InitializeComponent();
    }

InitializeComponent

注意,构造函数调用了InitializeComponent。这将在每个 XAML 文件的代码背后文件中成立。InitializeComponent的职责是初始化页面的所有元素。

当我们到达第四章时,你会看到我们试图最小化代码背后文件的 内容,主要是为了便于创建单元测试。到那时,我们将用命令替换我们的事件,但现在我们先不这么做。

虽然几乎所有 Microsoft 文档和现有的示例代码都使用 XAML 进行标记,但也可以使用 C#创建布局和视图。事实上,近年来,越来越多的 Microsoft 文档显示了这两种方法。

.NET MAUI Community Toolkit

本节需要您在上一章中添加的.NET MAUI 社区工具包。实际上,社区工具包是工具包集合的一部分,这些工具包是开源项目,并补充了.NET MAUI 发布版本中的内容。它们是由与微软开发者紧密合作的社区成员创建的。可以合理假设,许多社区工具包的功能将随着时间的推移正确迁移到.NET MAUI 中。我强烈建议您使用这些工具包,本书也是如此。

如果您能在 XAML 中做到,您就能在 C#中做到。

任何可以在 XAML 中声明的都可以在 C#中声明。包含是通过使用对象的children属性来管理的。事件处理程序必须在控件的一个实例上注册。也就是说,将为特定的按钮注册一个事件处理程序,正如您将在本例中看到的那样。

这里是我们在 XAML 中编写的代码转换为 C#:

using CommunityToolkit.Maui.Markup;    [1]
namespace ForgetMeNotDemo;
class MainPageCS : ContentPage
{
  private readonly Button counterBtn = new Button  [2]
  {
    Text = "Click Me",
    HorizontalOptions = LayoutOptions.Center,
  }.SemanticHint("Counts the number of times you click");
  public MainPageCS()
  {
    counterBtn.Clicked += OnCounterClicked;   [3]
    Content = new VerticalStackLayout    [4]
    {
      Spacing = 30,
      Padding = new Thickness(30, 0),
      VerticalOptions = LayoutOptions.Center,
      Children =   [5]
                {
                    new Image()
                    {
                        Source = "dotnet_bot.png",
                        HeightRequest = 200,
                        HorizontalOptions =
                          LayoutOptions.Center,
                    }.SemanticDescription("Cute dot net bot
                         waving hi to you!"), [6]
                    new Label()
                    {
                        Text="Hello, World",
                        FontSize=32,
                        HorizontalOptions =
                          LayoutOptions.Center,
                    }.SemanticHeadingLevel
                       (SemanticHeadingLevel.Level1),
                    new Label()
                    {
                        Text = "Welcome to .NET Multi-
                          platform App UI",
                        FontSize = 18,
                        HorizontalOptions =
                           LayoutOptions.Center,
                    }.SemanticHeadingLevel
                       (SemanticHeadingLevel.Level2)
                        .SemanticDescription(
                          "Welcome to dot net Multi
                            platform App UI"),
                    counterBtn, [7]
                }
    };
  }
  private int count = 0;
  private void OnCounterClicked(object sender, EventArgs e)
    [8]
  {
    count++;
    if (count == 1)
      counterBtn.Text = $"Clicked {count} time";
    else
      counterBtn.Text = $"Clicked {count} times";
    SemanticScreenReader.Announce(counterBtn.Text);
  }
}

让我们快速看一下如何连接这个新的MainPage。为了测试这个 C#版本,将AppShell.xaml中的ShellContent元素设置为指向您的新页面,如下所示:

<ShellContent
    Title="Home"
    ContentTemplate="{DataTemplate local:MainPageCS}"
    Route="MainPageCS" />

回到将 XAML 转换为 C#代码,让我们分析一下,以便更好地理解。这些数字指的是代码中括号内的数字:

  1. 我们首先添加一个using语句用于CommunityToolkit。我们需要这个用于语义提示,这些提示被屏幕阅读器用于视力有限或无视力的人。虽然一个完成的项目应该为每个控件都有这些,但我们不会在这本书中使用它们以节省空间和混淆。

  2. 我们想添加一个按钮,并且这个按钮需要一个事件处理程序(用于点击事件)。在这个例子中,Clicked事件的事件处理程序位于文件底部。为了向我们的按钮添加事件处理程序,我们必须首先定义Button。我们这样做是在构造函数之外,并设置其属性[2]。

  3. 在构造函数的非常开始处,我们将事件处理程序方法分配给Clicked事件。正如所注,该事件处理程序方法位于文件底部,尽管当然它可以是(也许应该是)在它自己的文件中[3]。

  4. 现在,我们已经准备好创建VerticalStackLayout以及其中包含的所有元素[4]。

  5. 这些元素将位于堆叠布局的Children集合中[5]。

  6. 注意,语义描述是通过流畅的语法附加到图像上的[6]。

  7. 在添加了所有其他元素之后,我们就可以将按钮插入到堆叠布局的Children集合中[7]。

  8. 我们将按钮的事件处理程序放在这个文件的底部,尽管您当然可以将其移动到不同的文件,可能是在不同的文件夹中。但是,如果您这样做,请记住为该命名空间添加一个using语句[8]。

关于注释的说明

在业界关于在 C# 代码中使用注释的问题上存在激烈的争议。我持一个非常极端的观点:代码应该几乎完全自解释。也就是说,如果您为变量、字段、方法等使用适当且描述性的名称,则不需要注释。我并不是一个狂热者;如果代码足够复杂,这里或那里的注释可以大有帮助,但注释会生锈,应该谨慎使用。因此,您会发现代码中注释很少,尽管我们将在接下来的段落中详细讲解代码。

我们工具集最近增加的功能是能够使用 Fluent C#,这可以使您的 C# 代码更加紧凑且易于阅读。

C# 与 Fluent C#

除了使用 C# 创建您的页面外,还有一个新的(截至 2022 年冬季)Community Toolkit 用于 Fluent C#。这并不会改变基本方法,但可以使创建 C# 页面更加简洁且易于理解。

要使用此功能,您需要添加 CommunityToolkit.Maui.Markup NuGet 包。请参考以下图示:

图 3.4 – 从 NuGet 获取标记包

图 3.4 – 从 NuGet 获取标记包

图 3.4 – 从 NuGet 获取标记包

该项目是开源的,可以在 github.com/communitytoolkit/Maui.Markup 上检查(并扩展!)ReadMe 文件将帮助您入门,尽管我们也会在本书中介绍该材料。

您需要做的第一件事是更新 MauiProgram.cs,使用以下代码片段将工具包添加到构建器中:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .UseMauiCommunityToolkit()
        .UseMauiCommunityToolkitMarkup()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", 
                "OpenSansRegular");
            fonts.AddFont("OpenSans-Semibold.ttf", 
                "OpenSansSemibold");
        });

您可以将 UseMauiCommunityToolkitMarkup 链接到 UseMauiCommunityToolkit 在构建器中[1]。

现在,您可以避免编写以下内容:

var entry = new Entry();
entry.WidthRequest = 400;
entry.HeightRequest = 40;

相反,您可以将其全部链接起来,只需写下这个:

new Entry().Size(200,40);

这使得代码更加简洁。

我将在整本书中提供 C# 和 Fluent C# 的示例;尽管如此,正如所提到的,我们将使用的主要标记语言是 XAML。

摘要

在本章中,我们研究了标记语言 XAML,它用于创建布局和控件。我们看到在 XAML 中可以完成的事情也可以在 C# 中完成,我们看到了有两种方式来编写 C#:传统的声明性方式和较新的流畅形式。

我们检查了一些重要的类(如 ButtonLabelImage 等)以及如何在代码后端类中处理事件。我还暗示,在下一章中,代码后端的事件处理程序将被命令及其在 ViewModel 中的实现所取代。

第四章 中,我们将深入了解在 .NET MAUI 中编写应用程序的主要架构:模型-视图-视图模型MVVM),我们将探讨数据绑定。然后我们将探索许多控件以及它们如何协同工作。

测验

  1. 用一句话来说,XAML 是什么?

  2. XAML 用于什么?

  3. 使用 XAML 的替代方案是什么?

  4. 我们如何使用 C# 在一个布局内部嵌套另一个布局?

  5. 什么是事件处理程序?

  6. 如果在 XAML 中声明了一个事件,事件处理程序在哪里?

尝试一下

是时候开始编写代码了!

创建一个名为 ForgetMeNotJesse 的新项目(你可能想在放置我的名字的地方使用你自己的名字)。理想情况下,将该项目置于源代码管理之下(参见本章顶部章节的 技术要求 部分)。

使用 .NET MAUI 模板创建你的项目,使用本书编写时的最新版本 .NET(.NET 7)。

运行你的程序以确保一切设置正确。

修改 MainPage,以便点击按钮时,在按钮下方更新一个标签,显示点击次数(除了在按钮本身显示外)。

一旦页面按预期工作,创建一个新页面,并在 C# 中而不是在 XAML 中重新创建你的 MainPage。为了测试,请记住将 AppShell.xaml 中的 ShellContent 元素设置为指向你的新页面,如下所示:

<ShellContent
    Title="Home"
    ContentTemplate="{DataTemplate local:MainPageCS}"
    Route="MainPageCS" />

如果你在任何地方遇到困难,请从本书的仓库中拉取 XAML 和 C# 分支,并将该解决方案与你的解决方案进行比较。

第四章:MVVM 和控件

第三章 中,我们探讨了 .NET MAUI 的基础知识,但我们的代码位于与 XAML 文件关联的后台代码文件中。然而,现在是时候将我们的注意力转向 .NET MAUI 的共识架构了。

模型-视图-视图模型 (MVVM) 不是一个工具或平台,而是一种架构。简单来说,它是一种组织代码和思考的方式,以优化 .NET MAUI 应用程序的创建并便于单元测试(参见 第九章)。

在最简单的形式中,MVVM 由三组文件组成,即三个命名空间,这本质上意味着三个文件夹(根据需要包含子文件夹)。依次来看,Model 是定义数据 形状 的类集合。这仅仅意味着表示数据的类被保存在模型中。

简单来说,View 是用户看到的页面。

ViewModel 是所有动作发生的地方。它是管理程序逻辑并包含在 View 中展示的 属性 的类集合。随着我们的深入,我们将探讨 ViewModel (VM) 属性。

在本章中,我们将探讨以下主题:

  • 设置 MVVM

  • 数据绑定

  • 视图

  • XAML 与 C#

  • 行为

  • 弹出窗口和对话框

  • 画笔

技术要求

对于本章,您需要 Visual Studio 的最新版本(任何版本)。

本书中的每一章都保存为一个分支。本章和下一章中显示的代码位于 github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/MVVMAndControls 分支中。如果您检查该分支,您将看到我们最终到达的位置,但如果您想查看一些中间步骤,只需检查对分支做出贡献的提交即可。然而,为了跟上进度,请从 github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/Navigation/tree/XAMLAndCSharp 分支作为起点。

为 MVVM 准备

MVVM 既是组织文件和文件夹的方式,也是一种架构方法。要开始使用 MVVM,我们将做两件事:

  1. 创建文件夹。

  2. 下载相关的社区工具包。

创建文件夹

我们将创建三个文件夹。在我告诉你这些文件夹的名称之前,我应该说明,关于确切命名它们的意见存在一些分歧。表 4.1 显示了文件夹名称及其替代方案:

我们将使用 名称 替代方案 1 替代方案 2
Model Models
View Views Pages
ViewModel ViewModels

表 4.1 – 文件夹命名

如你所见,关键的区别在于文件夹的名称是否应该使用复数,反映每个文件夹中将有多于一个文件的事实,或者使用单数(正如我们将做的),反映 Model-View-ViewModel 的名称。我想不出一个更不重要的争议,而且显然你选择什么只要保持一致就无关紧要。任意地,我们将使用前者。

因此,在你的项目中创建三个文件夹:ModelViewViewModel,如图 图 4**.1 所示:

图 4.1 – MVVM 文件夹

图 4.1 – MVVM 文件夹

MainPage 现在位置不正确。将 MainPage.xaml 拖到 View 文件夹(它将带其代码后置文件一起移动)。你需要修复 XAML 文件中的命名空间:

x:Class="ForgetMeNotDemo.View.MainPage"

在代码背后:

namespace ForgetMeNotDemo.View;

微软提供了由 .NET 社区创建但微软认可和支持的库,这些库不是 .NET MAUI 的一部分。这些社区工具包的大部分功能可能会迁移到 .NET MAUI 本身。

MVVM 社区工具包

打开 CommunityToolkit-MVVM 并点击 CommunityToolkit.MVVM。这个出色的工具包将使使用 MVVM 的编程比其他方式更容易。见图 图 4**.2

图 4.2 – 获取 NuGet 包

图 4.2 – 获取 NuGet 包

当我们谈到 源代码生成器 时,我们将回到如何使用此工具包。接下来,让我们看看一些视图。

探索视图

.NET MAUI 控件是一个泛称,用于页面、布局和视图。在本章中,我们将查看视图,而页面和布局将在下一章中回顾。在 第七章 中,我们将查看页面之间的导航。

视图与页面

从 MVVM 的角度来看,一个视图是一个页面。从 .NET MAUI 的角度来看,View 是一个控件。所以,为了让你完全困惑,一个视图由视图和布局组成。为了避免这种荒谬,我们将后者称为控件。一些框架将这些称为小部件。不时我会忘记,将这些控件称为视图,但上下文将清楚地表明我的意思。

.NET MAUI 控件是一个映射到每个目标平台上的本地控件的对象。因此,.NET MAUI 按钮映射到 iOS、Android、Macintosh 和 Windows 的本地按钮。

显示文本的主要方式是使用 LabelLabel 的继承树如下:

Object > BindableObject > Element > NavigableElement >

VisualElement > View > Label

对象当然是 C# 中每个类的基类。我们现在暂时跳过 BindableObject,并将 ElementNavigableElementVisualElement 一起分组为你在页面上可以看到的东西。这使我们回到了之前描述的 View,然后是 Label 本身。

Label 上最常用的属性是 TextTextLabel 显示的内容,因此你可以编写以下内容:

<Label Text="Hello World" />

这创建了一个显示标志性问候语的标签。但你可以用 Label 做更多的事情,就像我们在上一章中看到的那样。

Forget Me Not 标签

让我们在 Forget Me Not 的上下文中看看标签。我们已经有应用程序了,但它只是开箱即得。让我们修改这个第一页,为 Forget Me Not 创建初始页面。

请点击 MainPage.xaml 中的 VerticalStackLayout,这将折叠 VerticalStackLayout 并允许你一次性删除它,如图 图 4.3 所示:

图 4.3 – 折叠的 VerticalStackLayout

图 4.3 – 折叠的 VerticalStackLayout

接下来,转到代码后文件(MainPage.xaml.cs)并删除计数器和 OnCounterClicked 事件处理器。

清理完所有这些后,我们就可以开始添加新的代码了。我们需要一个可以放入标签的布局,所以让我们创建一个空的 VerticalStackLayout,并向其中添加一个显示 Welcome to Forget Me Not 的标签控制。

<VerticalStackLayout>
    <Label Text="Welcome to Forget Me Not"/>
</VerticalStackLayout>

我们已经准备好在此基础上构建了。让我们添加一些使 Label 看起来更好的更常见属性:

<VerticalStackLayout>
    <Label
        x:Name="HelloLabel"
        Margin="20"
        BackgroundColor="Red"
        FontAttributes="Bold"
        FontSize="Small"
        HorizontalOptions="Center"
        HorizontalTextAlignment="Center"
        Text="Welcome to Forget Me Not"
        TextColor="Yellow"
        VerticalTextAlignment="Center" />
</VerticalStackLayout>

我们将依次检查这些属性,但首先,图 4.4 显示了页面现在的样子:

图 4.4 – Label

图 4.4 – Label

注意,标题(主页)是页面的一部分。我们关心的是下面的标签。

在我们检查 Label 的属性之前,让我们先看看是否可以使其看起来更美观一些,只需添加一点内边距:

<VerticalStackLayout>
    <Label
        BackgroundColor="Red"
        FontAttributes="Bold"
        FontSize="Small"
        HorizontalOptions="Center"
        HorizontalTextAlignment="Center"
        LineBreakMode="WordWrap"
        Margin="20"
        MaxLines="5"
        Padding="10"
        Text="Welcome to Forget Me Not"
        TextColor="Yellow"
        VerticalTextAlignment="Center"
        x:Name="HelloLabel" />
</VerticalStackLayout>

这就给出了如图 图 4.5 所示的页面。

XAML Styler

注意,属性排列得很好,并且按字母顺序排列。这是由于一个名为 XAML Styler 的(免费)工具造成的,您可以从 Visual Studio Marketplace 获取它(bit.ly/XAMLstyler)。

图 4.5 – 带有内边距的 Label

图 4.5 – 带有内边距的 Label

好多了。让我们逐行检查前面的代码。

大多数这些属性都是不言自明的。BackgroundColor 属性控制整个标签。在我们的例子中,我们将 Padding 属性(如 第三章 中所述)设置为 10;因此,红色在文本周围有 10 的内边距。

如您所见,我们使用 FontAttributes 属性将文本设置为 Bold。可能的属性是 BoldItalicNone,其中 None 是默认值。

FontSize 可以输入设备无关的单位(例如,FontSize = "20")或者输入以下枚举常量之一,如 MicroSmallLarge 等。

HorizontalOptionsVerticalOptions 将标签放置在页面上的相对位置。我们在上一章中提到了这一点。在 HorizontalOptions 的情况下,选择是 Start(最左边)、Center(中间)或 End(最右边)。

下一个属性是LineBreakMode,它与MaxLines属性一起使用。它们共同决定了标签可以支持多少行文本以及文本将在哪里换行。为了看到这一点,修改文本,使其说“欢迎来到忘不了,非常高兴你在这里,没有你我们无法做到这一点,我们感激你的耐心。”正如你在图 4.6中看到的那样,文本现在在多行中居中,并且每行都在单词边界处换行。

![图 4.6 – 多行标签

![img/Figure_4.6_B19723.jpg]

图 4.6 – 多行标签

如前所述,Label有数十个属性,虽然我们已经涵盖了最重要的属性,但你总是可以在 Microsoft Learn 上查找其他属性。在这种情况下,你需要查看的页面是bit.ly/MicrosoftLabel

在 MVVM 模型中显示数据的关键是数据绑定,这使我们能够将视图和属性关联起来,然后允许.NET MAUI 在属性值变化时保持视图的更新。让我们继续探讨这一点。

数据绑定

.NET MAUI 最强大的功能之一是数据绑定,并且数据绑定与 MVVM 配合得非常好。想法是将数据(值)绑定到控件上。例如,我们可能有一个类,其中包含我们想要在标签上显示的文本,它被保存在一个公共属性中(你只能绑定到公共属性)。我们不需要从类中复制该文本到标签,我们只需告诉标签属性的名称。

公共属性将保存在ViewModel的类中。但我们必须回答这样一个问题:View如何知道在哪里查找属性?这通过设置BindingContext来处理。

让我们看看一个简单的例子。在ViewModel中创建一个名为MainViewModel.cs的新文件。

命名 ViewModel

最常见的命名约定是用单词page来命名页面,例如MainPageLoginPage,但在ViewModel的名称中省略单词page,例如MainViewModelLoginViewModel。所以,这就是我们在本书中要做的。

注意,其他程序员将使用MainPageViewModel这个名字。另一方面,有些人不使用单词page,而是使用view,例如MainViewLoginView。最重要的是你(和你的团队)要保持一致性,这样就可以轻松猜测和找到相关的页面和视图模型。

在继续之前,请注意 Visual Studio 已经将你的类放入了ForgetMeNotDemo.ViewModel命名空间(如果你将项目命名为ForgetMeNot,则命名空间将是ForgetMeNot.ViewModel)。这是基于.cs文件所在的文件夹。

确保类是公共的,并且被标记为partial。在.NET MAUI 中,所有绑定都是使用部分类完成的,这使得类的其余部分可以由内部处理和生成的部分类处理。

创建公共属性

我们现在想创建一个名为FullName的属性。

原始的做法看起来像这样:

private string fullName;
public string FullName
{
   get => fullName;
   set
   {
     fullName = value;
     OnPropertyChanged();
   }
}

然而,最好的方法是我们利用我们刚刚添加的 NuGet 包中的代码生成器。这些生成器通过使用属性来实现。在 [ObservableObject] 类声明上方添加一个属性,如下所示:

using CommunityToolkit.Mvvm.ComponentModel;
namespace ForgetMeNotDemo.ViewModel;
[ObservableObject]
public partial class MainViewModel
{
}

该属性将允许你生成属性。在每个属性上方,使用 ObservableProperty 属性:

[ObservableProperty]
  private string fullName;

这将导致 NuGet 包(无形中)生成大写公共属性及其 OnPropertyChanged() 方法调用,就像你亲自输入它们一样。

在我们查看如何设置 FullName 值之前,我们需要设置 BindingContext

设置 BindingContext

BindingContext 告诉你的 View 从哪里获取其绑定数据。你可以通过多种方式设置它;最常见的是在 View 类的代码后文件中设置(在这种情况下,MainPage.xaml.cs)。首先,我们声明一个 ViewModel 的实例:

private MainViewModel vm = new MainViewModel();
Next, we assign the BindingContext to that instance in the
  constructor:
public MainPage()
{
  InitializeComponent();
  BindingContext = vm;
}

这是类的代码后:

public partial class MainPage : ContentPage
{
  private MainViewModel vm = new MainViewModel();
  public MainPage()
  {
    InitializeComponent();
    BindingContext = vm;
  }
}

接下来,我们将看到如何将值分配给 ViewModel 类的属性。

名称

我通常不喜欢名字的缩写。有一些罕见的例外,使用 vm 作为 ViewModel 的约定非常强烈,以至于我屈服于同伴压力。

将值分配给视图模型类的属性

你可以在 ViewModel 中、在 ViewModel 构造函数中或在 OnAppearing 方法的重写中分配你的字符串。OnAppearingView 显示之前被调用,其外观如下(你将此放入代码后文件中):

protected override void OnAppearing()
{
  base.OnAppearing();
  vm.FullName = "Jesse Liberty";
}

我们将在 第七章 中返回 OnAppearing 和其兄弟 OnDisappearing 方法。

InitializeComponent

InitializeComponent 必须在所有代码后文件的构造函数中。InitializeComponent 负责初始化页面上的所有控件。

实现绑定

你现在可以绑定 FullName 属性到 Label。在 XAML 中,将 Label 文本属性更改为以下内容:

Text="{Binding FullName}"

命名属性和字段

myMemberField), though there is no agreement at all as to whether member fields should be prepended with an underbar as in _myMemberField. We won’t use the underbar in this book, but feel free to do so as long, again, as you are consistent.

使用绑定关键字告诉 LabelViewModel 中通过 BindingContext 设置的 FullName 属性获取其值。

你需要注意语法。它总是如下所示:开引号,开大括号,Binding 关键字,属性名,闭大括号,闭引号。好吧,我骗了你们。有时它可能更复杂,但这些元素总是存在的。

这个构造的结果是 FullName 的值被放置在标签的 Text 属性中,如图 图 4**.7 所示:

图 4.7 – 带有数据绑定文本的标签

图 4.7 – 带有数据绑定文本的标签

MVVM 程序的一个显著特点是逻辑在 ViewModel 中,而不是在代码后文件中,我们将在下一节中探讨这一点。

视图模型与代码后

您可以将更多内容放入 ViewModel(而不是代码后文件),这样测试您的程序就会更容易(请参阅 第九章 中的单元测试)。一些 MVVM 粉丝认为除了对 InitializeComponent 的必需调用外,代码后文件中不应有任何内容。他们认为甚至设置 ViewModel 也应该在 XAML 中完成,以使代码后文件尽可能空。

我对这个持有更为中庸的观点。我经常在代码后文件中设置 BindingContext。我也会像您在讨论命令时看到的,将所有的事件处理都移出代码后文件。

LoginPage.xaml.cs:
public partial class LoginPage : ContentPage
{
    LoginViewModel vm = new LoginViewModel();
    public LoginPage()
    {
        BindingContext = vm;
        InitializeComponent();
    }
}

注意,这里的 BindingContext 在调用 InitializeComponent 之前已经设置。虽然在大多数情况下两者可以互换,但在初始化页面之前设置所有绑定通常是良好的实践。因此,我们将坚持这里所示的方法。

非常规代码后

有时候,在代码后文件中放置一个方法会容易得多。但是要小心。99% 的情况下,当您觉得在代码后文件中放置某物非常重要时,您实际上可以在 ViewModel 中实现它,这要好得多(再次,为了测试)。但如果您确实需要在代码后文件中放置某些内容,请不要感到难过,也不要让其他 .NET MAUI 程序员欺负您。

大多数应用程序的开发中心,人们所响应的部分,是 用户界面UI)。在 .NET MAUI 中,UI 由布局中的视图组成。让我们将注意力转向最重要的视图。

视图

有许多控件用于显示和从用户获取数据。以下几节将介绍最常见和有用的控件,包括这里展示的:

  • 图片

  • 标签

  • 按钮

  • 图像按钮

  • 输入文本

图片

可以编写一个没有图片的 .NET MAUI 程序,但它可能看起来相当无聊。在 .NET MAUI 中管理图片比在 Xamarin.Forms 中要容易得多。现在,您不需要为 iOS 和 Android 的每个分辨率都准备一个图片,只需将一个图片放在资源文件夹中,.NET MAUI 就会为所有平台处理其余部分!

在这个例子中,我们将使用一个名为 flower.png 的图片,您可以从我们的 GitHub 仓库下载。如果您愿意的话,也可以使用您喜欢的任何图片。我们将把图片放在 Resources > Images 文件夹中。

当我们准备好显示它时,我们将使用 图像视图。以下是一个简单的示例:

<Image
    HeightRequest="200"
    HorizontalOptions="Center"
    Source="{Binding FavoriteFlower}" />

我只设置了三个属性,但它们完成了相当多的工作。HeightRequest 设置,正如您可能猜到的,页面中图片的高度(以设备无关的单位计,在这种情况下为 200)。我将其设置为居中。最重要的是,我已标识了源——即图片的名称。但不是将图片的名称锁定在 View 中,而是将其绑定到 MainPageViewModel 中的一个属性。

结果是 MainPage 现在看起来像 图 4**.8

图 4.8 – 将源绑定到 ViewModel 中的属性

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-csp-dev/img/Figure_4.8_B19723.jpg)

图 4.8 – 将源绑定到 ViewModel 中的属性

当然,还有许多其他属性可以设置。对我来说,一组最喜欢的属性是 Rotation 属性,它们可以在 xyz 轴上旋转。如果我将 RotationX 属性添加如下:

<Image
    HeightRequest="200"
    HorizontalOptions="Center"
    RotationX="45"
    Source="flower.png" />

图片旋转,如图 图 4.9 所示:

图 4.9 – RotationX=”45”

图 4.9 – RotationX=”45”

另一个有用的技巧是通过将 Opacity 设置为介于 01 之间的值来使图片半透明。图 4.10 展示了具有 .25 透明度的相同图片。我移除了 StackLayout 并用 Grid 取代了它。关于网格的更多内容将在下一章中介绍,但如果你只声明一个网格并将 LabelImage 放入其中,没有其他 Grid 属性,它们将一个叠在另一个上面:

<Grid>
    <Label
        BackgroundColor="Red"
        FontAttributes="Bold"
        FontSize="Small"
        HeightRequest="50"
        HorizontalOptions="Center"
        HorizontalTextAlignment="Center"
        LineBreakMode="WordWrap"
        Margin="20"
        MaxLines="5"
        Padding="10"
        Text="{Binding FullName}"
        TextColor="Yellow"
        VerticalTextAlignment="Center"
        x:Name="HelloLabel" />
    <Image
        HeightRequest="200"
        HorizontalOptions="Center"
        IsVisible="True"
        Opacity=".25"
        RotationX="45"
        Source="{Binding FavoriteFlower}"
        x:Name="BigFlower" />
</Grid>

图 4.10 展示了结果:

图 4.10 – 叠加和半透明效果

图 4.10 – 叠加和半透明效果

创造力的空间是无限的。

点击图片

许多人想要对图片做的关键事情之一就是点击它。这个问题的有两个解决方案。最简单的是使用一个按钮。

按钮可以有文本和许多其他属性,但最重要的是命令。命令告诉 ViewModel 当按钮被点击时应该做什么。

为了展示这是如何工作的,我将在我们的图片上添加一个新的属性 IsVisible 并将其设置为 true。只要它是 true,图片就是可见的。但,正如你可以想象的那样,将其设置为 false 使得大花朵不可见。不仅不可见,它还在页面上不占用任何空间,因此按钮将直接位于标签下方。

这里是 Button 的代码:

<Button
    Text="Click me"
    Command="{Binding ToggleFlowerVisibilityCommand}"/>

这是我能制作的 simplest 按钮了(我们将在稍后看看如何让它更好看)。这里的关键是 Command 参数。你可以通过 Binding 关键字知道 ToggleFlowerVisibility 将在 ViewModel 中。确实如此,但我们不是声明一个命令并将其指向一个方法,而是可以使用代码生成器来为我们做繁重的工作。

这里是修改后的 MainViewModel

[ObservableObject]
public partial class MainViewModel
{
  [ObservableProperty] private bool flowerIsVisible = true;
    [1]
  [ObservableProperty] private string fullName;
  [ObservableProperty] private string favoriteFlower =
    "flower.png";
  [RelayCommand]  [2]
  private void ToggleFlowerVisibility()  [3]
  {
    FlowerIsVisible = !FlowerIsVisible;
  }
}

这是一个 约定优于配置 的例子 – Button 中的 Command 属性是 ToggleFlowerVisibilityCommand,但当你使用 RelayCommand [2] 实现它时,你将其命名为 ToggleFlowerVisibility [3],省略了 Command

注意,我们创建了一个 FlowerIsVisible [1] 作为 ObservableProperty,我们只需在每次点击时将其从 true 切换到 false 再切回即可。

按钮属性

就目前而言,按钮将显示为在每个平台上原生显示的样子。但这些按钮可能相当难看。我们可以通过接管它们的外观的大部分来使它们看起来更漂亮。

这里是我的 Button 的 XAML,虽然不美观,但将展示你可以用来控制按钮外观的一些属性:

<Button
     BackgroundColor="Red"
     BorderColor="Black"
     BorderWidth="2"
     Command="{Binding ToggleFlowerVisibilityCommand}"
     CornerRadius="20"
     FontSize="Small"
     HeightRequest="35"
     Padding="5"
     Text="Don't Click Me"
     TextColor="Yellow"
     WidthRequest="150" />

我们有三个之前没有见过的新的属性。第一个是 BorderColor,它与 BorderWidth 一起使用。这为按钮提供了一个边框。由于我们已将 BackgroundColor 设置为 Red,边框会突出显示。最后一个新属性是 CornerRadius,它为我们提供了对其他方形按钮角落的圆润处理。把这些都放在一起,你就得到了一个看起来像 图 4.11 的按钮:

![图 4.11 – 一个看起来更好的按钮图片

图 4.11 – 一个看起来更好的按钮

为什么这个按钮仍然很丑?

我当然不是一个 UI 人员,所以我编写的页面通常相当丑陋,直到有人知道如何处理它们。这本书中的屏幕图像将反映出这种无能。

ImageButton

有时,我们宁愿在按钮上放一个图片,而不是文字。有一个 ImageButton 控件,它结合了许多 Image 控件和 Button 控件的属性:

<ImageButton
    BorderColor="Black"
    BorderWidth="2"
    Command="{Binding ToggleFlowerVisibilityCommand}"
    MaximumHeightRequest="75"
    MaximumWidthRequest="75"
    Padding="5"
    Source="{Binding FavoriteFlower}" />

你可以看到它如何与 Button 控件相似。事实上,我保留了命令绑定和源绑定,所以我们最终在大的图片下面得到了一个小花的图片,但点击小的一个(ImageButton)会使大的一个(Image)变得不可见,然后又变得可见,以此类推。我会在 图 4.12 中展示它们都可见的样子,因为很难在纸上切换图片:

![图 4.12 – ImageButton图片

图 4.12 – ImageButton

TapGestureRecognizer

处理在图片上点击的第二种方式是分配一个手势识别器。我们将分配的 GestureRecognizer 类型是 TapGestureRecognizer,它将识别图片本身被点击的情况。为了安全起见,我们将它设置为图片必须被双击。当发生这种情况时,图片会“噗!”地消失。

我们将移除 ImageButton,只保留 Image(和 Label)。这是我们的新 XAML 文件:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    BackgroundColor="White"
    x:Class="ForgetMeNotDemo.View.MainPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <ScrollView>
        <VerticalStackLayout>
            <Label
                BackgroundColor="Red"
                FontAttributes="Bold"
                FontSize="Small"
                HeightRequest="50"
                HorizontalOptions="Center"
                HorizontalTextAlignment="Center"
                LineBreakMode="WordWrap"
                Margin="20"
                MaxLines="5"
                Padding="10"
                Text="{Binding FullName}"
                TextColor="Yellow"
                VerticalTextAlignment="Center"
                x:Name="HelloLabel" />
            <Image
                HeightRequest="200"
                HorizontalOptions="Center"
                IsVisible="{Binding FlowerIsVisible}" [1]
                Source="{Binding FavoriteFlower}"
                x:Name="BigFlower">
                <Image.GestureRecognizers>     [2]
                    <TapGestureRecognizer      [3]
                        Command="{Binding
                          ImageTappedCommand}" [4]
                        NumberOfTapsRequired="2" /> [5]
                </Image.GestureRecognizers>
            </Image>
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

我们在 [1] 处看到,图片最初是可见的。在 Image 的开头和结尾括号之间,我们添加了 GestureRecognizer [2]。在 GestureRecognizer 中,我们添加了 TapGestureRecognizer [3],并且我们定义了 ImageTappedCommand [4],就像我们处理其他命令一样。最后,我们声明,为了触发命令,用户必须双击 [5]。

这里是一个从 ViewModelRelayCommand 的示例:

[RelayCommand]
private void ImageTapped()
{
  FlowerIsVisible = !FlowerIsVisible;
}

正如你所见,这个处理程序几乎与上一个相同。然而,这个处理程序不会按预期工作。在继续阅读之前,试着想想当我们双击图片(使其不可见)然后再次尝试这样做(使其可见)会发生什么。请慢慢来。我会在这里等待。

你可以通过回到 Button 或者在标签上放置一个 GestureRecognizer 来解决这个问题。

当你双击图片时,它确实会消失,因为 IsVisible 被设置为 false。然而,一旦它消失,它就消失了,并且没有东西可以点击来让它回来:

<Label
    BackgroundColor="Red"
    FontAttributes="Bold"
    FontSize="Small"
    HeightRequest="50"
    HorizontalOptions="Center"
    HorizontalTextAlignment="Center"
    LineBreakMode="WordWrap"
    Margin="20"
    MaxLines="5"
    Padding="10"
    Text="{Binding FullName}"
    TextColor="Yellow"
    VerticalTextAlignment="Center"
    x:Name="HelloLabel">
    <Label.GestureRecognizers>
        <TapGestureRecognizer Command="{Binding
          ImageTappedCommand}" />
    </Label.GestureRecognizers>
</Label>

这里有趣的是,TapGestureRecognizer命令指向相同的RelayCommand;它可以通过在图像上双击或单击标签来调用。

本节有两个要点:

  • TapGestureRecognizer允许您使任何控件可触摸,这可以非常强大。

  • 一旦View不可见,它就不再在页面上。您可以通过代码背后的方式再次使其可见,但仅限于通过不同的控件(就像我们使用ButtonImage那样)。

在显示文本后,应用程序最重要的方面是能够从用户那里获取文本。为此,主要视图是EntryEditor。让我们接下来看看它们。

输入文本

我们已经看了显示文本,现在让我们把注意力转向输入文本。有两个控件主要负责这一点:

  1. Entry: 用于输入一行文本

  2. Editor: 用于输入多行文本

这两个控件显然相关,但它们有不同的属性。Entry设计用于接受单行文本,而Editor处理多行输入。

要查看这些视图的工作情况,让我们为“忘记我”创建一个登录页面。

忘记我登录页面

我们一直在玩MainPage,但事实上,实际应用程序的MainPage非常简单:只是一个图像。登录页面更有趣。我们将对登录页面做一个初步的近似,这将使我们能够使用EntryEditor控件,尽管我们将随着进程的发展而改进这个页面。

创建登录页面

第一个任务是创建登录页面。要做到这一点,请右键单击LoginPage.xaml,如图图 4**.13所示:

![Figure 4.13 – Add Item dialog box]

![Figure_4.13_B19723.jpg]

图 4.13 – Add Item 对话框

XAML 与 C#

如果您希望使用 C#而不是 XAML 来创建登录页面的 UI,请选择ContentPage (C#)。我们将在本节中查看这两个,但让我们先从 XAML 版本开始。

检查 XAML 页面,其类设置为ForgetMeNotDemo.View.LoginPage,反映了命名空间(当我们创建位于View下的文件时,命名空间会自动生成)。XAML 还包括VerticalStackLayout,在其中,有Label

快速看一下代码背后的文件。注意,已经为您创建了namespace,并且页面继承自ContentPage

返回 XAML 页面,并在VerticalScrollView中删除Label。当应用程序完成时,它应该看起来像图 4**.14

![Figure 4.14 – Login.XAML (top portion)]

![Figure_4.14_B19723.jpg]

图 4.14 – Login.XAML(顶部部分)

如您所见,我们有两个标签。每个标签的右侧都有一个输入框,输入框有占位文本。一旦您开始在输入框中键入,占位文本就会消失。

此外,还有三个按钮。为了正确布局,我们希望使用 Grid,但我们不会在第六章中介绍网格,布局。但这不是问题,因为它给了我们查看嵌套 StackLayouts 和使用 HorizontalStackLayout 的机会。

要开始,我们只需创建顶层。我们希望标签中的文本具有灵活性,并且我们希望捕获 ViewModel 中尚未创建的属性中的 用户名 输入。让我们现在就做。在 LoginViewModel 类上右键单击。

LoginViewModel 中添加用于用户名的 ObservableProperty

namespace ForgetMeNotDemo.ViewModel
{
  [ObservableObject]
  internal partial class LoginViewModel
  {
    [ObservableProperty] private string name;

当用户在 Entry 控件中输入一个名字时,它将保存在这个属性中。

OneWay 和 TwoWay 绑定

控件可以是 OneWay,在这种情况下,控件从数据源(在这种情况下,属性)获取其值,但不能将其发送回去,或者 TwoWay,在这种情况下,控件从数据源获取数据,但也可以写回一个值。Entry 默认为 TwoWay

我们已经准备好在 XAML 中的 LoginPage 创建顶层:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    Title="LoginPage"
    x:Class="ForgetMeNotDemo.View.LoginPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <VerticalStackLayout>
        <HorizontalStackLayout WidthRequest="300">
            <Label
                FontSize="Medium"
                HorizontalOptions="Start"
                Margin="10,20,10,0"
                Text="User Name"
                VerticalOptions="Center"
                VerticalTextAlignment="Center" />
            <Entry
                HorizontalOptions="End"
                Placeholder="User Name"
                Text="{Binding Name}"
                WidthRequest="150" />
        </HorizontalStackLayout>
    </VerticalStackLayout>
</ContentPage>

我们在 Entry 上使用了三个属性:

  1. HorizontalOptions:这里我们将它设置为 End,这样 Entry 就会在行的最右边

  2. PlaceHolder:这是用户开始输入文本之前将显示的文本

  3. Text:我们将其绑定到 ViewModel 中的 Name 属性

看这个页面有一个问题:没有方法可以到达那里(目前还没有)。现在,而不是让程序在 MainPage 中打开,我们将让它打开到我们新的 LoginPage。为此,转到 AppShell.xaml 并将 ShellContent 改成这样:

<ShellContent
    Title="Home"
    ContentTemplate="{DataTemplate view1:LoginPage}"
    Route="LoginPage" />

我们将在第七章中讨论 Shell 和路由,但现在这将是可行的。

运行程序。如果你在 Android 设备或模拟器上运行它,它应该看起来大致像图 4.15

图 4.15 – LoginPage,第一次迭代

图 4.15 – LoginPage,第一次迭代

即使对我来说,这也不是很吸引人,但它确实展示了控件和布局。尝试在 用户名 输入框中输入。注意,占位符文本会立即消失。

我们需要一个方法来判断你输入的值是否真正绑定到 Name 属性。为此,让我们添加一个标签并将其绑定到 Name 属性。这样,当我们输入文本到输入框时,它将在 Label 中反映出来:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    Title="LoginPage"
    x:Class="ForgetMeNotDemo.View.LoginPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <VerticalStackLayout>
        <HorizontalStackLayout WidthRequest="300">
            <Label
                FontSize="Medium"
                HorizontalOptions="Start"
                Margin="10,20,10,0"
                Text="User Name"
                VerticalOptions="Center"
                VerticalTextAlignment="Center" />
            <Entry
                HorizontalOptions="End"
                Placeholder="User Name"
                Text="{Binding Name}"
                WidthRequest="150" />
        </HorizontalStackLayout>
        <Label
            Margin="10,30,10,0"
            Text="{Binding Name}" />
    </VerticalStackLayout>
</ContentPage>

然而,在我们能够使其工作之前,我们必须设置 BindingContext,就像我们在 MainPage 上做的那样。打开 XAML 页面的代码隐藏文件,并将 LoginViewModel 设置为绑定上下文:

public partial class LoginPage : ContentPage
{
    LoginViewModel vm = new LoginViewModel();
public LoginPage()
    {
        BindingContext = vm;
        InitializeComponent();
    }
}

当我们运行这个程序并在输入框中输入文本时,文本将保存在 ViewModel 中的 Name 属性中。由于标签绑定到相同的属性,文本也会立即在那里显示,如图 4.16所示。16*:

图 4.16 – 证明输入框绑定到 Name 属性

图 4.16 – 证明输入绑定到 Name 属性

现在我们知道它正在工作,删除标签。让我们为密码做同样的事情,就像我们对名字所做的那样,只是我们不想让任何人看到我们输入的密码。没问题,Entry 有一个 boolean 属性,IsPassword,我们将将其设置为 True

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    Title="LoginPage"
    x:Class="ForgetMeNotDemo.View.LoginPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <VerticalStackLayout>
        <HorizontalStackLayout WidthRequest="300">
            <Label
                FontSize="Medium"
                HorizontalOptions="Start"
                Margin="10,20,10,0"
                Text="User Name"
                VerticalOptions="Center"
                VerticalTextAlignment="Center" />
            <Entry
                HorizontalOptions="End"
                Placeholder="User Name"
                Text="{Binding Name}"
                WidthRequest="150" />
        </HorizontalStackLayout>
        <HorizontalStackLayout WidthRequest="300">
            <Label
                FontSize="Medium"
                HorizontalOptions="Start"
                Margin="10,10,10,0"
                Text="Password"
                VerticalOptions="Center"
                VerticalTextAlignment="Center" />
            <Entry
                HorizontalOptions="End"
                Placeholder="Password"
                IsPassword="True"
                Text="{Binding Password}"
                WidthRequest="150" />
        </HorizontalStackLayout>
    </VerticalStackLayout>
</ContentPage>

在继续之前,请注意我们有一个 VerticalStackLayout,其中包含两个 HorizontalStackLayouts。这是一个不常见的布局,但当我们转向 Grid 时,我们将在外观上获得更多的控制。

此 XAML 的结果显示在 图 4**.17 中:

图 4.17 – 在 Entry 上使用密码布尔值

图 4.17 – 在 Entry 上使用密码布尔值

让我们通过添加三个按钮来完成这个页面的第一次迭代。现在我们只为一个(提交)分配一个命令:

<HorizontalStackLayout Margin="10,10,10,0">
    <Button
        BackgroundColor="Gray"
        Command="{Binding SubmitCommand}"
        Margin="5"
        Text="Submit" />
    <Button
        BackgroundColor="Gray"
        Margin="5"
        Text="Create Account" />
    <Button
        BackgroundColor="Gray"
        Margin="5"
        Text="Forgot Password" />
</HorizontalStackLayout>

结果显示在 图 4**.18 中:

图 4.18 – 添加按钮

图 4.18 – 添加按钮

当你运行这个程序时,你可能注意到它运行得很好,除了点击 提交 没有做任何事情。这是因为我们命名了命令但从未实现它。我们将稍后进行操作。实际上,我们会长时间地推迟。我们将在到达 第十一章与 API 一起工作 时处理真正的实现。

标题

你可能已经注意到页面有一个 LoginPage 标题。好消息是这是免费的(.NET MAUI 在我们创建页面时创建了它)。然而,在 登录页面 之间有一个空格会更好。

在 XAML 页面的顶部是 ContentPage 的声明,并设置了第一个属性 Title

<ContentPage
    Title="LoginPage"

只需插入缺失的空格,一切都会变得完美。

编辑器

将文本输入到应用程序中的第二种主要方式是使用 Editor 控件。与 Entry 类的主要区别在于 Editor 是为多行数据输入设计的。你可以对文本有相当大的控制,你将在下一个示例中看到。

让我们在登录页面添加一个编辑器。我们将设置它,使其仅在用户点击 忘记密码 时可见。我们将鼓励用户解释他们上次看到密码的确切位置以及为什么我们在告诉他们要确保密码安全时他们如此粗心。

重新打开 LoginPage.xaml 并在 VerticalStackLayout 中添加一个 Editor,位于最底部:

<Editor
    FontSize="Small"
    HeightRequest="300"
    IsTextPredictionEnabled="True"  [1]
    Margin="10"
    MaxLength="500"   [2]
    Placeholder="Explain yourself here (up to 500
      characters)"
    PlaceholderColor="Red" [3]
    Text="{Binding LostPasswordExcuse}"
    TextColor="Blue"  [4]
    VerticalTextAlignment="Center" [5]
    x:Name="LoginEditor" />

我在编辑器上使用了许多属性,其中一些是新的。

[1] IsTextPredictionEnabled 允许你的编辑器为用户提供文本以完成他们的句子。你无疑在处理 Gmail 和其他应用程序时已经看到了这一点。这实际上是默认的 True;你可能想在请求用户姓名或其他可能令人烦恼的预测条件下将其设置为 False

[2] MaxLength 管理用户可以输入到编辑器中的字符数。

[3] PlaceHolderColor 允许你设置占位文本的颜色。

[4] 类似地,TextColor 设置用户输入文本的颜色。

[5] VerticalTextAlignment 设置文本在编辑器中的位置。

图 4.19 显示了用户在编辑器中输入任何内容之前 登录页面 的样子,而 图 4.20 显示了用户输入了几行文本后的样子:

![图 4.19 – 用户在编辑器中输入文本之前图片

图 4.19 – 用户在编辑器中输入文本之前

你可以将你想要的文本输入到编辑器中,最多不超过你在控件声明中设置的任何最大值。

![图 4.20 – 用户在编辑器中输入文本之后图片

图 4.20 – 用户在编辑器中输入文本之后

虽然边距只设置为 10,但按钮和文本之间有很大的空间。这是因为我们将 VerticalTextAlignment 设置为 Center。如果我们将其更改为 Start,文本将移动到编辑器的顶部,如图 图 4.21 所示:

![图 4.21 – 将编辑器中的文本移动到顶部(开始)位置图片

图 4.21 – 将编辑器中的文本移动到顶部(开始)位置

按钮本质上是依赖于事件的,但事件是在代码后文件中处理的,我们希望将所有逻辑都保留在 ViewModel 中。这个问题的答案是 EventToCommand 行为,我们将在下一节中考虑。

行为

Editor 控件有许多事件。这些事件可以通过代码后文件中的事件处理程序来处理,但正如之前解释的那样(以及解释),我们宁愿不这样做。因此,行为就出现了。

行为允许你在不创建子类的情况下向控件添加功能。它们 附加 行为。我们现在想要附加管理控制(Editor)中命令的能力,该控制没有命令。

.NET MAUI 社区工具包附带了许多行为,包括 EventToCommandBehavior。这个美妙的行为允许你将事件(本应在代码后文件中处理)转换为命令,该命令可以在 ViewModel 中处理。

我们想要更改 Editor 中的事件是 OnEditorCompleted,该事件在用户按下 Enter 键(或在 Windows 上,按下 Tab 键)时触发:

<Editor
    FontSize="Small"
    HeightRequest="300"
    IsTextPredictionEnabled="True"
    Margin="10"
    MaxLength="500"
    Placeholder="Explain yourself here (up to 500
        characters)"
    PlaceholderColor="Red"
    Text="{Binding LostPasswordExcuse}"
    TextColor="Blue"
    VerticalTextAlignment="Start"
    x:Name="LoginEditor">
    <Editor.Behaviors>
        <behaviors:EventToCommandBehavior
            EventName="Completed"
            Command="{Binding EditorCompletedCommand}" />
    </Editor.Behaviors>
</Editor>

语法让人联想到 GestureRecognizers,这并非巧合。想法是使控件能够拥有各种集合,并能够在 XAML 中声明这些集合。

当然,你可以在 C# 中声明相同的内容:

Var editor = new Editor();
var behavior = new EventToCommandBehavior
{
   EventName = nameof(Editor.Completed),
   Command= new EditorCompletedCommand()
};

如前所述,你可以在 XAML 中做任何事情,也可以在 C# 中做。

你在 ViewModel 中管理命令(无论你如何创建它),就像你管理任何其他命令一样。为了好玩,让我们在编辑器下方添加一个标签并将其绑定到 LostPasswordExcuse,但只有在用户按下 Enter 键时才显示。

Login.xaml 现在看起来是这样的:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    Title="Login Page"
    x:Class="ForgetMeNotDemo.View.LoginPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:behaviors="http://schemas.microsoft.com/dotnet/
        2022/maui/toolkit"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <VerticalStackLayout>
        <HorizontalStackLayout WidthRequest="300">
            <Label
                FontSize="Medium"
                HorizontalOptions="Start"
                Margin="10,20,10,0"
                Text="User Name"
                VerticalOptions="Center"
                VerticalTextAlignment="Center" />
            <Entry
                HorizontalOptions="End"
                Placeholder="User Name"
                Text="{Binding Name}"
                WidthRequest="150" />
        </HorizontalStackLayout>
        <HorizontalStackLayout WidthRequest="300">
            <Label
                FontSize="Medium"
                HorizontalOptions="Start"
                Margin="10,10,10,0"
                Text="Password"
                VerticalOptions="Center"
                VerticalTextAlignment="Center" />
            <Entry
                HorizontalOptions="End"
                IsPassword="True"
                Placeholder="Password"
                Text="{Binding Password}"
                WidthRequest="150" />
        </HorizontalStackLayout>
        <HorizontalStackLayout Margin="10,10,10,0">
            <Button
                BackgroundColor="Gray"
                Command="{Binding SubmitCommand}"
                Margin="5"
                Text="Submit" />
            <Button
                BackgroundColor="Gray"
                Margin="5"
                Text="Create Account" />
            <Button
                BackgroundColor="Gray"
                Margin="5"
                Text="Forgot Password" />
        </HorizontalStackLayout>
        <Editor
            FontSize="Small"
            HeightRequest="300"
            IsTextPredictionEnabled="True"
            Margin="10"
            MaxLength="5"
            Placeholder="Explain yourself here (up to 500
                characters)"
            PlaceholderColor="Red"
            Text="{Binding LostPasswordExcuse}"
            TextColor="Blue"
            VerticalTextAlignment="Start"
            x:Name="LoginEditor">
            <Editor.Behaviors>
                <behaviors:EventToCommandBehavior
                    EventName="Completed"
                    Command="{Binding EditorCompleted
                        Command}" />
            </Editor.Behaviors>
        </Editor>
        <Label
            FontSize="Small"
            IsVisible="{Binding EditorContentVisible}"
            LineBreakMode="WordWrap"
            Margin="10"
            Text="{Binding LostPasswordExcuse}"
            x:Name="EditorContents" />
    </VerticalStackLayout>
</ContentPage>

社区工具包为我们提供了一个在ViewModel中处理命令的更简单的方法。

弹出窗口和对话框

想要提醒用户某个条件或变化,或者从用户那里获取一些数据,使用警报,如图 4.22 所示并不罕见:

图 4.22 – 警告对话框

图 4.22 – 警告对话框

为了保持整洁,从LoginPage.xaml中删除编辑器和其关联的标签,并从ViewModel中删除构造函数和ICommand。在最终版本中我们不需要它们。

DisplayAlert对象只能从页面调用。稍后,你将看到如何在ViewModel中的按钮上处理SubmitCommand并发送消息到页面以显示警报。现在,让我们保持简单,将按钮的SubmitCommand更改为事件:

<Button
    BackgroundColor="Gray"
    Clicked="OnSubmit"
    Margin="5"
    Text="Submit" />

事件处理器放在代码后文件中。注意事件处理器的签名:

private async void OnSubmit(object sender, EventArgs e)
 {
   await DisplayAlert(
     "Submit",
     $"You entered {vm.Name} and {vm.Password}",
     "OK");
 }

图 4.22 中的对话框显示了数据,但没有与用户交互(除了告诉用户按确定关闭对话框)。然而,我们可以允许用户做出选择,并记录他们按下了哪个按钮。

事件处理器签名

OnSubmit想要是async,因为你想用await调用DisplayAlert。对于事件(仅限事件)async不是异步Task而是异步void。参数始终是Object类型和EventArgs或从EventArgs派生的类型。第一个通常命名为sender,因为这通常是引发事件的ViewEventArgs是空的,作为传递到事件处理器的特定类型参数的基类。由于我们不会在最终代码中使用事件处理器,所以你不必太担心这一点。

在这里,我们只考虑了三种警报类型中的一种。让我们看看其他两种。

向用户展示选择

当我们处理这些时,让我们看看其他两种类型的警报。一种要求用户从两个选择中选择一个。我们暂时将其添加到CreateAccount按钮上。

让我们在OnCreate按钮上添加一个clicked事件:

<Button
    BackgroundColor="Gray"
    Clicked="OnCreate"
    Margin="5"
    Text="Create Account" />

为了有一个地方显示结果,让我们在关闭HorizontalStackLayout标签后添加一个标签:

<Label Text="Create account?" x:Name="CreateAccount" />

代码后文件包含事件处理器,它将更新我们的标签:

private async void OnCreate(object sender, EventArgs e)
{
  CreateAccount.Text = (await DisplayAlert(
    "Create?",
    "Did you want to create an account?",
    "Yes",
    "No")).ToString();
}

DisplayAlert返回一个布尔值,所以我们调用ToString()将其放置在CreateAccount标签的文本字段中。对话框显示在图 4.23 中:

图 4.23 – 使用对话框提示选择

图 4.23 – 使用对话框提示选择

我们可以更进一步,向用户提供一系列选择。这通常被称为向导,因为它可以用来引导用户完成一系列操作。

ActionSheet

对话框的第三种变体是ActionSheet。在这里我们可以提出多个选择,并允许用户选择一个。我们将将其附加到忘记密码按钮的事件处理器:

<Button
    BackgroundColor="Gray"
    Clicked="OnForgotPassword"
    Margin="5"
    Text="Forgot Password" />

这里是事件处理器:

private async void OnForgotPassword(object sender,
  EventArgs e)
{
  CreateAccount.Text = (await DisplayActionSheet(
    "How can we solve this?", [1]
    "Cancel",   [2]
    null,       [3]
    "Get new password",
    "Show me my hint",
    "Delete account"));
}

[1] 第一个参数是标题。

[2] 第二个参数是 取消 按钮的文本。

[3] 第三个参数是 null 的文本。

这后面跟着一个选择列表。图 4**.24 展示了运行时的样子:

Figure 4.24 – 操作表

图 4.24 – 操作表

最后,有时我们希望允许用户输入自由格式数据。

显示提示

对话框的最后一个变体提供了一个提示,用户可以填写值。我们需要修改 OnCreate 的事件处理程序来展示这一点:

private async void OnCreate(object sender, EventArgs e)
{
  CreateAccount.Text = await DisplayPromptAsync(
    title:"New Account",
    message:"How old are you?",
    placeholder:"Please enter your age",
    keyboard:Keyboard.Numeric,
    accept: "OK",
    cancel: "Cancel");
}

在这个变体中,通常使用命名参数,因为有很多选项。图 4**.25 展示了它看起来是什么样子:

Figure 4.25 – 显示提示

图 4.25 – 显示提示

Toast

对话框的一个非常流行的替代方案是 Toast 视图。这是一个从页面底部弹出的弹出窗口(就像吐司从烤面包机中弹出一样),它显示其消息然后消失。

让我们再次修改 OnCreate 的处理程序,这次是为了显示一个吐司:

private async void OnCreate(object sender, EventArgs e)
{
    CancellationTokenSource = [1]
      new CancellationTokenSource();
    var message = "Your account was created";
    ToastDuration duration = ToastDuration.Short;  [2]
    var fontSize = 14;
    var toast = Toast.Make(message, duration, fontSize);
    await toast.Show(cancellationTokenSource.Token); [3]
}

当创建吐司时,您需要 cancellationToken。幸运的是,您可以从 CancellationTokenSource 对象的静态 Token 对象中实例化一个 [1] 和 [3]。

您可以使用 ToastDuration 枚举设置吐司显示的持续时间 [2]。选项有 LongShort

图 4**.26 展示了吐司:

Figure 4.26 – Toast 弹出

图 4.26 – Toast 弹出

Snackbar

如果您需要更多控制吐司的外观,可以使用与之密切相关的 SnackbarSnackbar 不仅有许多选项,而且它还有两个步骤。首先是显示吐司,其次是(可选的)动作——也就是说,当吐司被取消时,您想做什么?在这个例子中,我们将显示一个对话框。

选项的丰富性意味着事件处理程序比通常更广泛:

private async void OnCreate(object sender, EventArgs e)
{
  CancellationTokenSource =  [1]
    new CancellationTokenSource();
  var message = "Your account was created";  [2]
  var dismissalText = "Click Here to Close the SnackBar";
    [3]
  TimeSpan duration = TimeSpan.FromSeconds(10);  [4]
  Action = async () =>  [5]
    await DisplayAlert(
      "Snackbar Dismissed!",
      "The user has dismissed the snackbar",
      "OK");
  var snackbarOptions = new SnackbarOptions    [6]
  {
    BackgroundColor = Colors.Red,
    TextColor = Colors.Yellow,
    ActionButtonTextColor = Colors.Black,   [7]
    CornerRadius = new CornerRadius(20),
    Font = Microsoft.Maui.Font.SystemFontOfSize(14),
    ActionButtonFont = Microsoft.Maui.Font
      .SystemFontOfSize(14)
  };
  var snackbar = Snackbar.Make(
    message,
    action,
    dismissalText,
    duration,
    snackbarOptions);
  await snackbar.Show(cancellationTokenSource.Token);
}

[1] 我们首先创建 CancellationTokenSource,就像之前做的那样。

[2] 创建要显示在吐司中的消息。

[3] 添加一个可以点击以取消吐司的消息。

[4] 定义您希望吐司显示多长时间。您可以使用 TimeSpan 支持的任何时间单位(吐司可以显示几天!)。

[5] 当吐司被取消时,动作将发生。

[6] 这里是我们设置吐司特性的地方。

[7] 您可以独立设置吐司和取消文本的文字颜色。

图 4**.27 展示了在点击之前 Snackbar 的样子:

Figure 4.27 – Snackbar

图 4.27 – Snackbar

用户点击 SnackBar 后消失,并触发动作;在这种情况下,对话框出现,如图 图 4**.28 所示:

Figure 4.28 –Snackbar 取消后的动作

图 4.28 –Snackbar 取消后的动作

.NET MAUI 没有水平线控件,但我们可以将 BoxView 控件用作一个很好的替代品。

BoxView

最简单的控件之一是 BoxView,它只是在页面上绘制一个框:

<BoxView
    Color="Red"
    CornerRadius="20"
    HeightRequest="125"
    WidthRequest="100" />

图 4.29 显示了 BoxView 控件:

图 4.29 – 简单的 BoxView 控件

图 4.29 – 简单的 BoxView 控件

您可能会问这有什么好处?如果您将框的高度设置得非常小,宽度设置得非常大,您将得到一条很好的线条来分割您的页面。如果我们把以下内容放在 Password 输入之后,但在按钮之前,我们可以整洁地分割页面:

        <BoxView
            Color="Red"
            HeightRequest="2"
            Margin="0,20"
            WidthRequest="400" />

图 4.30 显示了这看起来是什么样子:

图 4.30 – 使用 BoxView 控件绘制线条

图 4.30 – 使用 BoxView 控件绘制线条

许多 UI 专家喜欢用边框来框定控件,可能还会使用 阴影。为此,您需要使用 Frame 控件。

Frame

如果您想在另一个控件周围创建边框,您将需要使用 Frame 控件。Frame 允许您定义边框的颜色、CornerRadius 以及边框是否有阴影。让我们为 Password 输入字段创建一个边框:

<Frame
     BorderColor="Blue"
     CornerRadius="5">
     <Entry
         HorizontalOptions="End"
         IsPassword="True"
         Placeholder="Password"
         Text="{Binding Password}"
         WidthRequest="150" />
 </Frame>

图 4.31 显示了结果:

图 4.31 – 在密码输入周围添加边框

图 4.31 – 在密码输入周围添加边框

您可以通过使用画笔来控制 BoxView 控件和其他许多控件的颜色。

Brushes

您可以使用画笔为任意数量的控件填充颜色。最容易看到这一功能的地方是使用 BoxView 控件,或者使用 Frame

有三种类型的画笔,实心线性渐变径向渐变。让我们更详细地探讨它们。

实心画笔

实心画笔用于您想用一个单一的颜色填充控件时。通常,实心画笔是隐含在控件的 BackgroundColor 属性中的,就像我们在绘制 BoxView 控件时上面看到的。

LinearGradientBrush

LinearGradientBrush 在称为渐变轴的线上绘制一个区域,其中包含两种或多种颜色的混合。您指定一个起点和一个终点,然后指定沿途的停止点(颜色切换的地方)。

起点和终点相对于绘制区域的边界,其中 0,0 是左上角(也是默认起点)和 1,1 是右下角(也是默认终点)。

为了说明这一点,我将把框架从密码周围移动到一个单独的空间:

<Frame
    BorderColor="Blue"
    CornerRadius="10"
    HasShadow="True"
    HeightRequest="100"
    WidthRequest="100">
    <Frame.Background>  [1]
        <LinearGradientBrush EndPoint="1,0"> [2]
            <GradientStop Color="Yellow" Offset="0.2" />
              [3]
            <GradientStop Color="Red" Offset="0.1" /> [4]
        </LinearGradientBrush>
    </Frame.Background>
</Frame>

[1] 在这里,我们在 Frame 上创建了一个 Background 属性。

[2] 在其中创建 LinearGradientBrush

注意,我们指定了 EndPoint 但没有指定 StartPoint,因为我们使用了默认的 StartPoint 0,0。通过从 0,01,0,我们创建了一个水平渐变。

[3] 我们将第一个 GradientStop 设置为 0.2

[4] 我们将第二个 GradientStop 设置为 0.1,这使得黄色的数量大约是红色的两倍。

图 4.32 显示了结果:

![图 4.32 – LinearGradientBrush图片

图 4.32 – LinearGradientBrush

渐变停止点

渐变停止点表示沿渐变向量的位置,范围从01。简而言之,这里显示的第一个渐变是渐变向量的二十分之一,第二个是十分之一。

渐变有两种类型:线性,如前一个示例所示,和径向,如以下所述。

RadialGradientBrush

如果我们用RadialGradientBrush做同样的事情,我们的坐标从中心开始,默认为0.5,0.5,我们提供一个半径作为双精度值。默认值是0.5。让我们使用RadialGradientBrush重现之前显示的LinearGradient

<Frame
    BorderColor="Blue"
    CornerRadius="10"
    HasShadow="True"
    HeightRequest="100"
    WidthRequest="100">
    <Frame.Background>
        <RadialGradientBrush>
            <GradientStop Color="Yellow" Offset="0.2" />
            <GradientStop Color="Red" Offset="0.1" />
        </RadialGradientBrush>
    </Frame.Background>
</Frame>

注意,我们没有指定中心或半径,因此我们使用默认值。图 4**.33 展示了结果:

![图 4.33 – RadialGradientBrush图片

图 4.33 – RadialGradientBrush

通过这种方式,我们已经到达了非常重要的一章的结尾。

摘要

这是一章很长的内容,我们涵盖了众多内容,但如果您将其分解,真正的主题如下:

  • MVVM

  • 数据绑定

  • 控件

当然,控件是一个相当大的主题,我们还没有完成。在下一章中,我们将讨论布局,但我们也会讨论如何设置控件样式、动画控件等。

您 90%的时间都在使用 90%的功能

我并没有试图涵盖每个控件,也没有涵盖我们看到的控件的所有属性。那样会把这本书变成一本百科全书,我的目标是向您展示您日常使用的 90%的.NET MAUI。如果您发现您需要不同的属性或不同的控件,那么,这正是(出色的)文档的作用。只需访问bit.ly/Liberty-Maui,或者询问谷歌或您当地的 AI 代理,您就能找到每一个角落和缝隙。

本章的主要收获是:

  • MVVM 是.NET MAUI 的基本架构。在 MVVM 中,Model是数据(我们尚未看到它的工作情况),View是 UI,ViewModel是所有逻辑的地方(或应该是)。我们在本章的大部分内容中打破了 MVVM,并将逻辑放在了代码-behind 文件中,但这只是为了方便,因为我们还没有讨论如何将数据放入和取出页面。

  • 数据绑定是您将ViewModel连接到View的方式。而不是将ViewModel中的属性数据复制到View的字段中,您将这个控件绑定到属性上,当属性的值发生变化时,控件会自动更新。

  • 有大量的控件可供您使用,并且每个控件都在所有支持的平台上以原生控件的形式显示:iOS、Android、Windows 和 macOS。它们被作为原生控件发出这一点非常重要。这不仅会使它们看起来正确,而且它们也会非常快。

  • 您可以在 XAML 中声明的任何内容都可以在 C#中声明。

  • 你可以控制每个控件的外观,使它们在每一个平台上看起来都一样——从颜色相同到看起来完全相同。这完全取决于你。这取决于你在每个控件上设置了多少属性。

问答

  1. MVVM 的优点是什么?

  2. 你如何创建 View 类和 ViewModel 之间的连接,以便数据绑定能够工作?

  3. 有哪些控件可以用于在表单中输入数据?

  4. 最常见的用于显示数据的控件是什么?

  5. 什么是 SnackBar

你试试看

  • 创建一个像表单一样的页面

  • 提供一些提示、输入字段和按钮以接受输入

  • 当用户填写字段并点击按钮时,显示一个确认吐司并在标签中显示他们的输入

  • 添加一张图片,并且作为额外加分项,使图片可点击,点击时显示一个祝贺对话框和/或吐司(或者如果你有雄心,可以使用 SnackBar!)

  • 随意混合使用额外的控件

第五章:高级控件

在上一章中,我们探讨了多个控件及其事件和命令的处理方法。在本章中,我们将探讨使用命令和消息将逻辑移动到ViewModel中。然后,我们将继续介绍样式,这些样式可以让你轻松地为控件提供统一的外观。

一个良好设计的用户体验的关键特性是,当某件事需要超过一秒钟的时间时,你让用户知道应用程序正在处理中,这样就不会显得你的应用程序已经冻结。

在本章中,我们将涵盖以下主题:

  • 保持用户对活动知情

  • 将事件处理移动到ViewModel

  • 发送和接收消息

  • 显示集合

  • 样式

技术要求

对于本章,你需要 Visual Studio。你可以在这里找到本章的源代码:github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/MVVMAndControls。如果你想继续学习,请继续你在第四章中正在工作的项目。

保持用户对活动知情

有两种方式让用户知道你的应用程序正在处理耗时操作:

  • ActivityIndicator

  • ProgressBar

ActivityIndicator基本上表示,“我在处理中,但不知道需要多长时间”,而ProgressBar表示,“我在处理中,已经完成了一半。”让我们更详细地探讨这两个控件。

ActivityIndicator

我们首先将ActivityIndicator添加到登录页面中按钮下方:

<ActivityIndicator
    Color="Blue"
    IsRunning="{Binding ActivityIndicatorIsRunning}" />

注意,IsRunning属性绑定到了ActivityIndicatorIsRunning属性。该属性位于LoginViewModel中(你可能会记得我们将其设置为绑定上下文):

[ObservableProperty] private bool activityIndicatorIsRunning   = true;

我将其默认值设置为true,因此当页面加载时活动指示器会运行。在创建账户后,让我们将其关闭:

private async void OnCreate(object sender, EventArgs e)
{
  CancellationTokenSource =
    new CancellationTokenSource();
  var message = "Your account was created";
  var dismissalText = "Click Here to Close the SnackBar";
  TimeSpan duration = TimeSpan.FromSeconds(10);
  Action  = async () =>
    await DisplayAlert(
      "Snackbar Dismissed!",
      "The user has dismissed the snackbar",
      "OK");
  var snackbarOptions = new SnackbarOptions
  {
    BackgroundColor = Colors.Red,
    TextColor = Colors.Yellow,
    ActionButtonTextColor = Colors.Black,
    CornerRadius = new CornerRadius(20),
    Font = Microsoft.Maui.Font.SystemFontOfSize(14),
    ActionButtonFont = Microsoft.Maui.Font
      .SystemFontOfSize(14)
  };
  var snackbar = Snackbar.Make(
    message,
    action,
    dismissalText,
    duration,
    snackbarOptions);
  await snackbar.Show(cancellationTokenSource.Token);
  vm.ActivityIndicatorIsRunning = false;
}

在创建页面代码中,除了添加最后一行之外,没有其他变化。在这里,我们进入ViewModel并设置ActivityIndicatorIsRunning属性为false。这应该会停止与showActivityIndicator属性绑定的ActivityIndicator

结果看起来像图 5.1

图 5.1 – ActivityIndicator

图 5.1 – ActivityIndicator

正在运行还是挂起?

注意,在某些情况下,你的程序可能会挂起,但活动指示器会继续旋转。这可能会给用户造成巨大的困惑和挫败感。避免这种问题的方法之一是设置一个计时器,如果任务在n秒内没有完成,你停止指示器,例如,显示一个错误对话框。当然,如果一切都已经挂起,你可能无法做到这一点,但通常,指示器也会冻结。

ActivityIndicator 很好,但它只告诉用户有事情在进行中,并没有说明它在任务中的进展程度。这正是 ProgressBar 的作用。

ProgressBar

ProgressBar 将任务分成分数部分(例如,百分比)并显示已完成的部分(分数、百分比等)。我们都见过进度条:理想情况下,它们在屏幕上平滑移动;实际上,当它们跟踪的任务完成时,它们通常会断断续续地移动。

我们将创建一个进度条,但我们将模拟一个动作。也就是说,我们将根据时间而不是实际任务进度来推进条。尽管如此,ProgressBar 的工作原理将变得明显。

要开始,让我们在 LoginPage.xaml 中注释掉 ActivityIndicator,并用 ProgressBar 替换它:

<!--<ActivityIndicator
    Color="Blue"
    IsRunning="{Binding ActivityIndicatorIsRunning}" />-->
<ProgressBar
    ProgressColor="Blue"
    x:Name="LoginProgressBar" />

在这里,我们只声明了 ProgressBar 的两个属性:其颜色和名称。名称允许我们在代码背后引用该条。当然,我们通常会根据 ViewModel 中的数据更新 ProgressBar,但现在,就像我们之前做的那样,我们将在代码背后(LoginPage.xaml.cs)完成这项工作。

下面是当用户点击 提交 按钮时启动和推进进度条的代码:

private async void OnSubmit(object sender, EventArgs e)
{
  for (double i = 0.0; i < 1.0; i += 0.1)  [1]
  {
    await LoginProgressBar.ProgressTo(i, 500,
      Easing.Linear); [2]
  }
  await DisplayAlert(  [3]
    "Submit",
    $"You entered {vm.Name} and {vm.Password}",
    "OK");
}

[1] 我们将根据 for 循环中计数器变量(i)的值设置 ProgressBar 的值。ProgressBar 的值范围从 01,进度或分数的百分比或分数被测量为这两个数字之间的值。在这里,我们将计数器变量初始化为 0.0,直到它达到 1.0 值,我们以十分之一递增。

[2] 在 for 循环中,我们在 ProgressBar 上调用 ProgressTo 方法。该方法接受三个值:

  1. 我们想要进展到的值

  2. 到达该值所需的时间,以毫秒为单位

  3. Easing(见下一节)

[3] 当进度条完成时我们将采取的行动。

另一个相关的功能是 Easing,它指的是动作从开始到全速所需的速度。让我们更深入地看看这一点。

Easing

Easing 指的是项目移动的模式。例如,火车不会突然从车站静止不动加速到每小时 75 英里;它会 逐渐加速 到最终速度。如果你绘制一个加速度图,它看起来像正弦波,而列举的两种 easing 值实际上是 SineIn(表示启动模式)和 SineOut(表示返回车站的模式)。

然而,在我们的情况下,我们希望进度条平滑且匀速移动,这正是 easing.Linear 所做的。

最终效果是进度条将在整个过程中进行动画。我们知道我们是以十分之一的速度从0移动到1,我们知道我们用半秒(500 毫秒)走完每十分之一;因此,我们知道从0.0(条上没有显示任何内容)到1.0(条完全填充了颜色)的整个旅程将花费五秒钟。

for循环结束时,对话框将弹出,给出一个很好的模拟进度条所跟踪的任务完成情况。该进度的一个快照显示在图 5.2中:

图 5.2 – 进度条已完成大约 75%

](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/dn-maui-csp-dev/img/Figure_5.2_B19723.jpg)

图 5.2 – 进度条已完成大约 75%

为了方便和保持简单,我们一直在使用代码后端来处理事件。当然,如第三章中所述,使用命令而不是事件,并在ViewModel中处理它们是有很好的理由的。让我们看看这一点。

将事件处理移至 ViewModel

假设当按钮被按下时,我们想在ViewModel中处理这个事实,这是ProgressBar所偏好的。

当我们想要与Clicked事件交互并添加一个命令时,在ViewModel中处理事情会变得复杂。

<Button
    BackgroundColor="Gray"
    Command="{Binding SubmitCommand}"
    Margin="5"
    Text="Submit" />

我们将在ViewModel中创建RelayCommand来处理Submit命令:

[RelayCommand]
private async void Submit()
{
  for (var i = 0.0; i < 1.0; i += 0.1)
  {
    await LoginPage.LoginProgressBar.ProgressTo(i, 500,
      Easing.Linear); [1]
  }
  await Application.Current.MainPage.DisplayAlert(
     [2]
    "Submit",
    $"You entered {Name} and {Password}",
    "OK");
}

[1] 在LoginPage上访问LoginProgressBar(稍后会详细介绍如何做到这一点),并像之前看到的那样调用ProgressTo

[2] 通过Application对象访问MainPage并调用DisplayAlert

那么,我们如何在 UI 中访问LoginProgressBar呢?我们需要LoginPage的一个静态成员来实现这一点。我们将LoginProgressBar的声明从可扩展应用程序标记语言XAML)中提取出来,并将其移动到代码后端:

public static ProgressBar LoginProgressBar;

我们需要确保在LoginPage的构造函数中初始化这一点:

public LoginPage()
{
  LoginProgressBar = new ProgressBar();

如果我们想在ProgressBar中添加它,就需要引用StackLayout,所以让我们按照以下方式命名:

<VerticalStackLayout x:Name="LoginStackLayout">

现在我们准备将ProgressBar添加到StackLayout的子元素中。这是完整的构造函数:

public LoginPage()
{
  LoginProgressBar = new ProgressBar();
  InitializeComponent();  [1]
  LoginStackLayout.Children.Add(LoginProgressBar); [2]
  BindingContext = vm;
}

[1] 注意到InitializeComponent在将ProgressBar添加到StackLayout的子元素之前调用。在调用此之前,LoginStackLayout将为 null。

[2] 通过在这里调用Add,将LoginProgressBar添加到 XAML 中创建的控件下面的LoginStackLayout

哎?让我们一步一步来。

分解步骤

当你运行此程序,输入用户名和密码,然后点击提交时,以下是发生的事情的顺序:

  1. ProgressBar是在页面构造函数中添加到页面的。

  2. 当你点击SubmitCommand时,会发送到ViewModel

  3. ViewModelSubmitRelayCommand中处理这一点。

  4. RelayCommand中,它更新了(静态的)LoginProgressBar

  5. 然后它通过MainPage调用DialogBox,它通过Application对象可以访问。

这工作得很好,但有点繁琐。使用 Application.Current.MainPage 并不罕见,但我们为了访问 ProgressBar 而跳过的圈子是。解决这个问题的方法是使用 Messaging,我们将在下一节中介绍。

可见性

通常认为,ViewModel 不应看到视图的方面(正如这里所做的那样)。在下一个示例中,我们将隔离 VM 和视图。

发送和接收消息

而不是深入到 View 中,我们可以在 ViewModel 信号 View 何时显示对话框或其他 View 相关元素时。

例如,假设我们想在用户点击 Command 时显示 Snackbar(这更受欢迎,因为它将逻辑放入 ViewModel)。然后 ViewModel 可能会处理数据或执行其他任何需要做的事情,并通过发送相关消息来指示 View 显示 Snackbar

这个想法是 ViewModel 发布一条消息,例如“任何订阅此消息的人,显示 Snackbar”,页面订阅此消息,因此在收到消息时显示 Snackbar

在某些情况下,可能会有多个 订阅者。同样,在某些情况下,多个 发布者 可以发送相同的消息,如图 5.3 所示:

![图 5.3 – 发布和订阅图 5.3 – B19723.jpg

图 5.3 – 发布和订阅

消息中心

.NET MAUI 有一个内置的消息系统,但它已被弃用,转而使用 .NET Community Toolkit MVVM 中提供的 WeakReferenceMessenger,我们将在下一节中介绍。

开始使用 WeakReferenceMessenger

要设置此环境,首先创建一个将作为消息的类。你可以在 ViewViewModel 都能看到的任何地方创建此类。为了方便,我将它放在 LoginPage 类之上:

public partial class ConstructMessage {}

接下来,在 LoginPage 构造函数中,我们需要注册接收此类消息。一旦收到消息,你可能调用一个方法,或者你也可以使用 Lambda 表达式来完成这项工作。

要注册接收消息,请使用 WeakReferenceMessengerRegister 方法。以下是实现此目的的代码:

    WeakReferenceMessenger.Default.Register
      <ConstructMessage> (this, async ( m,e) =>
    {
             // …
     });

在大括号之间的是你在收到该消息时想要做的事情。我已经将我们在事件处理器中使用的事件处理代码移动到这里:

    WeakReferenceMessenger.Default.Register
      <ConstructMessage> (this, async ( m,e) =>
    {
      CancellationTokenSource =
        new CancellationTokenSource();
      var message = "Your account was created";
      var dismissalText = "Click Here to Close the
        SnackBar";
      TimeSpan duration = TimeSpan.FromSeconds(10);
      Action = async () =>
        await DisplayAlert(
          "Snackbar Dismissed!",
          message,
          "OK");
      var snackbarOptions = new SnackbarOptions
      {
        BackgroundColor = Colors.Red,
        TextColor = Colors.Yellow,
        ActionButtonTextColor = Colors.Black,
        CornerRadius = new CornerRadius(20),
        Font = Microsoft.Maui.Font.SystemFontOfSize(14),
        ActionButtonFont = Microsoft.Maui.Font
          .SystemFontOfSize(14)
      };
      var snackbar = Snackbar.Make(
        message,
        action,
        dismissalText,
        duration,
        snackbarOptions);
      await snackbar.Show(cancellationTokenSource.Token);
      vm.ActivityIndicatorIsRunning = false;
    });

现在,我们只需要修改 LoginPage.xaml,以便 ViewModel 而不是代码背后的事件处理器:

<Button
    BackgroundColor="Gray"
    Command="{Binding CreateCommand}"
    Margin="5"
    Text="Create Account" />

这将在 ViewModel 中调用创建中继方法

[RelayCommand]
private void Create()
{
  WeakReferenceMessenger.Default.Send(new CreateMessage());
}

ViewModel 调用 Send 方法,发送 ConstructMessage 实例作为信号给任何已注册的监听器采取某些行动。在发送该消息之前,Create 方法可能执行其他最好在 ViewModel 而不是代码背后完成的工作。

当我们需要的逻辑在 ViewModel 中执行的动作只能由 View 执行时,这是一种更干净的解耦 ViewModelView 的方式。

WeakReferenceMessenger 也可以用来在 ViewModel 之间进行通信。

最后,它被称为 WeakReferenceMessenger,以区别于 StrongReferenceMessengerWeakReferenceMessenger 的优势,以及它通常被选择的原因,是它管理自己的内存和清理,因此你不需要。

在 C# 中创建页面

在继续之前,为了强调在 XAML 中能做的事情在 C# 中也能做,这里展示了我们将要在 Forget Me Not (ForgetMeNotDemo) 中使用的 LoginPage 版本,该版本是用 C# 编写的(在仓库中,这个页面被称为 LoginCS):

using CommunityToolkit.Maui.Markup;
using static CommunityToolkit.Maui.Markup.GridRowsColumns;
namespace ForgetMeNot.View
{
    class LoginCS : ContentPage
    {
        public LoginCS(LoginViewModel viewModel)  [1]
        {
            BindingContext = viewModel;
            var activity = new ActivityIndicator() [2]
            {
                HeightRequest = 50,
                Color = Color.FromRgb(0, 0, 0xF),
            };
            activity.IsEnabled = viewModel
              .ShowActivityIndicator; [3]
            Content = new VerticalStackLayout()
            {
                Children = [4]
                {
                    activity,
                    new Grid()  [5]
                    {
                        RowDefinitions = GridRowsColumns
                          .Rows.Define(
                            (Row.Username,Auto),
                            (Row.Password,Auto),
                            (Row.Buttons, Auto)
                            ),
                        ColumnDefinitions = GridRowsColumns
                          .Columns.Define(
                            (Column.Submit,Star),
                            (Column.Create, Star),
                            (Column.Forgot, Star)
                            ),
                        Children =
                        {
                            new Label()
                                .Text("User name")
                                .Row(Row.Username)
                                   .Column(0), [6]
                            new Entry()
                                .Placeholder("User name")
                                .Bind(Entry.TextProperty,
                                   nameof(LoginViewModel
                                     .LoginName))
                                .Row(Row.Username)
                                  .Column(1)
                                .ColumnSpan(2),
                            new Label()
                                .Text("Password")
                                .Row(Row.Password)
                                  .Column(0),
                            new Entry {IsPassword = true}
                                .Placeholder("Password")
                                .Bind(Entry.TextProperty,
                                 nameof(LoginViewModel
                                 .Password))
                                .Row(Row.Password)
                                  .Column(1)
                                .ColumnSpan(2),
                            new Button()
                                .Text("Submit")
                                .Row(Row.Buttons)
                                  .Column(Column.Submit)
                                .BindCommand(nameof
                                  (LoginViewModel
                                    .DoLoginCommand)),
                            new Button()
                                .Text("Create Account")
                                .Row(Row.Buttons)
                                  .Column(Column.Create)
                                .BindCommand(nameof
                                  (LoginViewModel
                                 .DoCreateAccountCommand)),
                            new Button()
                                .Text("Forgot Password")
                                .Row(Row.Buttons)
                                   .Column(Column.Forgot)
                                .BindCommand(nameof
                                (LoginViewModel
                                .ForgotPasswordCommand))
                        }
                    }
                }
            };
        }
    }
    enum Row
    {
        Username,
        Password,
        Buttons
    }
    enum Column
    {
        Submit,
        Create,
        Forgot
    }
}

[1] 我们首先声明一个类并给它 LoginViewModel。这是通过 依赖注入 实现的,这个主题在 第九章 中有详细讲解。

[2] ActivityIndicator 被实例化;它稍后将被添加到页面中。

[3] ActivityIndicatorIsEnabled 属性绑定到 ViewModel 中的一个属性。

[4] 我们通过向其 Children 集合中添加内容来添加到 StackLayout

[5] 我们还没有介绍 Grid,但你可以看到它是一个具有行和列的布局。我们将在下一章更深入地探讨它。

[6] 在 Grid 中,每个单独的行和列都可以从 枚举 中获得一个名称,或者可以通过其零基索引来引用。

本节的关键要点是,你当然可以在 C# 中创建所有控件及其命令和属性,就像在 XAML 中一样。我将继续深入 C#,但恐怕这会让你疯狂,因为我要在两者中展示每种类型,所以再次强调,对于布局和控制,我们将主要关注 XAML – 这是 .NET MAUI 的标准方法。

显示集合

通常会有一个数据集合,并且希望将其以列表的形式显示出来,使用户能够选择一个或多个项目,然后对这些项目进行一些操作。在 .NET MAUI 中有几种方法可以做到这一点,但最常见(也是最好的)方法是使用 CollectionView

要查看其工作情况,请检查 Preferences.xaml 以及其代码背后的 Preferences.xaml.csViewModelPreferencesViewModel.cs。在我们构建 Forget Me Not 的过程中,我们将广泛使用这个页面,但让我们先慢慢来。

我们的目标是创建一个包含用户偏好的列表(如衬衫尺寸、音乐类型等)。为此,我们将使用 CollectionView,当然,我们还需要一个可以查看的集合。完成后的页面将看起来像这样:

![图 5.4 – 偏好页面

![img/Figure_5.4_B19723.jpg]

图 5.4 – 偏好页面

而不是每行都是一个独特的对象,我们将创建一个可以重复显示的类型。在 Model 文件夹中创建一个 Preference 类:

namespace ForgetMeNotDemo.Model;
[ObservableObject]
public partial class Preference
{
    [ObservableProperty] private string preferencePrompt;
    [ObservableProperty] private string preferenceValue;
}

部分类

在 .NET MAUI 中,几乎所有类都是 部分类 以支持社区工具包的代码。

Preference 只有两个属性,都是字符串。PreferencePrompt 字符串将保存页面左侧的文本,而 PreferenceValue 字符串将保存用户在右侧的偏好。

我们首先需要的是这些 Preference 对象的集合。为了得到这个集合,我们将构建一个 Service,其最终任务是与 API 交互并获取我们的 Preference 对象列表。执行以下步骤:

  1. 创建一个名为 Services 的新文件夹。

  2. Services 中创建一个 PreferenceService 类。

  3. 在该文件中添加一个 GetPreferences 方法。

这里是代码:

public class PreferenceService
{
  public async Task<List<Preference>> GetPreferences()
  {
    return await GetPreferencesMock();
  }
  private async Task<List<Preference>> GetPreferencesMock(
  {
    return null;
  }
}

ViewModel 将在服务上调用 GetPreferences 并返回一个 Preference 对象的列表。我们稍后会讨论 PreferenceService 如何获取这些对象。

PreferencesViewModel 中,执行以下操作:

[ObservableObject]
public partial class PreferencesViewModel
{
  [ObservableProperty] private List<Preference>
    preferences;
  private readonly PreferenceService service; [1]
  public PreferencesViewModel()
  {
    service = new(); [2]
  }
  public async Task Init()
  {
    Preferences = await service.GetPreferences(); [3]
  }
}

[1] 声明一个 PreferenceService 的实例

[2] 在构造函数中初始化它

[3] 在 Init 方法中,用从服务返回的内容填充 Preferences 集合

依赖注入

第九章 中,我们将回顾依赖注入。那时,我们将传递一个 PreferenceService 接口,并让控制反转容器为我们提供服务。如果你觉得这不太明白,没问题;所有这些都会在第九章中变得清晰。第九章

那么,谁实例化了 ViewModel 并调用 Init?为了回答这个问题,我们转向 PreferencesPage 类的代码背后:

public partial class PreferencesPage : ContentPage
{
  private PreferencesViewModel vm;
  public Preferences()
  {
    vm = new PreferencesViewModel();
    BindingContext = vm;
    InitializeComponent();
 }
  protected override async void OnAppearing()
  {
    base.OnAppearing();
    await vm.Init();
  }

将 ViewModel 命名为 vm

我不太喜欢在代码中使用缩写,通常会为 ViewModel 的本地实例命名。但使用 vm 在 .NET MAUI(追溯到 Xamarin.Forms)中是一个惯例,所以我放纵自己。

在 .NET MAUI 中,你通常会想要控制的两个生命周期事件是页面首次显示时(OnAppearing)和页面关闭时(OnDisappearing)。让我们接下来探讨这个问题。

重写 OnAppearing

每次页面出现时,它的 OnAppearing 方法都会被调用。我们重写这个方法,以便调用 vm.Init()。我们这样做是因为 Init 是异步的,虽然我们可以使用 async 关键字使 OnAppearing 异步,但我们不能在构造函数中这样做。

OnInit(),反过来,在服务上调用 GetPreferences 并返回一个 Preference 对象的集合。

理解服务的工作原理

逐渐地,我们的 PreferenceServiceGetPreferences 方法将调用我们的 API 来获取数据库中的偏好设置列表,这些列表将存储在云端。目前,它将调用 GetPreferencesMock,这将手工制作列表并返回给我们。

这里是文件的一个摘录:

public class PreferenceService
{
  public async Task<List<Preference>> GetPreferences()
  {
    return await GetPreferencesMock();
  }
  private async Task<List<Preference>> GetPreferencesMock()
  {
    List<Preference> preferences = new()
    {
      new Preference()
      {
        PreferencePrompt = "Shirt Size",
        PreferenceValue = ""
      },
      new Preference()
      {
        PreferencePrompt = "Favorite Music Genre",
        PreferenceValue = ""
      },
      new Preference()
      {
        PreferencePrompt = "Favorite Color",
        PreferenceValue = ""
      },
      new Preference()
      {
        PreferencePrompt = "Favorite Food",
        PreferenceValue = ""
      },
      new Preference()
      {
        PreferencePrompt = "Favorite Movie",
        PreferenceValue = ""
      },
//…
    };
    return preferences;
  }
}

结果是一个 Preference 对象的集合。让我们看看如何显示这个集合。

显示偏好设置对象的集合

现在我们已经在 ViewModel 中有一个 Preference 对象的集合,我们可以创建我们的页面:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/
  dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/
                winfx/2009/xaml"
             x:Class="ForgetMeNotDemo.View.PreferencesPage"
             Title="Preferences">
    <ScrollView>
        <VerticalStackLayout>
            <Label
                Margin="5"
                Padding="5"
                HorizontalOptions="Center"
                LineBreakMode="WordWrap"  [1]
                Text="Please fill in as many preferences as
                you care to. &#10; &#10;The fields are
                'free form,' fill in anything you like.
                Remember, the more information you provide
                to your buddies the better they will be
                able to match to what you like. Each of the
                categories can be edited for your
                needs.&#10; &#10; Save as frequently as you
                like, and to edit, just change the value
                you entered and press save." />
            <Button
                Margin="30,20,0,0"
                Clicked="SavePreferences"   [2]
                Command="{Binding SavePreferencesCommand}"
                 [3]
                Text="Save" />
            <CollectionView
                Margin="20,20,10,10"
                ItemsSource="{Binding Preferences}" [4]
                SelectionMode="None">       [5]
                <CollectionView.ItemTemplate>  [6]
                    <DataTemplate>
                        <Grid ColumnDefinitions="*,2*">
                            <Entry  [7]
                                Grid.Column="0"
                                FontSize="10"
                                HorizontalOptions="Start"
                                HorizontalTextAlignment=
                                  "Start"
                                Text="{Binding
                                PreferencePrompt,
                                   Mode=TwoWay}" [8]
                                TextColor="{OnPlatform
                                  Black,  [9]
                                  iOS=White}" />
                            <Entry
                                Grid.Column="1"
                                FontSize="10"
                                HeightRequest="32"
                                HorizontalOptions="Start"
                                HorizontalTextAlignment=
                                  "Start"
                                Text="{Binding
                                 PreferenceValue,
                                   Mode=TwoWay}"
                                TextColor="{OnPlatform
                                   Black,
                                     iOS=White}"
                                WidthRequest="350" />
                        </Grid>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>
            <Button
                Margin="30,20,0,0"
                Clicked="SavePreferences"
                Command="{Binding SavePreferencesCommand}"
                Text="Save" />
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

在这个列表中有很多内容可以查看。让我们逐个来看:

[1] Label 有多行;我们之前见过。我们将 LineBreakMode 设置为 WordWrap 以在单词之间断行。注意使用 &#10;,它强制换行。

[2] 保存按钮非常不寻常,因为它不仅有点击事件处理器,还有一个命令!点击事件处理器将处理显示提示。

[3] 命令将在 ViewModel 中处理,并将调用服务中的 Save 方法(我们暂时不会实现)。

[4] ItemsSource 属性指向 CollectionView 将显示的集合。在我们的例子中,那就是 ViewModel 中的 Preferences 集合。

[5] SelectionMode 设置为 None,因为我们不会在这个显示中选择项。我们将编辑项,然后按 保存 按钮保存更改。

[6] 项模板表示,“这是我想让你逐个显示集合中每个项的方式。”

[7] 有趣的是,我们正在使用 Entry 作为提示。这允许用户更改提示,这正是我们想要的。我们无法预测每个类别,所以我们创建了许多,但我们允许用户根据需要调整列表。

[8] 我们不仅会显示数据库中的提示值,还希望将用户输入的内容写回。因此,我们将文本标记为双向(即数据 > 视图和视图 > 数据)

[9] 我们之前没有见过 onPlatform。这表示,“除了在这个平台上使用这个其他值之外,始终使用这个值。”在这里,我们说的是文本颜色是 Black,但在 Ios 上是 White

代码后置

你会记得,我们不仅处理了 ViewModel 中的命令,还在代码后置中处理了 Clicked 事件。以下是 PreferencesPage.xaml.cs 的其余部分:

    public void SavePreferences(object sender, EventArgs e)
    {
        ShowToast();
    }
    private async Task ShowToast()
    {
        var cancellationTokenSource = new
           CancellationTokenSource();
        var message = "Your preferences were saved";
        ToastDuration duration = ToastDuration.Short;
        var fontSize = 14;
        var toast = Toast.Make(message, duration,
          fontSize);
        await toast.Show(cancellationTokenSource.Token);
    }

事件处理器只是调用 ShowToast 方法,然后按照前面描述的方式执行。

这样,当保存偏好列表时,提示框会通知用户一切顺利。

一切都顺利吗?

在显示的代码中,我们只是假设一切顺利。为了正确地做到这一点,我们希望 ViewModel 等待 API 确认操作成功完成,然后它会向代码后置发送消息,然后,只有然后,才会显示提示框。

在我们继续之前,打开 AppShell.xaml 并按照以下方式更改启动方式:

    <ShellContent
        Title="Home"
        ContentTemplate="{DataTemplate
          view1:PreferencesPage}"
        Route="PreferencesPage" />

一旦我们到达 第七章,我们就可以停止这种无聊的事情,直接导航到我们想要的页面。

所有这些的效果都在 图 5.5 中展示:

图 5.5 – 预设页面

图 5.5 – 预设页面

图 5.5 中有一些需要注意的快速事项。左侧的字段已被下划线标注,表示它们是输入对象,而不是标签,因此可以修改。顶部的箭头指向字段是自由形式的,用户可以输入任何他们想要的内容,底部的箭头指向提示框。

到目前为止,我们一直在设置我们 UI 控件的众多属性。我们经常不得不在相同类型控制的各种实例上重复相同的属性。有更好的方法:样式,我们将在下一节中介绍。

样式

样式允许您通过将所有细节放在一个地方,为您的控件提供统一的外观。

您为控件类型(例如,Button)创建一个样式,并将该样式应用于该类型的所有控件(参见图5.6)。您还可以基于现有样式创建样式,扩展或修改原始样式。

样式可以存储在使用控件的页面上,或者可以存储在应用程序级别。在两种情况下,它们都在ResourceDictionary内部创建,通常在文件顶部声明。要使它们在应用程序级别可用,只需将它们放在App.xaml中。

样式的放置位置

如果您只打算在单个页面的对象上使用样式,那么将样式放在该页的资源中是有意义的。如果您想能够在多个页面上重用这些样式,那么您将希望它们在App.xaml中。

例如,让我们回到ResourceDictionary和我们的第一个样式。将此代码放在文件的顶部,紧接在ContentPage元素下方:

    <ContentPage.Resources>
        <Style TargetType="Label">
            <Setter Property="FontSize" Value="Medium"/>
            <Setter Property="HorizontalOptions"
               Value="Start"/>
            <Setter Property="Margin" Value="10"/>
            <Setter Property="VerticalOptions"
               Value="Center"/>
            <Setter Property="VerticalTextAlignment"
              Value="Center"/>
        </Style>
    </ContentPage.Resources>

您可以看到,我们已经为页面上的标签创建了一个样式。在这个样式中,我们设置了一系列属性及其值。这将应用于每个Label,因为这是一个隐式样式,如以下所述。

样式使用的关键是它们极大地简化了它们所应用的控件。例如,标签现在看起来是这样的:

<Label
    Text="User Name" />

它们不再被所有集中存储在ResourceDictionary中的样式信息所杂乱。这不仅使 XAML 更干净,而且如果您以后决定更改这些值之一,您只需在一个地方进行更改,而不是在整个页面上。因此,适用于 C#(不要重复自己)的相同清洁代码指南也适用于样式。

样式有两种类型:隐式和显式。TargetType,如我们之前所看到的。显式样式可以单独应用于控件。让我们接下来更详细地探讨这一点。

显式样式与隐式样式

要使样式显式,您给它一个键,如下所示:

<Style TargetType="Label" x:Key="LargeLabel">
    <Setter Property="FontSize" Value="Large" />
    <Setter Property="HorizontalOptions" Value="Start" />
    <Setter Property="Margin" Value="10" />
    <Setter Property="VerticalOptions" Value="Center" />
    <Setter Property="VerticalTextAlignment" Value="Center"
      />
</Style>
<Style TargetType="Label" x:Key="SmallLabel">
    <Setter Property="FontSize" Value="Small" />
    <Setter Property="HorizontalOptions" Value="Start" />
    <Setter Property="Margin" Value="10" />
    <Setter Property="VerticalOptions" Value="Center" />
    <Setter Property="VerticalTextAlignment" Value="Center"
      />
</Style>

您现在可以根据该键选择要应用于Label的这些样式中的哪一个:

<Label
    Text="User Name"
    Style="{StaticResource LargeLabel}"/>
<Label
    Text="Password"
    Style="{StaticResource SmallLabel}"/>

结果如图 5.6所示:

图 5.6 – 应用显式样式

图 5.6 – 应用显式样式

覆盖控件中的样式

如果您有一个要在所有(例如)标签上使用的样式,但有一个Label需要一两个不同的属性,一种处理方法是在该Label中进行更改。直接分配给控件的属性会覆盖样式的属性。另一方面,如果您有一组需要几乎相同属性但以某种方式不同的控件,那么您想要使用样式继承,这将在下一部分介绍。

样式继承或 BasedOn

LargeLabelSmallLabel 的结构有很多重复。你可以重构它以使用基础样式,然后在你的显式样式中添加更改。以下是一个示例:

<Style TargetType="Label">  [1]
    <Setter Property="FontSize" Value="Medium" />
    <Setter Property="HorizontalOptions" Value="Start" />
    <Setter Property="Margin" Value="10" />
    <Setter Property="VerticalOptions" Value="Center" />
    <Setter Property="VerticalTextAlignment"
      Value="Center" />
</Style>
<Style TargetType="Label" x:Key="BaseExplicitLabel"> [2]
    <Setter Property="FontSize" Value="Medium" />
    <Setter Property="HorizontalOptions" Value="Start" />
    <Setter Property="Margin" Value="10" />
    <Setter Property="VerticalOptions" Value="Center" />
    <Setter Property="VerticalTextAlignment"
      Value="Center" />
</Style>
<Style
    TargetType="Label"
    x:Key="LargeLabel"
    BasedOn="{StaticResource BaseExplicitLabel}"> [3]
    <Setter Property="FontSize" Value="Large" />
</Style>
<Style
    TargetType="Label"
    x:Key="SmallLabel"
    BasedOn="{StaticResource BaseExplicitLabel}">
    <Setter Property="FontSize" Value="Small" />
</Style>
            <Label
                Style="{StaticResource LargeLabel}" [4]
                Text="User Name" />

[1] 一个隐式标签样式

[2] 创建为其他样式提供基础样式的样式

[3] 使用基础样式属性的派生样式

[4] 使用派生样式

派生样式

注意,派生样式可以添加新属性(如这里所做的那样),它们可以覆盖基础样式中的值,或者两者都可以。还要注意,我们重构了样式,但不需要重构使用它的 Label

摘要

在本章中,我们深入探讨了 .NET MAUI 控件的一些更高级的方面。我们研究了 Activity 元素以及 ProgressBar。然后我们继续探讨将命令处理移动到 ViewModel 并使用消息在 ViewModelView 之间进行通信。

我们通过查看样式以及它们如何被用来提供统一的 UI 外观,以及我们如何通过使用样式继承(BasedOn)从相似样式中重构重复内容来结束本章。

在下一章中,我们将探讨如何在页面上布局 controls,超越我们迄今为止所使用的简单 StackLayouts

测验

  1. ActivityIndicatorProgressBar 之间的区别是什么?

  2. 事件和命令之间的区别是什么?

  3. WeakReferenceManager 是什么?

  4. 为什么你会使用样式?

  5. 你如何重构样式中的常见属性?

你试试看

创建一个小表单,假装收集用户信息以创建个人资料(姓名、年龄、地址等)。添加一个可点击的图片和两个按钮:一个用于接受输入的信息,另一个用于取消。

如果用户点击图片,弹出一个带有消息的对话框,但本身在 ViewModel 中处理点击。

ViewModel 中处理按钮点击。点击 Snackbar 显示他们保存的信息,格式良好。

第六章:布局

在前两章中,我们探讨了控件——请求和显示数据的组件——但控件需要在页面上进行定位,这个过程称为布局。布局是丑陋的应用程序与专业外观应用程序之间的区别。

您有几种布局控件可供选择,我们将在本章中介绍:

  • 垂直和水平堆叠布局

  • Grid

  • 滚动

  • 弹性布局

我不是设计师

为了使页面看起来专业,设计师必须与开发者合作,不仅要指定控件的位置,还要指定字体大小、字体、边距等。我不是设计师,我们创建的页面仅用于说明目的;它们不会很漂亮。

技术要求

本章的源代码可以在 GitHub 仓库的此分支下找到:github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/Layouts

堆叠布局

堆叠布局允许您将一个控件堆叠在另一个控件上方或并排放置。它们有三种类型:

  • StackLayout

  • VerticalStackLayout

  • HorizontalStackLayout

这些中的第一个是为了与 Xamarin.Forms 兼容而设计的,实际上已被弃用;其他两个则性能更佳。

我们已经看到了 VerticalStackLayoutHorizontalStackLayout 的应用。正如其名称所示,VerticalStackLayout 将一个控件放置在另一个控件的上方,而 HorizontalStackLayout 则将它们并排放置。使用 margins(对象之间的空间)和 padding(对象周围的空间),您只需使用这些控件就能调整出一个不错的布局:

<VerticalStackLayout x:Name="LoginStackLayout">
    <HorizontalStackLayout WidthRequest="300">
        <Label
            Style="{StaticResource LargeLabel}"
            Text="User Name" />
        <Entry
            HorizontalOptions="End"
            Placeholder="User Name"
            Text="{Binding Name}"
            WidthRequest="150" />
    </HorizontalStackLayout>
    <HorizontalStackLayout WidthRequest="300">
        <Label
            Style="{StaticResource SmallLabel}"
            Text="Password" />
        <Entry
            HorizontalOptions="End"
            IsPassword="True"
            Placeholder="Password"
            Text="{Binding Password}"
            WidthRequest="150" />
    </HorizontalStackLayout>
    <BoxView
        Color="Red"
        HeightRequest="2"
        Margin="0,20"
        WidthRequest="400" />

在这里,在 LoginPage 页面上,我们从一个 VerticalStackLayout 对象开始,它将包含其下直到关闭 </VerticalStackLayout> 标签的所有内容。紧接着,我们声明一个 HorizontalStackLayout 对象,它包含一个 Label(作为提示)和一个 Entry(收集用户的名字)。

HorizontalStackLayout 下方是一个第二个 HorizontalStackLayout,再下方是一个 BoxView。简而言之,VerticalStackLayout 继续将视图堆叠在彼此的上方。

虽然这对于一个非常简单的布局来说很好,但它有其局限性。在复杂布局中使用 VerticalStackLayoutHorizontalStackLayout 不会持续太久就会变得困难。

进入所有布局中最强大的布局:Grid

网格

对于灵活性来说,没有哪种布局能与 Grid 相提并论,尽管其基本用法非常简单。网格由行和列组成。您定义每个的大小,然后填充生成的框。

默认情况下,所有列的宽度都相同,所有行的长度都相同。行和列(默认情况下)通过从列 0、行 0 开始的偏移量进行标识。您可以省略 0(它是默认值),但我建议不要这样做以提高可读性。(这也是为什么我用 private 关键字标记私有方法和类的原因。)

我们可以使用Grid重新创建LoginPage页面。让我们看看第一次近似的全貌(我已经省略了资源部分,因为它没有变化):

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    Title="Login Page"
    x:Class="ForgetMeNotDemo.View.LoginPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:behaviors="http://schemas.microsoft.com/dotnet
     /2022/maui/toolkit"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <VerticalStackLayout x:Name="LoginStackLayout">   [1]
        <Grid  [2]
            ColumnDefinitions="*,*,*"  [3]
            RowDefinitions="*,*,*,*,*"  [4]
            x:Name="LoginGrid">
            <Label
                Grid.Column="0"  [5]
                Grid.Row="0"  [6]
                HorizontalOptions="End" [7]
                Margin="5,20,0,10"
                Text="User Name"
                VerticalOptions="Center" [8] />
            <Entry
                Grid.Column="1"
                Grid.ColumnSpan="2" [9]
                Grid.Row="0"
                HorizontalOptions="Center"
                Margin="5,20,0,10"
                Placeholder="User Name"
                Text="{Binding Name}"
                VerticalOptions="End"
                WidthRequest="150" />
            <Label                     [10]
                Grid.Column="0"
                Grid.Row="1"
                HorizontalOptions="End"
                Margin="5,10"
                Text="Password"
                VerticalOptions="Center" />
            <Entry
                Grid.Column="1"
                Grid.ColumnSpan="2"
                Grid.Row="1"
                HorizontalOptions="Center"
                IsPassword="True"
                Placeholder="Password"
                Text="{Binding Password}"
                VerticalOptions="Start"
                WidthRequest="150" />
            <BoxView
                Color="Red"
                Grid.Column="0"
                Grid.ColumnSpan="3" [11]
                Grid.Row="2"
                HeightRequest="2"
                Margin="0,10"
                WidthRequest="400" />

接下来要添加的是框架:

            <Frame
                BorderColor="Blue"
                CornerRadius="10"
                Grid.Column="0"
                Grid.Row="3"
                HasShadow="True"
                HeightRequest="50"
                WidthRequest="50">
                <Frame.Background>
                    <LinearGradientBrush EndPoint="1,0">
                        <GradientStop Color="Yellow" Offset="0.2" />
                        <GradientStop Color="Red"
                            Offset="0.1" />
                    </LinearGradientBrush>
                </Frame.Background>
            </Frame>
            <Frame
                BorderColor="Blue"
                CornerRadius="10"
                Grid.Column="1"
                Grid.ColumnSpan="2"
                Grid.Row="3"
                HasShadow="True"
                HeightRequest="50"
                WidthRequest="100">
                <Frame.Background>
                    <RadialGradientBrush>
                        <GradientStop Color="Yellow"
                            Offset="0.2" />
                        <GradientStop Color="Red"
                            Offset="0.1" />
                    </RadialGradientBrush>
                </Frame.Background>
            </Frame>

在设置好这些之后,我们可以添加三个按钮,然后关闭GridVerticalStackLayout

            <Button
                BackgroundColor="Gray"
                Command="{Binding SubmitCommand}"
                Grid.Column="0"
                Grid.Row="4"
                Margin="5"
                Text="Submit" />
            <Button
                BackgroundColor="Gray"
                Command="{Binding CreateCommand}"
                Grid.Column="1"
                Grid.Row="4"
                Margin="5"
                Text="Create Account" />
            <Button
                BackgroundColor="Gray"
                Clicked="OnForgotPassword"
                Grid.Column="2"
                Grid.Row="4"
                Margin="5"
                Text="Forgot Password" />
            <Label
                Grid.Column="0"
                Grid.ColumnSpan="3"
                Grid.Row="5"
                Text="
                x:Name="CreateAccount" />
        </Grid>
    </VerticalStackLayout>
</ContentPage>

[1] 我们将Grid放入VerticalStackLayout中,这样我们就可以在网格下方添加ProgressBar(将其添加到VerticalStackLayoutChildren集合中,该集合将只有两个成员:GridProgressBar)。

[2] 我们使用关键字声明Grid

[3] 我们声明了三个大小相等的列(*,*,*)。

“有些星星在远处” – 马克·吐温博士

当星星大小相同时,它们的影响不大,但如果我们,例如,想让第一个星星是其他星星的两倍大,我们会写如下:

ColumnDefinitions="2*,*,*"

在这种情况下,列将被分为四个相等的部分,第一列将获得其中两个,其他列各一个。结果是第一列的宽度是其他列的两倍。

[4] 类似地,我们声明了五个大小相等的行。

[5] 我们将标签放置在列 0 中。

[6] 我们将标签放置在行 0 中。

[7] 水平选项是相对于控件所在的列而言的。

[8] 垂直选项是相对于控件所在的行而言的。

[9] 一个控件可以跨越多个列。在这种情况下,条目从列 1 开始,跨越 2 列(即它占据了列 1 和 2)。

[10] 注意,我们不需要HorizontalStackLayout,因为提示与输入之间的位置是由它们所在的列及其水平选项(例如,开始、居中或结束)决定的。

[11] BoxView想要跨越整个网格,因此它从列 0 开始,列跨度为 3。

注意,其他内容无需更改。我调整了页边距和垂直选项,以获得所需的像素级对齐,但除此之外,XAML 保持不变。

另一点需要注意的是,我们有将垂直和水平选项以及页边距提取到样式中的机会。

StackLayout转换为网格的结果显示在图 6.1中。

图 6.1 – 第一个网格布局

图 6.1 – 第一个网格布局

注意,ProgressBar仍然可见。它在代码隐藏中添加到VerticalStackLayout,就像之前一样。

调整行和列的大小

RowHeightColumnWidth可以以三种方式之一定义:

  1. 绝对值:DIU 中的值

  2. 自动大小:根据单元格内容自动调整大小

  3. 星星:如前所述,按比例分配

目前,Grid的顶部看起来是这样的:

        <Grid  [2]
            ColumnDefinitions="*,*,*"
            RowDefinitions="*,*,*,*,*"

我们可以使用auto来表示每个控件将占据每行所需的房间量:

        <Grid  [2]
            ColumnDefinitions="*,*,*"
            RowDefinitions="auto,auto,auto,auto,auto"

让我们也将框架的高度设置为150auto为新扩大的框架分配足够的房间,如图图 6.2所示。

图 6.2 – 使用自动调整大小

图 6.2 – 使用自动调整大小

最佳实践 – 最小化使用自动

微软建议最小化使用 auto,因为它性能较差(布局引擎需要执行额外的计算)。尽管如此,有时它非常有用,尤其是在对象的大小将在运行时确定时。

我们可以将之前显示的行重写如下:

<Grid
    ColumnDefinitions="*,*,*"
    RowDefinitions="*,*,auto,auto,50,auto"

现在的计算将是找到三个 auto 行的实际大小,并添加 50 设备无关单位,用于第五行。然后,我们将网格大小中剩余的部分平均分配给第一行和第二行。结果如下所示:

图 6.3 – 结合星号、绝对和自动

图 6.3 – 结合星号、绝对和自动

如您所见,通过混合和匹配,您可以创建一个外观糟糕的 UI。另一方面,如果交给设计师,这三个选项(绝对星号auto)可以用来创建具有精确尺寸控制的美丽 UI。

命名行和列

在前面的代码中,我们通过零基偏移量来引用每一行和每一列。因此,框架位于 Grid.row[3]Grid.column[0] 以及 Grid.column[1]。对于大型网格,这可能会变得令人困惑且难以管理。

在 C# 中,.NET MAUI 提供了使用枚举来命名行和列的选项。为了看到这一点,让我们创建一个替代的登录页面,完全使用 C#,然后看看它。

我们将稍微简化页面,移除 BoxViewFrame,以保持我们对行和列操作的焦点。

首先,我们将定义枚举,这些枚举将分别作为我们行和列的名称:

enum Row
{
  Username,
  Password,
  Buttons
}
enum Column
{
  Submit,
  Create,
  Forgot
}

你可以使用什么来命名完全取决于你;通常,你会使用一些描述那些行和列内容的名称。因此,在这里,我的第一行将包含 Username,我的第二行将包含 Password,我的第三行将包含我们的三个 Buttons

注意,列是按照按钮类型命名的。这使得使用这些行与这些列一起使用变得困难(或令人困惑)。我们将通过在这些行上回退到使用偏移量来解决此问题。

这是完整的类,我将其命名为 LoginCS.cs

class LoginCS : ContentPage
{
  public LoginCS()  [1]
  {
    BindingContext = new LoginViewModel();
    Content = new VerticalStackLayout() [2]
    {
      Children =
                {
                    new Grid()  [3]
                    {
                        RowDefinitions = GridRowsColumns
                           .Rows.Define(
                            (Row.Username,Auto), [4]
                            (Row.Password,Auto),
                            (Row.Buttons, Auto)
                            ),
                        ColumnDefinitions = GridRowsColumns
                            .Columns.Define(
                            (Column.Submit,Star), [5]
                            (Column.Create, Star),
                            (Column.Forgot, Star)
                            ),
                        Children =
                        {
                            new Label()
                                .Text("User name")
                                .Row(Row.Username)
                                    .Column(0), [6]
                            new Entry()
                                .Placeholder("User name")
                                .Bind(Entry.TextProperty,
                                  nameof(LoginViewModel
                                   .Name))
                                .Row(Row.Username)
                                   .Column(1)
                                .ColumnSpan(2),
                            new Label()
                                .Text("Password")
                                .Row(Row.Password)
                                  .Column(0),
                            new Entry {IsPassword = true}
                                .Placeholder("Password")
                                .Bind(Entry.TextProperty,
                                    nameof(LoginViewModel
                                      .Password))
                                .Row(Row.Password)
                                  .Column(1)
                                .ColumnSpan(2),
                            new Button()
                                .Text("Submit")
                                .Row(Row.Buttons).Column
                                  (Column.Submit) [7]
                                .Margin(5)
                                .BindCommand(nameof
                                 (LoginViewModel
                                   .SubmitCommand)),
                            new Button()
                                .Text("Create Account")
                                .Margin(5)
                                .Row(Row.Buttons).Column
                                  (Column.Create)
                                .BindCommand(nameof
                                (LoginViewModel
                                  .CreateCommand)),
                            new Button()
                                .Margin(5)
                                .Text("Forgot Password")
                                .Row(Row.Buttons)
                                   .Column(Column.Forgot)
                        }
                    }
                }
    };
  }
}

[1] 工作在构造函数中完成

[2] 如同在 XAML 中,我们以 VerticalStackLayout 开始

[3] GridVerticalStackLayout 的子项

[4] 我们定义第一行使用枚举名称和 auto 的大小

[5] 我们定义第一列使用枚举列名和星号的大小(相当于 1 *

[6] 注意,虽然行名是有意义的,所以我使用了它,但列名则没有意义,所以我只使用偏移量

[7] 在这里,行名和列名都是有意义的,使用这些名称而不是偏移量来了解正在发生的事情要容易得多

毫无疑问,.Row(Row.Buttons).Column(Column.Create) 比起 Row[4].Column[1] 更容易理解。

如果你想要使用这个页面,别忘了在 AppShell.xaml 中指向你的新页面,LoginCS.cs

哎呀

Submit 按钮会导致程序崩溃,因为 LoginViewModel 中的 Submit 命令正在寻找 LoginPage.LoginProgressBar。我们可以修复这个问题,但这里的目的是展示你可以用 C# 重新编写 LoginPage

在本书的剩余部分,我们将保持使用原始的 LoginPage.xaml,因为它更完整。

ScrollView

通常,你想要显示的数据会比页面能容纳的还要多。这在处理列表时尤其常见,但对于表单来说也可能如此。ScrollView 控件会围绕你的其他控件,并允许它们进行滚动。

我们在 PreferencesPage 中看到了 ScrollView 的使用,我们在 ScrollView 控件中包裹了 VerticalStackLayout

<ScrollView>
    <VerticalStackLayout>

预设的数量略多于一次显示在手机屏幕上的数量。如果你在 PreferenceService 中添加更多预设,你可以更清楚地看到滚动效果。

FlexLayout

FlexLayoutVerticalStackLayoutHorizontalStackLayout 类似,但有一个关键的区别:如果你使用其中一个堆叠布局,并且项目延伸到页面末尾(你没有使用 ScrollView),那么任何不适合的内容都不会被渲染。

FlexLayout – 看起来熟悉吗?

如果你曾经使用过 CSS,那么 FlexLayout 可能很熟悉。FlexLayout 与 Flexible Box Layout 非常相似,实际上它基于 CSS 模块。

你可以通过从 PreferencesPage 中移除 ScrollView 来看到 FlexLayout 的效果。所有剩余的预设都不可访问。

使用 FlexLayout,项目会被包裹到下一行或列。你通过在 FlexLayout 中设置方向来定义这一点。可能的方向如下:

  • Row: 水平堆叠子项

  • Row-reverse: 以反向顺序水平堆叠

  • Column: 垂直堆叠子项

  • Column-reverse: 以反向顺序垂直堆叠

移除 VerticalStackLayout 并用 FlexLayout 替换它。将方向设置为 Row

<FlexLayout
    Direction="Row">

Figure 6**.4 展示了结果。它很丑,但它传达了正在发生的事情。多余的项目水平包装。

Figure 6.4 – 使用 FlexLayout 打乱屏幕

Figure 6.4 – 使用 FlexLayout 打乱屏幕

让我们看看我们是否能提出一个更好、不那么丑陋的例子。

包装

FlexLayout 的一个属性是 Wrap,默认为 no-wrap。然而,大多数时候,你希望它进行包装,最终你会得到这个美妙的语法:

Wrap = "``Wrap"

我们将回到 Login 页面,并在结束前,我们添加一个包含四个按钮的 HorizontalStackLayout,这些按钮不太适合,如图 6**.5 所示。

Figure 6.5 – 按钮没有适应行

Figure 6.5 – 按钮没有适应行

现在,我们将用 FlexLayout 替换 HorizontalStackLayout,并将 Wrap 设置为 Wrap

<FlexLayout
    Direction="Row"
    Wrap="Wrap">

FlexLayout 看到第四个按钮无法适应,并将其包裹到下一行,如图下所示:

图 6.6 – FlexLayout 包裹按钮

图 6.6 – FlexLayout 包裹按钮

.NET MAUI 添加了一个 BindableLayout 对象,坦白说,我觉得它并不特别有用。

摘要

在本章中,我们探讨了在 .NET MAUI 应用程序设计中使用的原理布局。其中最强大和灵活的是 Grid,尽管 HorizontalStackLayoutVerticalStackLayout 通常用于相对简单的布局。

在下一章中,我们将探讨如何从一个页面移动到另一个页面,以及如何在移动过程中发送数据。我们将查看 Shell 和路由,这是页面导航的基本方面。

测验

  1. 你可以定义网格中列宽的哪三种方式?

  2. 如果一个网格的列定义看起来像这样 – (2*, auto, *, 100) – 空间将如何划分?

  3. 如果一个 Button 对象定义如下:

    new Button()
    
        .Margin(5)
    
        .Text("Forgot Password")
    
        .Row(Row.Buttons).Column(Column.Forgot)
    

我们对其位置了解多少?

  1. Grid 相比于使用 VerticalStackLayoutHorizontalStackLayout 有什么优势?

  2. 为什么 BindableLayout 比例如 CollectionView 更不实用?

你试试

创建一个看起来像标准四功能计算器的页面。使用 图 6.7 中所示的布局。加分项:实现功能并在 Label 中显示结果。

图 6.7 – 四功能计算器

图 6.7 – 四功能计算器

第二部分 – 中级主题

在掌握基础知识后,我们将继续探讨许多中级主题,包括如何从一个页面导航到另一个页面以及如何存储数据,包括用户的偏好和关系数据库中的数据。我们将以创建单元测试这一最重要的话题结束。

本部分包含以下章节:

  • 第七章, 理解导航

  • 第八章, 存储和检索数据

  • 第九章, 单元测试

第七章:理解导航

到目前为止,我们一次只处理一个页面,没有其他方式可以到达页面,除非在AppShell.xaml中设置它。当然,这对于实际应用来说是不够的,所以在本章中,我们将探讨从页面到页面的各种导航选项。您将看到,.NET MAUI 使用shell 导航,我们将深入探讨这个过程。

本章包括以下主题:

  • 探索 TabBar

  • 创建关于和伙伴页面

  • 壳导航

  • 路由

  • 从页面到页面传递值

技术要求

要充分利用本章内容,您需要 Visual Studio 的副本。本章中显示的完成代码的源代码可以在以下位置找到:github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/Navigation。如果您想跟随,从第六章的完成代码开始。

探索 TabBar

ForgetMeNot 的主要导航形式将是TabBar控件。Tab Bar 是一种在不通过其他页面的情况下跳转到特定页面的方式。它由每个页面底部的图标和有时描述性文本组成,如下面的截图所示:

图 7.1 – 完成项目中的标签栏

图 7.1 – 完成项目中的标签栏

底部的四个标签,如图图 7.1所示,将直接将用户带到相应的页面。

主页

在这里,您可以看到我们在MainPage上创建的标签,我们在第四章中创建了它,其中一个我们将其命名为主页

您在AppShell.xaml中创建TabBar。在TabBar标签内,为每个页面提供一个ShellContent元素。ShellContent具有Title属性(显示的文本)、Icon属性(显示的图像)和ContentTemplate,它指定了此标签的内容:

<TabBar >
    <ShellContent
        Title="Home"
        ContentTemplate="{DataTemplate view:MainPage}"
        Icon="icon_home" />
    <ShellContent
        Title="About"
        ContentTemplate="{DataTemplate view:About}"
        Icon="icon_about" />
    <ShellContent
        Title="Preferences"
        ContentTemplate="{DataTemplate view:Preferences}"
        Icon="icon_prefs" />
    <ShellContent
        Title="Buddies"
        ContentTemplate="{DataTemplate view:BuddyList}"
        Icon="icon_buddies" />
</TabBar>

接下来,我们需要创建每个ContentTemplate指向的页面,这样我们才能看到TabBar在工作。

创建关于和伙伴页面

要看到这种导航工作,您需要添加缺失的页面:关于伙伴。创建关于页面非常直接。右键单击视图文件夹,然后选择添加新项。如果需要,展开添加新项对话框。

从左侧面板中选择AboutPage.xaml,如图图 7.2所示:

图 7.2 – 创建 AboutPage

图 7.2 – 创建 AboutPage

关于页面非常简单,不需要任何新的控件类型。

组装关于页面

让我们组装BindingContext。最后,我们需要ViewModel。最终,关于页面将向服务请求其版本号,但到目前为止,我们将硬编码它:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/
  2021/maui"
   xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="ForgetMeNotDemo.View.AboutPage"
             Title="About">
    <VerticalStackLayout Margin="10" Spacing="10">
        <HorizontalStackLayout Spacing="10">
            <Label
                FontAttributes="Bold"
                FontSize="22"
                Text="About this app"
                VerticalOptions="End" />
            <Label
                FontSize="22"
                Text="v0.1"
                VerticalOptions="End" />
        </HorizontalStackLayout>
        <HorizontalStackLayout Spacing="10">
            <Label
                FontAttributes="Bold"
                FontSize="22"
                Text="Api Version"
                VerticalOptions="End" />
            <Label
                FontSize="22"
                Text="{Binding ApiVersion}"
                VerticalOptions="End" />
        </HorizontalStackLayout>
        <Label
            HeightRequest="60"
            Text="This app is written in XAML and C# with
            .NET MAUI by Jesse Liberty and Rodrigo Juarez."
            VerticalTextAlignment="Center" />
        <Label
            HeightRequest="60"
            Text="Concept and original design by Robin
              Liberty"
            VerticalTextAlignment="Center" />
        <Label FontSize="Small" Text="Icons from IconScout:
          https://iconscout.com" />
    </VerticalStackLayout>
</ContentPage>

后台代码文件看起来像这样(目前):

  public AboutPage()
  {
    BindingContext = new AboutViewModel();
    InitializeComponent();
  }

最后,ViewModel看起来像这样(目前):

namespace ForgetMeNotDemo.ViewModel;
[ObservableObject]
public partial class AboutViewModel
{
  [ObservableProperty] private string apiVersion;
  public AboutViewModel()
  {
    apiVersion = "1.0";
  }
}

关于页面目前看起来像这样:

![Figure 7.3 – 关于页面

![img/B19723_Figure_7.3.jpg]

图 7.3 – 关于页面

这将为我们提供一些可以操作的东西。

接下来,我们需要一个BuddiesPage,即列出所有用户的朋友和亲戚的页面。每个朋友都将有一个我们可以用来在他们需要礼物时使用的偏好列表。

目前,我们只需使用当我们右键点击视图并添加一个新的.NET MAUI XAML页面时获得的现成页面,如图图 7.4所示:

![Figure 7.4 – 创建 Buddies 页面

![img/B19723_Figure_7.4.jpg]

图 7.4 – 创建 Buddies 页面

接下来,打开BuddiesPage.xaml并做一个小改动。在Label控制的Text字段中,将Welcome to .NET MAUI!改为Buddies Page,这样当我们导航到那里时就会知道我们在哪里。如果你愿意,在标题中的单词之间也可以添加一个空格:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/
  2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="ForgetMeNotDemo.View.BuddiesPage"
             Title="Buddies Page">
    <VerticalStackLayout>
        <Label
            Text="Buddies Page"
            VerticalOptions="Center"
            HorizontalOptions="Center" />
    </VerticalStackLayout>
</ContentPage>

接下来,通过右键点击ViewModel文件夹并选择添加 | 来创建BuddiesViewModel

最后,我们需要告诉应用程序从哪里开始。我们将在App.xaml.cs中这样做,我们将MainPage设置为新的AppShell(这是我们启动程序并为我们设置 shell 导航的方式,如前所述):

namespace ForgetMeNotDemo;
public partial class App : Application
{
public App()
{
InitializeComponent();
MainPage = new AppShell();
}
}

这是AppShell.xaml现在应该看起来像的样子:

<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    Shell.FlyoutBehavior="Disabled"
    x:Class="ForgetMeNotDemo.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:local="clr-namespace:ForgetMeNotDemo"
    xmlns:view="clr-namespace:ForgetMeNotDemo.View"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <TabBar>
        <ShellContent
            Title="Home"
            ContentTemplate="{DataTemplate view:MainPage}"
            Icon="icon_home" />
        <ShellContent
            Title="About"
            ContentTemplate="{DataTemplate view:AboutPage}"
            Icon="icon_about" />
        <ShellContent
            Title="Preferences"
            ContentTemplate="{DataTemplate
              view:PreferencesPage}"
            Icon="icon_prefs" />
        <ShellContent
            Title="Buddies"
            ContentTemplate="{DataTemplate
              view:BuddiesPage}"
            Icon="icon_buddies" />
    </TabBar>
</Shell>

一件额外的事情。注意每个标签页都有一个图标。为了使其工作并达到预期效果,你可能需要在网上找到图标,或者通过检查本章的Navigation分支的源代码来获取它们。

在任何情况下,只需将图像复制到项目的resources\image文件夹中,将三个点替换为计算机上的完整路径。

图片支持

对于那些使用过Xamarin.Forms的人来说,将不再需要为 iOS 和 Android 创建不同尺寸的图像并将它们分发到各个文件夹的日子将令人高兴。将.svg文件放入images文件夹,.NET MAUI 将为你完成所有剩余的工作!(你也可以使用.png文件,但它不会很好地缩放。为了说明这一点,我将我们的花朵图像保存为.png文件。)

运行应用程序并点击各个标签页。你应该看到它导航到我们创建的各个页面。注意在图 7.5中,当前标签页点亮了——你得到这种效果是免费的,你不需要为当前选中和不选中的图标各创建一个图标。

![Figure 7.5 – 主标签页“点亮”

![img/B19723_Figure_7.5.jpg]

图 7.5 – 主标签页“点亮”

现在我们已经设置了标签页和页面,是时候看看在没有标签页的页面之间导航时如何从一个页面移动到另一个页面了。

Shell 导航

如果你的应用程序只是通过 TabBar 访问我们访问的四个页面,那么你需要的关于导航的知识就到此为止了。当然,你几乎肯定会有比这更多的页面,并且你将需要一个从页面导航到页面的方法。

要在不使用TabBar的情况下查看页面间的导航,我们需要另一个要导航到的页面。让我们创建BuddyDetailsPage,我们将从Buddies页面导航到它。

再次,使用现成的页面,但将Label改为显示Buddy``Details Page

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/
  2021/maui"
     xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
         x:Class="ForgetMeNotDemo.View.BuddyDetailsPage"
             Title="Buddy Details Page">
    <VerticalStackLayout>
        <Label
            Text="Buddy Details Page"
            VerticalOptions="Center"
            HorizontalOptions="Center" />
    </VerticalStackLayout>
</ContentPage>

接下来,回到显示“转到详情”的Button,并给它分配GoToDetailsCommand命令:

        <Button Text="Go to details"
               Command="{Binding GoToDetailsCommand}" />

GoToDetails命令中。我们处理器的目标是导航到BuddyDetails。我们通过shell navigation来实现这一点。以下是实现此功能的代码:

[RelayCommand]
private async Task GoToDetails()
{
  await Shell.Current.GoToAsync("buddydetailspage");
}

BindingContext

记住,为了让GoToDetails命令生效,你必须在代码隐藏文件中通过设置BindingContext将 XAML 绑定到ViewModel

在这个常见的结构中,你将页面名称传递给Shell.Current上的GoToAsync静态方法。关键问题是,它如何知道buddydetailspage是什么,鉴于buddydetailspage是一个字符串?这个答案在于路由*,我们将在下一节中介绍。

路由

在.NET MAUI 中,你将在AppShell.xaml.cs中注册你的路由。例如,要将buddydetailspage字符串连接到实际的BuddyDetailsPage,你需要添加以下内容:

Routing.RegisterRoute("buddydetailspage",
  typeof(BuddyDetailsPage));

我们将为所有页面创建一个路由条目,包括我们可以通过标签访问的页面。这将给我们最大的灵活性:

public partial class AppShell : Shell
{
  public AppShell()
  {
    InitializeComponent();
    Routing.RegisterRoute("buddiespage",
      typeof(BuddiesPage));
    Routing.RegisterRoute("buddydetailspage",
      typeof(BuddyDetailsPage));
    Routing.RegisterRoute("aboutpage", typeof(AboutPage));
    Routing.RegisterRoute("preferencespage",
      typeof(PreferencesPage));
    Routing.RegisterRoute("loginpage", typeof(LoginPage));
    Routing.RegisterRoute("mainpage", typeof(MainPage));
  }
}

现在路由就像魔法一样工作。你告诉它你想去哪里,传递路由,.NET MAUI 处理导航。如果程序尚未运行,请启动程序并轻触Buddies标签。在Buddies页面上,轻触Details按钮,嘿,你就在Buddy Details页面上!简单得很。

能够从一个页面导航到另一个页面是很好的,但通常第一个页面有第二个页面需要的数据。所以,让我们看看如何将数据发送到第二个页面。

从页面到页面传递值

当从一个页面导航到另一个页面时,你通常会想要传递一个值。有几种方法可以做到这一点;这里是最常见的两种:

  1. 使用与在网页上导航到页面时相同的url (?)语法

  2. 使用字典传递导航参数

使用 url (?)语法传递值

让我们回到Buddies页面。目前,Button有一个GoToDetailsCommand命令。但是,Details页面需要知道要显示哪个 Buddy 的详细信息。

我们将修改ViewModel中的RelayCommand以传递BuddyId。为了使这生效,我们需要一个Buddy对象(它将具有Id)。然而,Buddy 只是这个程序用户类型之一,所以让我们首先定义User类型:

[ObservableObject]
public partial class User
{
    [ObservableProperty]
    private string name;
    [ObservableProperty]  [1]
    private string id;
    [ObservableProperty]
    private List<Buddy> buddies;   [2]
    [ObservableProperty]
 //   private List<Invitation> invitations; [3]
    [ObservableProperty]
    private List<Preference> preferences; [4]
}

[1] 这里是我们需要的Id属性。

[2] 用户可能有一组buddies(我们稍后会回到这一点)。

[3] 用户可能有一组invitations,这些邀请被发送给潜在的buddies(我们稍后会回到这一点)。

[4] 用户有一个preference对象的列表,正如我们之前看到的。

Buddy类继承自User类。以下是完整的类定义,尽管我们现在不会使用这些属性中的大多数:

public partial class Buddy : User
{
    [ObservableProperty] private string emailAddress;
    [ObservableProperty] private string? phoneNumber;
    [ObservableProperty] private string?
      mailingAddressLine1;
    [ObservableProperty] private string?
      mailingAddressLine2;
    [ObservableProperty] private string? website;
    [ObservableProperty] private string? twitter;
    [ObservableProperty] private string? facebook;
    [ObservableProperty] private string? instagram;
    [ObservableProperty] private string? linkedIn;
    [ObservableProperty] private string? venmoName;
    [ObservableProperty] private DateTime buddySince;
}

继承ObservableObject

注意,Buddy类没有标记ObservableObject属性。这是因为它继承自标记为ObservableObjectUser类。

我们希望将新页面分配给传递的Buddy对象的Id。我们可以使用传递数据的方法之一(例如,URL 方法或字典)来实现这一点。

传递 Buddy Id

使用?语法将Id返回到Id

private async Task GoToDetails()
{
  await Shell.Current.GoToAsync
    ($"buddydetailspage?id={Id}");
}

如果您想传递两个属性,例如IdName,您可以使用&&将它们连接起来。这应该与您在浏览器中使用的 URL 非常熟悉:

private async Task GoToDetails()
{
  await Shell.Current.GoToAsync
    ($"buddydetailspage?id={Id}&&name={"BuddyName"});
}

如果我们没有在BuddiesViewModel中包含IdName,则这不会起作用,所以让我们在这里添加它们:

public partial class BuddiesViewModel
{
  [ObservableProperty] private string id = "001";
  [ObservableProperty] private string name = "jesse";

GoToAsync的调用将更改页面到BuddyDetailsPage,并将参数发送到相关的ViewModelBuddyDetailsViewModel)。

QueryProperty

我们使用QueryProperty属性以及属性名称标记接收的ViewModel,以便将其与ViewModelGoToAsync方法中使用的字符串关联起来。

为了使这更清晰,让我们创建BuddyDetailsViewModel,并将其标记为ObservableObject。我们将给它两个属性:IdName

[ObservableObject]
public partial class BuddyDetailsViewModel
{
  [ObservableProperty] private string id;
  [ObservableProperty] private string name;
}

我们希望将传入的第一个参数(id)分配给Id属性,希望将传入的第二个参数分配给Name属性。为此,我们使用QueryProperty属性(放置在类上方):

using CommunityToolkit.Mvvm.ComponentModel;
namespace ForgetMeNotDemo.ViewModel;
[ObservableObject]
[QueryProperty(nameof(Id), "id")]
[QueryProperty(nameof(Name), "buddyname")]
public partial class BuddyDetailsViewModel
{
  [ObservableProperty] private string id;
  [ObservableProperty] private string name;
}

现在您已经熟悉了两种传递数据的方式,让我们看看如何将它们集成到程序的流程中。

整合起来

Buddies页面上,用户点击GoToDetails

这在BuddiesViewModel中触发了GoToDetails中继命令。

该方法调用以下方法:

await Shell.Current.GoToAsync
  ($"buddydetailspage?id={{Id}}&&buddyname={{Name}}");

这个GoToAsync调用将我们转移到BuddyDetailsPage,但将两个参数(IdName)传递给BuddyDetailsViewModel

BuddyDetailsViewModel解析QueryProperty属性并将值分配给相关属性。

最终效果是您现在在BuddyDetailsPage上,与ViewModel关联的IdName属性已填充了值。

要看到这个效果,请转到BuddyDetailsPage.xaml文件并添加两个标签控件,一个绑定到Id,另一个绑定到Name

这是 XAML 页面:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    Title="Buddy Details Page"
    x:Class="ForgetMeNotDemo.View.BuddyDetailsPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <VerticalStackLayout>
        <Label
            HorizontalOptions="Center"
            Text="Buddy Details Page"
            VerticalOptions="Center" />
        <Label Text="{Binding Id}"/>
        <Label Text="{Binding Name}"/>
    </VerticalStackLayout>
</ContentPage>

记得在代码-behind 页面中设置BindingContext

运行程序并点击Buddies标签。在Buddies页面上,点击GoToDetails。您将被转移到BuddiesDetails页面,并且将显示两个值。

立即停止

在继续之前,确保您理解所有这些是如何联系在一起的。如果需要,从“从页面传递到页面”部分重新阅读。

使用字典传递值

有时,你将想要传递整个对象(或更多)到接收的 ViewModel。你这样做是通过实例化一个字典,其中键是一个字符串,值是一个对象。

让我们修改 GoToDetails 以接受整个 Buddy 对象。首先,我们需要创建一个 Buddy 对象并将其放入 BuddyViewModel

[ObservableProperty] private Buddy rodrigo = new Buddy
{
  Id = "002",
  Name = "Rodrigo Juarez",
  Website = "https://jesseliberty.com"
};

接下来,我们需要创建我们的字典。我们可以选择任何任意的字符串作为键,并传入我们刚刚创建的 Buddy 对象 (rodrigo):

[RelayCommand]
private async Task GoToDetails()
{
  var navigationParameter = new Dictionary<string, object>
  {
    {"TheBuddy", Rodrigo}
  };
  await Shell.Current.GoToAsync($"buddydetailspage",
    navigationParameter);
}

再次,我们被重定向到 Buddy 对象本身被传入。在 Buddy 对象字段顶部分配传入的 Buddy,我们使用字典中的键:

[ObservableObject]
[QueryProperty(nameof(MyBuddy), "TheBuddy")]
public partial class BuddyDetailsViewModel
{

我们将为页面添加三个属性以进行绑定:

[ObservableProperty] private string id;
[ObservableProperty] private string name;
[ObservableProperty] private string? website;

注意,website 是一个可空字符串。这是因为它在 Buddy 定义中被标记为 nullable。为了使这可行,你将想要启用 nullable,至少在这个页面上,如果不是整个项目。

管理传入的 Buddy 对象的最简单方法是以下:

private Buddy myBuddy;
public Buddy MyBuddy
{
  get => myBuddy;
  set
  {
    Id = value.Id;
    Name = value.Name;
    Website = value.Website;
  }
}

如果你转到 BuddyDetailsPage.xaml 并添加一个文本绑定到 website 的标签,结果将如图 图 7.6 所示:

图 7.6 – 在传入 Buddy 对象后的 Buddy 详情页面

图 7.6 – 在传入 Buddy 对象后的 Buddy 详情页面

图 7.6 – 传入 Buddy 对象后的 Buddy 详情页面

组合起来

这一开始可能会让人感到困惑,因此值得一步一步地了解整个过程。

BuddiesPage 中,用户点击 GoToDetailsCommand,该命令在 BuddiesViewModel 中处理。

ViewModel 中,我们有一个 Buddy 属性(如 Models 文件夹中定义)。该 Buddy 对象的标识符是 rodrigo,并且初始化了它的三个字段。

然后,我们组装一个字典,将其用作 GoToAsync 方法的参数。我们传入我们想要导航到的页面的名称(如记录在 AppShell.xaml.cs 文件中的 Routing.RegisterRoute 方法)。

我们还传入了我们刚刚创建的字典。

.NET MAUI 导航到页面,我们的字典通过关联的 ViewModel (BuddyDetailsViewModel) 进行路由。在那里,QueryProperty 属性将 MyBuddy 属性与 queryid 关联,我们在 BuddiesPage 中使用了它。

它匹配的属性是 Buddy 类型,因此我们可以使用传入的 Buddy 对象的属性设置本地属性(value)。

由于 BuddyDetailsPageLabels 绑定到 ViewModel 中的这些属性,所以会显示正确的内容。

摘要

在本章中,我们重点介绍了如何在不使用 TabBar 的情况下从一个页面跳转到另一个页面,通过使用壳导航和路由。我们还探讨了如何使用 URL 语法或通过传递包含您想要发送的对象或值的字典来从第一个页面传递数据到第二个页面。

在下一章中,我们将探讨存储和检索数据。

问答

  1. 你在哪里定义 TabBar

  2. TabBar ShellContent 的三个属性是什么?

  3. 路由在哪里注册?

  4. 导航到另一个页面的方法是什么?

  5. 我们都看到了哪些方法可以将数据传递到页面?

你试试看

LoginViewModel 中修改 RelayCommand Submit 以显示进度条,然后导航到好友页面。首先以字符串的形式传入用户名和密码,然后以字典的形式传入。暂时修改好友页面以显示传入的值。

第八章:存储和检索数据

您现在拥有了创建和导航页面、布局以及用于填充页面的控件的所有基础知识。恭喜!您现在是一名.NET MAUI 程序员。

本章开始介绍本书的中间部分,其中您将了解如何存储和检索数据,然后创建单元测试——这两个都是编写现实世界应用,尤其是企业应用的关键方面。

程序与数据交互,大多数需要在应用关闭后存储数据,并在应用恢复时按需恢复。在本章中,我们将考虑两种变体——用户偏好的长期持久化和长期数据库存储。

本章我们将涵盖以下主题:

  • 存储用户偏好

  • 在您的设备上存储到数据库

技术要求

要跟随本章内容,您需要 Visual Studio。您还将安装另一个NuGet包,如本章后面所示。

本章完成代码的源代码可以在以下位置找到:github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/persistence。要跟随本章内容,您需要使用上一章的代码。

存储用户偏好

大多数应用允许用户设置可以存储在手机上并在应用启动时检索的偏好设置。.NET MAUI 提供了这项服务,可以轻松存储键/值对,例如主题偏好、最后使用日期、登录名等。

.NET MAUI 提供了IPreferences接口来帮助存储这些偏好。使用它以及相关的Preferences类(都在Microsoft.Maui.Storage命名空间中),您可以存储以下类型的字符串键和值:

  • 布尔

  • 双精度浮点数

  • Intint32singleint64

  • String

  • DateTime

持久化日期时间

DateTime值存储为 64 位整数,并使用ToBinaryFromBinary方法进行编码和解码。

让我们创建一个UserPreferences页面,包含一个简短表单来收集用户的偏好。我们还将添加Button,用于显示所有已保存的偏好,并允许用户删除一个或全部。

名称冲突

我们有一个偏好设置页面,这可能会引起问题,因为我们想使用内置的Preferences对象。为了解决这个问题,请转到PreferencesViewModel并将List<Preference>重命名为preferenceList。不应该有其他冲突。最安全的重命名方法是使用 Visual Studio 的重命名功能,您可以通过将光标放在名称上并输入Control-R R来访问它。重命名后,您可能需要根据 Visual Studio 的最新更新手动重命名ObservableProperties

新的UserPreferences页面将从用户那里收集三个偏好,具体如下:

  • 用户的显示名称

  • 偏好的主题

  • 应用是否可以在蜂窝或 Wi-Fi 上使用

浅色和深色主题

在应用程序中提供浅色和深色主题已经变得很常见。在 .NET MAUI 中,你可以为用户提供选择,或者如果你有雄心壮志,你可以创建自己的主题。

我们将收集但不实现与浅色和深色主题相关的用户偏好。

这里是 UserPreferences 页面:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    Title="User Preferences"
    x:Class="ForgetMeNotDemo.View.UserPreferencesPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <VerticalStackLayout>
        <Grid
            ColumnDefinitions="*,*"
            RowDefinitions="*,*,*,*">
            <Label
                Grid.Column="0"
                Grid.Row="0"
                Text="Display Name" />
            <Entry
                Grid.Column="1"
                Grid.Row="0"
                Placeholder="Your name as you want it
                   displayed"
                Text="{Binding DisplayName}" />

收集了用户的显示名称后,我们可以继续询问他们希望选择哪两个互斥主题之一。为此,我们将使用 RadioButtonGroup 和两个 RadioButtons,初始化 Light 为选中状态:

            <Label
                Grid.Column="0"
                Grid.Row="1"
                Text="Theme" />
            <HorizontalStackLayout
                Grid.Column="1"
                Grid.Row="1"
                RadioButtonGroup.GroupName="{Binding
                    ThemeGroupName}" [1]
                RadioButtonGroup.SelectedValue="{Binding
                    ThemeSelection}"> [2]
                <RadioButton Content="Dark" />
                <RadioButton
                    Content="Light"
                    IsChecked="True" /> [3]
            </HorizontalStackLayout>

我们现在想询问用户是否应该在连接到 Wi-Fi 时才使用应用程序。我们可以使用一个开关控件来实现,其中 on 表示 WiFi onlytrue

            <Label
                Grid.Column="0"
                Grid.Row="2"
                Text="Wifi Only?" />
            <Switch                  [4]
                Grid.Column="1"
                Grid.Row="2"
                HorizontalOptions="Start"
                IsToggled="{Binding WifiOnly}"
                OnColor="Green"
                ThumbColor="Blue" />
            <Button
                Command="{Binding SavePreferencesCommand}"
                Grid.Column="0"
           Grid.ColumnSpan="2"
                Grid.Row="3"
                HorizontalOptions="Center"
                Text="Save" />
        </Grid>
    </VerticalStackLayout>
</ContentPage>

[1] 在这里,我们引入了一个新的控件,RadioButton。单选按钮可以是隐式或显式的组。隐式组是通过将所有 RadioButtons 放入同一个容器中(例如,VerticalStackLayout)来创建的。显式组被赋予 GroupName,正如我们所看到的。

[2] 用户的选项通过 SelectedValue 属性记录。

[3] 在定义 RadioButtons 时,你可以(并且应该)设置一个 IsChecked=true

[4] 开关控件切换开和关(falsetrue)。

现在我们已经知道了如何收集用户想要保存的信息,让我们看看 .NET MAUI 提供的轻量级机制来完成这项任务。

UserPreferencesViewModel

如您所预期,我们首先会为绑定的控件创建属性:

[ObservableObject]
public partial class UserPreferencesViewModel
{
  [ObservableProperty] private string displayName;
  [ObservableProperty] private string themeSelection;
  [ObservableProperty] private bool wifiOnly;
  public string ThemeGroupName => "Theme";

接下来,我们需要处理 SavePreferences 命令。我们通过使用 .NET MAUI 的 Preferences 对象,调用静态的 Set 方法来完成:

[RelayCommand]
public async Task SavePreferences()
{
  Preferences.Default.Set("DisplayName", displayName);
  Preferences.Default.Set("ThemeSelection",
    themeSelection);
  Preferences.Default.Set("WifiOnly", wifiOnly);
}

.NET MAUI 将为我们处理持久性。

现在我们已经创建了页面,让我们设置导航以到达它。

导航到用户偏好

我们需要一种方法来访问我们新的页面。一个典型的放置位置是将 Button 作为 VerticalStackLayout 的最后一个项目:

<Button
    Command="{Binding OpenPreferencesCommand}"
    Text="Preferences"
    WidthRequest="150"
    Margin="10,50,10,0"/>

OnPreferences 命令简单地导航到我们的新页面:

[RelayCommand]
public async Task OpenPreferences()
{
  await Shell.Current.GoToAsync("userpreferences");
}

确保在调用此方法之前在 AppShell 中注册 userpreferences 页面。

页面可能看起来并不美观,但它已经准备好收集用户的偏好,如下所示:

图 8.1 – 偏好页面

图 8.1 – 偏好页面

用户现在可以设置他们的偏好。下次他们启动应用程序时,我们将想要检索这些偏好并相应地设置应用程序。

检索偏好

当用户关闭应用程序时,偏好将被保留。下次我们返回到偏好页面时,我们应该看到偏好已恢复。我们通过在 Preferences.Default 上使用 Get 方法来实现这一点。

Get 方法接受两个参数,默认值。我们将将其放在 ViewModel 构造函数中,以便在显示时填充偏好页面:

public UserPreferencesViewModel()
{
  displayName = Preferences.Default.Get("DisplayName",
    "Unknown");
  themeSelection = Preferences.Default.Get
    ("ThemeSelection", "Light");
  wifiOnly = Preferences.Default.Get("WifiOnly", false);
}

注意,Get 方法的第一个参数是键,正如在 SavePreferences 方法中 Set 方法定义的那样。第二个参数是如果键不存在时将提供的默认值。

检查键

虽然在尝试检索之前您不必检查键是否存在,但有时您会想要区分返回的值是默认值还是实际存储的值(例如,WiFiOnly 是否为 false,因为它确实是 false,还是因为该键不存在而收到了默认值?)。

要管理这一点,您可以在 Preferences.Default 上使用 ContainsKey 方法:

bool knowsWifi = Preferences.DefaultContainsKey("WifiOnly");

用户现在可以存储他们的首选项,理论上它们可以被恢复。让我们确保这一点是正常工作的。

测试持久性

要验证这一点,通过 UserPreferences 页面导航到 UserPreferences 页面,您应该看到您输入的值已经恢复。

清除

如果您想清除特定的 UserPreference,请使用 Remove 方法:

Preferences.Default.Remove("DisplayName");

要删除所有这些,请使用 Clear 方法:

Preferences.Default.Clear()

Preferences 接口旨在以键/值对的形式存储简单数据。Microsoft 警告说,不要存储长字符串,因为它可能会对性能产生负面影响。如果您需要存储更复杂或更大的数据,您将需要使用数据库,而对于许多 .NET MAUI 开发者来说,首选的数据库是 SQLite

轻量级持久机制非常适合存储相对较短的字符串和其他原始数据,但如果您要存储大量数据,您将需要一个真正的数据库。

在您的设备数据库中存储

在您的设备上存储数据有几个竞争者。最受欢迎的是 SQLite,它是一个开源的、小巧的、快速的且高度可靠的数据库。它是世界上使用最广泛的数据库,并内置在所有手机和大多数计算机中。

安装 SQLite

要开始,请安装 sqlite-net-pcl NuGet 包的最新版本,如 图 8.2 所示。

![Figure 8.2 – 安装 sqlite-net-pcl]

![img/Figure_8.2_B19723.jpg]

图 8.2 – 安装 sqlite-net-pcl

安装正确的包

在 NuGet 上有多个 SQLite 包可用。您想要的是 sqlite-net-pcl,其作者为 SQLite-net,如 图 8.3 所示。

![Figure 8.3 – 作者应为 SQLite-net]

![img/Figure_8.3_B19723.jpg]

图 8.3 – 作者应为 SQLite-net

一旦安装了 sqlitepclraw.bundle_green,如果它没有自动安装,请手动安装 SQLitePCLRaw.bundle_green,如 图 8.4 所示。

![Figure 8.4 – 安装 SQLitePCLRaw.bundle_green]

![img/Figure_8.4_B19723.jpg]

图 8.4 – 安装 SQLitePCLRaw.bundle_green

安装完包后,您就可以设置程序以创建和使用 SQLite 数据库了。

开始使用 SQLite

要创建你的数据库,你需要存储数据库文件名及其路径以及其他常量值。为此,右键单击你的项目并创建一个 Constants.cs 文件。为了方便,我将创建一个 Database 文件夹并将其放置在那里:

namespace ForgetMeNotDemo.Database;
public static class Constants
{
  public const string DatabaseFilename =
    "ForgetMeNotDemo.db3"; [1]
  public const SQLite.SQLiteOpenFlags Flags = [2]
    SQLite.SQLiteOpenFlags.ReadWrite |
    SQLite.SQLiteOpenFlags.Create |
    SQLite.SQLiteOpenFlags.SharedCache;
  public static string DatabasePath =>
    Path.Combine(FileSystem.AppDataDirectory,
      DatabaseFilename); [3]
}

[1] 为你的数据库设置名称。你可以使用显示的名称,也可以将其重命名为你喜欢的任何名称。

[2] 设置文件应该如何管理的标志。在这里,我们将其设置为读写模式,如果不存在则创建数据库,最后启用多线程数据库访问。

[3] 将我们之前创建的数据库文件名附加到应用程序的目录名上。

在建立了这些常量之后,我们就可以创建数据库了。我们将把这个工作封装在一个类中。

数据库类

将数据库访问层包装在类中是一种常见的模式,它可以抽象化它,使其与应用程序的其余部分解耦。我们将把所有的查询逻辑放入这个类中。这种数据库关注点的集中化将有助于使我们的应用程序随着时间的推移而可扩展。

该类需要一个 Init() 方法来创建数据库和我们的第一个表。为了让我们开始,让我们创建一个表来存储所有我们的首选项:

using ForgetMeNotDemo.Database;
using ForgetMeNotDemo.Model;
using SQLite;
namespace ForetMeNotDemoDatabase;
public class ForgetMeNotDemoDatabase
{
    private SQLiteAsyncConnection Database;  [1]
    private async Task Init()
    {
        if (Database is not null)  [2]
            return;
        Database = new SQLiteAsyncConnection(  [3]
              Constants.DatabasePath,
              Constants.Flags);
        await Database.CreateTableAsync<Preference>(); [4]
    }
}

[1] 声明一个类型为 SQLiteAsyncConnection 的对象,并将其命名为 Database

[2] 如果它已经存在,则返回(即,将其视为单例)。

[3] 创建 SQliteAsyncConnection,传入 constant 类中的路径和标志。

[4] 创建我们的第一个表,声明我们将存储在表中的对象类型(即 Preference 对象)。

我们已经准备好开始使用数据库,添加和操作我们的表。

CRUD

对于几乎所有数据库来说,我们都希望支持 创建、读取、更新和删除CRUD)。现在,让我们只实现创建和读取记录的方法。创建方法通常与更新方法结合使用。

创建/更新

我们将需要知道一个 Preference 是否已经在表中,这样当我们得到一个 Preference 记录时,我们知道是添加它还是更新它。它需要一个唯一的 ID。幸运的是,SQLite 在提供 ID 方面非常出色。

首先在 Model 文件夹中打开 Preference.cs 并添加一个 id 属性:

[ObservableObject]
public partial class Preference
{
    [ObservableProperty] private int id;
    [ObservableProperty] private string preferencePrompt;
    [ObservableProperty] private string preferenceValue;
}

接下来,返回到 ForgetMeNotDemoDatabase.cs 并添加 SavePreference 方法:

public async Task<int> SavePreference(Preference
    preference) [1]
{
    await Init();  [2]
    if (preference.Id != 0) [3]
    {
        return await Database.UpdateAsync(preference);
    }
    else
    {
        return await Database.InsertAsync(preference);
    }
}

[1] 我们的 SavePreference 方法接受一个类型(Preference)作为参数,并返回更新的行数(在这种情况下,零或一)。

[2] 每次执行操作时在数据库上调用 Init

[3] 检查 Preference 对象是否有 Id。由于 Idint 类型,它默认为零,所以如果它不是零,我们需要进行更新;否则,我们需要进行插入。

现在我们能够创建(或更新)一个记录,让我们编写代码来从数据库中读取这些数据。

读取

我们将希望能够从数据库中获取所有我们的首选项。为此,我们将创建一个 GetPreferences 方法,它返回一个 Preference 对象的列表:

public async Task<List<Preference>> GetPreferences()
{
    await Init();
    return await Database.Table<Preference>();
}

软删除

当我们编写Delete方法时,我们可能想要进行一次软删除——也就是说,将其标记为已删除而不是实际删除。为了实现这一点,你需要在Preference中添加另一个属性Deleted,以及int类型。然后,我们的读取语句将包含一个where子句,检查Deleted属性是否等于零。

一旦你有了数据库设计,你需要决定你是打算在设备上本地保留数据库,还是在云端通过你的 API 访问。

本地还是远程?

对于这个应用,设计问题在于我们是否想在设备上的表里存储偏好、好友、邀请等等,或者使用云中的网络服务和数据库。

为了便于在发送和接收邀请以及偏好列表时进行安全交互,我们决定将所有数据库操作移至云端。然而,本章中的内容不仅与ForgetMeNotDemo相关;如果你决定在手机或电脑上本地存储数据,它也会对你有所帮助。

摘要

在本章中,我们回顾了两种存储数据的方式。最简单且最轻量级的是使用.NET MAUI 的偏好设置功能。如果你只需要存储原始数据和针对持久化程序用户偏好的短字符串,这非常合适。

如果你需要持久化更大量的数据,你需要一个数据库,而对于设备上的存储来说,最流行的类型无疑是 SQLite。我们检查了 SQLite 的 CRUD 功能,然后指出了一种替代方案,即不是在设备上存储所有内容,而是在云中存储并通过程序的 API 访问。

习题

  1. 处理用户数据简单存储的键/值对的类是什么?

  2. 我们向Get方法传递哪两个值来检索存储的值?

  3. 我们需要哪些NuGet包来在.NET MAUI 中与 SQLite 一起工作?

  4. 我们使用什么类型的对象来创建表?

你来试试

将剩余的 CRUD 操作添加到Preference表(例如,删除和按 ID 获取)。

第九章:单元测试

到目前为止,我们一直专注于创建应用程序,但如果不引入单元测试就做得太过分,是有风险的。在本章中,我们将专注于使用最佳实践编写全面而有意义的单元测试。

测试驱动开发(TDD)

一些开发者认为单元测试应该在代码之前(TDD)进行,但这超出了本书的范围。

单元测试对于创建健壮的应用程序和确保在发货前您的应用程序正常工作至关重要。它也是调试的关键方面,可以立即告诉您您刚刚更改或添加的内容是否破坏了应用程序的某些方面。

为了便于进行单元测试,您希望使用依赖注入,这样您就可以模拟耗时的服务,例如 API、数据库等。我们将花费时间与模拟一起,确保我们正在按预期处理数据。

本章的具体内容包括:

  • 为什么创建单元测试?

  • 开始创建单元测试

  • 模拟

  • 依赖注入

  • NSubstitute

技术要求

要跟随本章内容,您需要 Visual Studio。您还需要安装两个 NuGet 包,具体请参考本章本身所示。如果您打算边读边输入代码,您应该从上一章的源代码开始:github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/persistence

本章的源代码可以在以下位置找到:github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/UnitTests

为什么创建单元测试?

您将在生产应用程序上运行许多类型的测试。这些包括单元测试(测试应用程序的一个小部分——通常是方法)、集成测试(程序各部分运行在一起的效果)、UI 测试(确保与 UI 的交互按预期进行)和端到端测试(确保整个程序按预期工作)。

单元测试是这个过程的关键部分,并为每个方法和每个逻辑单元创建。实际上,通常为每个单元创建多个测试,以便您可以测试 happy path、sad path 和边界条件。

happy path是指数据符合预期的情况。sad path是指数据可预测地错误(例如,用户未输入必填字段)的情况。

(以123作为用户名)。

单元测试的一个关键好处是它们可以使您的代码更少脆弱。没有单元测试,很容易陷入一种情况,即这里的更改破坏了那里的代码,而您直到运行整个程序或更糟糕的是,您的客户发现了这个问题,才知道代码被破坏了。

所有这一切的关键是,研究表明,在单元测试期间发现的错误比在开发后期发现的错误更容易修复且成本更低。例如,在 20 世纪 90 年代,Capers Jones 分析了 400 多个软件项目的错误数据,并发现每个开发阶段的错误修复成本增加了 6 到 7 倍。

此外,单元测试是应用程序的优秀文档,精确地描述了在广泛的情况下你期望发生什么。与注释不同,注释会生锈——也就是说,与代码不同步——单元测试永远不会脱离代码,因为当你的代码以使预期结果发生变化的方式改变时,它们会中断。

早早投票,经常投票

在你对代码进行每次更改后运行所有单元测试是很重要的。你希望尽快捕捉到意外的和不受欢迎的副作用。然而,为了实现这一点,你的单元测试必须是快速的。一套运行时间较长的单元测试会被使用得较少。它们运行的时间越长,程序员运行它们的频率就越低。

良好的单元测试不仅速度快,而且彼此之间是隔离的。这意味着一个单元测试不依赖于另一个测试的结果或状态——它们的运行顺序不应该有任何影响。

你希望能够查看单元测试的结果,并立即识别出出了什么问题,以便你可以快速修复它。为了实现这一点,你的单元测试应该做到以下几点:

  • 一次只测试一个东西

  • 命名要规范

如果你在一个单元测试中测试了多个东西,并且测试失败,你将不知道哪个问题是罪魁祸首。命名良好的单元测试可以让你一眼看出它们在测试什么,以及出了什么问题。

这是一个糟糕的单元测试名称示例:DoesGetBuddiesWork

这是一个好的单元测试名称:GettingBuddiesListDoesNotThrowAnException

如果测试失败,查看好的单元测试名称可以立即告诉你出了什么问题。

单元测试名称

一些程序员为单元测试使用非常严格的命名方案。例如,有些人会通过方法的名称、条件以及预期的结果来创建名称。所以,你可能会有这样的名称:GetBuddiesList_WhenEmpty_ShouldNotThrowAnException

这些是有用的,因为浏览测试名称可以给你提供很多信息。

记住,随着程序的扩展,你的单元测试集也会随之增长。当你有成百(甚至上千)个测试时,你希望能够快速地浏览你的测试,以至于你不会介意在每次有意义的更改后运行它们,并且当其中一个或多个测试失败时,你希望知道测试了什么,而无需打开测试文件查看。

创建单元测试

要开始,右键单击解决方案并选择 添加新项目。在对话框中,使用下拉菜单选择 单元测试。有多个单元测试框架。最受欢迎的是较老的 NUnit 和较新的 xUnit。我们将选择 xUnit 测试项目,如图 图 9**.1 所示:

图 9.1 – 选择单元测试类型

图 9.1 – 选择单元测试类型

  1. 点击 .Tests,如图所示:

图 9.2 – 命名测试项目

图 9.2 – 命名测试项目

  1. 点击 下一步 并选择 .NET 平台(本书将使用 .NET 7)。

Visual Studio 将创建你的项目以及第一个单元测试类和方法。由于这是通用的,请删除该类并创建一个名为 PreferencesTests 的类。

设置项目引用

在做任何事情之前,我们需要让 ForgetMeNotDemo.Tests 知道 ForgetMeNotDemo 项目。为此,右键单击测试项目并选择 添加 | 项目引用,并在 ForgetMeNotDemo 旁边勾选复选框,如图 图 9**.3 所示:

图 9.3 – 从单元测试项目中引用 ForgetMeNotDemo

图 9.3 – 从单元测试项目中引用 ForgetMeNotDemo

在设置好所有这些之后,我们就准备好编写我们的第一个单元测试了,这个测试仅用于确保测试结构已就位且正在运行。

注意

这将无法构建。请参阅本章后面出现的 调整项目文件 部分。

创建第一个单元测试

为了确保世界一切正常,打开 UnitTest1 并添加一个必须通过测试的方法:

namespace ForgetMeNotDemo.Tests
{
  public class UnitTest1
  {
    [Fact]
    public void MustBeTrue()
    {
      Assert.True(true);
    }
  }
}

xUnit 测试有两种类型:

  • 事实:这些是不变的 – 它们总是使用相同的数据,并且应该总是通过

  • 理论:这是一套执行相同代码但给定不同输入参数的测试

让我们探索理论。我们创建的第一个测试 MustBeTrue 简单地断言值 truetrue。这是一个很好的第一个测试,因为它将测试你的单元测试是否设置正确。

要运行此测试,请单击 测试 | 运行所有测试 菜单项 – 但请注意, 不会工作

为了使这可行,我们需要对项目文件进行一些调整。

调整项目文件

问题在于你的 .NET MAUI .csproj 项目文件列出了以下 TargetFrameworks

<TargetFrameworks>net7.0-android;net7.0-ios;net7.0-
  maccatalyst</TargetFrameworks>

然而,单元测试项目文件看起来像这样:

<TargetFramework>7.0</TargetFramework>

为了修复这种差异,退出 Visual Studio 并使用你喜欢的文本编辑器(不是 Word 或其他添加特殊字符的程序 – 我喜欢使用 Visual Studio Code,但随便你用什么)打开你的 .NET MAUI 项目 .csproj 文件。将 <TargetFramework> 修改为包含 .net7.0

<TargetFrameworks>net7.0;net7.0-android;net7.0-ios;net7.0-
  maccatalyst</TargetFrameworks>

您已经完成了一半。下一个问题是,我们需要将测试输出为 DLL,但项目的输出是 EXE。最好的解决方法是添加一个条件 - 只有当目标框架不是 7.0 时才输出为 EXE:

<OutputType Condition="'$(TargetFramework)' !=
  'net7.0'">Exe</OutputType>

重新打开 Visual Studio 并打开解决方案。您的测试现在应该可以工作了。

运行测试

首先,将 UnitTest1 重命名为 PreferencesTests。然后,转到菜单并选择 测试 | 测试探索器。这将打开(惊喜!)测试探索器。点击如图 图 9**.4 所示的绿色 播放 按钮:

图 9.4 – 播放按钮

图 9.4 – 播放按钮

您的项目将构建并测试探索器将运行您的测试,并显示如图 图 9**.5 所示的结果:

图 9.5 – 测试结果

图 9.5 – 测试结果

从顶部向下阅读,它显示 ForgetMeNotDemo.Tests 有一个测试,绿色勾选表示 ForgetMeNotDemo.Tests 中的所有测试都已通过。

ForgetMeNotDemo.Tests 内将有一个所有测试类的列表 - 在这种情况下,只有一个,PreferencesTests,这也显示有一个测试并且它通过了。

最后,在 PreferencesTests 内将有一个每个单独测试的列表,再次,绿色勾选表示测试通过。

恭喜,您已经创建了第一个测试,运行了它,并看到了它通过!

现在,让我们坐下来为 ForgetMeNotDemo 编写一些测试。

ForgetMeNotDemo 单元测试

要开始,我们一次检查一个 ViewModel,注意方法。我们这样做是因为我们想要测试的是业务逻辑,如果您做得正确,大部分的业务逻辑将位于 ViewModel 类中。

例如,将我们的注意力转向 PreferencesViewModel,我们看到 Init() 方法。Init 的任务是填充 PreferenceList 集合。目前,我们将忽略它是如何做到这一点的,只是编写一个测试来确保它确实如此。

实现 AAA 模式

在我们开始之前,创建一个 PreferenceService 的接口,正如本书前面所描述的(打开 PreferenceService,右键单击类名,并选择 提取接口)。

单元测试的经典设计模式是 Arrange, Act, Assert (AAA) 模式。也就是说,您设置测试(Arrange),然后调用一个或两个方法(Act),然后检查以确保您得到了预期的结果(Assert)。让我们看看它是如何实施的(注意,这个测试有两个缺陷将在后面讨论):

  [Fact]
  public async void AfterCallingInitPreferencesIsNotEmpty()
  {
    // Arrange
    IPreferenceService service = new PreferenceService();
    preferencesViewModel = new PreferencesViewModel();
    // Act
    await preferencesViewModel.Init();
    // Assert
    Assert.NotEmpty(preferencesViewModel.PreferenceList);
  }

在这里,我们设置了 IPreferenceService,这是我们创建 PreferencesViewModel 所需要的,然后我们创建了一个该 ViewModel 的实例。

在此基础上,我们可以调用 Init() 方法。

现在,我们将使用 Assert 来测试结果。Assert 有许多方法,您可以使用它们来测试测试的成功。这些包括但不限于以下内容:

  • Assert.True

  • Assert.False

  • Assert.Equal<T>(T expected, T actual)

  • Assert.InRange<T>(T actual, T low, T high)

  • Assert.Null

  • Assert.NotNull

  • Assert.IsType<T>(object obj)

  • Assert.Empty(IenumerableCollection)

  • Assert.Contains<T>(T expected, Ienumerable<t> collection)

还有更多。完整的列表可以在xUnit仓库中找到:github.com/xunit/assert.xunit/blob/main/Assert.cs。各种断言被组织成类,每个类都有各种Assert方法。部分列表如图9**.6所示:

图 9.6 – 断言类部分列表

图 9.6 – 断言类部分列表

在我们的情况下,我们断言在运行Init之后,PreferenceList不为空。打开测试资源管理器,然后点击运行视图中的所有测试按钮,如图所示:

图 9.7 – 运行视图中的所有测试按钮

图 9.7 – 运行视图中的所有测试按钮

测试运行后,测试资源管理器会给我们显示结果,如下图所示:

图 9.8 – 测试资源管理器结果

图 9.8 – 测试资源管理器结果

让我们看看图中每个编号选项的含义:

[1] 测试数量

[2] 通过的测试数量

[3] 失败的测试数量

[4] 测试的总结和持续时间说明

[5] 在文本所在上下文中的每个通过的测试。绿色勾号表示通过,红色叉号表示失败。注意,每个测试的时间都被列出。还要注意,测试最多花费了 6 毫秒,但整个测试套件花费了 408 毫秒。这种差异是开始测试过程的开销。这很快就会被所有测试的时间所淹没。

这个测试有什么问题?

我之前提到,这个测试有两个显著的缺陷。第一个是调用Init可能不会填充PreferenceList,因为服务可能返回零条记录。我们需要通过断言PreferenceList不是 null 来调整这一点。

第二个,更重要的问题是,这个测试依赖于运行PreferenceService。如果我们检查PreferenceService的代码,我们会看到对GetPreferences的调用有一个重大问题:

public async Task<List<Preference>> GetPreferences()
{
  return await GetPreferencesMock();
}

目前,在开发应用程序时,我们调用GetPreferencesMock,这只是在PreferenceService中的一个方法。但那不是我们完成应用程序的方式。在第十一章中,我们将将其转换为进行 API 调用。API 调用可能需要不确定的时间,并且可能会使我们的测试停止。

为了解决这个问题,我们需要一个快速返回且返回可预测集合(或如果我们想测试这种情况,则为空集合)的模拟PreferenceService

模拟

经常在测试时,你需要与一个需要不确定时间的方法进行交互,例如从数据库中检索数据,或者更糟糕的是,从 API(即从互联网而不是从你的设备本地)中检索数据。

调用此类方法可能会使你的单元测试突然停止,使其几乎无法使用。为了避免这种情况,我们使用一个名为 模拟 的对象创建数据库或 API 的假表示。

模拟提供了两个优点:它们会立即响应,也许更重要的是,它们的响应是可预测的。一旦编写,它们提供相同的输入,模拟将始终提供相同的输出。

为了使用模拟,我们需要为我们的一些类实现依赖注入,所以让我们从这里开始。

依赖注入

到目前为止,每次我们需要在类内部使用一个对象时,我们都是传入该对象,或者在类的主体中创建它。这会创建一个 PreferencesViewModel,我们需要一个 PreferenceService 对象。我们迄今为止采取的方法是在构造函数中 new 一个

private readonly PreferenceService service;
public PreferencesViewModel()
{
  service = new();
}

依赖注入解耦了类,并允许进行更强大的单元测试,正如我们在继续讨论模拟时将看到的。我们不想 new 一个 PreferenceService,而是想传入一个接口,让 .NET MAUI 为我们创建它(也就是说,没有调用函数会将接口添加到构造函数调用中 – 这将自动完成)。

不仅用于测试

依赖注入可以在你的整个项目中使用,而不仅仅是用于单元测试。实际上,当与 控制反转IoC)容器结合使用时,依赖注入为在整个应用程序中解耦对象创建了一个强大的模式。关于 IoC 容器的内容将在后面介绍。

创建接口

要做这件事,我们首先需要创建一个 IPreferenceService 接口。

Resharper

接下来我将展示的内容都将使用 Resharper,这是严肃的 .NET MAUI 程序员的一个必备工具,但它不是免费的。你当然可以手动完成所有这些;只是 Resharper 让它变得容易得多。由于我强烈推荐购买 Resharper,我将展示如何使用该工具完成以下操作。(请注意,作为微软 MVP,我免费获得我的 Resharper 复制品。)

首先,转到 解决方案资源管理器,打开 PreferenceService,然后按照以下步骤操作:

  1. 右键单击类名,选择 重构此。将出现一个如图所示的上下文菜单:

图 9.9 – 重构上下文菜单

图 9.9 – 重构上下文菜单

  1. 选择 提取接口,将出现一个如图所示的对话框:

图 9.10 – 提取接口

图 9.10 – 提取接口

  1. 确保选中所有公共方法,并选择将接口移动到 其自己的文件 单选按钮。

嘿,奇迹!你将在同一目录(Services)中有一个接口文件,如图 图 9.11 所示:

图 9.11 – 在服务文件夹中创建新的接口文件

图 9.11 – 在服务文件夹中创建新的接口文件

打开你的新文件,你会看到一个典型的 C# 接口:

public interface IPreferenceService
{
  public Task<List<Preference>> GetPreferences();
  public Task<List<Preference>> GetPreferencesMock();
}

现在,检查原始的 PreferenceService。Resharper 足够友好地指定了 PreferenceService 实现 IPreferenceService

public class PreferenceService : IPreferenceService

请将 PreferenceService.GetPreferencesMock 公开。

使用接口,我们可以使用构造函数注入——也就是说,我们可以定义我们将要传递接口实例到构造函数中,然后传递实现该接口的任何内容。

修改类构造函数

让我们回到 PreferencesViewModel。由于我们知道我们将使用依赖注入将 PreferenceService 发送到 ViewModel,我们可以修改 PreferenceService 的声明和构造函数:

Private readonly IPreferenceService service;   [1]
public PreferencesViewModel(IPreferenceService service) [2]
{
  this.service = service; [3]
}

[1] 我们将局部服务成员更改为接口。

[2] 我们将 IPreferenceService 传递到构造函数中。

[3] 我们将成员分配给传入的参数。

但是谁调用带有 IPreference 服务的 PreferencesViewModel,那个方法又是从哪里获取它的?

答案是 IoC 容器负责所有这些。

.NET MAUI IoC 容器

.NET MAUI 内置了一个 IoC 容器,我们通过注册我们想要管理的接口来使用它。你可以在 MauiProgram.cs 中的 CreateMauiApp 方法中这样做:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder(); [1]
     builder
    .UseMauiApp<App>()
    .UseMauiCommunityToolkit()
    .UseMauiCommunityToolkitMarkup()
    .ConfigureFonts(fonts =>
    {
      fonts.AddFont("OpenSans-Regular.ttf",
        "OpenSansRegular");
      fonts.AddFont("OpenSans-Semibold.ttf",
        "OpenSansSemibold");
    })
    .UseMauiMaps();
if DEBUG
    builder.Logging.AddDebug();
endif
    return builder.Build();
}

[1] 中所示,我们首先实例化一个 MauiAppBuilder 对象。然后,我们在构建器上附加了许多其他配置要求。

我们将使用它来注册所有接口到我们的服务中。实际上,我们还将注册我们的视图和 ViewModels,这样我们就可以通过依赖注入将它们传递到方法中。

注册你的接口、服务和 ViewModels

.NET MAUI 提供了一个 IoC 容器。通过注册我们的接口、服务等,.NET MAUI 将在我们需要时提供所需的内容,而无需我们手动创建实例。除此之外,IoC 容器还将解决所有依赖关系。

要注册 IPreferences 接口,我们添加一个调用 Builder.Services.AddTransient,传入接口和实现该接口的类:

builder.Services.AddTransient<IPreferenceService,
  PreferenceService>();

Builder.Services 提供了两种注册接口的方式:

  • AddTransient

  • AddSingleton

当你可能或可能不会实例化对象时,你会使用 AddTransient(我们可能永远不会查看用户的偏好设置,因此可能永远不会需要该服务)。当你知道你将需要该对象,并且创建多个对象没有意义时,你会使用 AddSingleton

当我们在这里时,让我们注册所有 ViewModels。我们不需要为它们提供接口,因为我们不会通过依赖注入将它们传递到任何地方:

builder.Services.AddTransient<AboutViewModel>();
builder.Services.AddTransient<BuddiesViewModel>();
builder.Services.AddTransient<BuddyDetailsViewModel>();
builder.Services.AddTransient<PreferencesViewModel>();
builder.Services.AddTransient<LoginViewModel>();

将它们组合起来,这就是现在的 CreateMauiApp 看起来的样子:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
  builder
    .UseMauiApp<App>()
    .UseMauiCommunityToolkit()
    .UseMauiCommunityToolkitMarkup()
    .ConfigureFonts(fonts =>
    {
      fonts.AddFont("OpenSans-Regular.ttf",
         "OpenSansRegular");
      fonts.AddFont("OpenSans-Semibold.ttf",
         "OpenSansSemibold");
    })
    .UseMauiMaps();
if DEBUG
    builder.Logging.AddDebug();
endif
  builder.Services.AddTransient<IPreferenceService,
    PreferenceService>();
  builder.Services.AddTransient<AboutViewModel>();
  builder.Services.AddTransient<BuddyDetailsViewModel>();
  builder.Services.AddTransient<PreferencesViewModel>();
  builder.Services.AddTransient<LoginViewModel>();
  return builder.Build();
}

注意,所有的注册都是在我们在 Builder 对象上调用 Build 的结果返回之前发生的。

我们将使用依赖注入来注入模拟对象,这些对象通常会被用于不可预测的时间。也就是说,我们不必等待数据库或 API 调用,我们可以注入一个模拟数据库或模拟服务,并立即得到一个可预测的响应。

我们的第一步是决定使用哪个模拟库。

使用 NSubstitute 包

有许多不同的模拟库可供选择,有些是免费的,有些是商业的。对于这本书,我们将使用NSubstitute,这是一个开源且免费的选项,可以作为 NuGet 包使用。

要开始,请按照以下步骤操作:

  1. 右键单击你的解决方案,选择ManageNugetPackagesForSolution

  2. 前往NSubstitute

你想要的第一包是 Anthony Egerton 等人编写的NSubstitute,如图所示:

图 9.12 – NSubstitute NuGet 包

图 9.12 – NSubstitute NuGet 包

  1. 在右侧,点击你想要添加此内容的工程(ForgetMeNotDemo.Tests),然后点击Install,如图图 9.13所示:

图 9.13 – 安装 NSubstitute

图 9.13 – 安装 NSubstitute

  1. 安装完成后,安装NSubstitute.Analyzers.CSharp,如图所示:

图 9.14 – 选择 NSubstitute.Analyzers.CSharp

图 9.14 – 选择 NSubstitute.Analyzers.CSharp

虽然不是强制性的,但这个第二个库将检测你在使用 NSubstitute 时可能犯的错误。像之前一样将其安装到测试项目中。

将 NSubstitute 添加到测试固定装置中

要将 NSubstitute 添加到测试固定装置中,请将using NSubstitute;添加到 C#文件的顶部。

我们现在可以创建PreferenceService的替代品。

模拟依赖于构造函数依赖注入

转到PreferencesViewModel的构造函数,注意服务被注入为一个接口:

public PreferencesViewModel(IPreferenceService service)

{

this.service = service;

}

这非常重要。模拟只与构造函数注入一起工作。

在我们的单元测试中,让我们为服务声明一个模拟:

[Fact]
public async void AfterCallingInitPreferencesIsNotEmpty()
{
  // Arrange
  var service = Substitute.For<IPreferenceService>();   [1]
  var = new PreferencesViewModel(service); [2]
  // Act
  await preferencesViewModel.Init();
  // Assert
  Assert.NotEmpty(preferencesViewModel.PreferenceList);
}

[1] 声明IpreferenceService的模拟。

[2] 将该模拟传递给PreferencesViewModel构造函数。

运行测试。它因集合不能为空而失败。为什么?

原始服务返回一个Preference对象列表,但我们的新模拟不是。我们需要教会模拟返回一组可预测的Preference对象。

这里是Arrange方法的顶部,我们在其中创建了一些Preference对象,然后将它们添加到一个列表中:

public async void AfterCallingInitPreferencesIsNotEmpty()
{
  // Arrange
  Preference pref1 = new()
  {
    Id = 1,
    PreferencePrompt = "Shirt Size",
    PreferenceValue = "Large"
  };
  Preference pref2 = new()
  {
    PreferencePrompt = "Favorite Music Genre",
    PreferenceValue = "Jazz"
  };
  List<Preference> prefs = new()
  {
    pref1,
    pref2
  };

我们现在可以创建我们的替代品:

var serviceMock = Substitute.For<IPreferenceService>(); [1]
  serviceMock.GetPreferences() [2]
  .Returns(prefs); [3]

[1] 创建模拟。

[2] 告诉模拟它将模拟哪个方法。

[3] 告诉模拟在调用该方法时应该返回什么。

我们使用模拟来调用PreferencesViewModel构造函数,你将记得它需要IpreferenceService

preferencesViewModel = new PreferencesViewModel
  (serviceMock);

在测试的Act部分,我们将对那个PreferencesViewModel对象调用Init,然后断言列表不为空。这次会成功,因为所依赖的服务现在可以预测会返回两个首选项的列表。

测试边缘情况

如果PreferenceService没有返回记录会发生什么?这会导致ViewModel崩溃吗?我们可以测试这一点:

  [Fact]
  public async void AfterCallingInitPreferencesIsEmptyButNo
    Exception()
  {
    // Arrange
    List<Preference> preferences = new(); [1]
    var serviceMock = Substitute.For<IPreferenceService>();
    serviceMock.GetPreferences()
      .Returns(preferences); [2]
    preferencesViewModel = new PreferencesViewModel
      (serviceMock);
    // Act
    var exception = await Record.ExceptionAsync (async ()
      => await preferencesViewModel.Init()); [3]
    // Assert
    Assert.Null(exception); [4]
  }

[1] 将List<Preference>设置为空。

[2] 让服务返回空的首选项列表。

[3] 使用Record.ExceptionAsync并传入对Init的调用。这将返回异常或 null,如果没有抛出异常。

[4] 断言没有抛出异常。

所有 NSubstitute 用法的完整描述可在nsubstitute.github.io/help.html找到。

摘要

在本章中,我们回顾了编写单元测试和全面测试程序的重要性。简而言之,单元测试允许你自信地编码,知道如果你进行更改并且它破坏了看似无关的东西,你将立即发现。

我们看到,有时你的单元测试必须与较慢的外部系统(API、数据库等)交互,并且你可以通过使用模拟来保持亚秒级响应时间;我们选择的模拟库是NSubstitute,尽管还有其他免费的模拟系统(一个非常流行的是Moq)。

为了便于使用模拟,我们探讨了依赖注入并简要回顾了 IoC 容器的作用。在下一章消费 REST 服务中,我们将查看从基于云的服务(Azure)获取数据,而不是模拟数据。

问答

  1. 为什么编写单元测试很重要?

  2. 你将测试的大部分代码在哪里?

  3. 为什么使用模拟?

  4. 为什么依赖注入对模拟很重要?

你试试看

识别 ViewModel 或服务中与 API 或数据库交互的方法,并编写一个使用模拟的单元测试。

第三部分 - 高级主题

在本节的最后一部分,我们将深入探讨忍者级别的主题,包括如何与基于 REST 的服务(在我们的案例中是 Azure)进行交互,以及如何根据运行时数据修改我们应用程序的外观。

本部分包含以下章节:

  • 第十章消费 REST 服务

  • 第十一章探索高级主题

第十章:消费 REST 服务

到目前为止,当我们需要数据时,我们通过使用硬编码的对象来伪造它。然而,在现实世界的程序中,你将大部分数据从本地数据库(如第八章中讨论的)中获取,或者你将通过 API 与云中的服务交互。你可以使用几种架构之一进行交互,其中最流行的是 .NET MAUI 的 表示状态转移REST)。

REST 是一种描述类如何在互联网上交互的模式。REST 的关键是它是无状态的——也就是说,客户端和 API 之间不需要持续连接。

忘不了架构被设计为使用 REST 服务和数据库,这将管理会员资格、身份验证和用户数据的持久化。在本章中,我们将查看以下内容:

  • 使用 REST 服务

  • 忘不了 API 架构

  • API 领域对象

  • 数据传输对象DTOs

  • API 客户端类

  • 使用 API

技术要求

要跟随本章内容,你需要 Visual Studio。如果你打算在跟随时输入代码,你将需要上一章的分支。

本章完成项目的源代码可以在github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/REST找到。

使用 REST 服务

到目前为止,我们所做的一切工作都是本地化的(一个手机、Windows 或 Mac)。忘不了的设计涉及到在云端使用一个服务来管理我们所有的数据——程序邀请、注册、身份验证、数据持久化等等。

客户端通过 REST API(也称为 RESTful API)与服务器交互。

一个 ForgetMeNot.API

了解更多关于 REST

对于我们的目的来说,这就是你需要了解的所有关于 REST 的内容,但如果你好奇,你可以在en.wikipedia.org/wiki/Representational_state_transfer上找到更多信息。

忘不了 API 架构

当我们在第八章中查看获取用户偏好时,我们使用了偏好服务。这个服务,直到现在,使用了一种返回硬编码值的方法。当然,这是一个临时的权宜之计,这样我们就可以一次专注于一件事情。我们现在准备好与在线 API 交互了。

服务在哪里?

我已经在 Azure 上创建了一个在线的 Web 服务,网址是forgetmenotapi20230113114628.azurewebsites.net/

我的目的是保持这个服务的运行,以便你可以实现客户端并得到有意义的结果,但鉴于可能存在维护成本,在你阅读这段文字的时候,该服务可能已经不再可用。如果是这样,你仍然可以通过阅读 API 代码和使用硬编码的数据来获得你需要的 95%,就像我们到目前为止所做的那样。

此外,请注意,仅仅访问 URL 是没有用的。只有当我们结合基本 URL 和特定任务相关的补充时,才会发生魔法。你可以通过创建一个账户或登录一个账户来测试 API 是否仍然可用。如果这可行,那么 API 的其余部分也应该可以正常工作。

为了方便我们与 API 的 REST 交互,我们将使用非常流行的开源 RestSharp 库 (restsharp.dev/)。它将为我们做所有繁重的工作。(我们将使用 class 库模板。)

创建项目

要开始,我们需要三个新的项目。右键单击 解决方案 并选择 添加新项目。这三个项目的名称是:

  • ForgetMeNot.API.Domain (API 域对象)

  • ForgetMeNot.API.Dto (DTOs)

  • ForgetMeNot.ApiClient (API 的包装器)

Api.DomanAPI.Dto 都是类库。APIClient 是一个 webapi

我们将依次查看这些项目,填写所有详细信息。

因为我们将使用 API 数据库,我们可以删除本地 SQLite 数据库。为此,注释掉或删除 Constants 文件和整个 ForgetMeNotDemoDatabase.cs 文件。

完善模型

在我们创建与我们的模型类中的项目对应的类之前,我们需要完善它们。

让我们从 Model 目录中的 Preference 类开始。我们为 SQLite 添加了一个 Id 属性;我们不再需要它了,所以我们可以将其删除。同时,从 PreferencesTests.AfterCallingInitPreferencesIsNotEmpty() 中也删除它。

设计规范说明,用户可以邀请朋友成为伙伴。我们需要添加一个模型来描述邀请:

using CommunityToolkit.Mvvm.ComponentModel;
namespace ForgetMeNotDemo.Model;
[ObservableObject]
public partial class Invitation
{
  [ObservableProperty] private string buddyCode;
  [ObservableProperty] private int buddyId;
}

类似地,我们需要跟踪 场合,例如生日和周年纪念日,以便我们记得使用“忘不了”来购买礼物:

[ObservableObject]
public partial class Occasion
{
  [ObservableProperty] private string name;
  [ObservableProperty] private DateTime date;
  [ObservableProperty] private int numDaysToNotify;
}

我们还需要几个额外的 Model 类,例如 User 和它的派生类 Buddy。应用的所有者是一个用户,以及他们所有的伙伴。以下是 User Model 类:

[ObservableObject]
public partial class User
{
  [ObservableProperty] private string name;
  [ObservableProperty] private string id;    [1]
  [ObservableProperty] private List<Buddy> buddies; [2]
  [ObservableProperty] private List<Invitation>
    invitations; [3]
  [ObservableProperty] private List<Preference>
    preferences; [4]

[1] 我们已经将 ID 迁移到了基类。对于(现在不再存在的)本地数据库,我们不再需要它,但我们需要在服务器上使用 ID。它是一个字符串,因为服务器将创建一个 全局唯一 ID (GUID)。

[2] 每个用户可以有任意数量的 Buddies

[3] 每个用户可以发送任意数量的邀请。

[4] 每个用户将有一个 Preference 对象的列表。

注释掉的代码

注意,GitHub 仓库中现有的代码中,invitations 属性被注释掉了。请取消注释它。

Buddy 类在此基础上构建:

public partial class Buddy : User
{
  [ObservableProperty] private string emailAddress;
  [ObservableProperty] private string? phoneNumber;
  [ObservableProperty] private string? mailingAddressLine1;
  [ObservableProperty] private string? mailingAddressLine2;
  [ObservableProperty] private string? website;
  [ObservableProperty] private string? twitter;
  [ObservableProperty] private string? facebook;
  [ObservableProperty] private string? instagram;
  [ObservableProperty] private string? linkedIn;
  [ObservableProperty] private string? venmoName;
  [ObservableProperty] private InvitationStatus status;
  [ObservableProperty] private List<OccasionModel>
    occasions;
  [ObservableProperty] private DateTime buddySince;
}

注释掉的代码

目前,请不要取消注释InvitationStatusOccasionModel

我们存储了关于每个伙伴的大量信息,包括他们成为我们伙伴的时间、共享的场合以及我们发送给Buddy类的邀请状态。

检查 API 域对象

API 域对象是客户端模型类的超集。这是因为 API 需要一些在客户端不可见的数据。右键单击ForgetMeNot.API.Domain并创建以下类:

  • InvitationStatus

  • Invite

  • Occasion

  • Related

  • Roles

  • User

  • UserPreference

让我们逐一介绍它们,从User开始(注意,这个类使用了在以下代码中定义的UserPreference,所以不要构建,直到你有了这两个类):

public class User
{
    public Guid Id { get; set; }
    public string FullName { get; set; }
    public string Email { get; set; }
    public string HashedPassword { get; set; }
    public bool IsEmailConfirmed { get; set; }
    public string Role { get; set; }
    public List<UserPreference> Preferences { get; set; }
}

如你所见,在服务器上,每个User实例都有一个唯一的 ID。大多数其他属性与客户端上的相同,尽管它们可能没有相同的标识符(例如,FullName而不是Name)。这不是问题,因为当我们从服务器获取对象时,我们会进行映射。

然而,有几个新的字段——例如,IsEmailConfirmedHashedPasswordRole。这些由服务器用于身份验证。

让我们创建Roles文件。它是一个包含我们将支持的两种角色的静态类:

public static class Roles
{
    public static string Admin = "admin";
    public static string User = "user";
}

接下来,我们将关注UserPreference类。这对应于客户端Model文件夹中的Preference类:

public class UserPreference
{
    public string PreferencePrompt { get; set; }
    public string PreferenceValue { get; set; }
}

注意,API 在某些方面是独立于客户端的。我们用不同的名字调用这个类,并且没有使用代码生成器。

接下来,我们需要代表InvitationOccasion的类。让我们从Invitation开始:

public class Invite
{
    public Invite()
    {
        Id = Guid.NewGuid();
    }
    public Guid Id { get; set; }
    public User CreatedByUser { get; set; }
    public User? AcceptedByUser { get; set; }
    public InvitationStatus Status { get; set; }
    public DateTime CreationDate { get; set; }
    public DateTime? EndDate { get; set; }
    public string InvitedUserName { get; set; }
    public string InvitedUserCustomMessage { get; set; }
}

这个类有一个InvitationStatus类型的属性。为此也创建一个文件。这只是一个枚举:

public enum InvitationStatus
 {
     Waiting,
     Expired,
     Accepted,
     Rejected
 }

这是Occasion类:

public class Occasion
{
    public Occasion()
    {
        Id = Guid.NewGuid();
    }
    public Guid Id { get; set; }
    public User? ForUser { get; set; }
    public string? OccasionName { get; set; }
    public DateTime Date { get; set; }
    public int NumDaysToNotify { get; set; }
}

NumDaysToNotify的目的允许用户指定他们希望在场合前多少天收到通知(这个功能留给你作为练习!)。

最后,我们添加一个将用户与其所有的Occasions和伙伴关联起来的类。注意,我们使用User作为Buddy,因为基类包含了我们所需的所有信息:

public class Related
{
    public Related()
    {
        Occasions = new List<Occasion>();
        Users = new List<User>();
    }
    public Guid Id { get; set; }
    public string RelatedDescription { get; set; }
    public List<User> Users { get; set; }
    public List<Occasion> Occasions { get; set; }
    public DateTime Since { get; set; }
}

这个项目就到这里了。没有方法;这实际上只是一组基于服务器的模型对象。

一旦我们有了模型,我们需要确定如何将数据从服务器传输到客户端以及从客户端传输回服务器。为此,我们需要 DTOs(数据传输对象)。

审查 DTOs

如你所猜,ForgetMeNot.Api.Dto项目将包含 DTOs。这些将对应于模型对象,但设计为在服务器和客户端之间来回传递。

项目引用

您需要从ForgetMeNot.Api.Dto添加一个项目引用到ForgetMeNot.Api.Domain

让我们从BuddyDto.cs开始:

using ForgetMeNot.Api.Domain;
namespace ForgetMeNot.Api.Dto
{
    public class BuddyDto
    {
        public BuddyDto()
        {
        }
        public BuddyDto(User user)
        {
            UserId = user.Id;
            FullName = user.FullName;
            Email = user.Email;
            Preferences = new List<UserPreference>();
            if (user.Preferences?.Any(p =>
                p.PreferenceValue != null) ?? false)
            {
                Preferences = user.Preferences.Where(p =>
                    p.PreferenceValue != null).ToList();
            }
            Occasions = new List<OccasionDto>();
        }
        public Guid UserId { get; set; }
        public string FullName { get; set; }
        public string Email { get; set; }
        public List<UserPreference> Preferences { get; set; }
        public List<OccasionDto> Occasions { get; set; }
    }
}

注意,Buddy的构造函数接受User。如前所述,Buddy类从User派生,通过将User传递到构造函数中,我们可以设置BuddyUser属性。

注意我们还在使用 OccasionDto 对象的列表。这些在 ForgetMeNot.Api.Dto 项目中。

其他 DTO 文件

ForgetMeNot.Api.Dto 中的其他关键文件与模型类无关,而是客户端和服务器之间交换的数据,以方便管理账户——例如,AccountCreateRequest

public class AccountCreateRequest
{
    public string FullName { get; set; }
    public string Email { get; set; }
    public string PlainPassword { get; set; }
}

在创建账户时,需要将所有这些发送到服务器。有一个 DTO 用于请求更新用户记录,它只包含 IdFullNameEmail。一个重要的 DTO 是 UserResponse,它包含与 User Domain 对象相对应的信息:

public class UserResponse
{
  public Guid Id { get; set; }
  public string FullName { get; set; }
  public string Email { get; set; }
  public bool IsEmailConfirmed { get; set; }
  public string Role { get; set; }
  public List<UserPreference> Preferences { get; set; }
  public UserResponse()
  {
  }
  public UserResponse(User user)
  {
    Id = user.Id;
    FullName = user.FullName;
    Email = user.Email;
    Role = user.Role;
    IsEmailConfirmed = user.IsEmailConfirmed;
    Preferences = user.Preferences;
  }
}

您传入一个 User 对象,UserResponse 会将其转换为 DTO。

同样,您可以将一个 User 对象传递到 ProfileResponse 中,并返回一个 ProfileResponse DTO:

public class ProfileResponse
{
  public Guid Id { get; set; }
  public string FullName { get; set; }
  public string Email { get; set; }
  public bool IsEmailConfirmed { get; set; }
  public string Role { get; set; }
  public List<UserPreference> Preferences { get; set; }
  public ProfileResponse(User user)
  {
    Id = user.Id;
    FullName = user.FullName;
    Email = user.Email;
    Role = user.Role;
    IsEmailConfirmed = user.IsEmailConfirmed;
    Preferences = user.Preferences;
  }
}

最后一个拼图是包裹 API 在客户端类中,以方便与云中的数据进行交互。

理解 ForgetMeNot.APIClient

第三个 API 项目 ForgetMeNot.APIClient 中只有一个类——Client.cs。这是客户端(ForgetMeNotDemo)将要与之交互的 REST 服务的包装器。

我们从四个成员变量开始:

public class Client
{
    RestClient client; [1]
    string baseUrl;   [2]
    string username;   [3]
    string password;

[1] 如前所述,RestClient 是我们用来管理 REST 交互的库(通过 NuGet 获取,如前所述)。

[2] baseURL 是所有 API 调用的前缀,在我们将 API 移动到 Azure 时创建。如前所述,它可在 forgetmenotapi20230113114628.azurewebsites.net/ 找到。

[3] usernamepassword 被客户端用来访问用户的记录。

Client 的构造函数接受 baseUrl,将其分配给字段,然后调用 SetClient()

public Client(string baseUrl)
{
    this.baseUrl = baseUrl;
    SetClient();
}
void SetClient()
{
    var options = new RestClientOptions(baseUrl)  [1]
    {
        ThrowOnAnyError = false,
        MaxTimeout = 10000
    };
    client = new RestClient(options); [2]
}

[1] 我们希望为这个 REST 客户端创建的选项创建一个健壮的接口;我们不会在任何错误上抛出异常,并且不会在 10 秒内超时。

[2] 设置了选项后,我们可以创建一个新的 RestClient,它在 RestSharp 中定义。

文件的其他部分被划分为客户端的重要行为部分,从认证用户的代码开始。

认证

我们设置了一个 IsAuthenticated 属性,该属性设置为 client.Authenticator 是否为 null

然后我们有一个 Login 方法,它接受一个 LoginRequest 对象,设置 usernamepassword,并调用 Authenticate

public async Task Login(LoginRequest request)
{
    username = request.Username;
    password = request.Password;
    await Authenticate();
}

项目参考

您需要参考 DTO 项目。

LoginRequest 在 DTO 项目中定义,并且仅有两个字符串属性,UsernamePassword(参考以下代码块)。

Authenticate 方法使用 RestSharp 的 OAuth 认证——也就是说,繁重的工作再次由 RestSharp 完成:

async Task Authenticate()
{
    var request = new RestRequest("auth/gettoken");
    request.AddBody(new { username, password });
    var accessToken = await client.PostAsync<string>
        (request);
    client.Authenticator = new OAuth2Authorization
        RequestHeaderAuthenticator(accessToken, "Bearer");
}

幸运的是,您不需要理解如何实现这一功能;您只需传入用户名和密码,RestSharp 会为您处理其余部分。

客户端与客户端

记住,即使你处于 Client 类中,client 字段也是 RestSharp 对象。

我们有一个辅助方法来获取当前的 API 版本:

public Task<string?> Version()
{
    var request = new RestRequest("util/version");
    return client.GetAsync<string?>(request);
}

这将带我们到文件的配置文件部分,在那里我们可以获取和更新 profile 对象。

配置文件

需要两个方法来处理配置文件。第一个是获取配置文件:

public Task<ProfileResponse?> GetProfile()
{
    var request = new RestRequest("profile/me");
    return client.GetAsync<ProfileResponse?>(request);
}

这使用了之前检查过的 ProfileResponse DTO。本节中的第二个方法是用来更新配置文件的:

public Task UpdateProfile(ProfileUpdateRequest
    profileUpdateRequest)
{
    var request = new RestRequest("profile/me");
    request.AddBody(profileUpdateRequest);
    return client.PutAsync(request);
}

这段代码使用了在 ForgetMeNot.Api.Dto 中定义的 ProfileUpdateRequest 对象。

同样,这里所有有趣的工作都是由 RestSharp 完成的。正如你所见,客户端实际上只是 RestSharp 方法的包装。

让我们通过查看更多用于管理 Buddy 对象的方法来加强这一点。

伙伴区域

这个区域包括我们需要与伙伴交互的方法,GetBuddyCreateInvitationGetBuddy 返回一个 BuddyDto 对象列表:

public Task<List<BuddyDto>?> GetBuddy()
{
    var request = new RestRequest("buddy");
    return client.GetAsync<List<BuddyDto>?>(request);
}

CreateInvitation 返回 Guid,这是结果 Invitation 对象的 Id 属性:

public Task<Guid?> CreateInvitation(InviteCreateRequest
  inviteCreateRequest)
{
    var request = new RestRequest("buddy/invite");
    request.AddBody(inviteCreateRequest);
    return client.PostAsync<Guid?>(request);
}

这段代码使用了在 ForgetMeNot.Api.Dto 中定义的 InviteCreateRequest

最后,我们有一个方法来获取所有用户——也就是说,所有这些用户的伙伴:

public Task<List<UserResponse>?> GetUserList()
{
    var request = new RestRequest("user");
    return client.GetAsync<List<UserResponse>?>(request);
}

我们得到的是之前看到的 UserResponse DTO 对象列表。

现在我们已经检查了服务器提供的所有项目,我们就可以让 ForgetMeNotDemo 与 API 交互以获取、存储和检索数据。

使用 API

Client 类及其支持的 DTOAPI 领域类就位后,我们就可以与 API 交互来创建账户和登录,以及存储和检索我们的偏好。

创建账户

新用户首先会创建一个账户。为了使这可行,我们需要将用户带到 CreateAccount.xaml,在那里他们可以填写他们的名字、电子邮件和密码。为了实现这一点,我们必须对 登录创建 账户 页面进行一些实质性的更改。

让我们首先将应用程序指向以登录开始。修改 App.xaml.cs 中的 App 方法,使其看起来像这样:

public App(LoginViewModel loginViewModel) [1]
{
  InitializeComponent();
  MainPage = new LoginPage(loginViewModel); [2]
}

[1] 让 IoC 容器传入 LoginViewModel 的一个实例。

[2]MainPage(程序的入口点)设置为 LoginPage

登录 页面的工作现在将是允许用户登录或将他们带到 创建 账户 页面。

一定要在 AppShell.xaml.cs 中添加 CreateAccount 的路由:

Routing.RegisterRoute("createaccount",
  typeof(CreateAccountPage));

接下来,让我们修改 登录 页面。

修改登录页面

既然我们要将认证管理交给服务器,我们需要一个不同的 LoginPage.xaml.cs 并用这个简单版本替换它:

using CommunityToolkit.Maui.Core.Views;
using ForgetMeNotDemo.ViewModel;
namespace ForgetMeNotDemo.View;
public partial class LoginPage : ContentPage
{
  public LoginPage(LoginViewModel viewModel)
  {
    BindingContext = viewModel;
    InitializeComponent();
  }
}

LoginViewModelApp 传入,如前述代码所示。

现在,让我们修改 LoginPage 以专注于登录用户或将他们重定向到创建新账户。

更新登录页面

我们将对LoginPage进行一些重大更改。为了避免混淆,删除所有内容并替换为以下内容:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="ForgetMeNot.View.LoginPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:iOsSpecific="clr-namespace:Microsoft.Maui
      .Controls.PlatformConfiguration.iOSSpecific;
        assembly=Microsoft.Maui.Controls"
    Title="Login"
    iOsSpecific:Page.UseSafeArea="True"
    Shell.NavBarIsVisible="False"
    Shell.PresentationMode="ModalAnimated">
    <ContentPage.Resources>
        <ResourceDictionary>
            <Style x:Key="Prompt" TargetType="Label">   [1]
              <Setter Property="TextColor" Value="Black" />
              <Setter Property="FontSize" Value="Medium" />
              <Setter Property="FontAttributes"
                  Value="Bold" />
                <Setter Property="HorizontalTextAlignment"
                  Value="Center" />
                <Setter Property="VerticalTextAlignment"
                  Value="Center" />
                <Setter Property="VerticalOptions"
                  Value="Center" />
                <Setter Property="HorizontalOptions"
                  Value="End" />
            </Style>
            <Style x:Key="LoginButton" TargetType="Button">
              <Setter Property="BackgroundColor"
                Value="LightGray" />
              <Setter Property="Margin" Value="0,20,0,0" />
              <Setter Property="TextColor" Value="Black" />
             <Setter Property="WidthRequest" Value="125" />
            </Style>
        </ResourceDictionary>
    </ContentPage.Resources>

在样式设置到位后,我们准备创建LabelsEntry控件来获取用户的用户名和密码:

<VerticalStackLayout>
        <Grid
            ColumnDefinitions="*,*,*"
            RowDefinitions="Auto,Auto,Auto,Auto"
            RowSpacing="10">
            <Label
                Grid.Row="0"
                Grid.Column="0"
                Style="{StaticResource Prompt}"
                Text="User name" />
            <Entry
                Grid.Row="0"
                Grid.Column="1"
                Grid.ColumnSpan="2"
                Placeholder="User name"
                Text="{Binding LoginName}"
                WidthRequest="150" />
            <Label
                Grid.Row="1"
                Grid.Column="0"
                HorizontalOptions="End"
                Style="{StaticResource Prompt}"
                Text="Password" />
            <Entry
                Grid.Row="1"
                Grid.Column="1"
                Grid.ColumnSpan="2"
                IsPassword="True"
                Placeholder="Password"
                Text="{Binding Password}"
                WidthRequest="150" />
            <Button
                Grid.Row="2"
                Grid.Column="0"
                Command="{Binding DoLoginCommand}"
                Style="{StaticResource LoginButton}"
                Text="Submit" />  [2]

一旦用户填写了字段(或者由于他们忘记了密码而无法填写),我们将提供以下选项:

             <Button
                Grid.Row="2"
                Grid.Column="1"
                Command="{Binding DoCreateAccountCommand}"
                Style="{StaticResource LoginButton}"
                Text="Create Account" /> [3]
            <Button
                Grid.Row="2"
                Grid.Column="2"
                BackgroundColor="LightGray"
                Command="{Binding ForgotPasswordCommand}"
                Style="{StaticResource LoginButton}"
                Text="Forgot Password" /> [4]
        </Grid>
        <ActivityIndicator  [6]
            x:Name="activityIndicator"
            HeightRequest="50"
            IsRunning="{Binding ShowActivityIndicator}"
            Color="Blue" />
    </VerticalStackLayout>
</ContentPage>

[1] 我稍微扩展了两种样式,以最小化控件中的样式。

[2] 稍后点击ViewModel)。

[3] 点击CreateAccount页面。

[4] 忘记密码(正如他们所说)留作练习。

使用这个新的以 API 为导向的LoginViewModel

AccountService

在更新LoginViewModel之前,我们需要创建AccountService类及其相关接口:

using ForgetMeNot.Api.Dto;
using ForgetMeNot.ApiClient;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ForgetMeNotDemo.Services
{
  public class AccountService : IAccountService
  {
    readonly Client apiClient;
    public AccountService(Client apiClient)
    {
      this.apiClient = apiClient;
    }
    public async Task CreateAccount(AccountCreateRequest
      accountCreateRequest)
    {
      await apiClient.CreateAccount(accountCreateRequest);
    }
    public async Task GetNewPassword()
    {
    }
    public async Task Login(LoginRequest request)
    {
      await apiClient.Login(request);
    }
    public bool IsLoggedIn()
    {
      return apiClient.IsAuthenticated;
    }
  }
}

这个类用于账户的创建和验证。有了这个,我们就准备好更新LoginViewModel

更新LoginViewModel

LoginViewModel必须更新以满足更新后的LoginPage类的新要求。再次,删除所有内容并替换为以下内容:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ForgetMeNot.API.Dto;
using ForgetMeNotDemo.Services;
using ForgetMeNotDemo;
namespace ForgetMeNotDemo.ViewModel
{
  [ObservableObject]
  public partial class LoginViewModel
  {
    private AccountService;           [1]
    [ObservableProperty] private string loginName;
    [ObservableProperty] private string password;
    [ObservableProperty] private bool showActivityIndicator
      = false;

构造函数通过控制反转IoC)容器传递给AccountService,并保留该服务以供其其他方法使用:

    public LoginViewModel(AccountService accountService)
      [2]
    {
      this.accountService = accountService;
    }
    [RelayCommand]
    public async Task DoLogin()
    {
      try
      {
        LoginRequest loginRequest = new LoginRequest [3]
        {
          Username = LoginName,
          Password = Password
        };

我们将设置ActivityIndicator在请求 API 登录用户时显示:

        ShowActivityIndicator = true;
        await accountService.Login(loginRequest); [4]
        ShowActivityIndicator = false;
        if (accountService.IsLoggedIn()) [5]
        {
          Application.Current.MainPage = new AppShell();
          await Shell.Current.GoToAsync("mainpage");
        }
        else [6]
        {
          await Application.Current.MainPage.DisplayAlert
            ("Login failure",
              "Your username and password do not match our
                records", "Ok");
        }
      }
      catch (Exception exception)
      {
        await Application.Current.MainPage.DisplayAlert
          ("Authorization failure",
            "Your username and password do not match our
              records", "Ok");
        Console.WriteLine(exception);
      }

实现管理忘记密码的逻辑留作练习:

    }
    [RelayCommand]
    public async Task ForgotPassword()
    {
[7]
    }

我们将创建新账户的责任委托给服务器:

    [RelayCommand]
    public async Task DoCreateAccount() [8]
    {
      try
      {
        Application.Current.MainPage = new AppShell();
        await Shell.Current.GoToAsync($"createaccount");
      }
      catch (Exception e)
      {
        Console.WriteLine(e);
      }
    }
  }
}

[1] 我们创建AccountService字段,它将在ViewModelClient类之间进行调解。

[2] IoC 传递给我们需要的AccountService,我们将将其分配给刚刚创建的AccountService成员。

[3] 我们将用户名和密码打包成一个LoginRequest对象。我们从ForgetMeNot.API.DTO获取这个类:

public class LoginRequest
{
    public string Username { get; set; }
    public string Password { get; set; }
}

[4] 我们打开ActivityIndicator,将LoginRequest传递给 API,然后,当我们得到响应时,关闭ActivityIndicator。我们将在下一步查看AccountService实际上在做什么。

[5] 我们询问AccountService登录是否成功。如果是(愉快的路径),我们重置MainPage(远离LoginPage)并导航到那里。

[6] 如果登录失败(不愉快的路径),我们通知用户我们无法登录他们,并给他们另一个机会尝试。

[7] 本书将不会实现重置密码的代码。

AccountService负责登录。让我们看看下一个。

使用AccountService进行登录

为了安全起见,我们希望服务器负责根据电子邮件地址和密码验证用户:

public async Task Login(LoginRequest request) [1]
{
  await apiClient.Login(request);
}
public bool IsLoggedIn() [2]
{
  return apiClient.IsAuthenticated;
}

AccountService的其他方法(我们将在稍后返回)中,以下两个方法是:

[1] 登录简单地将处理登录的责任委托给apiClient,通过API进行登录,传递包含用户名和密码的LoginRequest

[2] 类似地,IsLoggedIn布尔方法使用apiClient来查看当前用户是否已认证

用户还有第二个选项,即点击CreateAccountPage

设置创建账户页面

创建账户页面提示用户输入用户名、密码以及他们的电子邮件。为了简化,在这个例子中,我们只要求输入一次密码,但我们确实实现了验证:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="ForgetMeNotDemo.View.CreateAccountPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:behaviors="http://schemas.microsoft.com/dotnet/
      2022/maui/toolkit"
    Title="CreateAccount">
    <VerticalStackLayout>
        <Entry
            HorizontalOptions="FillAndExpand"
            Keyboard="Text"
            Placeholder="Enter Name"
            Text="{Binding Name}">
            <Entry.Behaviors>
                <behaviors:UserStoppedTypingBehavior
                Command="{Binding  ValidateNameCommand}"
                StoppedTypingTimeThreshold="500" />  [1]
            </Entry.Behaviors>
        </Entry>

[1] 我们使用 Community Toolkit 的StoppedTypingBehavior来检测用户何时完成字段的输入。我们将StoppedTypingTimeThreshold设置为500——即半秒。这意味着一旦用户半秒内没有输入任何内容,我们就假设他们已经完成,并启动验证。注意,命令设置为ValidateNameCommand。这将在ViewModel中处理(如后文所示),并返回一个布尔值,表示用户是否输入了有效的名称:

        <Label
            FontSize="13"
            IsVisible="{Binding ShowNameErrorMessage}"
              [1]
            Text="{Binding NameErrorMessage}"
            TextColor="Red" />
        <Entry
            HorizontalOptions="FillAndExpand"
            Keyboard="Email"
            Placeholder="Enter Email"
            Text="{Binding Email}">
            <Entry.Behaviors>
                <behaviors:UserStoppedTypingBehavior
                  Command="{Binding ValidateEmailCommand}"
                    StoppedTypingTimeThreshold="500" />
                      [2]
            </Entry.Behaviors>
        </Entry>

[1] 只有当名称验证失败时才显示标签。

[2] 现在,像对名称一样对电子邮件进行相同的处理,当用户停止输入时调用ValidateEmailCommand

        <Label
            FontSize="13"
            IsVisible="{Binding ShowEmailErrorMessage}"
            Text="{Binding EmailErrorMessage}"
            TextColor="Red" />
        <Entry
            HorizontalOptions="FillAndExpand"
            IsPassword="True"   [1]
            Keyboard="Default"
            Placeholder="Enter Password"
            Text="{Binding Password}">
            <Entry.Behaviors>
                <behaviors:UserStoppedTypingBehavior
                Command="{Binding ValidatePasswordCommand}"
                  StoppedTypingTimeThreshold="500" />
            </Entry.Behaviors>
        </Entry>
        <Label
            FontSize="13"
            IsVisible="{Binding ShowPasswordErrorMessage}"
            Text="{Binding PasswordErrorMessage}"
            TextColor="Red" />
        <Button
            Margin="0,30,0,0"
            BackgroundColor="LightGray"
            Command="{Binding SignUpCommand}" [2]
            CornerRadius="5"
            HorizontalOptions="Center"
            IsEnabled="{Binding EnableButton}"
            Text="Sign up"
            TextColor="Black"
            TextTransform="None"
            WidthRequest="100" />
    </VerticalStackLayout>
</ContentPage>

[1] 条目的IsPassword属性设置为true,当用户输入字符时,密码将显示为一串星号。

[2] 一旦字段有效,执行Signup命令。

所有的支持命令和验证都在CreateAccountViewModel中。

设置 CreateAccountViewModel

在这个文件中,我们看到的第一件事是所有的属性:

[ObservableProperty] accountService;
[ObservableProperty] private string name;
[ObservableProperty] private string email;
[ObservableProperty] private string password;
[ObservableProperty] private string nameErrorMessage;
[ObservableProperty] private string emailErrorMessage;
[ObservableProperty] private string passwordErrorMessage;
[ObservableProperty] private bool showNameErrorMessage;
[ObservableProperty] private bool showEmailErrorMessage;
[ObservableProperty] private bool showPasswordErrorMessage;
[ObservableProperty] private bool enableButton;
[ObservableProperty] private bool isValidName;
[ObservableProperty] private bool isValidEmail;
[ObservableProperty] private bool isValidPassword;

注意,除了提示外,还有错误消息的属性。还有一个布尔属性EnableButton,默认为false(如果你没有设置布尔值,它默认为false)。

构造函数接受通过 IoC 传递的AccountService(这在MauiProgram.cs中已注册)。更新客户端构造函数以接受一个作为baseUrl的字符串:

var apiClient = new Client("https://forgetmenotapi
  20230113114628.azurewebsites.net/");
builder.Services.AddSingleton(apiClient);
builder.Services.AddTransient<AccountService>();

我们将转向CreateAccountViewModel类:

public CreateAccountViewModel(AccountService
  accountService)
{
  this.accountService = accountService;
}

让我们检查一个Validation方法。

业务需求是有效的名称至少有两个字符。验证的代码是一个简单的if语句,如下所示:

[RelayCommand]
public Task ValidateName()
{
  if (!string.IsNullOrEmpty(Name) && Name.Length >= 2)
  {
    IsValidName = true;
    ShowNameErrorMessage = false;
    EnableButton = IsValidName && IsValidEmail &&
      IsValidPassword; [1]
  }
  else
  {
    NameErrorMessage = "*Please enter a name with at least
      two characters";
    IsValidName = false;
    ShowNameErrorMessage = true;  [2]
    EnableButton = IsValidName && IsValidEmail &&
      IsValidPassword;
  }
  return Task.CompletedTask;
}

[1] EnableButton属性(用于确定当名称、电子邮件和密码都有效时返回true)。

[2] 如果名称无效,则将ShowNameErrorMessage属性设置为true,并显示错误消息。

在下一章中,我们将探讨.NET MAUI 为更优雅的验证方法提供的支持。

在这个文件中最重要的命令是响应SignUpCommand的命令。

处理注册命令

SignUp方法检查确保字段有效(通过确保EnableButtontrue),然后创建一个AccountCreateRequest对象,该对象定义在ForgetMeNot.Api.Dto中:

public class AccountCreateRequest
{
    public string FullName { get; set; }
    public string Email { get; set; }
    public string PlainPassword { get; set; }
}

将该对象传递给accountService上的CreateAccount方法。让我们看看ForgetMeNot.Api.Dto AccountCreateRequest中的整个方法:

[RelayCommand]
async Task SignUp()
{
  if (EnableButton)
  {
    AccountCreateRequest = new() [1]
    {
      Email = this.Email,
      FullName = Name,
      PlainPassword = Password
    };
    try
    {
      await accountService.CreateAccount
        (accountCreateRequest); [2]
      await Application.Current.MainPage.DisplayAlert(
        "Sign up  completed",
          "Your user has been created successfully", "Ok");
            [3]
      await Shell.Current.GoToAsync(".."); [4]
    }
    catch (Exception e)
    {
      await Application.Current.MainPage.DisplayAlert("Sign
        up failed",
          "We were not able to create an account with that
            user name", "Ok");
    }
  }
}

[1] 首先创建 AccountCreateRequest 对象,如前所述。

[2] 在服务上调用 CreateAccount。我们稍后将查看该方法。

[3] 如果一切正常,显示一个对话框(或者,如我们之前所做的那样,一个吐司)。

[4] 一旦创建用户账户,就返回一页到 登录 页面。

AccountService 中的 CreateAccount 方法所做的只是将 AccountCreateRequest 对象传递给 apiClientCreateAccount 方法。

这里描述的机制就其本身而言是正确的,但它们不包括最终应用程序应该有的邀请响应(用户邀请一个朋友,然后创建账户)。

我们不要忘记,我们最初创建 LoginCS 是为了模仿 C# 中的 XAML。你需要调整 LoginCS 以匹配 XAML 文件中的命名约定,或者完全注释掉它,因为我们不再使用它。

是时候运行程序并确保我们所做的一切都正常工作了。然而,当你进行这么多更改时,有时你可能会遇到无法解释的构建错误。

如果无法构建怎么办

假设你已经检查了所有代码,它是正确的,但你遇到了奇怪的构建错误(例如 InitializeComponents not found),那么可能是时候清理一切了。为此,关闭 Visual Studio 并导航到你的文件所在的文件夹。删除每个项目中的 binobj 目录,如图 图 10.1* 所示。

![图 10.1 – 删除 bin 和 obj 目录图片

图 10.1 – 删除 bin 和 obj 目录

请按照以下步骤操作:

  1. 重新启动 Visual Studio 并立即从菜单中选择 构建 | 清理解决方案。最后,选择 构建 | 重新构建解决方案,这将强制进行完整的重新构建而不是增量构建。给你的项目一点时间来稳定并运行它。

你应该直接被带到 登录 页面,如图中所示:

![图 10.2 – 登录页面图片

图 10.2 – 登录页面

  1. 接下来,点击 Entry 控件的 Placeholder 属性。

![图 10.3 – 创建账户页面图片

图 10.3 – 创建账户页面

  1. 填写字段并点击 注册。你的账户将在服务器上创建,并将出现一个对话框来通知你操作成功,如图 图 10.4* 所示。

![图 10.4 – 成功注册图片

图 10.4 – 成功注册

如果你尝试登录,你会收到一个 unauthorized 消息。问题是系统不想要用户名;它想要用户的电子邮件。

未授权

当然,任何错误的用户名或无效的密码都会收到未授权的消息。

让我们修复 LoginPage.xaml 并登录,如图 图 10.5* 所示。

![图 10.5 – 使用我们的新账户登录图片

图 10.5 – 使用我们的新账户登录

当登录验证成功后,你将被直接带到主页。

重要的是要注意并享受这样一个事实:账户创建和身份验证都通过 API 在云端进行。

摘要

在本章中,我们回顾了如何与 API 交互。通过检查 API 域和 DTO 项目,我们深入了解了这种交互的内部机制,并看到 APIClient 类如何封装所有 API 调用来简化客户端的体验并使其更加直观。

这是一个相对高级的话题,在下一章中,我们将深入探讨更多高级话题,帮助你从一名 .NET MAUI 新手程序员成长为专家。

问答

  1. 什么是 DTO?

  2. 为什么我们不需要本地 SQLite 数据库?

  3. API 客户端类的作用是什么?

  4. 账户创建发生在哪里?

  5. 身份验证发生在哪里?

你来试试

实现忘记密码的客户端代码。

第十一章:探索高级主题

现在,你拥有了中级.NET MAUI 程序员的技能和知识。你看到了如何布局控件以及管理和操作这些控件。然后你学习了 MVVM 设计模式。这些都是基础。

之后,你进阶到了 Shell 导航,使用 SQLite 进行数据持久化,以及编写单元测试的至关重要的技能。

这最后一章将带你超越这个水平,进入专家.NET MAUI 知识的领域。在本章中,我们将涵盖以下主题:

  • 在运行时选择数据模板

  • 管理视觉状态

  • 利用社区工具包的行为

  • 使用触发器采取行动

  • 验证表单

技术要求

本章的源代码可以在github.com/PacktPublishing/.NET-MAUI-for-C-Sharp-Developers/tree/AdvancedTopics找到。如果你希望跟随,请确保使用上一章的代码。

在运行时选择数据模板

你在第五章中看到了数据模板在集合视图中使用的情况。现在让我们重新审视那段代码,并在此基础上进行扩展,以便我们能够在运行时根据对象本身的数据修改每个对象的显示。

回顾一下,我们是从PreferenceService开始的,我们模拟了获取Preference对象列表的过程。现在,我们只需稍作工作就可以从 API 中获取它。修改IPreferenceService以移除GetPreferencesMock

接下来,我们需要对PreferenceService进行重大重构以与客户端交互。删除你有的内容,并使用以下内容:

using ForgetMeNot.ApiClient;
using ForgetMeNotDemo.Model;
namespace ForgetMeNotDemo.Services;
public class PreferenceService : IPreferenceService
{
  readonly Client apiClient;
  public PreferenceService(Client apiClient)
  {
    this.apiClient = apiClient;
  }
  public async Task<List<Preference>> GetPreferences()
  {
    try
    {
      var response = await apiClient.GetProfile();
      return response?.Preferences.Select(p => new
         Preference
      {
        PreferencePrompt = p.PreferencePrompt,
        PreferenceValue = p.PreferenceValue
      }).ToList();
    }
    catch (Exception e)
    {
      await Application.Current.MainPage.DisplayAlert
        ("Preferences error",
        "We were unable to get your preferences", "Ok");
      Console.WriteLine(e);
    }
    return null;
  }
}

这里没有什么新的内容;它与我们在第十章中获取Buddies时看到的内容直接平行。现在我们有一个Preference对象的集合,我们可以像在第五章中那样(如前一章中在PreferencesPage中所示)在CollectionView中显示它们:

<CollectionView
    ItemsSource="{Binding PreferenceList}"
    Margin="20,20,10,10"
    SelectionMode="None">
    <CollectionView.ItemTemplate>
        <DataTemplate>
            <Grid ColumnDefinitions="*,2*">
                <Entry
                    FontSize="10"
                    Grid.Column="0"
                    HorizontalOptions="Start"
                    HorizontalTextAlignment="Start"
                    Text="{Binding PreferencePrompt,
                        Mode=TwoWay}"
                    TextColor="{OnPlatform Black,
                                           iOS=White}" />
                <Entry
                    FontSize="10"
                    Grid.Column="1"
                    HeightRequest="32"
                    HorizontalOptions="Start"
                    HorizontalTextAlignment="Start"
                    Text="{Binding PreferenceValue,
                      Mode=TwoWay}"
                    TextColor="{OnPlatform Black,
                                           iOS=White}"
                    WidthRequest="350" />
            </Grid>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

注意到CollectionViewItemTemplate是在CollectionView本身的声明中声明的,但这并不是声明ItemTemplate的唯一方式。让我们看看另一种方法。

将 ItemTemplates 声明为资源

你可以将ItemTemplateCollectionView的定义中移除,并将其移动到ResourceDictionary中:

    <ContentPage.Resources>  [1]
        <ResourceDictionary>
            <DataTemplate x:Key="PreferenceTemplate"> [2]
                <Grid ColumnDefinitions="*,2*"> [3]
                    <Entry
                        FontSize="10"
                        Grid.Column="0"
                        HorizontalOptions="Start"
                        HorizontalTextAlignment="Start"
                        Text="{Binding PreferencePrompt,
                          Mode=TwoWay}"
                        TextColor="{OnPlatform Black,
                                iOS=White}" />
                    <Entry
                        FontSize="10"
                        Grid.Column="1"
                        HeightRequest="32"
                        HorizontalOptions="Start"
                        HorizontalTextAlignment="Start"
                        Text="{Binding PreferenceValue,
                           Mode=TwoWay}"
                        TextColor="{OnPlatform Black,
                                         iOS=White}"
                        WidthRequest="350" />
                </Grid>
            </DataTemplate>
        </ResourceDictionary>
    </ContentPage.Resources>

让我们看看我们在这里做了什么:

  • [1]:在文件顶部,我们声明了一个Resources部分,包含一个ResourceDictionary

  • [2]:我们创建了DataTemplate并为其提供了一个键,以便我们稍后可以引用它

  • [3]DataTemplate的其余部分与在CollectionView内部一样

CollectionView现在要简单得多——它只是声明其ItemTemplate属性为我们创建的StaticResource

<CollectionView
    ItemsSource="{Binding PreferenceList}"
    ItemTemplate="{StaticResource PreferenceTemplate}"
    Margin="20,20,10,10"
    SelectionMode="None">
</CollectionView>

这很有价值,但除了它开辟了新的可能性之外,几乎没有什么令人兴奋的。

数据模板的位置

在这里,我们在资源部分显示了 DataTemplate,但你也可以将其放在不同的文件中。

数据模板选择

你可以在同一个 ResourceDictionary 中创建两个或更多额外的 DataTemplates。这允许 .NET MAUI 在即将显示每个 项目 时检查它,并根据条件在可用的 DataTemplates 中进行选择。

我们知道当我们获取首选项时,一些会有值,一些则没有。假设我们想告诉用户输入一个值,当值为空时将提示变成红色。我们可以创建两个 DataTemplates:

    <ContentPage.Resources>
        <ResourceDictionary>
            <DataTemplate x:Key="PreferenceTemplate">  [1]
                <Grid ColumnDefinitions="*,2*">
                    <Entry
                        FontSize="10"
                        Grid.Column="0"
                        HorizontalOptions="Start"
                        HorizontalTextAlignment="Start"
                        Text="{Binding PreferencePrompt,
                           Mode=TwoWay}"
                        TextColor="{OnPlatform Black,  [2]
                                       iOS=White}" />
                    <Entry
                        FontSize="10"
                        Grid.Column="1"
                        HeightRequest="32"
                        HorizontalOptions="Start"
                        HorizontalTextAlignment="Start"
                        Text="{Binding PreferenceValue,
                           Mode=TwoWay}"
                        TextColor="{OnPlatform Black,
                                               iOS=White}"
                        WidthRequest="350" />
                </Grid>
            </DataTemplate>
            <DataTemplate x:Key=
              "PreferenceTemplateEmpty">
              [3]
                <Grid ColumnDefinitions="*,2*">
                    <Entry
                        FontSize="10"
                        Grid.Column="0"
                        HorizontalOptions="Start"
                        HorizontalTextAlignment="Start"
                        Text="{Binding PreferencePrompt,
                          Mode=TwoWay}"
                        TextColor="{OnPlatform Red,
                                   iOS=Yellow}" />    [4]
                    <Entry
                        FontSize="10"
                        Grid.Column="1"
                        HeightRequest="32"
                        HorizontalOptions="Start"
                        HorizontalTextAlignment="Start"
                        Text="{Binding PreferenceValue,
                            Mode=TwoWay}"
                        TextColor="{OnPlatform Black,
                                               iOS=White}"
                        WidthRequest="350" />
                </Grid>
            </DataTemplate>
         </ResourceDictionary>
    </ContentPage.Resources>

让我们看看这个:

  • [1]:第一个数据模板

  • [2]:正常文本颜色

  • [3]:第二个数据模板(具有自己的键)

  • [4]文本颜色

现在,显然的问题是,.NET MAUI 如何知道显示哪个?为了这个,我们需要一个 DataTemplateSelector

DataTemplateSelector 类

你必须做的第一件事是创建一个包含显示哪个模板逻辑的类。我将其命名为 PreferenceDataTemplateSelector。由于我只打算有一个,所以我将其放在 Services 文件夹中:

using ForgetMeNotDemo.Model;
namespace ForgetMeNotDemo.Services;
public class PreferenceDataTemplateSelector :
  DataTemplateSelector  [1]
{
  public DataTemplate PreferenceTemplate { get; set; }
    [2]
  public DataTemplate PreferenceTemplateEmpty { get; set; }
  protected override DataTemplate OnSelectTemplate(object
    item,  [3] BindableObject container)
  {
    if (((Preference)item)?.PreferenceValue == null)
      return PreferenceTemplateEmpty;
    return ((Preference) item).PreferenceValue.Length > 0 ?
      PreferenceTemplate : PreferenceTemplateEmpty;    [4]
  }
}

你必须做以下事情:

[1]:你的类必须从 DataTemplateSelector 继承。

[2]:你需要为每个 DataTemplates 创建一个公共属性。

[3]:重写 OnSelectTemplate 虚拟方法。

[4]:添加确定显示哪个模板的逻辑。

类已经就位,我们需要一个相应的资源。

将模板选择器添加到页面的资源中

返回到 PreferencesPage.xaml。在页面声明中添加 Xmlns:services="clr-namespace:ForgetMeNotDemo.Services"。然后,在 ResourceDictionary 中添加以下内容:

<services:PreferenceDataTemplateSelector
                PreferenceTemplate="{StaticResource
                    PreferenceTemplate}"
                PreferenceTemplateEmpty="{StaticResource
                    PreferenceTemplateEmpty}"
                x:Key="PreferenceDataTemplateSelector" />

这为我们刚刚创建的类中的名称提供了链接。我们现在有了逻辑,但如何将其连接到 CollectionView 呢?

将 DataTemplateSelector 添加到 CollectionView

将所有这些连接到 CollectionView 只需设置一个 ItemTemplate

<CollectionView
    ItemTemplate="{StaticResource PreferenceDataTemplate
       Selector}"
    ItemsSource="{Binding PreferenceList}"
    Margin="20,20,10,10"
    SelectionMode="None" />

所有这些都汇集在一起。CollectionView 在资源中查找 PreferenceDataTemplateSelector,它与我们所创建的包含显示哪个 DataTemplate 逻辑的类相关联。结果如 图 11**.1 所示:

图 11.1 – 数据模板选择

图 11.1 – 数据模板选择

DataTemplateSelector 是一种非常强大的方式来控制运行时显示的内容。类似的机制封装在视觉状态的概念中。

管理视觉状态

在任何给定时刻,每个 VisualElement 都有一个 视觉状态(例如,VisualElement 是否有 焦点?它是否 选中?)。你可以想象在 C# 中以编程方式响应这种状态,但在 XAML 中声明性地响应视觉状态的变化有优势。这样做将更多的 UI 管理集中在一个地方——你的视图(例如,MainPage.xaml)。

VisualElement

VisualElement 是所有控件(和页面)的基类。

根据其状态在VisualElement上设置视觉属性的VisualStates对象,并根据在 XAML 中设置的属性显示VisualElement

这迫使人们提出问题:什么是视觉状态?

定义通用视觉状态

.NET MAUI 为控件定义了一套通用视觉状态:

  • 正常

  • 禁用

  • 有焦点

  • 已选择

  • 鼠标悬停(适用于 Windows 和 macOS)

.NET MAUI 还允许你定义自己的视觉状态,尽管这不太常见。

你使用这些视觉状态来设置VisualElement上的属性。例如,你可能根据按钮的VisualState来改变按钮的外观。一个例子将使这一点更加清晰。

按钮视觉状态示例

当你第一次访问登录页面时,你会看到提交按钮是禁用的。我们希望它显示为灰色。一旦你填写了您的电子邮件密码字段,按钮应该变为浅绿色。如果你将光标移至按钮,它应该通过变为全绿色来表示它具有焦点。你可以通过创建视觉状态来声明性地完成所有这些,如图图 11.2所示。2*:

图 11.2 – 按钮的视觉状态

你可以为单个按钮设置视觉状态,或者,正如我们将在这里做的那样,你可以将视觉状态的 XAML 放入样式并应用于所有按钮。以下是按钮的完整Style

<Style x:Key="LoginButton" TargetType="Button"> [1]
    <Setter Property="Margin" Value="0,20,0,0" />
    <Setter Property="TextColor" Value="Black" />
    <Setter Property="WidthRequest" Value="125" />
    <Setter Property="VisualStateManager
      .VisualStateGroups"> [2]
        <VisualStateGroupList>
            <VisualStateGroup x:Name="CommonStates"> [3]
                <VisualState x:Name="Normal"> [4]
                    <VisualState.Setters> [5]
                        <Setter Property="BackgroundColor"
                           Value="LightGreen" /> [6]
                    </VisualState.Setters>
                </VisualState>
                <VisualState x:Name="Focused">
                    <VisualState.Setters>
                        <Setter Property="BackgroundColor"
                           Value="Green" />
                    </VisualState.Setters>
                </VisualState>
                <VisualState x:Name="Disabled">
                    <VisualState.Setters>
                        <Setter Property="BackgroundColor"
                          Value="Gray" />
                    </VisualState.Setters>
                </VisualState>
            </VisualStateGroup>
        </VisualStateGroupList>
    </Setter>
</Style>

在这里,我们有以下内容:

  • [1]:我们首先声明一个正常的Style——在这种情况下,它适用于每个按钮

  • [2]:你可能有一个或多个视觉状态组(我们有一个)

  • [3]:第一个组(在这种情况下,只有一个)是CommonStates

  • [4]:我们依次声明每个VisualState(在这里,我们从Normal开始)

  • [5]:对于每个状态,我们可以声明一组Setter

  • [6]:我们的第一个(在这种情况下,唯一的)Setter设置了BackgroundColor属性

然后我们继续为所有其他状态设置Setter。注意,我们没有为PointerOver设置Setter,这意味着在 Windows 和 macOS 上,如果你将鼠标悬停在按钮上,将不会有任何变化。

.NET MAUI 为控件定义了专门的视觉状态。例如,Button添加了按下状态,而CheckBox添加了已选中状态,CollectionViews添加了已选择

.NET MAUI 社区工具包为管理你的应用的外观和行为提供了进一步的帮助,它包含大量行为。

利用社区工具包行为

我们已经看到了来自社区工具包的一个将事件转换为命令的行为(EventToCommandBehavior),这允许我们在ViewModel中对这些事件做出响应。

社区工具包是开源的

社区工具包不是.NET MAUI 的官方部分,它由(惊喜!)社区提供的代码组成——也就是说,独立于微软的开发者。尽管如此,微软的文档包括并越来越多地整合了社区工具包。

CommunityToolkit提供了一套处理许多其他常见编程需求的行为。其中许多行为帮助验证输入。例如,CommunityToolkit包括以下内容:

  • 字符验证

  • 数字验证

  • 必需的字符串验证

  • 文本验证

  • URI 验证

你将行为附加到控件上。例如,让我们为登录页面添加一条规则,说明用户名必须是一个有效的电子邮件地址。首先,在头文件中添加所需的命名空间:

Xmlns:behaviors=http://schemas.microsoft.com/dotnet/2022/maui/toolkit

你就可以使用 Community Toolkit 行为来测试有效的电子邮件:

<Entry
    Grid.Column="1"
    Grid.ColumnSpan="2"
    Grid.Row="0"
    Placeholder="Please enter your email address"
    Text="{Binding LoginName}"
    WidthRequest="150">
    <Entry.Behaviors>   [1]
        <behaviors:EmailValidationBehavior  [2]
            InvalidStyle="{StaticResource InvalidUserName}"
              [3]
            ValidStyle="{StaticResource ValidUserName}" [4]
            Flags="ValidateOnValueChanged" /> [5]
    </Entry.Behaviors>
</Entry>

执行以下操作:

[1]: 开始Entry标签的Behaviors部分。

[2]: 选择你想要的行为(在这种情况下,电子邮件验证)。

[3]: 识别无效电子邮件地址的样式。

[4]: 识别有效电子邮件地址的样式。

[5]: 添加验证行为。它们有标志来指示何时进行验证(在这种情况下,当值改变时),如下面的图所示:

Figure 11.3 – 验证标志

图 11.3 – 验证标志

还有其他几种非验证行为。这些包括帮助动画视图、进度条动画、帮助自定义设备状态栏颜色和样式的行为,以及当用户停止输入时触发动作的行为。

最后一个在允许用户搜索大量数据时非常有用。与其让搜索在用户输入时增量,或者强迫用户点击搜索按钮,不如让搜索在用户停止输入指定时间后开始:

Place the following code at the top of PreferencesPage.xaml
<Entry Placeholder="Search" x:Name="SearchEntry">
    <Entry.Behaviors>
        <behaviors:UserStoppedTypingBehavior
            Command="{Binding PreferencesSearchCommand}"
              [1]
            CommandParameter="{Binding Source={x:Reference
               SearchEntry}, Path=Text}"    [2]
            MinimumLengthThreshold="4"  [3]
            ShouldDismissKeyboardAutomatically="True" [4]
            StoppedTypingTimeThreshold="500" /> [5]
    </Entry.Behaviors>
</Entry>

让我们看看这段代码做了什么:

  • [1]: 当用户停止输入时,在您的ViewModel中调用此命令。

  • [2]: 将此参数(Entry的文本)传递给命令。

  • [3]: 除非至少输入这么多字符,否则不要执行命令。

  • [4]: 当你执行命令时,收起键盘。

  • [5]: 等待这么长时间(半秒)以表示用户已经停止输入。

当你将此添加到你的 XAML 中,并且用户在输入框中输入Shoe时,命令被触发,并传递参数。图 11**.4显示了参数被传递到PreferencesPageViewModel中的命令处理器:

Figure 11.4 – 传递搜索字符串

图 11.4 – 传递搜索字符串

行为是一种声明系统在 XAML 中应该如何表现的方式。另一种将响应动作移动到 XAML 触发器中的强大机制。

使用触发器采取行动

触发器允许你根据数据变化在 XAML 中声明控件的外观。你还可以使用状态触发器来改变控件的视觉状态,如前面所示。

例如,我们可能想强制执行DataTrigger

<Button
    Command="{Binding DoCreateAccountCommand}"
    Grid.Column="1"
    Grid.Row="2"
    Style="{StaticResource LoginButton}"
    Text="Create Account">
    <Button.Triggers>  [1]
        <DataTrigger
            Binding="{Binding Source={x:Reference
               passwordEntry}, Path=Text.Length}"
            TargetType="Button"
            Value="0"> [2]
            <Setter Property="IsEnabled" Value="False" />
              [3]
        </DataTrigger>
    </Button.Triggers>
</Button>

让我们看看这段代码做了什么:

  • [1]: 这开始于ButtonTriggers集合。

  • [2]:这创建了一个 DataTrigger 并将其绑定到名为 passwordEntry 的输入控件中的文本长度。将 TargetType 设置为 Button(必需)并设置触发它的值(即,如果密码输入文本的长度为 0,则触发触发器)。

  • [3]:此代码使用设置器来声明触发器触发时会发生什么。

简而言之,当 密码 字段为空时,创建 按钮应被禁用,一旦它不为空,按钮应被启用。

预期之外的行为

你正在检查的字段(密码)必须将其文本初始化为 "" 才能正常工作。否则,它将是 null,触发器可能不会按预期工作。为了解决这个问题,在 ViewModel 中初始化属性:

[``可观察属性``]

public string password = string.Empty;

此触发器的结果在 图 11.5图 11.6 中显示。

图片 2

图 11.5 – 当密码字段为空时的触发器

图 11.5 中,密码 字段为空,而在 图 11.6 中,已经输入了字符到 密码 字段:

图片 3

图 11.6 – 当密码字段不为空时的触发器

为了好玩,将 PropertyIsEnabled 更改为 IsVisible。现在,当你进入页面时,按钮不会出现,但当你将字符输入到 密码 字段时,它会出现。

摘要

在本章中,我们回顾了四个关键的高级主题,这些主题允许你声明式地管理应用程序的行为:

  • 选择数据模板允许你根据集合中每个元素的特定内容来更改数据的显示。

  • 管理视图状态允许你根据该控件的状态(例如,它是否有焦点)来修改控件的外观。

  • 行为允许(包括但不限于)数据验证,并协助提供你声明的操作。

  • 触发器根据其他控件中的数据值或其他控件的状态来更改控件的外观。

这就标志着 .Net MAUI for C# Developers 的结束。你现在已经完全准备好创建真实世界的专业 .NET MAUI 应用程序了。

与许多编程技能一样,成功的关键在于动手经验。如果你目前没有在 .NET MAUI 项目上工作,你将想要在信息处于你脑海中心时为自己分配一个项目。

这是我多年来一直有的一个应用想法,你可以自由地编写(如果你愿意,还可以出售)。我将无限制地给你:

创建一个看起来很棒的应用程序,收集你在网上书店(使用它们的公共 API)上评分为五星的所有书籍。然后,收集那些也给出了大量书籍五星评价的人。排除任何给那些书籍评分为少于五星的人。现在,找到那些似乎与你意见一致的人,并找出他们评分为五星而你尚未阅读的任何书籍。这些是你想阅读的书籍。

享受这个项目!我希望你喜欢这本书。

测验

回答以下问题以测试你对本章知识的掌握:

  1. 为什么你会使用 Visual State Manager?

  2. .NET MAUI 如何决定使用哪个数据模板?

  3. 命名一个不用于验证的行为。

  4. 当一个触发器被触发时,它是如何知道该做什么的?

你试试

修改 登录 页面,使其执行以下操作:

  • 只有当用户名是有效的电子邮件地址且密码至少有一个字符时,才禁用 登录 按钮。

  • 修改 忘记密码 按钮的大小,使其在获得焦点时(即按下制表符键)加倍并变为粉色,失去焦点时恢复到正常大小和颜色。

评估

本节包含所有章节的问题答案。

第一章,组装你的工具和创建你的第一个应用程序

  1. 你可以通过选择 创建一个新项目 从启动对话框创建一个新的项目。如果你是通过点击 文件 | 新项目 直接进入 Visual Studio 的,也可以这样做。

  2. 使用 视图 | 解决方案资源管理器

  3. .xaml 扩展名表示该文件包含 XAML 标记。

  4. 代码隐藏文件。

  5. MauiProgram.cs

第三章,XAML 和 Fluent C#

  1. XAML 是一种基于 XML 的标记语言。

  2. 在 .NET MAUI 中使用 XAML 声明布局和控件。

  3. 而不是使用 XAML 编写,你可以使用 C# 创建你的布局和控件。

  4. 我们通过使用 Children 属性在布局内部嵌套布局或控件。

  5. 事件处理程序是一个注册到 UI 事件的方法。

  6. 事件处理程序位于代码隐藏中。

第四章,MVVM 和控件

  1. MVVM 有两个主要优点。首先,如果你的逻辑在代码隐藏文件中,那么几乎不可能对 .NET MAUI 应用程序进行单元测试 – 将逻辑放在 ViewModel 中是至关重要的,正如我们将在即将到来的单元测试章节中看到的。其次,MVVM 很好地将 UI 与你的逻辑解耦,允许你更改一个而不破坏另一个。

  2. 最重要的 BindingContext。通常将 ViewModel 作为 View 的绑定上下文。

  3. Entry 控件和 Editor 控件。

  4. Label 控件。

  5. SnackBar 是一个高度可配置的 Toast – 一个从页面底部弹出的弹出窗口,然后可以通过计时器耗尽或用户点击它来消失。

第五章,高级控件

  1. ActivityIndicator 显示有 某事 正在进行,而 ProgressBar 告诉用户任务完成的百分比。

  2. 我们关心的基本区别是,事件通常在代码隐藏中处理,而命令在 ViewModel 中处理。在 ViewModel 中处理命令更可取,因为它使得创建单元测试更容易或成为可能。

  3. WeakReferenceManager 是在消息传递中使用的首要对象,允许 ViewModelView 或另一个 ViewModel 发送通知,而不需要对该对象的引用,从而支持松耦合。

  4. 样式允许你在控件实例之间创建统一的外观,集中属性并提供良好设计的代码的所有优势。

  5. 重构样式的 一种方法 是创建一个基本样式,然后使用BasedOn创建派生类型,根据需要添加或覆盖属性。

第六章,布局

  1. 星号,auto和 dpi 中的值

  2. 将 100 dpi 分配给最后一列,这是第二列所需的大小,然后将第一列和第三列按比例 2:1 进行划分

  3. 行和列偏移量由枚举常量定义

  4. Grid允许更精确地对控件进行对齐和定位

  5. BindableLayout不允许你进行选择

第七章,理解导航

  1. AppShell.xaml

  2. TitleContentTemplateIcon

  3. AppShell.xaml.cs

  4. Shell.Current.GoToAsync

  5. URL 语法或使用字典

第八章,存储和检索数据

  1. Preferences(不要与UserPreferences混淆)。

  2. 键和默认值。

  3. SQLite-net-pcl和可能SQLitePCLRaw.bundle_green,如果它们没有包含在SQLite-net-pcl中。

  4. SQLiteAsyncConnection

第九章,单元测试

  1. 单元测试对于确保代码质量至关重要,并允许你添加和更改代码,同时有信心知道,如果你破坏了某些东西,你将立即发现。

  2. .NET MAUI 应用程序中的大部分可测试代码将在 ViewModel 中,或者可能是服务中。

  3. 当你需要一个较慢的服务来测试你的代码中的方法时,一个模拟可以代替该服务并立即给出响应。

  4. 为了向你的测试提供一个模拟,你必须能够将其注入代码中,以替代运行时对象。

第十章,消费 REST 服务

  1. 一个 DTO 负责持有将发送到 API 或从 API 发送的数据。

  2. 数据库现在在云中,并通过 API 进行管理。作为客户端,我们不知道,也不需要知道正在使用哪种数据库。

  3. 它封装了所有的 API 调用,以便客户端可以像与一个普通的 CLR 对象POCO)交互一样与 API 交互。

  4. 账户创建通过 API 在云中完成。

  5. 认证通过 API 在云中完成。

第十一章,探索高级主题

  1. 根据控件的状态(例如,是否有焦点)修改控件的外观。

  2. 在 XAML 中,添加一个DataTemplateSelector来指示潜在的模板,然后添加一个继承自DataTemplateSelector的类,重写OnSelectTemplate并返回要显示的DataTemplate

  3. 我们已经看到了EventToCommand行为,它允许你向只有事件的控件添加命令,让你可以在ViewModel中处理事件/命令。

  4. 你添加带有属性的 setter 来改变值,并将其设置为指定的值。

posted @ 2025-10-23 15:06  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报