Flutter-秘籍-全-

Flutter 秘籍(全)

原文:Flutter Recipes

协议:CC BY-NC-SA 4.0

一、开始

本章中的食谱帮助你设置你的本地开发环境,为构建 Flutter 应用做好准备。根据机器的操作系统,设置步骤可能会有所不同。你只需要按照你自己的要求使用食谱。在使用了本章中的方法之后,你应该能够在模拟器或者物理设备上运行第一个 Flutter 应用。

1.1 在 Windows 上安装 Flutter SDK

问题

你有一台 Windows 机器,你想在这台机器上开始 Flutter 开发。

解决办法

在 Windows 机器上安装 Flutter SDK,设置 Android 平台。

讨论

Flutter SDK 支持 Windows 平台。在 Windows 上安装 Flutter 并不像你想象的那样困难。首先,您需要确保您的本地开发环境满足最低要求。您需要 64 位 Windows 7 SP1 或更高版本,以及至少 400MB 的可用磁盘空间供 Flutter SDK 使用。Flutter SDK 还要求 Windows PowerShell 5.0 或更高版本以及 Git for Windows 在机器上可用。

Windows PowerShell 5.0 预装了 Windows 10。对于 Windows 10 之前的 Windows 版本,需要按照微软的说明( https://docs.microsoft.com/en-us/powershell/scripting/setup/installing-windows-powershell )手动安装 PowerShell 5.0。您可能已经安装了 Git for Windows,因为 Git 是一个非常流行的开发工具。如果您可以在 PowerShell 中运行 Git 命令,那么您就可以开始了。否则需要下载 Git for Windows ( https://git-scm.com/download/win )并安装。安装 Git for Windows 时,请确保在“调整路径环境”页面中选择了“从命令行和第三方软件安装 Git”选项;见图 1-1 。

img/479501_1_En_1_Fig1_HTML.jpg

图 1-1

Git for Windows 安装程序

满足这些最低要求后,您可以从官方网站( https://flutter.dev/docs/get-started/install/windows )下载 Flutter SDK zip 包。将下载的 zip 文件解压缩到本地计算机上的所需位置。建议避免使用安装了 Windows 的系统驱动程序。在提取的目录中,双击文件flutter_console.bat启动 Flutter 控制台并运行 Flutter SDK 命令。

为了能够在任何 Windows 控制台中运行 Flutter SDK 命令,我们需要将 Flutter SDK 添加到PATH环境变量中。安装目录的bin的完整路径应该添加到PATH中。要在 Windows 10 上修改路径

  1. 打开开始搜索,键入“env”并选择“编辑系统环境变量”。

  2. 单击“环境变量…”按钮,在“系统变量”部分的第一列中找到带有“路径”的行。

  3. 在“编辑环境变量”对话框中,点击“新建”,输入已安装的 Flutter SDK 的 bin 目录的路径。

  4. 单击“确定”关闭所有对话框。

现在,您可以打开一个新的 PowerShell 窗口并键入命令flutter --version来验证安装;见图 1-2 。

img/479501_1_En_1_Fig2_HTML.jpg

图 1-2

在 Windows 上成功安装 Flutter SDK

Windows 上仅支持 Android 平台。按照配方 1-7 继续设置。

1.2 在 Linux 上安装 Flutter SDK

问题

你有一台 Linux 机器,你想在这台机器上开始 Flutter 开发。

解决办法

在 Linux 机器上安装 Flutter SDK,设置 Android 平台。

讨论

Flutter SDK 支持 Linux 平台。然而,鉴于有许多不同的 Linux 发行版可用,安装 Flutter SDK 的实际步骤可能会略有不同。这个方法是基于在 LTS 的 Ubuntu 18.04 上安装 Flutter SDK。

Flutter SDK 需要几个命令行工具在本地环境中可用,包括bashmkdirrmgitcurlunzipwhich。对于大多数 Linux 发行版,默认情况下应该已经包含了命令bashmkdirrmunzipwhich。验证这一点最简单的方法是打开一个终端窗口,键入这些命令来查看输出。如果没有安装命令,您会看到“找不到命令”错误。gitcurl不太可能默认包含。大多数 Linux 发行版都提供了内置的包管理器来安装这些工具。对于 Ubuntu,可以使用apt-get;请参见以下命令。

$ sudo apt-get update
$ sudo apt-get install -y curl git

安装成功完成后,您可以键入命令curlgit进行验证。

现在你可以从官方网站( https://flutter.dev/docs/get-started/install/linux )下载 Flutter SDK 压缩包。将下载的 zip 文件解压缩到本地计算机上的所需位置。打开终端窗口,导航到提取的 Flutter SDK 的目录,并运行以下命令来验证安装。

$ bin/flutter --version

建议将 Flutter SDK 的bin目录添加到PATH环境变量中,这样flutter命令可以在任何终端会话中运行。对于 Ubuntu,可以编辑文件~/.profile

$ nano ~/.profile

将下面一行添加到该文件并保存。

export PATH="<flutter_dir>/bin:$PATH"

在当前终端窗口中,您需要运行source ~/.profile以使更改生效。或者您可以简单地创建一个新的终端窗口。在任何终端窗口中键入flutter --version进行验证。你会看到与图 1-2 相同的输出。

Linux 上只支持 Android 平台。按照配方 1-7 继续设置。

1.3 在 macOS 上安装 Flutter SDK

问题

你有一台 macOS 机器,你想在这台机器上开始 Flutter 开发。

解决办法

安装 Flutter SDK,在 macOS 机器上设置 Android 和 iOS 平台。

讨论

对于 macOS,Flutter SDK 需要几个命令行工具在本地环境中可用。这些工具是bashmkdirrmgitcurlunzipwhich。macOS 系统中应该已经有这些工具了。您可以简单地在终端中键入这些命令进行验证。安装缺失工具最简单的方法就是使用自制软件( https://brew.sh/ )。在设置 iOS 开发环境时,自制软件也很重要。使用brew install安装工具,例如brew install git安装 Git。

安装所需工具后,我们可以从官方网站( https://flutter.dev/docs/get-started/install/macos )下载 Flutter SDK 压缩包。将下载的 zip 文件解压缩到本地计算机上的所需位置。flutter命令位于提取位置的bin目录下。

要在任何终端会话中运行flutter命令,应该更新PATH环境变量以包含 Flutter SDK 的bin目录。这通常通过更新壳的轮廓来完成。对于默认的 bash,这个文件是~/.bash_profile。对于 zsh 来说,这个文件就是~/.zshrc。修改该文件以包含以下行。

export PATH=<flutter_install_dir>/bin:$PATH

要让当前终端窗口使用更新后的PATH,需要运行source ~/.bash_profile。您也可以启动一个新的终端窗口,该窗口将自动使用PATH的更新值。

在任一终端窗口中运行flutter --version来验证安装。您将看到与图 1-2 相同的输出。

macOS 上同时支持 Android 和 iOS 平台。按照配方 1-4 和 1-7 继续设置。

1.4 设置 iOS 平台

问题

你想为 iOS 平台开发 Flutter 应用。

解决办法

在 Mac 上为 Flutter SDK 设置 iOS 平台。

讨论

要为 iOS 开发 Flutter 应用,你需要有一台至少安装了 Xcode 9.0 的 Mac。要设置 iOS 平台,您需要完成以下步骤:

img/479501_1_En_1_Fig3_HTML.jpg

图 1-3

Flutter 医生的输出

  1. 从 App Store 安装 Xcode ( https://developer.apple.com/xcode/ )。

  2. 验证 Xcode 命令行工具的路径。运行以下命令显示命令行工具的当前路径。通常你应该会看到类似/Applications/Xcode.app/Contents/Developer的输出。

    $ xcode-select -p
    
    

    如果输出中显示的路径不是您想要的路径,例如,您安装了不同版本的 Xcode 命令行工具,请使用xcode-select -s切换到不同的路径。如果没有安装命令行工具,使用xcode-select --install打开安装对话框。

  3. 您需要打开 Xcode 一次以接受其许可协议。或者您可以选择运行命令sudo xcodebuild -license来查看并接受它。

  4. Flutter SDK 需要 iOS 平台的其他工具,包括 libimobiledevice、usbmuxd、ideviceinstaller、ios-deploy、CocoaPods ( https://cocoapods.org/ )。所有这些工具都可以用自制软件安装。如果您运行命令flutter doctor,它会显示使用 Homebrew 安装这些工具的命令。只需运行这些命令并使用flutter doctor再次检查。当你看到“iOS toolchain”的绿色勾号时,iOS 平台设置成功,可供 Flutter SDK 使用;样本输出见图 1-3 。

1.5 设置 iOS 模拟器

问题

你需要一个快速的方法来测试 iOS 平台上的 Flutter 应用。

解决办法

设置 iOS 模拟器。

讨论

Xcode 为不同的 iOS 版本提供模拟器。您可以使用 Xcode ➤偏好设置中的标签组件下载其他模拟器。要打开模拟器,请运行以下命令。

$ open -a Simulator

当模拟器打开时,您可以使用菜单硬件设备切换不同设备和 iOS 版本的组合。

模拟器启动后,运行flutter devices应该会显示模拟器。

1.6 设置 iOS 设备

问题

你已经在 iOS 模拟器上完成了你的 Flutter 应用的测试,你想在真实的 iOS 设备上测试它们。

解决办法

将 Flutter 应用部署到 iOS 设备。

讨论

在将 Flutter 应用部署到 iOS 设备之前,您需要运行flutter doctor来验证 iOS 工具链是否设置正确。要在设备上开发和测试 Flutter 应用,你需要有一个 Apple ID。如果您想将应用分发到 App Store,您还需要注册 Apple 开发者计划。

第一次连接物理设备进行 iOS 开发时,您需要信任 Mac 来连接您的设备。Flutter 应用需要在部署到设备之前进行签名。在 Xcode 中打开 Flutter app 的ios/Runner.xcworkspace文件。在常规选项卡中,在签约部分选择正确的团队。如果您选择连接的设备作为运行目标,Xcode 将完成代码签名的必要配置。捆绑标识符必须是唯一的。

img/479501_1_En_1_Fig4_HTML.jpg

图 1-4

Xcode 中的应用签名

Flutter 应用可以使用 Xcode 或命令flutter run部署到设备上。首次部署应用时,您可能需要信任 iOS 设备上设置应用的通用设备管理中的开发证书。

1.7 设置 Android 平台

问题

你想为 Android 平台开发 Flutter 应用。

解决办法

安装 Android Studio,在本地机器上设置 Android 平台。

讨论

要开发 Android 平台的 Flutter apps,首先需要设置 Android 平台。Flutter SDK 由于其 Android 平台依赖性,需要完整安装 Android Studio,所以我们必须安装 Android Studio。

进入 ANDROID STUDIO 下载页面( https://developer.android.com/studio/ ),点击“下载 Android Studio”按钮。您需要接受条款和条件才能下载。下载页面会检查您的平台,并提供最适合下载的版本。如果提供的选项不是您想要的,请单击“下载选项”并从所有下载选项列表中选择;见图 1-5 。

img/479501_1_En_1_Fig5_HTML.jpg

图 1-5

Android Studio 的下载选项

Android Studio 提供了一个基于 GUI 的安装程序,所以在本地机器上安装和运行它非常容易。安装 Android Studio 还会安装 Android SDK、Android SDK 平台工具和 Android SDK 构建工具。即使你选择不使用 Android Studio 作为 IDE,Android 开发仍然需要 Android SDK 和相关工具。

在 Android Studio 中首选项的 Android SDK 页面,还可以安装额外的 Android SDK 平台和工具;参见图 1-6 。Android Studio 还会提示已安装的 Android SDK 平台和工具的可用更新。

img/479501_1_En_1_Fig6_HTML.jpg

图 1-6

在 Android Studio 中管理 Android SDK

1.8 设置 Android 模拟器

问题

你需要一种快速的方法来测试 Android 平台的 Flutter 应用。

解决办法

设置 Android 模拟器。

讨论

开发 Flutter 应用时,可以在 Android 模拟器上运行,看看应用运行的结果。要设置 Android 模拟器,您可以完成以下步骤。

在 Android Studio 中打开一个 Android 项目,选择工具 ➤ Android ➤ AVD 管理器打开 AVD 管理器,点击“创建虚拟设备…”;见图 1-7 。

img/479501_1_En_1_Fig7_HTML.jpg

图 1-7

Android 虚拟设备管理器

选择一个设备定义,比如 Nexus 6P,点击下一步;参见图 1-8 。

img/479501_1_En_1_Fig8_HTML.jpg

图 1-8

选择硬件

为您想要模拟的 Android 版本选择一个系统映像,然后单击“下一步”;参见图 1-9 。

img/479501_1_En_1_Fig9_HTML.jpg

图 1-9

选择一个系统映像

为“仿真性能”选择“硬件- GLE 2.0”以启用硬件加速,然后单击“完成”;参见图 1-10 。

img/479501_1_En_1_Fig10_HTML.jpg

图 1-10

选择模拟性能

一个新的 AVD 被创建并在 AVD 管理器中列出。Android Studio 官方网站提供了关于如何管理 AVD 的全面指南( https://developer.android.com/studio/run/managing-avds ),如果你想了解关于 AVD 配置的更多细节。

在 AVD 管理器中,单击绿色三角形按钮启动模拟器。模拟器启动并显示默认的 Android 主屏幕可能需要一些时间。

1.9 设置 Android 设备

问题

您已经在模拟器上完成了对 Flutter 应用的测试,并且您想要在真实的 Android 设备上测试它们。

解决办法

设置您的 Android 设备运行 Flutter 应用。

讨论

要设置您的 Android 设备,您可以完成以下步骤:

  1. 您需要在设备上启用开发人员选项和 USB 调试。查看安卓官方网站上的说明( https://developer.android.com/studio/debug/dev-options#enable )。你可能还需要在 Windows 机器上安装谷歌 USB 驱动( https://developer.android.com/studio/run/win-usb )。

  2. 使用 USB 电缆将您的设备插入电脑。设备会提示一个对话框询问权限,授权您的电脑访问您的设备。

  3. 运行命令flutter devices来验证 Flutter SDK 可以识别你的设备。

可以使用 Android Studio 或命令flutter run将 Flutter 应用部署到设备上。

1.10 使用命令行创建 Flutter 应用

问题

您已经设置了本地环境来开发 Flutter 应用。即使使用 Android Studio 或 VS 代码是一个很好的开发选择,您可能仍然想知道如何从命令行完成这项工作。

解决办法

使用 Flutter SDK 中的命令创建和构建 Flutter 应用。

讨论

使用像 Android Studio 和 VS Code 这样的工具可以让 Flutter 开发变得容易得多。然而,知道如何使用命令行工具构建 Flutter 应用仍然很有价值。这对持续集成很重要。它还允许您使用任何其他编辑器来开发 Flutter 应用。

命令flutter create可以用来创建一个新的 Flutter app。实际上,Android Studio 和 VS Code 都使用这个命令来创建新的 Flutter 应用。下面的命令在目录flutter_app中创建新的 Flutter 应用。

$ flutter create flutter_app

该命令在指定目录中创建各种文件,作为新应用的框架代码。导航到目录flutter_app并使用flutter run运行该应用。

1.11 使用 Android Studio 创建 Flutter 应用

问题

在开发 Flutter 应用时,您希望拥有一个强大的 IDE 来满足大多数需求。

解决办法

使用 Android Studio 创建 Flutter 应用。

讨论

既然我们已经安装了 Android Studio 来为 Flutter SDK 搭建 Android 平台,那么使用 Android Studio 作为开发 Flutter 应用的 IDE 是一个很自然的选择。Android Studio 本身就是一个基于 IntelliJ 平台的强大 IDE。如果你使用过 JetBrains 的其他产品,如 IntelliJ IDEA 或 WebStorm,你可能会发现使用 Android Studio 非常容易。

要使用 Android Studio 进行 Flutter 开发,需要 Flutter 和 Dart 插件。要安装这两个插件,在 Android Studio 的首选项对话框中打开插件页面,点击“浏览存储库…”按钮。在打开的对话框中,输入“Flutter”搜索要安装的 Flutter 插件;见图 1-11 。单击绿色的安装按钮进行安装。这也将提示您安装 Dart 插件。单击“是”也安装它。重启 Android Studio。

img/479501_1_En_1_Fig11_HTML.jpg

图 1-11

在 Android Studio 中安装 Flutter 插件

重启 Android Studio 后,你应该会看到一个新的选项来启动一个新的 Flutter 项目。Flutter 项目向导有不同的页面来配置新项目。

第一页允许你选择新的 Flutter 项目的类型。页面中的描述显示了这四种不同项目类型的区别。大多数时候,我们会创建一个 Flutter 应用。

img/479501_1_En_1_Fig12_HTML.jpg

图 1-12

选择 Flutter 项目的类型

第二页允许您定制新的 Flutter 项目的基本配置,包括项目名称、位置和描述。

img/479501_1_En_1_Fig13_HTML.jpg

图 1-13

基本项目配置

最后一页允许您定制一些高级项目配置。公司域用于创建项目的唯一标识符。

img/479501_1_En_1_Fig14_HTML.jpg

图 1-14

高级项目配置

完成向导后,一个新项目被创建并在 Android Studio 中打开。

1.12 使用 VS 代码创建 Flutter 应用

问题

你想用一个轻量级的编辑器来开发 Flutter 应用。

解决办法

使用 VS 代码创建 Flutter 应用。

讨论

VS Code ( https://code.visualstudio.com/ )是前端开发者社区里比较流行的轻量级编辑器。通过对 Flutter 和 Dart 的扩展,我们也可以使用 VS 代码进行 Flutter 开发。在 VS 代码中打开 Extensions 选项卡,搜索“flutter”安装 Flutter 扩展;参见图 1-15 。Flutter 延伸取决于 Dart 延伸,也将安装。安装完这两个扩展后,我们可以打开命令面板,搜索“flutter”来获得可用的 Flutter 命令。

img/479501_1_En_1_Fig15_HTML.jpg

图 1-15

在 VS 代码中安装 Flutter 扩展

要在 VS 代码中创建新的 Flutter,打开命令面板并运行 Flutter: New Project 命令。在打开的对话框中输入新项目的名称。选择项目的目录。VS 代码为新创建的项目打开一个新窗口。

1.13 运行 Flutter 应用

问题

你想在模拟器或设备上运行 Flutter 应用。

解决办法

使用flutter run命令或 ide 运行 Flutter 应用。

讨论

根据您开发 Flutter 应用的首选方法,有不同的方法来运行 Flutter 应用。在运行 Flutter 应用之前,您必须至少有一个正在运行的仿真器或连接的设备:

img/479501_1_En_1_Fig16_HTML.jpg

图 1-16

在 Android Studio 中选择设备

  • 命令flutter run启动当前的 Flutter 应用。

  • 在 Android Studio 中,从图 1-16 所示的下拉菜单中选择仿真器或设备,然后点击运行按钮启动应用。

  • 在 VS 代码中,选择调试启动不调试启动 app。

1.14 了解 Flutter 应用的代码结构

问题

你想知道 Flutter 应用的典型结构。

解决办法

浏览 Flutter SDK 生成的样例 app,了解文件。

讨论

在深入开发 Flutter 应用的细节之前,您应该了解 Flutter 应用的代码结构,这样您就知道在哪里添加新文件。Flutter 应用为应用中的各种文件提供了预定义的目录结构。当一个新的应用创建时,你可以看看生成的文件,并对它们有一个基本的了解。表 1-1 显示了创建的 app 的目录和文件。

表 1-1

一个 Flutter app 的目录和文件

|

名字

|

描述

|
| --- | --- |
| lib | app 源代码主目录。文件main.dart通常是 app 的入口点。 |
| test | 包含测试文件的目录。 |
| android | Android 平台的文件。 |
| ios | iOS 平台的文件。 |
| pubspec.yaml | Dart 发布工具的包描述。 |
| pubspec.lock | Dart 发布工具的锁定文件。 |
| .metadata | Flutter SDK 使用的 Flutter 项目描述。 |

1.15 修复 Flutter SDK 的配置问题

问题

您希望确保本地开发环境的配置对于 Flutter 开发是正确的。

解决办法

使用命令flutter doctor

讨论

安装 Flutter SDK 后,需要配置其他支持工具。命令flutter doctor是提供必要帮助的主要工具。该命令检查本地环境并报告 Flutter SDK 安装的状态。对于发现的每个问题,它还会给出如何修复它们的说明。您需要做的就是应用建议的修复并再次运行flutter doctor来验证结果。没有必要修复flutter doctor报告的所有问题。如果一些问题不相关,您可以放心地忽略它们。例如,如果您不打算使用 VS 代码作为主要的 IDE,那么是否安装 VS 代码并不重要。

1.16 摘要

本章的食谱提供了如何让你的本地机器为 Flutter 应用开发做好准备的指导。flutter doctor是一个有用的设置工具。通过遵循此命令提供的说明,您应该能够修复大多数配置问题。在下一章,我们将看到使用 Dart SDK、Flutter SDK 和 IDEs 提供的工具的方法。

二、了解工具

没有各种工具的帮助,构建 Flutter 应用是不可能成功的。在开发过程中,我们可能需要使用 Dart SDK、Flutter SDK 和 IDEs 中的工具。善用这些工具可以提高你的生产力。本章涵盖了 Dart SDK、Flutter SDK、Android Studio 和 VS Code 工具的使用。

2.1 使用 Dart 天文台

问题

你想知道一个正在运行的 Flutter app 的内部情况。

解决办法

使用 Dart SDK 提供的 Dart 天文台。

讨论

Dart Observatory 是 Dart SDK 提供的工具,用于分析和调试 Dart 应用。由于 Flutter 应用也是 Dart 应用,所以 Observatory 也可以用于 Flutter 应用。Observatory 是调试、跟踪和分析 Flutter 应用的重要工具。天文台允许你

  • 查看应用的 CPU 配置文件。

  • 查看应用的内存分配配置文件。

  • 交互式调试应用。

  • 查看应用堆的快照。

  • 查看应用生成的日志。

当使用flutter run启动 Flutter app 时,Observatory 也会启动并等待连接。您可以指定 Observatory 监听的端口,或者让它默认监听一个随机端口。您可以在命令输出中看到访问天文台的 URL。在浏览器中导航到网址,就可以看到天文台的 UI。

注意

为了获得最佳效果,建议在使用天文台时使用谷歌浏览器。其他浏览器可能无法正常工作。

观察站用户界面的顶部显示 Dart 虚拟机信息;见图 2-1 。点击刷新按钮更新信息。

img/479501_1_En_2_Fig1_HTML.jpg

图 2-1

灾难援助反应队天文台的虚拟机信息

底部显示了分离株列表;参见图 2-2 。每个 Flutter 应用的入口点文件都有一个初始隔离。对于每个隔离,饼图显示虚拟机活动的明细。在饼图的右侧,有一个链接列表指向其他天文台功能的不同屏幕。

img/479501_1_En_2_Fig2_HTML.jpg

图 2-2

在 Dart 天文台隔离信息

这些观察屏幕的细节超出了本食谱的范围;参考官方文档( https://dart-lang.github.io/observatory/ )进行说明。

2.2 使用热重装和热重启

问题

当开发 Flutter 应用时,在您进行了一些代码更改后,您希望快速看到结果。

解决办法

使用 Flutter SDK 提供的热重装和热重启。

讨论

在构建移动应用时,能够有效地查看代码更改的效果至关重要,尤其是在构建 UI 时。这使我们能够快速看到实际的 UI 并迭代地更新代码。在更新应用时,保持应用的当前状态也非常重要。否则,手动将应用重置到之前的状态并继续测试会非常痛苦。假设您正在开发的组件只有注册用户可以访问,为了实际测试该组件,如果在应用更新之间没有保留状态,您可能需要在每次进行代码更改时登录。

Flutter SDK 提供的热重载是一个杀手级特性,可以显著提高开发人员的工作效率。使用热重新加载,应用更新之间的状态是反常的,因此您可以立即看到 UI 更新,并从您进行更改的最后一个执行点继续开发和测试。

根据 Flutter 应用的启动方式,有不同的方式来触发热重装。只有调试模式下的 Flutter 应用可以热重装:

  • 当 app 被命令flutter run启动时,在终端窗口输入r触发热重装。

  • 当 Android Studio 启动应用时,保存文件会自动触发热重装。也可以点击Flutter 热重装按钮手动触发。

  • 当应用由 VS 代码启动时,保存文件会自动触发热重装。也可以用键盘快捷键 Control-F5 运行命令 Flutter: Hot Reload 来手动触发。

如果应用热重新加载成功,您可以在控制台中看到热重新加载的详细信息。图 2-3 显示了在 Android Studio 中保存文件触发热重装时的控制台输出。

img/479501_1_En_2_Fig3_HTML.jpg

图 2-3

热重装输出

热重新加载非常有用,您可能希望它对您所做的所有代码更改都可用。不幸的是,仍有一些情况下热重装可能不起作用:

  • 您的代码更改会引入编译错误。您需要修复这些编译错误,热重装才能继续。

  • 热重新加载会保留应用状态,并尝试使用保留的状态来重新构建小部件树,以反映新的更改。如果您的代码更改修改了状态,那么对小部件的更改可能无法使用旧的保留状态。假设我们有一个小部件,用于显示用户的个人资料信息。在以前的版本中,用户的状态只包含用户名和名称。在新版本中,状态被更新以包括新属性email,并且小部件被更新以显示新属性。热重新加载后,小部件仍然使用旧状态,看不到新属性。在这种情况下,需要热重启来获取状态变化。

  • 对全局变量和静态字段的初始值设定项的更改只能在热重启后反映出来。

  • 对应用的main()方法的更改可能只有在热重启后才会反映出来。

  • 当枚举类型更改为常规类或常规类更改为枚举类型时,不支持热重新加载。

  • 更改类型的泛型声明时,不支持热重新加载。

如果热重新加载不起作用,您仍然可以使用热重启,这将从头重新启动应用。您可以确保热重启将反映您所做的所有更改。根据 Flutter 应用的启动方式,触发热重启有不同的方式:

  • 当 app 被flutter run启动后,在终端窗口输入R触发热重启。

  • Android Studio 启动 app 时,点击Flutter 热重启按钮触发热重启。

  • 当 VS 代码启动 app 时,点击重启按钮,或者从命令面板运行命令Flutter:****Hot Restart触发热重启。

2.3 升级 Flutter SDK

问题

您希望让 Flutter SDK 保持最新,以获得最新的特性、错误修复和性能改进。

解决办法

跟踪不同的 Flutter SDK 通道并升级 SDK。

讨论

有时,我们可能需要升级 Flutter SDK 以获得新功能、错误修复和性能改进。Flutter SDK 有不同的渠道获取更新。每个通道实际上是 Flutter SDK 的存储库中的一个 Git 分支。执行命令flutter channel显示所有可用通道;见图 2-4 。标有星形符号的频道是当前频道。在图 2-4 中,电流通道为stable

img/479501_1_En_2_Fig4_HTML.jpg

图 2-4

命令的输出flutter channel

表 2-1 显示了 Flutter SDK 的四个通道。

表 2-1

Flutter SDK 通道

|

引导

|

描述

|
| --- | --- |
| stable | 稳定构建的渠道。这是产品开发的推荐渠道。 |
| beta | 上个月最佳构建频道。 |
| dev | 最新全面测试版本的通道。在此通道中运行的测试比master多。 |
| master | 积极开发最新变化的渠道。如果您想尝试最新的功能,这是要跟踪的频道。这个通道中的代码通常工作正常,但有时可能会意外中断。使用本频道风险自担。 |

我们可以使用命令flutter channel [<channel-name>]切换到不同的频道。例如,flutter channel master切换到master频道。要获得当前通道的更新,运行命令flutter upgrade。以下命令显示了切换通道的典型方式。

$ flutter channel master
$ flutter upgrade

2.4 在 Android Studio 中调试 Flutter 应用

问题

您正在使用 Android Studio 开发 Flutter 应用,并希望找出代码无法按您预期的方式运行的原因。

解决办法

使用 Android Studio 内置的 Flutter 调试支持。

讨论

调试是开发人员日常工作的重要组成部分。调试时,我们可以在运行时看到实际的代码执行路径,并检查变量值。如果您有使用其他编程语言的经验,您应该已经具备了基本的调试技能。

在 Android Studio 中,你可以点击编辑器中某一行的左边来添加断点。点击调试图标或者使用菜单运行调试在调试模式下启动 app 见图 2-5 。

img/479501_1_En_2_Fig5_HTML.jpg

图 2-5

单击调试图标开始调试

一旦代码执行遇到断点,执行就会暂停。您可以检查变量值,并使用调试工具栏中的按钮交互式地继续执行。在调试模式下,有不同的面板可以查看相关信息。

图 2-6 中的帧视图显示了当前执行的帧。

img/479501_1_En_2_Fig6_HTML.jpg

图 2-6

Android Studio 中的框架视图

图 2-7 中的变量视图显示变量和对象的值。在这个视图中,我们还可以添加表达式来监视值。

img/479501_1_En_2_Fig7_HTML.jpg

图 2-7

Android Studio 中的变量视图

图 2-8 中的控制台视图显示了显示在控制台上的信息。

img/479501_1_En_2_Fig8_HTML.jpg

图 2-8

Android Studio 中的控制台视图

2.5 在 Android Studio 中查看 Flutter 应用的概要

问题

你希望看到 Flutter 应用的轮廓,以便清楚地了解小部件是如何组织的。

解决办法

在 Android Studio 中使用 Flutter Outline 视图。

讨论

在 Android Studio 中,可以从菜单视图工具窗口Flutter 轮廓打开 Flutter 轮廓视图。此视图显示当前打开文件的树状层次结构;见图 2-9 。Flutter 轮廓视图与文件编辑器相链接。在 Flutter Outline 视图中选择一个元素会使编辑器滚动并突出显示这个元素的源代码。此链接是双向的;编辑器中的选择也会导致在 Flutter 轮廓视图中选择相应的元素。

img/479501_1_En_2_Fig9_HTML.jpg

图 2-9

Android Studio 中的 Flutter 轮廓视图

Flutter Outline 视图中的工具栏有不同的操作来管理小部件。例如,中心小部件按钮用一个Center小部件包装当前小部件。

2.6 调试 VS 代码中的 Flutter 应用

问题

您正在使用 VS 代码开发 Flutter 应用,并且想要找出为什么代码没有按照您预期的方式工作。

解决办法

使用 VS 代码中内置的 Flutter 调试支持。

讨论

在 VS 代码中,你可以点击编辑器中某一行的左边来添加断点。使用菜单调试开始调试在调试模式下启动应用。

图 2-10 显示了调试模式下的 VS 代码视图。此视图中有不同的面板:

  • 变量–显示变量的值。

  • 观察–管理观察表达式并查看其值。

  • 调用堆栈–查看当前调用堆栈。

  • 断点–添加断点的视图。

  • 调试控制台–查看输出到控制台的消息。

顶部的操作栏包含的操作包括继续、单步执行、单步执行、单步执行、重新启动和停止。

img/479501_1_En_2_Fig10_HTML.jpg

图 2-10

在 VS 代码中调试

2.7 创建 Flutter 项目

问题

您想要创建不同类型的 Flutter 项目。

解决办法

使用带有不同参数的命令flutter create

讨论

flutter create是 Flutter SDK 提供的创建 Flutter 项目的命令。在菜谱 1-10 中,我们使用这个命令来创建一个简单的 Flutter 应用。在配方 1-11 中,我们还看到了 Android 提供的向导来创建新的 Flutter 项目,它允许对已创建的项目进行定制。在引擎盖下,Android Studio 也使用了flutter create命令。该命令支持不同场景的不同参数。以下代码是flutter create的基本用法。输出目录将包含新项目的文件。

$ flutter create <output directory>

项目类型

使用参数-t--template指定要创建的项目类型。有四种类型的项目;见表 2-2 。

表 2-2

Flutter 项目类型

|

项目类型

|

描述

|
| --- | --- |
| app | Flutter 应用。这是默认类型。 |
| package | 一个包含模块化 Dart 代码的可共享的 Flutter 项目。 |
| plugin | 一个可共享的 Flutter 项目,包含 Android 和 iOS 平台特定的代码。 |

下面的命令显示了如何创建一个 Flutter 包和插件。

$ flutter create -t package my_package
$ flutter create -t plugin my_plugin

在创建插件的时候,我们也可以使用参数-i或者--ios-language来指定 iOS 代码的编程语言。Objective-C 的可能值为objc,Swift 的可能值为swift。默认值为objc。对于 Android 代码,我们可以用自变量-a或者--android-language来指定 Android 代码的编程语言。可能的值是 Java 的java和 Kotlin 的kotlin。默认值为java。以下命令显示了如何使用 Swift for iOS 和 Kotlin for Android 创建一个 Flutter 插件。

$ flutter create -t plugin -i swift -a kotlin my_plugin

代码示例

当创建一个 Flutter 应用时,我们可以使用参数-s--sample来指定样本代码作为新应用的文件lib/main.dart。给定一个样本 id,该命令尝试加载 URL 为 https://docs.flutter.dev/snippets/<sample_id>.dart 的 dart 文件。

项目配置

创建项目时有一些常规配置可用;参见表 2-3 。

表 2-3

Flutter 项目配置

|

争吵

|

描述

|

缺省值

|
| --- | --- | --- |
| --project-name | 这个新的 Flutter 项目的名称。该名称必须是有效的 dart 包名称。 | 从输出目录名派生 |
| --org | 这个新的 Flutter 项目的组织名称。该值应该采用反向域表示法,例如,com.example。该值用作 Android 代码的 Java 包名和 iOS 包标识符中的前缀。 | com.example |
| --description | 这个新 Flutter 项目的描述。 | 新的 Flutter 项目 |

以下命令使用表 2-3 中的项目配置。

$ flutter create --org=com.mycompany --description="E-commerce app" my_ecommerce_app

启用或禁用功能

有额外的标志来启用或禁用某些功能;见表 2-4 。一次只能指定每对中的一个参数。前缀为--no的参数名表示禁用一个特性,另一个表示启用一个特性。例如,--overwrite表示启用覆盖,--no-overwrite表示禁用覆盖。默认值“开”或“关”分别表示默认情况下该功能是启用还是禁用。例如,--overwrite--no-overwrite对的默认值 Off 表示默认使用--no-overwrite

表 2-4

flutter create的特点

|

争论

|

描述

|

缺省值

|
| --- | --- | --- |
| --overwrite / --no-overwrite | 是否覆盖现有文件。 | 离开 |
| --pub / --no-pub | 项目创建后是否运行flutter packages get。 | 在 |
| --offline / --no-offline | 是否在离线模式下运行flutter packages get。仅在--pub开启时适用。 | 离开 |
| --with-driver-test / --no-with-driver-test | 是否添加flutter_driver依赖,生成样本 Flutter 驱动测试。 | 离开 |

2.8 运行 Flutter 应用

问题

你想运行 Flutter 应用。

解决办法

使用带有不同参数的命令flutter run

讨论

flutter run是 Flutter SDK 提供的启动 Flutter apps 的命令。flutter run针对不同的使用场景有很多说法。

不同的构建风格

默认情况下,flutter run会构建应用的调试版本。调试版本适用于支持热重装的开发和测试。对于不同的场景,您可以使用其他的构建风格;见表 2-5 。

表 2-5

构建 Flutter 运行的风味

|

争吵

|

描述

|
| --- | --- |
| --debug | 调试版本。这是默认的构建风格。 |
| --profile | 专门用于性能分析的版本。此选项目前不支持模拟器目标。 |
| --release | 准备发布到 app store 的发布版本。 |
| --flavor | 由特定于平台的构建设置定义的自定义应用风格。这需要在 Android Gradle 脚本和自定义 Xcode 方案中使用产品风格。 |

其他选项

参数-t--target指定应用的主入口点文件。它必须是一个包含main()方法的 Dart 文件。默认值为lib/main.dart。下面的命令使用lib/app.dart作为入口点文件。

$ flutter run -t lib/app.dart

如果您的应用有不同的路线,请使用参数--route来指定运行应用时要加载的路线。

如果你想记录正在运行的 Flutter app 的进程 id,使用参数--pid-file指定文件来写进程 id。有了进程 id,您可以发送信号SIGUSR1来触发热重装,发送信号SIGUSR2来触发热重启。在下面的命令中,进程 id 被写入文件~/app.pid

$ flutter run --pid-file ~/app.pid

现在我们可以使用kill向正在运行的 Flutter app 发送信号。

$ kill -SIGUSR1 $(<~/app.pid)
$ kill -SIGUSR2 $(<~/app.pid)

表 2-6 显示了flutter run支持的其他论点。

表 2-6

flutter run的额外参数

|

争论

|

描述

|

缺省值

|
| --- | --- | --- |
| --hot / --not-hot | 是否应启用热重装。 | 在 |
| --build / --no-build | 在运行应用之前,是否应该构建它。 | 在 |
| --pub / --no-pub | 是否先运行flutter packages get再运行。 | 在 |
| --target-platform | 为 Android 设备构建应用时,指定目标平台。可能的值有defaultandroid-armandroid-arm64。 | default |
| --observatory-port | 指定观察站调试器连接的端口。 | 0(随机自由港) |
| --start-paused | 让应用以暂停模式启动,并等待调试器连接。 |   |
| --trace-startup | 开始追踪。 |   |
| --enable-software-rendering | 使用 Skia 启用渲染。 |   |
| --skia-deterministic-rendering | 与--enable-software-rendering一起使用时,提供 100%确定性 Skia 渲染。 |   |
| --trace-skia | 启用 Skia 代码跟踪。 |   |

图 2-11 显示运行命令flutter run的输出。从输出中,我们可以看到正在运行的 app 的天文台端口,这对于其他 Flutter SDK 命令与正在运行的 app 协同工作非常重要。我们可以通过按不同的键与控制台进行交互。例如,按“r”触发热重装。按下“h”后,flutter run会显示一条关于它可以接受的所有命令的帮助消息。

img/479501_1_En_2_Fig11_HTML.jpg

图 2-11

Flutter 运行命令的输出

2.9 构建 Flutter 应用二进制文件

问题

你想为 Android 和 iOS 平台构建应用二进制文件。

解决办法

使用命令flutter build

讨论

为了将 Flutter 应用部署到设备上并发布到应用商店,我们需要为 Android 和 iOS 平台构建二进制文件。命令flutter build支持构建这些二进制文件。

为 Android 构建 APK 文件

命令flutter build apk为你的应用构建 APK 文件。表 2-7 显示了该命令支持的参数。

表 2-7

Flutter 生成参数 apk

|

争吵

|

描述

|
| --- | --- |
| --debug | 构建调试版本。 |
| --profile | 构建一个专门用于性能分析的版本。 |
| --release | 构建发布版本,准备发布到 app store。 |
| --flavor | 构建由特定于平台的构建设置定义的自定义应用风格。这需要在 Android Gradle 脚本和自定义 Xcode 方案中使用产品风格。 |
| --pub / --no-pub | 构建 app 前是否运行flutter packages get。 |
| --build-number = | 一个整数,用于指定递增的内部版本号。对于每个版本,该值必须是唯一的。该值被用作“versionCode”。 |
| --build-name = | 格式为x.y.z的字符串版本号。该值被用作“versionName”。 |
| --build-shared-library | 编译成 a∫。所以归档吧。 |
| --target-platform | 目标平台。可能的值是android-armandroid-arm64。 |

建立 APK 文件时,--release是默认模式。下面的命令构建了一个发布版本,版本号为5,版本名为0.1.0

$ flutter build apk --build-number=5 --build-name=0.1.0

为 iOS 构建

命令flutter build ios构建 iOS 应用捆绑包。该命令的参数--debug--profile--release--flavor--pub--no-pub--build-number--build-versionflutter build apk相同。--build-number的值作为CFBundleVersion,而--build-name的值作为CFBundleShortVersionString

它也有其他的论点;参见表 2-8 。

表 2-8

颤动构建 ios 的额外参数

|

争吵

|

描述

|
| --- | --- |
| --simulator | 为 iOS 模拟器创建一个版本。 |
| --no-simulator | 为 iOS 设备构建一个版本。 |
| --codesign / --no-codesign | 是否对应用包进行签名。默认值为--codesign。 |

默认情况下,flutter build ios为设备构建 app,即使用--no-simulator。以下命令为模拟器构建了一个调试版本,但没有对应用包进行签名。

$ flutter build ios --debug --no-codesign --simulator

2.10 安装 Flutter 应用

问题

你想把 Flutter 应用安装到模拟器或者设备上。

解决办法

使用命令flutter install

讨论

命令flutter install将当前的 Flutter 应用安装到仿真器或设备上。要安装该应用,您需要至少启动一个模拟器或连接一个设备。在安装应用之前,目标仿真器或设备应该有一个可用的二进制文件。首先使用flutter build构建二进制文件。

以下命令安装构建的二进制文件。

$ flutter install

2.11 管理包

问题

你想要管理 Flutter 应用的依赖关系。

解决办法

使用命令flutter packages

讨论

使用包是管理项目依赖关系的捷径。Flutter 继承了相同的依赖性管理方式。你可能在其他编程平台上看到过类似的概念。为了让依赖关系管理工作,我们需要有一种方法来描述可共享的组件及其依赖关系。我们还需要一个工具来获取依赖关系。表 2-9 显示了不同平台的包管理工具。Flutter SDK 使用命令flutter packages来管理依赖关系,它使用了底层的 Dart pub工具。

表 2-9

包管理工具

|

平台

|

描述文件

|

工具

|
| --- | --- | --- |
| Node.js | package.json | 新公共管理故事 |
| 镖摆动 | pubspec.yaml | pub``flutter packages |
| 爪哇 | pom.xml``build.gradle | 专家格拉德尔 |
| 红宝石 | Gemfile | 大错 |

命令flutter packages get下载 Flutter 项目中的依赖包。命令flutter packages upgrade升级一个 Flutter 项目中的包。这两个命令简单地围绕 Dart 的底层pub工具。我们也可以使用flutter packages pub直接调用 Dart pub工具。命令flutter packages不能做太多,因为它提供的功能有限。您可以随时使用flutter packages pub将任务委派给 Dart pub工具。

注意

你应该使用flutter packages getflutter packages upgrade来管理 Flutter 应用的依赖关系。不应使用 Dart pub 工具中的命令pub getpub upgrade。如果您需要 Dart pub工具的更多功能,请使用flutter packages pub

命令flutter packages testpub run test相同,但与flutter test不同。由flutter packages test运行的测试托管在一个纯 Dart 环境中,所以像dart:ui这样的库是不可用的。这使得测试运行得更快。如果您正在构建不依赖于 Flutter SDK 中任何包的库,您应该使用这个命令来运行测试。

2.12 运行 Flutter 试验

问题

您已经为 Flutter 应用编写了测试,并且您想要确保这些测试通过。

解决办法

使用命令flutter test

讨论

测试是可维护软件项目的重要组成部分。你应该对 Flutter 应用进行测试。命令flutter test运行 Flutter 应用的测试。运行该命令时,您可以提供一个以空格分隔的相对文件路径列表,以指定要运行的测试文件。如果没有提供文件,则包含test目录中文件名以_test.dart结尾的所有文件。下面的命令运行测试文件test/mytest.dart

$ flutter test test/mytest.dart

筛选要运行的测试

参数--name指定正则表达式来匹配要运行的测试的名称。一个测试文件可以包含多个测试。如果只需要做简单的子串匹配,就用--plain-name代替。以下命令显示了--name--plain-name的用法。

$ flutter test --name="smoke\d+"
$ flutter test --plain-name=smoke

您可以使用--name--plain-name指定多个匹配条件。要运行的测试需要匹配所有给定的条件。以下命令同时使用了--name--plain-name

$ flutter test --name="smoke.*" --plain-name=test

测试覆盖率

如果你想知道你的测试的覆盖范围,使用参数--coverage。测试结束后,flutter test生成测试覆盖信息并保存到文件coverage/lcov.info中。可以使用参数--coverage-path指定覆盖信息的输出路径。如果你有基本的覆盖率数据,你可以把它放入路径coverage/lcov.base.info并传递参数--merge-coverageflutter test,然后 Flutter SDK 会使用 lcov 合并这两个覆盖率文件。

要查看覆盖率报告,您需要安装 lcov。在 macOS 上,可以使用自制软件安装 lcov。

$ brew install lcov

命令genhtml从 lcov 覆盖信息文件生成 HTML 文件。以下命令生成 HTML 覆盖率报告。打开生成的文件index.html查看报告。

$ genhtml coverage/lcov.info --output-directory coverage_report

调试测试

如果你想调试一个测试文件,你可以使用参数--start-paused。这种模式下只允许一个测试文件。执行会暂停,直到连接了调试器。以下命令调试文件test/simple.dart

$ flutter test --start-paused test/simple.dart

其他选项

还有其他有用的论据;参见表 2-10 。

表 2-10

flutter test的额外参数

|

争论

|

描述

|

缺省值

|
| --- | --- | --- |
| --j--concurrency | 要运行的并发测试的数量。 | 6 |
| --pub / --no-pub | 是否在运行测试之前运行flutter packages get。 | 在 |

2.13 分析代码

问题

您的 Flutter 代码编译成功,并且在测试中看起来不错。但是,您想知道在您的代码中是否有任何潜在的错误或不良的代码实践。

解决办法

使用命令flutter analyze

讨论

即使您的代码成功编译并通过了所有测试,代码仍有可能存在潜在的错误或不好的味道。例如,声明了一个局部变量,但从未使用过。尽可能保持代码的整洁是一个好习惯。Dart 提供了分析器来分析源代码以发现潜在的错误。

命令flutter analyze接受目录列表来扫描 Dart 文件。如果没有提供路径,flutter analyze 只分析当前工作目录。以下命令分析目录~/my_app/lib

$ flutter analyze ~/my_app/lib

分析结果可以用参数--write写入文件。默认情况下,结果会写入控制台。您还可以传递参数--watch让分析器观察文件系统的变化,并连续运行分析。

表 2-11 显示了flutter analyze的额外参数。

表 2-11

flutter analyze的额外参数

|

争论

|

描述

|

缺省值

|
| --- | --- | --- |
| --current-package / --no-current-package | 是否分析当前项目。如果--no-current-package被启用并且没有指定目录,那么将不进行任何分析。 | 在 |
| --pub / --no-pub | 运行分析前是否运行flutter packages get。 | 在 |
| --preamble / --no-preamble | 是否显示正在分析的当前文件。 | 在 |
| --congratulate / --no-congratulate | 是否在没有错误、警告、提示或 lints 的情况下显示输出。 | 在 |
| --watch | 持续监视文件系统的变化,并运行分析作为响应。 |   |

命令flutter analyze将代码分析委托给 Dart dartanalyzer工具。我们可以使用项目根目录中的文件analysis_options.yaml来定制分析行为。

图 2-12 显示了在代码中发现一个问题的flutter analyze的输出。

img/479501_1_En_2_Fig12_HTML.jpg

图 2-12

Flutter 分析命令的输出

2.14 管理仿真器

问题

您希望管理 Flutter SDK 使用的不同模拟器。

解决办法

使用命令flutter emulators

讨论

在为 Flutter SDK 设置 Android 和 iOS 平台时,我们还为 Android 和 iOS 创建了模拟器。对于 Android,我们可以使用 AVD 管理器来管理仿真器。对于 iOS,我们可以使用 Xcode 来管理模拟器。如果我们能以同样的方式管理 Android 模拟器和 iOS 模拟器,那将非常方便。命令flutter emulators是管理仿真器的工具。

运行flutter emulators显示所有可供 Flutter SDK 使用的仿真器;见图 2-13 。

img/479501_1_En_2_Fig13_HTML.jpg

图 2-13

指令 Flutter 模拟器的输出

要启动模拟器,使用flutter emulators --launch <emulator_id>。以下命令启动Nexus_6P_API_28模拟器。您只需要提供部分 ID 来找到要启动的模拟器。部分 ID 只能匹配一个仿真器。

$ flutter emulators --launch Nexus

我们还可以使用flutter emulators --create创建一个新的 Android 模拟器。下面的命令创建一个名为Pixel的新模拟器。此命令只能创建基于像素设备的模拟器。

$ flutter emulators --create --name Pixel

2.15 截图

问题

你想截图你正在运行的应用。

解决办法

使用命令flutter screenshot

讨论

Android 模拟器和 iOS 模拟器都提供了截图的原生功能。对于 iOS 模拟器,这可以使用菜单文件新屏幕截图来完成。对于 Android 模拟器,这可以通过点击浮动控制栏中的屏幕截图图标来完成。但是使用 UI 控件并不方便。默认情况下,模拟器拍摄的屏幕截图会保存到桌面。您必须配置模拟器以保存到所需的位置。

命令flutter screenshot比模拟器中的内置特性更容易使用。可以使用参数-o--output指定保存截图的位置;请参见以下命令。

$ flutter screenshot -o ~/myapp/screenshots/home.png

flutter screenshot可以拍摄不同类型的截图。参数--type接受表 2-12 中的值。

表 2-12

截图的类型

|

类型

|

描述

|
| --- | --- |
| Device | 使用设备的原生屏幕截图功能。该屏幕截图包括当前显示的整个屏幕。这是默认类型。 |
| Rasterizer | 使用光栅化器渲染的 Flutter 应用的屏幕截图。 |
| skia | 渲染成 Skia 图片的 Flutter app 截图。 |

对于rasterizerskia类型,需要参数--observatory-port提供运行 app 的 Dart 天文台端口号。该端口显示在命令flutter run的输出中。

2.16 附加到正在运行的应用

问题

你的 Flutter 应用不是用flutter run启动的,但是你需要想和它互动。

解决办法

使用命令flutter attach

讨论

当使用flutter run启动 Flutter 应用时,我们可以使用控制台进行交互。但是,该应用也可以通过其他方式启动。例如,我们可以关闭设备上的应用,然后再打开它。在这种情况下,我们失去了对正在运行的应用的控制。flutter attach提供了一种连接正在运行的应用的方式。

如果应用已经在运行,并且你知道它的观测站的端口,使用flutter attach --debug-port来连接它。以下命令附加到正在运行的应用。

$ flutter attach --debug-port 10010

如果没有提供观察端口,flutter attach会开始监听和扫描新激活的应用。当检测到一个新的天文台时,这个命令会自动连接到应用。

$ flutter attach

在图 2-14 中,flutter attach最初在等待一个新的 Flutter app 启动。一旦一个 Flutter 应用被启动,flutter attach连接到它并显示与flutter run相同的控制台。

img/479501_1_En_2_Fig14_HTML.jpg

图 2-14

Flutter 附着命令的输出

2.17 跟踪正在运行的 Flutter 应用

问题

你想跟踪一个正在运行的应用的执行。

解决办法

使用命令flutter trace

讨论

要开始跟踪,我们需要知道正在运行的应用的观察站端口,并用参数--debug-port将这个端口提供给flutter trace。默认情况下,跟踪运行10秒,并将结果 JSON 文件写入当前目录,文件名如trace_01.jsontrace_02.json等等。在下面的命令中,观察端口是51240

$ flutter trace --debug-port=51240

使用参数-d--duration指定跟踪运行的持续时间(秒)。以下命令运行跟踪 5 秒钟。

$ flutter trace --debug-port=51240 -d 5

如果您喜欢手动控制跟踪进度,您可以先使用flutter trace --start开始跟踪,然后在稍后使用flutter trace --stop停止跟踪。值得注意的是,调用flutter trace --stop时,跟踪需要等待--duration中指定的时间后才会停止。在下面的命令中,在第二个flutter trace --stop之后,跟踪再等待 10 秒才停止,这是--duration的默认值。

$ flutter trace --start
$ flutter trace --stop

要立即停止跟踪,请使用以下命令。

$ flutter trace --stop -d 0

2.18 配置 Flutter SDK

问题

你想配置不同设置的 Flutter SDK。

解决办法

使用命令flutter config

讨论

命令flutter config允许配置一些 Flutter SDK 设置。表 2-13 显示了flutter config的参数。

表 2-13

flutter config的参数

|

争论

|

描述

|

缺省值

|
| --- | --- | --- |
| --analytics / --no-analytics | 是否报告匿名工具使用统计和崩溃报告。 | 在 |
| --clear-ios-signing-cert | 清除已存储的用于为 iOS 设备部署的应用签名的开发证书。 |   |
| --gradle-dir | 设置 Gradle 安装目录。 |   |
| --android-sdk | 设置 Android SDK 目录。 |   |
| --android-studio-dir | 设置 Android Studio 安装目录。 |   |

要删除设置,只需将其配置为空字符串。以下命令禁用分析报告。

$ flutter config --no-analytics

2.19 显示应用日志

问题

您希望看到运行在模拟器或设备上的 Flutter 应用生成的日志。

解决办法

使用命令flutter logs

讨论

即使我们可以调试 Flutter 应用的代码来找出某些问题的原因,日志对于错误诊断仍然非常有价值。在 Flutter 应用中生成日志最简单的方法是调用print()方法。命令flutter logs监视设备上生成的日志,并打印到控制台。

$ flutter logs

如果您想在读取日志之前清除日志历史,请使用参数-c--clear

$ flutter logs -c

图 2-15 显示了flutter logs的输出。

img/479501_1_En_2_Fig15_HTML.jpg

图 2-15

Flutter 日志命令的输出

2.20 格式化源代码

问题

您希望确保应用的源代码遵循相同的代码风格。

解决办法

使用命令flutter format

讨论

让你的应用拥有相同的代码风格是一个很好的实践,特别是对于开发团队。一致的代码风格也有利于代码评审。命令flutter format可以格式化源代码文件,以匹配 Dart 的默认代码样式。

要运行flutter format,您需要提供一个用空格分隔的路径列表。以下命令格式化当前目录。

$ flutter format .

flutter format简单地将格式化任务委托给 Dart dartfmt工具。代码样式在镖语官方指南( https://dart.dev/guides/language/effective-dart/style )中有描述。表 2-14 显示了flutter format的额外参数。

表 2-14

Flutter 格式的额外参数

|

争吵

|

描述

|
| --- | --- |
| -n--dry-run | 只显示哪些文件将被修改,而不实际修改它们。 |
| --set-exit-if-changed | 如果该命令改变了格式,返回退出代码1。 |
| -m--machine | 将输出格式设置为 JSON。 |

2.21 列出连接的设备

问题

您希望看到所有可以被 Flutter SDK 使用的连接设备。

解决办法

使用命令flutter devices

讨论

Flutter SDK 要求在运行某些命令之前至少准备好一个仿真器或设备。Flutter SDK 使用术语“设备”来指代 Android 模拟器、iOS 模拟器和真实设备。命令flutter devices列出了 Flutter SDK 可以使用的所有设备。图 2-16 显示了flutter devices的输出。

img/479501_1_En_2_Fig16_HTML.jpg

图 2-16

Flutter 装置的输出

2.22 运行集成测试

问题

您已经使用 Flutter Driver 编写了集成测试,并且想要运行这些测试。

解决办法

使用命令flutter drive

讨论

Flutter Driver 是 Flutter SDK 提供的运行集成测试的工具。当运行集成测试时,应用本身运行在模拟器或设备上,但是测试脚本运行在您的本地机器上。在测试期间,测试脚本连接到正在运行的应用,并向应用发送命令来模拟不同的用户操作。测试脚本可以执行像点击和滚动这样的动作。它还可以读取小部件属性并验证它们的正确性。

flutter drive是运行集成测试的命令。它可以自己启动应用或连接到现有的运行应用。当flutter drive启动 app 时,它可以取与flutter run相同的参数,包括--debug--profile--flavor--route--target--observatory-port--pub--no-pub--trace-startup。这些参数与flutter run中的含义相同。连接已有 app 时,需要用已有 app 的天文台 URL 指定参数--use-existing-app;请参见以下命令。

$ flutter drive --use-existing-app=http://localhost:50124

当启动测试脚本时,flutter drive根据应用的入口点文件使用一个约定来定位测试脚本文件。使用参数--target指定入口点文件,默认值为lib/main.dartflutter drive试图在test_driver目录中找到同名但带有后缀_test.dart的测试脚本文件。例如,如果入口点文件是lib/main.dart,它试图找到测试脚本文件test_driver/main_test.dart。您可以使用参数--driver明确地指定测试脚本文件;请参见以下命令。

$ flutter drive --driver=test_driver/simple.dart

如果应用由flutter drive启动,那么应用将在测试脚本完成后停止,除非参数--keep-app-running被指定为保持运行。当连接到一个现有的应用时,应用在测试脚本完成后继续运行,除非参数--no-keep-app-running被指定来停止它。以下命令在测试后保持应用运行。

$ flutter drive --keep-app-running

2.23 启用 Flutter SDK 命令的 Bash 完成

问题

当键入 Flutter SDK 命令时,您希望为您的 shell 提供完成支持。

解决办法

使用命令flutter bash-completion设置完成。

讨论

有了 shell 完成支持,当您键入一些命令时,shell 会尝试完成它。flutter bash-completion打印设置脚本,以支持 bash 和 zsh 的完成。如果没有提供参数,安装脚本将被打印到控制台。如果提供了文件路径,安装脚本将被写入该文件。

在 macOS 上,我们可以先用自制软件安装bash-completion

$ brew install bash-completion

如果您正在使用 bash,您需要修改文件~/.bash_profile来添加下面一行。

[ -f /usr/local/etc/bash_completion ] && . /usr/local/etc/bash_completion

然后可以运行flutter bash-completion将设置脚本保存到目录/usr/local/etc/bash_completion.d;请参见以下命令。

$ flutter bash-completion /usr/local/etc/bash_completion.d/flutter

最后,您应该运行source ~/.bash_profile或重启 shell 来实现完成。

如果您正在使用 zsh,您可以将设置脚本添加到文件~/.zshrc中。首先你需要在~/.zshrc的顶部添加下面一行。

autoload bashcompinit
bashcompinit

然后您需要运行下面的命令来将设置脚本添加到~/.zshrc

$ flutter bash-completion >> ~/.zshrc

最后,您应该运行source ~/.zshrc或重启 shell 来实现完成。

2.24 清理 Flutter 应用的构建文件

问题

你想要清理 Flutter 应用的构建文件。

解决办法

使用命令flutter clean

讨论

命令flutter clean删除build目录中的文件。build目录的磁盘空间可能很大,即使对于小应用也是如此。比如搭建好 Flutter sample app 后,build目录的大小大概是 200M。学习 Flutter 的时候,可能会创建很多小 app 进行测试。当你认为你已经使用完这些应用时,运行flutter clean是个好主意。你会发现你可以回收大量的磁盘空间。

2.25 管理 Flutter SDK 缓存

问题

你想要显式地管理 Flutter SDK 的缓存。

解决办法

使用命令flutter precache

讨论

Flutter SDK 在bin/cache目录中保存了所需工件的缓存。该目录包含 Dart SDK、Flutter 引擎、材质字体和 Gradle wrapper 的二进制文件。如果该缓存不存在,则会自动填充。命令flutter precache显式更新缓存。除了configprecachebash-completionupgrade命令之外,大多数 Flutter 命令在执行前都会自动更新缓存,所以大多数时候你不需要显式运行这个命令。

e 有参数-a--all-platforms来指定是否应该下载所有平台的工件。默认情况下,只下载当前平台的工件。

$ flutter precache -a

2.26 摘要

这一章是关于你在开发 Flutter 应用时可能需要用到的工具。您可能不需要使用所有这些工具。在 ide 的帮助下,您可以执行 ide 中的大多数操作。这些工具的知识仍然很有价值,因为您可以使用这些工具做更多的事情。在下一章中,我们将看到关于 Dart 语言基本部分的配方。

三、基本 Dart

Flutter 项目可以有跨平台代码和特定于平台的代码。跨平台代码是用 Dart 写的。充分了解 Dart 是构建 Flutter 应用的先决条件。Dart 语言的细节超出了本书的范围。您可以找到大量与 Dart 相关的在线资源。然而,涵盖 Dart 的基本部分对于构建 Flutter 应用还是很有帮助的。本章中的食谱涵盖了 Dart 的不同方面。如果您对 Dart 知识有信心,可以跳过这一章。

3.1 了解内置类型

问题

你想知道 Dart 的内置类型。

解决办法

Dart 有内置的数字、字符串、布尔值、列表、地图、符文和符号类型。

讨论

Dart 有几个内置类型,包括数字、字符串、布尔值、列表、地图、符文和符号。

民数记

Dart 中的数字可以是不大于 64 位的整数值或 IEEE 754 标准指定的 64 位双精度浮点数。类型intdouble分别代表这两种类型的数字。类型numintdouble的超类型。与 Java 中的原始类型不同,Dart 中的数字也是对象。他们有办法和他们一起工作。

在清单 3-1 中,x的类型是int,而y的类型是double。方法toRadixString()通过将值转换成指定的基数返回一个字符串值。方法toStringAsFixed()确保给定的小数位数保存在字符串表示中。double的静态方法tryParse()试图将字符串解析为double文字。

var x = 10;
var y = 1.5;
assert(x.toRadixString(8) == '12');
assert(y.toStringAsFixed(2) == '1.50');
var z = double.tryParse('3.14');
assert(z == 3.14);

Listing 3-1
Numbers

用线串

Dart 字符串是 UTF-16 代码单元的序列。单引号或双引号都可以用来创建字符串。用哪个引语并不重要。关键是要在整个代码库中保持一致。Dart 内置了对字符串插值的支持。可以使用形式${expression}将表达式嵌入字符串。使用字符串时,会计算嵌入表达式的值。如果表达式是一个标识符,那么{}可以省略。在清单 3-2 中,name是一个标识符,所以我们可以在字符串中使用$name

var name = 'Alex';
assert('The length of $name is ${name.length}' == 'The length of Alex is 4');

Listing 3-2String interpolation

如果您想要连接字符串,您可以简单地将这些字符串文字相邻放置,而不使用+操作符;参见清单 3-3 。

var longString = 'This is a long'
  'long'
  'long'
  'string';

Listing 3-3String concatenation

创建多行字符串的另一种方法是使用带单引号或双引号的三重引号;参见清单 3-4 。

var longString2 = "'
This is also a long
  long
  long
  string
"';

Listing 3-4Multi-line string

布尔运算

使用类型bool表示布尔值。bool类型只有两个对象:truefalse。值得注意的是,ifwhileassert中只能使用bool值作为检查条件。JavaScript 有更广泛的真值和假值的概念,而 Dart 遵循更严格的规则。例如,if ('abc')在 JavaScript 中有效,但在 Dart 中无效。

在清单 3-5 中,name是一个空字符串。为了在if中使用它,我们需要调用 getter isEmpty。我们还需要对null0进行显式检查。

var name = ";
if (name.isEmpty) {
  print('name is emtpy');
}
var value;
assert(value == null);

var count = 5;
while(count-- != 0) {
  print(count);
}

Listing 3-5Booleans

列表和地图

列表和地图是常用的集合类型。在 Dart 中,数组是List对象。可以使用文字或构造函数创建列表和映射。建议尽可能使用集合文字。清单 3-6 展示了如何使用文字和构造函数创建列表和映射。

var list1 = [1, 2, 3];
var list2 = List<int>(3);
var map1 = {'a': 'A', 'b': 'B'};
var map2 = Map<String, String>();

Listing 3-6
Lists and maps

符文

符文是字符串的 UTF-32 代码点。要在字符串中表示 32 位 Unicode 值,我们可以使用形式\uXXXX,其中XXXX是码位的四位十六进制值。如果码位不能用四位十六进制值表示,那么需要用{}将这些数字换行,例如\u{XXXXX}。在清单 3-7 中,字符串值包含两个表情符号。

var value = '\u{1F686} \u{1F6B4}';
print(value);

Listing 3-7
Runes

标志

符号对象代表一个运算符或标识符。可以使用构造函数Symbol(<name>)或符号文字# 创建符号。用相同名称创建的符号是相等的;见清单3-8T3。当您希望按名称引用标识符时,应该使用符号。

assert(Symbol('a') == #a);

Listing 3-8
Symbols

3.2 使用枚举类型

问题

您希望有一种类型安全的方法来声明一组常数值。

解决办法

使用枚举类型。

讨论

像其他编程语言一样,Dart 也有枚举类型。要声明枚举类型,请使用enum关键字。枚举中的每个值都有一个index getter 来获取该值从零开始的位置。使用values获得一个枚举中所有值的列表。枚举通常用在switch语句中。在清单 3-9 中,枚举类型TrafficColor有三个值。第一个值red的索引是0

enum TrafficColor { red, green, yellow }

void main() {
  assert(TrafficColor.red.index == 0);
  assert(TrafficColor.values.length == 3);

  var color = TrafficColor.red;
  switch (color) {
    case TrafficColor.red:
      print('stop');
      break;
    case TrafficColor.green:
      print('go');
      break;
    case TrafficColor.yellow:
      print('be careful');
  }
}

Listing 3-9Enumerated type

3.3 使用动态类型

问题

你不知道对象的类型或者你不关心类型。

解决办法

使用dynamic类型。

讨论

Dart 是一种强类型语言。大多数时候,我们希望一个对象有一个定义好的类型。但是,有时候我们可能不知道或者不关心实际的类型;我们可以用dynamic作为类型。动态类型经常与Object类型混淆。Objectdynamic都允许所有值。如果你想声明所有的对象都被接受,应该使用Object。如果类型是dynamic,我们可以使用is运算符来检查它是否是想要的类型。使用runtimeType可以检索实际类型。在清单 3-10 中,value的实际类型是int,然后类型改为String

dynamic value = 1;
print(value.runtimeType);
value = 'test';
if (value is String) {
  print('string');
}

Listing 3-10Use dynamic type

3.4 了解功能

问题

您希望了解 Dart 中的函数。

解决办法

Dart 中的函数非常强大和灵活。

讨论

Dart 中的函数是对象,类型为Function。函数可以赋值,传入函数参数,并用作函数返回值。在 Dart 中创建高阶函数非常容易。一个函数可以有零个或多个参数。有些参数是必需的,有些是可选的。必需的参数首先出现在参数列表中,后面是可选参数。可选的位置参数包含在[]中。

当一个函数有一长串参数时,很难记住这些参数的位置和意义。最好使用命名参数。使用@required注释可以根据需要标记命名参数。参数可以使用=指定默认值。如果没有提供默认值,则默认值为null

在清单 3-11 中,函数sum()有一个可选的位置参数initial,默认值为0。函数joinToString()有一个必需的命名参数separator和两个可选的命名参数prefixsuffixjoinToString()中使用的箭头语法是只有一个表达式的函数体的简写。语法=> expr{ return expr; }相同。使用箭头语法使代码更短,更容易阅读。

import 'package:meta/meta.dart';

int sum(List<int> list, [int initial = 0]) {
  var total = initial;
  list.forEach((v) => total += v);
  return total;
}

String joinToString(List<String> list,
        {@required String separator, String prefix = ", String suffix = "}) =>
    '$prefix${list.join(separator)}$suffix';

void main() {
  assert(sum([1, 2, 3]) == 6);
  assert(sum([1, 2, 3], 10) == 16);

  assert(joinToString(['a', 'b', 'c'], separator: ',') == 'a,b,c');
  assert(
      joinToString(['a', 'b', 'c'], separator: '-', prefix: '*', suffix: '?') ==
          '*a-b-c?');
}

Listing 3-11Function parameters

有时候你可能不需要一个函数的名字。这些匿名函数在提供回调时非常有用。在清单 3-12 中,一个匿名函数被传递给方法forEach()

var list = [1, 2, 3];
list.forEach((v) => print(v * 10));

Listing 3-12Anonymous functions

3.5 使用 Typedefs

问题

你想要一个函数类型的别名。

解决办法

使用 typedefs。

讨论

在 Dart 中,函数是对象。函数是类型Function的实例。但是函数的实际类型是由它的参数类型和返回值类型定义的。当函数用作参数或返回值时,重要的是实际的函数类型。typedef在 Dart 中允许我们创建一个函数类型的别名。类型别名可以像其他类型一样使用。在清单 3-13 ,Processor<T>是函数类型的别名,它有一个T类型的参数和一个void类型的返回。该类型用作函数process()中的参数类型。

typedef Processor<T> = void Function(T value);

void process<T>(List<T> list, Processor<T> processor) {
  list.forEach((item) {
    print('processing $item');
    processor(item);
    print('processed $item');
  });
}

void main() {
  process([1, 2, 3], print);
}

Listing 3-13typedef

3.6 使用级联运算符

问题

您希望对同一对象进行一系列操作。

解决办法

在 Dart 中使用级联运算符(..)。

讨论

Dart 有一个特殊的级联操作符(..),允许我们对同一个对象进行一系列操作。为了在其他编程语言中对同一对象进行链式操作,我们通常需要创建一个 fluent API,其中每个方法都返回当前对象。Dart 中的 cascade 操作符使这一要求变得不必要。即使方法不返回当前对象,它们仍然可以被链接。级联运算符也支持字段访问。在清单 3-14 中,级联运算符用于访问类UserAddress中的字段和方法。

class User {
  String name, email;
  Address address;

  void sayHi() => print('hi, $name');
}

class Address {
  String street, suburb, zipCode;
  void log() => print('Address: $street');
}

void main() {
  User()
    ..name = 'Alex'
    ..email = 'alex@example.org'
    ..address = (Address()
      ..street = 'my street'
      ..suburb = 'my suburb'
      ..zipCode = '1000'
      ..log())
    ..sayHi();
}

Listing 3-14Using cascade operator

3.7 覆盖运算符

问题

您希望覆盖 Dart 中的运算符。

解决办法

为运算符定义类中的重写方法。

讨论

Dart 有许多操作员。只能覆盖这些运算符的子集。这些可覆盖的操作符是<+|[]>/^[]=<=~/&~>=*<<==-%>>。对于某些类,使用运算符比使用方法更简洁。例如,List类覆盖了列表连接的+操作符。代码[1] + [2]非常容易理解。在清单 3-15 中,类Rectangle覆盖了操作符<>来按区域比较实例。

class Rectangle {
  int width, height;
  Rectangle(this.width, this.height);

  get area => width * height;

  bool operator <(Rectangle rect) => area < rect.area;
  bool operator >(Rectangle rect) => area > rect.area;
}

void main() {
  var rect1 = Rectangle(100, 100);
  var rect2 = Rectangle(200, 150);
  assert(rect1 < rect2);
  assert(rect2 > rect1);
}

Listing 3-15Overriding operators

3.8 使用构造函数

问题

您希望创建 Dart 类的新实例。

解决办法

使用构造函数。

讨论

和其他编程语言一样,Dart 中的对象是由构造函数创建的。通常,构造函数是通过声明与其类同名的函数来创建的。构造函数可以有参数来提供初始化新对象所需的值。如果没有为类声明构造函数,则提供不带参数的默认构造函数。这个默认构造函数只是调用超类中的无参数构造函数。但是,如果声明了构造函数,这个默认的构造函数就不存在。

一个类可以有多个构造函数。您可以以ClassName.identifier的形式命名这些构造函数,以便更好地阐明含义。

在清单 3-16 中,类Rectangle有一个带四个参数的常规构造函数。它还有一个命名的构造函数Rectangle.fromPosition

class Rectangle {
  final num top, left, width, height;

  Rectangle(this.top, this.left, this.width, this.height);

Rectangle.fromPosition(this.top, this.left, num bottom, num right)
      : assert(right > left),
        assert(bottom > top),
        width = right - left,
        height = bottom - top;

  @override
  String toString() {
    return 'Rectangle{top: $top, left: $left, width: $width, height: $height}';
  }
}

void main(List<String> args) {
  var rect1 = Rectangle(100, 100, 300, 200);
  var rect2 = Rectangle.fromPosition(100, 100, 300, 200);
  print(rect1);
  print(rect2);
}

Listing 3-16
Constructors

使用工厂创建对象是很常见的。Dart 有一种特殊的factory构造函数来实现这种模式。工厂构造函数并不总是返回一个类的新实例。它可能返回缓存的实例,或者子类型的实例。在清单 3-17 中,类ExpensiveObject有一个命名的构造函数ExpensiveObject._create()来实际创建一个新实例。工厂构造函数只在_instancenull时调用ExpensiveObject._create()。运行代码时,可以看到消息“created”只打印了一次。

class ExpensiveObject {
  static ExpensiveObject _instance;
  ExpensiveObject._create() {
    print('created');
  }

  factory ExpensiveObject() {
    if (_instance == null) {
      _instance = ExpensiveObject._create();
    }
    return _instance;
  }
}

void main() {
  ExpensiveObject();
  ExpensiveObject();
}

Listing 3-17Facto+ry constructor

3.9 扩展类

问题

您希望从现有的类中继承行为。

解决办法

从现有类扩展以创建子类。

讨论

Dart 是一种面向对象的编程语言。它提供了对继承的支持。一个类可以使用关键字extends从一个超类扩展而来。超类可以在子类中称为super。子类可以覆盖超类的实例方法、getters 和 setters。重写成员应该用@override注释进行注释。

抽象类是使用abstract修饰符定义的。抽象类不能被实例化。抽象类中的抽象方法没有实现,必须由非抽象子类实现。

在清单 3-18 中,类Shape是用抽象方法area()抽象的。类RectangleCircle都从Shape扩展而来,并实现了抽象方法area()

import 'dart:math' show pi;

abstract class Shape {
  double area();
}

class Rectangle extends Shape {
  double width, height;
  Rectangle(this.width, this.height);

  @override
  double area() {
    return width * height;
  }
}

class Square extends Rectangle {
  Square(double width) : super(width, width);
}

class Circle extends Shape {
  double radius;
  Circle(this.radius);

  @override
  double area() {
    return pi * radius * radius;
  }
}

void main() {
  var rect = Rectangle(100, 50);
  var square = Square(50);
  var circle = Circle(50);
  print(rect.area());
  print(square.area());
  print(circle.area());
}

Listing 3-18Inheritance

3.10 向类中添加功能

问题

您希望重用一个类的代码,但受到 Dart 的单一继承的限制。

解决办法

使用 mixins。

讨论

继承是重用代码的一种常见方式。Dart 只支持单一继承,即一个类最多只能有一个超类。如果你想重用来自多个类的代码,应该使用 mixins。一个类可以使用关键字with声明多个 mixins。mixin 是一个从Object扩展的类,并在构造函数上声明。可以使用class将 mixin 声明为常规类,或者使用mixin将其声明为专用 mixin。在清单 3-19 中,CardHolderSystemUser是混合。类AssistantStudent扩展而来,有 mixin SystemUser,所以我们可以使用Assistant实例的useSystem()方法。

class Person {
  String name;

  Person(this.name);
}

class Student extends Person with CardHolder {
  Student(String name) : super('Student: $name') {
    holder = this;
  }
}

class Teacher extends Person with CardHolder {
  Teacher(String name) : super('Teacher: $name') {
    holder = this;
  }
}

mixin CardHolder {
  Person holder;

  void swipeCard() {
    print('${holder.name} swiped the card');
  }
}

mixin SystemUser {
  Person user;

  void useSystem() {
    print('${user.name} used the system.');
  }
}

class Assistant extends Student with SystemUser {
  Assistant(String name) : super(name) {
    user = this;
  }

}

void main() {
  var assistant = Assistant('Alex');
  assistant.swipeCard();
  assistant.useSystem();
}

Listing 3-19Mixins

3.11 使用接口

问题

你想有一个契约让课程遵循。

解决办法

使用类的隐式接口。

讨论

您应该熟悉作为类契约的接口。与其他面向对象的编程语言不同,Dart 没有接口的概念。每个类都有一个隐式接口,包含该类的所有实例成员及其实现的接口。你可以使用implements来声明一个类实现了另一个类的 API。在清单 3-20 中,类CachedDataLoader实现了类DataLoader的隐式接口。

class DataLoader {
  void load() {
    print('load data');
  }
}

class CachedDataLoader implements DataLoader {
  @override
  void load() {
    print('load from cache');
  }
}

void main() {
  var loader = CachedDataLoader();
  loader.load();
}

Listing 3-20
Interfaces

3.12 使用泛型

问题

当您的代码被设计为使用不同的类型时,您希望具有类型安全。

解决办法

使用泛型类和泛型方法。

讨论

对于开发人员来说,泛型并不是一个陌生的概念,尤其是对于 Java 和 C#开发人员来说。使用泛型,我们可以向类和方法添加类型参数。泛型通常在集合中用于创建类型安全的集合。清单 3-21 显示了通用集合在 Dart 中的用法。Dart 泛型类型被具体化,这意味着类型信息在运行时可用。这就是为什么names的类型是List<String>的原因。

var names = <String>['a', 'b', 'c'];
print(names is List<String>);
var values = <String, int>{'a': 1, 'b': 2, 'c': 3};
print(values.values.toList());

Listing 3-21Generic collections

我们可以使用泛型来创建处理不同类型的类。在清单 3-22 中,Pair<F, S>是一个泛型类,有两个类型参数FS。使用extends指定泛型类型参数的上限。CardHolder中的类型参数P有一个类型Person的上界,所以CardHolder<Student>有效。

class Pair<F, S> {
  F first;
  S second;

  Pair(this.first, this.second);
}

class Person {}

class Teacher extends Person {}

class Student extends Person {}

class CardHolder<P extends Person> {
  P holder;
  CardHolder(this.holder);
}

void main() {
  var pair = Pair('a', 1);
  print(pair.first);
  var student = Student();
  var cardHolder = CardHolder(student);
  print(cardHolder is CardHolder<Student>);
  print(cardHolder);
}

Listing 3-22Generic types

泛型方法可以添加到常规类中。在清单 3-23 中,常规类Calculator有两个泛型方法addsubtract

class Calculator {
  T add<T extends num>(T v1, T v2) => v1 + v2;
  T subtract<T extends num>(T v1, T v2) => v1 - v2;
}

void main() {
  var calculator = Calculator();
  int r1 = calculator.add(1, 2);
  double r2 = calculator.subtract(0.1, 0.2);
  print(r1);
  print(r2);
}

Listing 3-23Generic methods

3.13 使用库

问题

您希望重用 Dart SDK 或社区中的库。

解决办法

使用import导入库以在您的应用中使用它们。

讨论

在开发重要的 Dart 应用时,不可避免地要使用库。这些库可以是 Dart SDK 中的内置库,也可以是社区贡献的库。要使用这些库,我们需要先用import导入它们。import只有一个参数来指定库的 URI。内置库有 URI 方案dart:,比如dart:htmldart:convert。社区包有 URI 方案package:,由 Dart pub工具管理。清单 3-24 展示了导入库的例子。

import 'dart:html';
import 'package:meta/meta.dart';

Listing 3-24Import libraries

两个库可能导出相同的标识符。为了避免冲突,我们可以使用as为其中一个库或者两个库提供前缀。在清单 3-25 中,lib1.dartlib2.dart都导出了类Counter。在给这两个库分配不同的前缀后,我们可以使用前缀来访问类Counter

import 'lib1.dart' as lib1;
import 'lib2.dart' as lib2;

lib1.Counter counter;

Listing 3-25Rename libraries

您不需要导入库的所有成员。使用show显式包含成员。使用hide显式排除成员。在清单 3-26 中,导入库dart:math时,只导入Random;导入库dart:html时,只排除Element

import 'dart:math' show Random;
import 'dart:html' hide Element;

Listing 3-26Show and hide members

3.14 使用异常

问题

您希望处理 Dart 应用中的故障。

解决办法

使用throw报告故障。使用try-catch-finally处理异常。

讨论

代码失败。代码报告失败并处理它们是很自然的事情。Dart 的异常机制与 Java 类似,只是 Dart 中的所有异常都是未检查的异常。Dart 中的方法不声明它们可能抛出的异常,所以不需要捕捉异常。但是,未捕获的异常会导致隔离挂起,并可能导致程序终止。正确的故障处理也是健壮应用的一个关键特征。

报告故障

我们可以使用throw来抛出异常。事实上,所有非null对象都可以被抛出,不仅仅是实现类型ErrorException的类型。建议只投掷ErrorException类型的物体。

一个Error对象代表代码中不应该发生的 bug。例如,如果一个列表只包含三个元素,试图访问第四个元素会导致抛出一个RangeError。与异常不同,错误不是用来被捕获的。当错误发生时,最安全的方法是终止程序。Error它们携带着关于为什么会发生的清晰信息。

Error s 相比,Exception s 被设计为以编程方式被捕获和处理。例如,发送 HTTP 请求可能不会成功,因此我们需要在代码中处理异常来处理失败。Exception s 通常携带关于失败的有用数据。我们应该创建从Exception扩展的自定义类型来封装必要的数据。

捕捉异常

当抛出异常时,您可以捕捉它以阻止它传播,除非您重新抛出它。捕捉异常的目标是处理它。如果不想处理异常,就不应该捕捉它。使用trycatchon捕获异常。如果不需要访问异常对象,使用on就足够了。使用catch,您可以访问异常对象和堆栈跟踪。使用on指定要捕获的异常类型。

当你捕捉到一个异常时,你应该处理它。但是,有时您可能只想部分处理它。在这种情况下,您应该使用rethrow来重新抛出异常。捕捉异常但不完全处理它是一种糟糕的做法。

如果您希望无论是否抛出异常都运行一些代码,您可以将代码放在一个finally子句中。如果没有抛出异常,finally子句在try块之后运行。如果抛出异常,finally子句在匹配的catch子句之后运行。

在清单 3-27 中,函数getNumber()抛出一个自定义异常类型ValueTooLargeException。在函数main()中,异常被捕获并再次抛出。

import 'dart:math' show Random;

var random = Random();

class ValueTooLargeException implements Exception {
  int value;
  ValueTooLargeException(this.value);

  @override
  String toString() {
    return 'ValueTooLargeException{value: $value}';
  }
}

int getNumber() {
  var value = random.nextInt(10);
  if (value > 5) {
    throw ValueTooLargeException(value);
  }
  return value;
}

void main() {
  try {
    print(getNumber());
  } on ValueTooLargeException catch (e) {
    print(e);
    rethrow;
  } finally {
    print('in finally');
  }
}

Listing 3-27Use exceptions

3.15 摘要

学习一门新的编程语言不是一件容易的事情。尽管 Dart 看起来与其他编程语言相似,但 Dart 仍然有一些独特的功能。本章仅简要介绍 Dart 中的重要功能。

四、小部件基础

在构建 Flutter 应用时,大部分时间你都在处理小部件。本章提供了关于 Flutter 中窗口小部件的基本背景信息。它还介绍了几个显示文本、图像、图标、按钮和占位符的基本小部件。

4.1 了解小部件

问题

你想知道如何在 Flutter 中使用组件。

解决办法

在 Flutter 中,小部件无处不在。

讨论

如果你已经参与了用户界面的开发,你应该熟悉像部件或组件这样的概念。这些概念代表了创建用户界面的可重用构件。一个好的用户界面库应该有大量的高质量且易于使用的组件。按钮、图标、图像、菜单、对话框和表单输入都是组件的例子。组件可大可小。复杂组件通常由小组件组成。您可以通过遵循组件模型来创建自己的组件。您还可以选择将您的组件共享给社区。良好的组件生态系统是用户界面库成功的关键因素。

Flutter 使用小部件来描述用户界面中可重用的构件。与其他库相比,Flutter 中的小部件是一个更广泛的概念。不仅按钮和表单输入等常见组件是窗口小部件,布局约束在 Flutter 中也表示为窗口小部件。例如,如果您想将一个小部件放在一个框的中央,您只需将这个小部件包装成一个Center小部件。小部件也用于检索上下文数据。例如,DefaultTextStyle小部件得到的TextStyle应用于未样式化的Text小部件。

Flutter 中的小部件是用户界面一部分的不可变描述。小部件类的所有字段都是最终的,并在构造函数中设置。小部件构造函数只有命名参数。一个部件可以有一个或多个部件作为子部件。Flutter 应用的小部件创建了一个树状层次结构。Flutter 应用入口点文件的main()方法使用runApp()方法启动应用。runApp()的唯一参数是一个Widget对象。这个Widget对象是应用小部件树的根。小部件只是静态配置,描述如何配置层次结构中的子树。为了实际运行这个应用,我们需要一种方法来管理小部件的实例化。

Flutter 使用Element来表示树中特定位置的Widget的实例化。一个Widget可以被实例化零次或多次。将 ?? 转变为 ?? 的过程叫做膨胀。Widget类有一个createElement()方法来将小部件膨胀为Element的具体实例。Flutter 框架负责管理元素的生命周期。与元素相关联的窗口小部件可能会随着时间而改变。框架更新元素以使用新的配置。

当运行应用时,Flutter framework 负责渲染元素以创建渲染树,因此最终用户可以实际看到用户界面。渲染树由根为RenderViewRenderObject组成。如果你使用的是 Android Studio,你可以在 Flutter Inspector 视图中看到窗口小部件树和渲染树。选择查看➤工具窗口➤Flutter 检查器打开 Flutter 检查器视图。图 4-1 显示了 Flutter Inspector 中的 widgets 树。顶部面板显示小部件树,而底部面板显示小部件的详细信息。

img/479501_1_En_4_Fig1_HTML.jpg

图 4-1

Flutter 检查器中的 Widgets 树

图 4-2 显示了 Flutter 检查器中的渲染树。根是一个RenderView

img/479501_1_En_4_Fig2_HTML.jpg

图 4-2

在 Flutter 检查器中渲染树

4.2 了解构建上下文

问题

您想要访问与小部件树中的小部件相关的信息。

解决办法

WidgetBuilder函数有一个BuildContext参数,用于访问小部件树中与小部件相关的信息。你可以在StatelessWidget.build()State.build()方法中看到BuildContext

讨论

当构建小部件时,小部件在小部件树中的位置可能决定其行为,特别是当它有一个InheritedWidget作为其祖先时。BuildContext类提供了访问位置相关信息的方法;见表 4-1 。

表 4-1

构建上下文的方法

|

名字

|

描述

|
| --- | --- |
| ancestorInheritedElementForWidgetOfExactType | 获取与给定类型的InheritedWidget的最近祖先小部件对应的InheritedElement。 |
| ancestorRenderObjectOfType | 获取最近的祖先RenderObjectWidget小部件的RenderObject。 |
| ancestorStateOfType | 获取最近祖先StatefulWidget小部件的State对象。 |
| rootAncestorStateOfType | 获取最远祖先StatefulWidget小部件的State对象。 |
| ancestorWidgetOfExactType | 获取最近的祖先Widget。 |
| findRenderObject | 获取小部件的当前RenderObject。 |
| inheritFromElement | 用给定的祖先InheritedElement注册这个BuildContext,以便当祖先的小部件改变时,这个BuildContext被重建。 |
| inheritFromWidgetOfExactType | 获取给定类型的最接近的InheritedWidget并注册这个BuildContext,以便当小部件改变时,这个BuildContext被重建。 |
| visitAncestorElements | 访问祖先元素。 |
| visitChildElements | 访问子元素。 |

BuildContext其实是Element类的接口。在StatelessWidget.build()State.build()方法中,BuildContext对象表示当前小部件膨胀的位置。在清单 4-1 中,ancestorWidgetOfExactType()方法用于获取类型Column的祖先小部件。

class WithBuildContext extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Column column = context.ancestorWidgetOfExactType(Column);
    return Text(column.children.length.toString());
  }
}

Listing 4-1Use BuildContext

4.3 了解无状态小部件

问题

您希望创建一个没有可变状态的小部件。

解决办法

StatelessWidget类扩展。

讨论

当使用一个 widget 来描述用户界面的一部分时,如果这个部分可以使用 widget 本身的配置信息和它所在的BuildContext来完整描述,那么这个 widget 应该从StatelessWidget扩展而来。当创建一个StatelessWidget类时,您需要实现接受一个BuildContext并返回一个Widgetbuild()方法。在清单 4-2 中,HelloWorld类从StatelessWidget类扩展而来,并在build()方法中返回一个Center小部件。

class HelloWorld extends StatelessWidget {
  const HelloWorld({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text('Hello World!'),
    );
  }
}

Listing 4-2Example of StatelessWidget

4.4 了解有状态小部件

问题

您希望创建一个具有可变状态的小部件。

解决办法

StatefulWidget类扩展。

讨论

如果用户界面的一部分可能动态变化,你需要从StatefulWidget类扩展。对于由它们创建的State对象中管理的状态,它们本身是不可变的。一个StatefulWidget子类需要实现返回一个State<StatefulWidget>对象的createState()方法。当状态改变时,State对象要调用setState()方法通知框架触发更新。在清单 4-3 中,_CounterState类是Counter小部件的State对象。当按钮被按下时,值在setState()方法中被更新,该方法更新_CounterState小部件以显示新值。

class Counter extends StatefulWidget {
  @override
  _CounterState createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int value = 0;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Text('$value'),
        RaisedButton(
          child: Text('+'),
          onPressed: () {
            setState(() {
              value++;
            });
          },
        ),
      ],
    );
  }
}

Listing 4-3Example of StatefulWidget

4.5 了解继承的小部件

问题

您希望沿着小部件树向下传播数据。

解决办法

InheritedWidget类扩展。

讨论

当构建部件的子树时,您可能需要沿着部件树向下传播数据。例如,子树的根部件可能定义一些上下文数据,例如,从服务器检索的配置数据。子树中的其他小部件可能也需要访问上下文数据。一种可能的方法是将上下文数据添加到小部件的构造函数中,然后将数据作为子小部件的构造函数参数进行传播。这种解决方案的主要缺点是,您需要为子树中的所有小部件添加构造函数参数。尽管有些小部件可能实际上不需要数据,但是它们仍然需要将数据传递给它们的子部件。

更好的方法是使用InheritedWidget类。BuildContext类有一个inheritFromWidgetOfExactType()方法来获取特定类型InheritedWidget的最近实例。使用InheritedWidget,您可以将上下文数据存储在一个InheritedWiget实例中。如果小部件需要访问上下文数据,可以使用inheritFromWidgetOfExactType()方法获取实例并访问数据。如果一个继承的小部件改变了状态,它将导致它的消费者重新构建。

在清单 4-4 ,ConfigWidget类中有数据config。静态的of()方法为config值获取最近的祖先ConfigWidget实例。方法updateShouldNotify()确定何时应该通知消费者窗口小部件。

class ConfigWidget extends InheritedWidget {
  const ConfigWidget({
    Key key,
    @required this.config,
    @required Widget child,
  })  : assert(config != null),
        assert(child != null),
        super(key: key, child: child);

  final String config;

  static String of(BuildContext context) {
    final ConfigWidget configWidget =
        context.inheritFromWidgetOfExactType(ConfigWidget);
    return configWidget?.config ?? ";
  }

  @override
  bool updateShouldNotify(ConfigWidget oldWidget) {
    return config != oldWidget.config;
  }
}

Listing 4-4Example of InheritedWidget

在清单 4-5 中,ConfigUserWidget类使用ConfigWidget.of()方法获得config值。

class ConfigUserWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('Data is ${ConfigWidget.of(context)}');
  }
}

Listing 4-5Use of ConfigWidget

在清单 4-6 中,ConfigWidget实例的config值为“Hello!”还有一个后裔ConfigUserWidget的实例。

ConfigWidget(
  config: 'Hello!',
  child: Center(
    child: ConfigUserWidget(),
  ),
);

Listing 4-6Complete example

4.6 显示文本

问题

你想显示一些文本。

解决办法

使用TextRichText小部件。

讨论

几乎所有的应用都需要向终端用户显示一些文本。Flutter 提供了几个与文本相关的类。TextRichText是显示文本的两个小部件。事实上,Text内部使用RichTextText小部件的build()方法返回一个RichText实例。TextRichText的区别在于Text使用最近的封闭DefaultTextStyle对象的样式,而RichText需要显式样式。

文本

文本有两个构造函数。第一个构造函数Text()接受一个String作为要显示的文本。另一个构造函数Text.rich()接受一个TextSpan对象来表示文本和样式。创建Text小部件最简单的形式是Text('Hello world'),它使用最近的封装DefaultTextStyle对象的样式显示文本。Text()Text.rich()构造函数都有几个命名参数来定制它们;参见表 4-2 。

表 4-2

Text()和 Text.rich()的命名参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| style | TextStyle | 文本的样式。 |
| textAlign | TextAlign | 文本应如何水平对齐。 |
| textDirection | TextDirection | 文本的方向。 |
| locale | Locale | 基于 Unicode 选择字体的区域设置。 |
| softWrap | bool | 是否在软换行符处断开文本。 |
| overflow | TextOverflow | 如何处理文本溢出? |
| textScaleFactor | double | 缩放文本的因子。 |
| maxLines | int | 最大行数。如果文本超出限制,它将根据溢出中指定的策略被截断。 |
| semanticsLabel | String | 文本的语义标签。 |

TextAlign是一个枚举类型,其值如表 4-3 所示。

表 4-3

文本对齐值

|

名字

|

描述

|
| --- | --- |
| left | 将文本与其容器的左边缘对齐。 |
| right | 将文本与其容器的右边缘对齐。 |
| center | 将文本在其容器的中心对齐。 |
| justify | 对于以软换行符结尾的文本行,拉伸这些行以填充容器的宽度;对于以硬换行符结尾的文本行,将它们向起始边缘对齐。 |
| start | 将文本与其容器的前缘对齐。对于从左到右的文本,前导边缘是左边缘,而对于从右到左的文本,前导边缘是右边缘。 |
| end | 将文本在其容器的后沿对齐。后缘与前缘相反。 |

建议始终使用TextAlignstartend,而不是leftright,以便更好地处理双向文本。TextDirection是具有值ltrrtl的枚举类型。TextOverflow是一个枚举类型,其值如表 4-4 所示。

表 4-4

文本溢出值

|

名字

|

描述

|
| --- | --- |
| clip | 剪裁溢出的文本。 |
| fade | 将溢出的文本渐变为透明。 |
| ellipsis | 在溢出的文本后添加省略号。 |

DefaultTextStyle是一个InheritedWidget,其属性styletextAlignsoftWrapoverflowmaxLines与表 4-2 中的命名参数含义相同。如果在构造函数Text()Text.rich()中提供了一个命名参数,那么提供的值会覆盖最近的祖先DefaultTextStyle对象中的值。清单 4-7 展示了几个使用Text小部件的例子。

Text('Hello World')

Text(
  'Bigger Bold Text',
  style: TextStyle(fontWeight: FontWeight.bold),
  textScaleFactor: 2.0,
);

Text(
  'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt',
  maxLines: 1,
  overflow: TextOverflow.ellipsis,
);

Listing 4-7Examples of Text

文本扫描

构造函数Text.rich()将一个TextSpan对象作为必需的参数。TextSpan代表一段不可变的文本。TextSpan()构造函数有四个命名参数;参见表 4-5 。TextSpan组织是有等级制度的。一个TextSpan对象可能有许多TextSpan对象作为子对象。子元素可以覆盖父元素的样式。

表 4-5

TextSpan()的命名参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| style | TextStyle | 文本和子对象的样式。 |
| text | String | 范围中的文本。 |
| children | List<TextSpan> | 作为这个跨度的孩子。 |
| recognizer | GestureRecognizer | 接收事件的手势识别器。 |

清单 4-8 展示了使用Text.rich()的例子。此示例使用不同的样式显示句子“快速的棕色狐狸跳过懒惰的狗”。

Text.rich(TextSpan(
  style: TextStyle(
    fontSize: 16,
  ),
  children: [
    TextSpan(text: 'The quick brown '),
    TextSpan(
        text: 'fox',
        style: TextStyle(
          fontWeight: FontWeight.bold,
          color: Colors.red,
        )),
    TextSpan(text: ' jumps over the lazy '),
    TextSpan(
        text: 'dog',
        style: TextStyle(
          color: Colors.blue,
        )),
  ],
));

Listing 4-8Example of Text.rich()

法官文本

RichText总是使用TextSpan对象来表示文本和样式。RichText()构造函数有一个TextSpan类型的必需命名参数text。它还有可选的命名参数textAligntextDirectionsoftWrapoverflowtextScaleFactormaxLineslocale。这些可选的命名参数与表 4-2 中的Text()构造函数含义相同。

显示在RichText中的文本需要明确的样式。您可以使用DefaultTextStyle.of()BuildContext对象中获取默认样式。这正是Text在内部做的事情。Text小部件获取默认样式,并与样式参数中提供的样式合并,然后创建一个RichText,用一个TextSpan包装文本和合并的样式。如果你发现你确实需要使用默认样式作为基础,你应该直接使用Text而不是RichText。清单 4-9 显示了一个使用RichText的例子。

RichText(
  text: TextSpan(
    text: 'Level 1',
    style: TextStyle(color: Colors.black),
    children: [
      TextSpan(
        text: 'Level 2',
        style: TextStyle(fontWeight: FontWeight.bold),
        children: [
          TextSpan(
            text: 'Level 3',
            style: TextStyle(color: Colors.red),
          ),
        ],
      ),
    ],
  ),
);

Listing 4-9Example of RichText

4.7 对文本应用样式

问题

您希望显示的文本具有不同的样式。

解决办法

TextStyle来描述风格。

讨论

TextStyle描述应用于文本的样式。TextStyle()构造函数有很多命名参数来描述样式;见表 4-6 。

表 4-6

TextStyle()的命名参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| color | Color | 文本的颜色。 |
| fontSize | Double | 字体大小。 |
| fontWeight | FontWeight | 字体粗细。 |
| fontStyle | FontStyle | 字体变体。 |
| letterSpacing | Double | 每个字母之间的间隔。 |
| wordSpacing | Double | 每个单词之间的空格。 |
| textBaseLine | TextBaseLine | 将此文本范围与其父范围对齐的公共基线。 |
| height | Double | 文本的高度。 |
| locale | Locale | 用于选择区域特定标志符号的区域设置。 |
| foreground | Paint | 文本的前景。 |
| background | Paint | 文本的背景。 |
| shadows | List<Shadow> | 文本下面画的阴影。 |
| decoration | TextDecoration | 文本的修饰。 |
| decorationColor | Color | 文本装饰的颜色。 |
| decorationStyle | TextDecorationStyle | 文本装饰的样式。 |
| debugLabel | String | 调试样式的描述。 |
| fontFamily | String | 字体的名称。 |
| package | String | 如果字体是在包中定义的,与fontFamily一起使用。 |

FontWeight类定义值w100w200w300w400w500w600w700w800w900FontWeight.w100最薄,而w900最厚。FontWeight.boldFontWeight.w700的别名,而FontWeight.normalFontWeight.w400的别名。FontStyle是具有两个值italicnormal的枚举类型。TextBaseline是具有值alphabeticideographic的枚举类型。

TextDecoration类定义了不同类型的文本装饰。您还可以使用构造函数TextDecoration.combine()通过组合一系列TextDecoration实例来创建一个新的TextDecoration实例。例如,TextDecoration.combine([TextDecoration.underline, TextDecoration.overline])实例在文本的上面和下面画线。表 4-7 显示了TextDecoration中的常数。

表 4-7

文本装饰常数

|

名字

|

描述

|
| --- | --- |
| none | 没有装饰。 |
| underline | 在文本下面画一条线。 |
| overline | 在文本上方画一条线。 |
| lineThrough | 在文本中画一条线。 |

TextDecorationStyle是一个枚举类型,其值如表 4-8 所示。TextDecorationStyle定义由TextDecoration创建的线条的样式。

表 4-8

文本修饰 Style values

|

名字

|

描述

|
| --- | --- |
| solid | 画一条实线。 |
| double | 画两条线。 |
| dotted | 画一条虚线。 |
| dashed | 画一条虚线。 |
| wavy | 画一条正弦曲线。 |

清单 4-10 显示了一个使用TextDecorationTextDecorationStyle的例子。

Text(
  'Decoration',
  style: TextStyle(
    fontWeight: FontWeight.w900,
    decoration: TextDecoration.lineThrough,
    decorationStyle: TextDecorationStyle.dashed,
  ),
);

Listing 4-10Example of using TextDecoration and TextDecorationStyle

如果您想要创建一个更新了一些属性的TextStyle实例的副本,那么使用copyWith()方法。apply()方法也创建了一个新的TextStyle实例,但是它允许使用 factor 和 delta 更新一些属性。例如,命名参数fontSizeFactorfontSizeDelta可以更新字体大小。用"fontSize * fontSizeFactor + fontSizeDelta"计算fontSize的更新值。您也可以使用相同的模式更新heightletterSpacingwordSpacing的值。对于fontWeight,仅支持fontWeightDelta。在清单 4-11 中,应用于文本的TextStyle更新了fontSizedecoration的值。

Text(
  'Scale',
  style: DefaultTextStyle.of(context).style.apply(
        fontSizeFactor: 2.0,
        fontSizeDelta: 1,
        decoration: TextDecoration.none,
      ),
);

Listing 4-11Update TextStyle

4.8 显示图像

问题

您想要显示从网络加载的图像。

解决办法

使用带有图像 URL 的Image.network()来加载和显示图像。

讨论

如果您在自己的服务器或其他地方托管了图像,您可以使用Image.network()构造函数来显示它们。Image.network()构造函数只需要加载图片的 URL。应该使用命名参数widthheight为图像小部件指定特定的尺寸,或者将其放在设置严格布局约束的上下文中。这是因为加载图像时,图像的尺寸可能会改变。如果没有严格的大小限制,图像小部件可能会影响其他小部件的布局。在清单 4-12 中,图像小部件的大小由命名参数widthheight指定。

Image.network(
  'https://picsum.photos/400/300',
  width: 400,
  height: 300,
);

Listing 4-12Example of Image.network()

所有下载的图像都被缓存,不管 HTTP 头。这意味着所有 HTTP 缓存控制头都将被忽略。您可以使用缓存克星来强制刷新缓存的图像。例如,您可以向图像 URL 添加一个随机字符串。

如果加载图像需要额外的 HTTP 头,您可以指定类型为Map<String, String>headers参数来提供这些头。一个典型的用例是加载需要 HTTP 头进行身份验证的受保护图像。

如果一个图像不能覆盖一个盒子的整个区域,你可以使用类型ImageRepeatrepeat参数来指定图像如何重复。ImageRepeat是一个枚举类型,其值如表 4-9 所示。默认值为noRepeat

表 4-9

图像重复值

|

名字

|

描述

|
| --- | --- |
| Repeat | 在 x 和 y 两个方向重复。 |
| repeatX | 仅在 x 方向重复。 |
| repeatY | 仅在 y 方向重复。 |
| noRepeat | 不重复。未覆盖的区域将是透明的。 |

在清单 4-13 中,图像被放入一个比图像大的SizedBox中。通过使用ImageRepeat.repeat,该框被该图像填充。

SizedBox(
  width: 400,
  height: 300,
  child: Image.network(
    'https://picsum.photos/300/200',
    alignment: Alignment.topLeft,
    repeat: ImageRepeat.repeat,
  ),
);

Listing 4-13Repeated images

4.9 显示图标

问题

你想用图标。

解决办法

使用图标显示材质设计中的图标或社区中的图标包。

讨论

图标在移动应用中被广泛使用。与文本相比,图标在表达同样的语义时占用更少的屏幕空间。图标可以由字体符号或图像创建。Icon小部件是用字体字形绘制的。一个字体字形用IconData类描述。要创建一个IconData实例,字体中该图标的 Unicode 码位是必需的。

Icons类有许多预定义的IconData常量用于材质设计中的图标(Material . io/tools/icons/)。例如,Icons.call是名为“通话”的图标的IconData常量。如果应用使用材质设计,那么这些图标可以开箱即用。CupertinoIcons类有许多为 iOS 风格图标预定义的IconData常量。

Icon()构造函数已经命名了参数sizecolor来分别指定图标的大小和颜色。图标总是正方形的,宽度和高度都与大小相等。size 的默认值是 24。清单 4-14 创建一个大小为 100 的红色Icons.call图标。

Icon(
  Icons.call,
  size: 100,
  color: Colors.red,
);

Listing 4-14Example of Icon()

要使用流行的字体牛逼图标,可以使用包font_awesome_flutter ( https://pub.dartlang.org/packages/font_awesome_flutter )。将包依赖关系添加到pubspec.yaml文件后,您可以导入该文件以使用FontAwesomeIcons类。类似于Icons类,FontAwesomeIcons类有许多IconData常量,用于 Awesome 字体中的不同图标。清单 4-15 创建一个大小为 80 的蓝色FontAwesomeIcons.angry图标。

Icon(
  FontAwesomeIcons.angry,
  size: 80,
  color: Colors.blue,
);

Listing 4-15Use Font Awesome icon

4.10 使用带文本的按钮

问题

你想使用带有文本的按钮。

解决办法

使用按钮部件FlatButtonRaisedButtonOutlineButtonCupertinoButton

讨论

Flutter 有不同类型的按钮用于材质设计和 iOS。这些按钮部件都有一个必需的参数onPressed来指定按下时的处理函数。如果onPressed处理器是null,按钮被禁用。按钮的内容由类型为Widget的参数child指定。FlatButtonRaisedButtonOutlineButton对触摸有不同的风格和行为反应:

  • 一个FlatButton有零海拔和没有可见的边界。它通过填充由highlightColor指定的颜色来对触摸做出反应。

  • 一个RaisedButton有高程,用颜色填充。它通过将仰角增加到highlightElevation来对触摸做出反应。

  • 一个OutlineButton有边界,初始高度为 0.0,背景透明。它对触摸的反应是用颜色使其背景不透明,并将其高度增加到highlightElevation

应该用在工具栏、对话框、卡片上,或者内嵌在其他有足够空间让按钮显而易见的地方。RaisedButton s 应该用在使用空间不足以让按钮突出的地方。OutlineButtonRaisedButtonFlatButton的杂交。OutlineButton s 可以在FlatButton s 和RaisedButton s 都不合适的情况下使用。

如果你更喜欢 iOS 风格的按钮,你可以使用CupertinoButton小部件。CupertinoButton通过渐出和渐入对触摸做出反应。清单 4-16 展示了创建不同类型按钮的例子。

FlatButton(
  child: Text('Flat'),
  color: Colors.white,
  textColor: Colors.grey,
  highlightColor: Colors.red,
  onPressed: () => {},
);

RaisedButton(
  child: Text('Raised'),
  color: Colors.blue,
  onPressed: () => {},
);

OutlineButton(
  child: Text('Outline'),
  onPressed: () => {},
);

CupertinoButton(
  child: Text('Cupertino'),
  color: Colors.green,
  onPressed: () => {},
);

Listing 4-16Different types of buttons

4.11 使用带图标的按钮

问题

你想使用带有图标的按钮。

解决办法

使用IconButton控件、FlatButton.icon()RaisedButton.icon()OutlineButton.icon()

讨论

创建带有图标的按钮有两种方法。如果只有图标就够了,使用IconButton widget。如果图标和文本都需要,使用构造函数FlatButton.icon()RaisedButton.icon()OutlineButton.icon()

IconButton构造函数需要icon参数来指定图标。FlatButton.icon()RaisedButton.icon()OutlineButton.icon()分别使用参数iconlabel来指定图标和文本。清单 4-17 显示了使用IconButton()RaisedButton.icon()的例子。

IconButton(
  icon: Icon(Icons.map),
  iconSize: 50,
  tooltip: 'Map',
  onPressed: () => {},
);

RaisedButton.icon(
  icon: Icon(Icons.save),
  label: Text('Save'),
  onPressed: () => [],
);

Listing 4-17Examples of IconButton() and RaisedButton.icon()

4.12 添加占位符

问题

您希望添加占位符来表示稍后将添加的小部件。

解决办法

使用占位符。

讨论

在实现一个应用的界面之前,你通常对这个应用的外观有一个基本的概念。您可以从将界面分解成许多小部件开始。您可以在开发过程中使用占位符来表示未完成的小部件,这样您就可以测试其他小部件的布局。例如,如果您需要创建两个小部件,一个显示在顶部,而另一个显示在底部。如果您选择首先创建底部小部件,并为顶部小部件使用占位符,您可以在所需位置看到底部小部件。

Placeholder()构造函数接受命名参数colorstrokeWidthfallbackWidthfallbackHeight。占位符被绘制为一个矩形和两条对角线。参数colorstrokeWidth分别指定线条的颜色和宽度。默认情况下,占位符适合其容器。然而,如果占位符的容器是无界的,它使用给定的fallbackWidthfallbackHeight来确定大小。fallbackWidthfallbackHeight都有默认值400.0。清单 4-18 显示了一个Placeholder小部件的例子。

Placeholder(
  color: Colors.red,
  strokeWidth: 1,
  fallbackHeight: 200,
  fallbackWidth: 200,
);

Listing 4-18Example of Placeholder

4.13 总结

微件在 Flutter 应用中无处不在。本章提供了对 Flutter 中控件的基本介绍,包括StatelessWidgetStatefulWidgetInheritedWidget。本章还介绍了显示文本、图像、图标、按钮和占位符的常用基本小部件的用法。下一章将讨论 Flutter 中的布局。

五、布局小部件

在构建用户界面时,布局总是一项具有挑战性的任务。就移动应用而言,考虑到设备的大量不同屏幕分辨率,布局要复杂得多。本章介绍了 Flutter 布局的相关方法。

5.1 了解 Flutter 中的布局

问题

你想知道在 Flutter 中布局是如何工作的。

解决办法

Flutter 中的布局是由一组小部件实现的。这些布局小部件包装其他小部件,以应用不同的布局约束。

讨论

对于移动应用,布局必须能够适应不同的屏幕分辨率,而无需编写大量难以维护的代码。幸运的是,随着布局技术的发展,现在构建响应式布局更容易了。如果你有使用 CSS 进行 web 开发的经验,你可能听说过 W3C 的 CSS 灵活框布局模块规范(https:// www.w3.org/TR/css-flexbox-1/ )。flex 布局模型非常强大,因为它允许开发人员表达布局应该是什么样子,而不是如何实现实际的布局。这种声明式方法将繁重的工作转移到底层框架。结果布局代码更容易理解和维护。

例如,如果您想在容器的中心放置一个盒子,旧的方法可能需要计算盒子和容器的大小来确定盒子的位置。使用 flex 布局时,布局可以简化为清单 5-1 中的 CSS 代码。

.container {
  display: flex;
  width: 400px;
  height: 400px;
  justify-content: center;
  align-items: center;
  border: 1px solid green;
}

.item {
  width: 200px;
  height: 200px;
  border: 1px solid red;
}

Listing 5-1CSS code to center an item

flex 布局的理念现在不仅用于网页设计,也用于移动应用。React Native 使用 flex 布局( https://facebook.github.io/react-native/docs/flexbox )。Flutter 也采用了 flex 布局的思想。正如菜谱 4-1 中所讨论的,布局是作为小部件实现的。你可以在 Flutter 中看到像FlexRowColumnFlexible这样的小部件类,它们的名字来源于 flex 布局概念。CSS 中的 flex 布局模型超出了本书的范围。然而,理解这个 W3C 规范仍然是有价值的,它可以帮助您更好地理解 Flutter 中的 flex 布局。

渲染对象

Flutter 中的布局算法负责确定渲染树中每个RenderObject实例的尺寸和位置。RenderObject class 非常灵活,可以使用任何坐标系或布局协议。RenderObject类用layout()方法定义了基本的布局协议。layout()方法有一个必需的Constraints类型的位置参数。类指定了孩子必须遵守的布局约束。对于一个特定的Constraints实例,可能有多个结果可以满足它。只要允许,孩子可以自由使用这些结果。有时,一个Constraints实例可能只给子进程留下一个有效的结果。这种Constraints实例据说很紧。严格约束通常不太灵活,但是它们提供了更好的性能,因为具有严格约束的小部件不需要重新布局。

layout()方法有一个命名参数parentUsesSize来指定父节点是否需要使用子节点计算的布局信息。如果parentUsesSize为真,意味着父布局依赖于子布局。在这种情况下,每当孩子需要布局时,家长可能也需要布局。布局完成后,每个RenderObject实例将有一些字段被设置为包含布局信息。实际存储的信息取决于布局实现。这条布局信息存储在parentData属性中。

默认情况下,Flutter 使用由RenderBox类实现的 2D 笛卡尔坐标系。RenderBox类用BoxConstraints类实现了盒子布局模型。在盒子布局模型中,每个RenderBox实例被视为一个矩形,其大小被指定为一个Size实例。每个盒子都有自己的坐标系。左上角的坐标是(0,0),右下角的坐标是(宽度,高度)。RenderBox类使用BoxParentData作为布局数据的类型。BoxParentData.offset属性指定在父坐标系中绘制子对象的偏移量。

框约束

一个BoxConstraints实例由四个命名的双参数指定:minWidthmaxWidthminHeightmaxHeight。这些值必须满足以下规则。double.infinity是约束的有效值:

  • 0.0 <= minWidth < = maxWidth < = double.infinity

  • 0.0 <= minHeight < = maxHeight < = double.infinity

在框布局之后,RenderBox实例的大小必须满足应用于它的BoxConstraints实例的约束:

  • minWidth < = Size.width < = maxWidth

  • minHeight < = Size.height < = maxHeight

如果轴中的最小约束和最大约束相同,则该轴被严格约束。例如,如果minWidthmaxWidth的值相同,那么 width 是紧的。当宽度和高度都很紧的时候,一个BoxConstraints实例被称为是紧的。如果一个轴上的最小约束是0.0,那么这个轴是松散的。如果最大约束在一个轴上不是无限的,那么这个轴是有界的;否则,这个轴是无界的。

布局算法

在长方体布局模型中,布局是使用渲染树一次完成的。它首先通过传递约束来遍历渲染树。在此阶段,渲染对象使用其父对象传递的约束进行布局。在第二阶段,它通过传递确定每个渲染对象的大小和偏移的具体结果来遍历渲染树。

布局小部件

Flutter 为不同的布局需求提供了一组布局小部件。这些小部件有两类。第一类是包含单个子组件的小部件,它们是SingleChildRenderObjectWidget类的子类。第二类是可以包含多个孩子的小部件,这些孩子是MultiChildRenderObjectWidget类的子类。这些小部件的构造函数也有类似的模式。第一个命名参数是类型为Keykey。单个子布局小部件构造器的最后一个命名参数是Widget类型的child,而多个子布局小部件构造器的最后一个命名参数是List<Widget>类型的children

这些布局小部件是RenderObjectWidget类的子类。RenderObjectWidget类用于配置RenderObjectElementRenderObjectElementRenderObject

5.2 将小部件放在中央

问题

您希望将一个小部件放在另一个小部件的中心。

解决办法

用一个Center小部件包装这个小部件。

讨论

要将一个小部件放在另一个小部件的中央,只需将该小部件包装在一个Center小部件中。这个小部件将被水平和垂直放置在Center小部件的中心。这个Center小部件将是原始父小部件的子部件。中心构造函数有两个命名参数widthFactorheightFactor,分别指定宽度和高度的尺寸因子。清单 5-2 展示了一个使用Center小部件的例子。

Center(
  widthFactor: 2.0,
  heightFactor: 2.0,
  child: Text("Center"),
)

Listing 5-2Example of Center widget

Center widget 实际上是Align widget 的子类,带有Alignment.centeralignment集合。Center微件的行为与配方 5-3 中讨论的Align微件相同。

5.3 对齐小部件

问题

您希望将一个小部件与其父小部件的不同位置对齐。

解决办法

用一个Align小部件包装这个小部件。

讨论

使用Align微件,您可以在不同位置对齐子微件。Align小部件构造器有一个AlignmentGeometry类型的命名参数 alignment 来指定对齐。Center widget 实际上是一种特殊的Align widget,其alignment总是设置为Alignment.centerAlign widget 构造器也有命名参数widthFactorheightFactor

class 有两个子类用于不同的情况。Alignment类代表视觉坐标中的对齐。Alignment有两个属性xy来表示 2D 坐标系矩形中的位置。属性xy分别指定水平和垂直方向上的位置。Alignment(0.0, 0.0)表示矩形的中心。单位 1.0 表示从矩形的中心到一边的距离。单位 2.0 表示矩形在特定方向上的长度。例如,x 的值 2.0 表示矩形的宽度。x的正值表示位置在中心的右侧,而x的负值表示位置在左侧。同样的规则也适用于y的值。Align有几个常用位置的常量;见表 5-1 。

表 5-1

对齐常数

|

名字

|

价值

|

描述

|
| --- | --- | --- |
| bottomCenter | Alignment(0.0, 1.0) | 底边的中心点。 |
| bottomLeft | Alignment(-1.0, 1.0) | 底边最左边的点。 |
| bottomRight | Alignment(1.0, 1.0) | 底边最右边的点。 |
| center | Alignment(0.0, 0.0) | 水平和垂直居中。 |
| centerLeft | Alignment(-1.0, 0.0) | 左边缘的中心点。 |
| centerRight | Alignment(1.0, 0.0) | 右边缘的中心点。 |
| topCenter | Alignment(0,0, -1.0) | 顶边的中心点。 |
| topLeft | Alignment(-1.0, -1.0) | 顶边最左边的点。 |
| topRight | Alignment(1.0, -1.0) | 顶边的最右点。 |

如果要在对齐时考虑文本方向,需要使用AlignmentDirectional类而不是Alignment类。AlignmentDirectional类拥有start属性而非xstart值的增长方向与文本方向相同。当文本方向为从左向右时,start 的值与Alignment中的x含义相同。如果文本方向是从右向左,则start的值与Alignment中的x相反。AlignmentDirectional类也有几个常量用于常用的位置;见表 5-2 。这些常量用startend代替leftright来表示不同的方向。

表 5-2

alignmentdireactional 常数

|

名字

|

价值

|

描述

|
| --- | --- | --- |
| bottomCenter | AlignmentDirectional (0.0, 1.0) | 底边的中心点。 |
| bottomStart | AlignmentDirectional(-1.0, 1.0) | 起点侧的底角。 |
| bottomEnd | AlignmentDirectional(1.0, 1.0) | 端侧的底角。 |
| center | AlignmentDirectional (0.0, 0.0) | 水平和垂直居中。 |
| centerStart | AlignmentDirectional(-1.0, 0.0) | 起始边的中心点。 |
| centerEnd | AlignmentDirectional(1.0, 0.0) | 末端边缘的中心点。 |
| topCenter | AlignmentDirectional(0,0, -1.0) | 顶边的中心点。 |
| topStart | AlignmentDirectional(-1.0, -1.0) | 起点侧的顶角。 |
| topEnd | AlignmentDirectional(1.0, -1.0) | 端侧的顶角。 |

AlignmentGeometryresolve()方法接受一个类型为TextDirection的参数,并返回一个Alignment实例。您可以使用这个方法将一个AlignmentDirectional实例转换成一个Alignment实例。

传递给其子对象的 constrained 是在这个小部件的 constraints 对象上调用loosen()方法的结果。这意味着孩子可以选择不超过这个部件的尺寸。小部件本身的大小取决于参数widthFactorheightFactor的值及其约束对象。对于宽度,如果widthFactor不为空或者constraints.maxWidthdouble.infinity,则宽度是受约束条件约束的最接近childWidth * (widthFactory ?? 1.0)的值。否则,宽度由约束决定。同样的规则也适用于身高。

清单 5-3 展示了一个使用Align小部件的例子。

Align(
  alignment: Alignment.topLeft,
  child: SizedBox(
    width: 200,
    height: 200,
    child: Center(
      child: Text("TopLeft"),
    ),
  ),
)

Listing 5-3Example of Align widget

5.4 对小部件施加约束

问题

您希望对小部件施加布局约束。

解决办法

使用ConstrainedBoxSizedBox

讨论

如配方 5-1 所述,ConstraintsBoxContraints实例通常分别用于RenderObjectRenderBoxlayout()方法中。构建小部件树时,您可能还想对小部件施加布局约束。在这种情况下,可以使用ConstrainedBox widget。ConstrainedBox构造函数有一个必需的类型为BoxConstraints的命名参数约束,用于指定施加在子对象上的约束。

SizedBox widget 可以被视为一种特殊的ConstrainedBoxSizedBox已经命名了参数widthheight,用于使用BoxConstraints.tightFor()方法创建一个紧约束。SizedBox(width: width, height: height, child: child)ConstrainedBox(constraints: BoxConstraints.tightFor(width: width, height: height), child: child)相同。如果你想施加严格的约束,那么SizedBoxConstrainedBox更方便。SizedBox有其他常用用例的命名构造函数;见表 5-3 。

表 5-3

SizedBox 构造函数

|

名字

|

意义

|

描述

|
| --- | --- | --- |
| SizedBox.expand() | SizedBox(width: double.infinity, height: double.infinity) | 只要它的父节点允许。 |
| SizedBox.shrink() | SizedBox(width: 0.0, height: 0.0) | 尽可能小。 |
| SizedBox.fromSize() | SizedBox(width: size.width; height: size.height) | 具有指定大小的盒子。 |

应用于子部件的实际约束是提供的constraints参数和由ConstrainedBoxSizedBox的父部件提供的约束的组合。通过调用providedContraints.enforce(parentContraints)完成组合。结果约束尊重父约束,并尽可能接近所提供的约束。ConstrainedBoxSizedBox的大小是布局后子控件的大小。

清单 5-4 展示了使用ConstrainedBoxSizedBox的四个例子。第一个例子是典型的SizedBox使用模式。带有SizedBox.shrink()的第二个示例导致图像不显示。第三个例子是典型的ConstrainedBox使用模式。最后一个例子显示了一个ConstrainedBox实例如何考虑来自父实例的约束。

SizedBox(
  width: 100,
  height: 100,
  child: Text('SizedBox'),
)

SizedBox.shrink(
  child: Image.network('https://picsum.photos/50'),
)

ConstrainedBox(
  constraints: BoxConstraints(
    maxWidth: 50,
    minHeight: 50,
  ),
  child: Text('ConstrainedBox'),
)

ConstrainedBox(
  constraints: BoxConstraints(
    maxWidth: 200,
  ),
  child: ConstrainedBox(
    constraints: BoxConstraints(
      maxHeight: 200,
    ),
    child: Image.network('https://picsum.photos/300'),
  ),
)

Listing 5-4Examples of ConstrainedBox and SizedBox

5.5 不对小部件施加任何限制

问题

您希望对小部件施加约束,以允许它们以自然大小呈现。

解决办法

使用UnconstrainedBox

讨论

UnconstrainedBox是配方 5-4 中ConstrainedBox的反义词。UnconstrainedBox对其子不加约束。孩子可以在UnconstrainedBox实例提供的无限空间上自由渲染。UnconstrainedBox将通过遵循自身约束的限制,尝试使用子部件的大小来确定自身的大小。

如果子微件的尺寸大于UnconstrainedBox所能提供的最大尺寸,子微件将被裁剪。否则,子小部件将根据类型AlignmentGeometry的参数 alignment 的值进行对齐。如果子级溢出了父级,则在调试模式下会显示一条警告。使用UnconstrainedBox时,仍然可以使用Axis类型的参数constrainedAxis向一个轴添加约束。则仅允许子对象在另一个轴上不受约束地进行渲染。

在清单 5-5 中,UnconstrainedBox小部件被放置在一个具有固定宽度和高度的SizedBox小部件中。UnconstrainedBox小部件被限制在水平轴上,这意味着最小和最大宽度都是 100 像素。图像的宽度是 200 像素,因此它被缩小到 100 像素以满足宽度限制。这导致图像高度缩小到 150 像素,超过了父SizedBox小部件的最大高度 100 像素。在调试模式下运行时,您可以看到警告消息,上面和下面溢出了 25px。

SizedBox(
  width: 100,
  height: 100,
  child: UnconstrainedBox(
    constrainedAxis: Axis.horizontal,
    child: Image.network('https://picsum.photos/200/300'),
  ),
)

Listing 5-5Example of UnconstrainedBox

5.6 忽略父窗口时对窗口小部件施加约束

问题

无论小部件放在哪里,您都希望施加约束。

解决办法

使用OverflowBox

讨论

当对小部件施加约束时,通常会考虑父小部件的约束。尊重父约束使得小部件的布局灵活,以适应不同的用例。有时,您可能希望小部件只考虑显式提供的约束,而忽略父约束。在这种情况下,可以使用OverflowBox

OverflowBox构造器已经命名了参数alignmentminWidthmaxWidthminHeightmaxHeight。如果任何约束相关参数为空,则使用父约束的相应值。如果为所有四个约束相关的参数提供非空值,那么OverflowBox的子元素的布局与当前的小部件完全无关。

在清单 5-6 中,OverflowBox小部件是用所有四个约束相关参数的非空值创建的,所以即使它被放在SizedBox小部件中,它的大小也总是Size(200, 200)

SizedBox(
  width: 100,
  height: 100,
  child: OverflowBox(
    minWidth: 200,
    minHeight: 200,
    maxWidth: 200,
    maxHeight: 200,
    child: Image.network('https://picsum.photos/300'),
  ),
)

Listing 5-6Example of OverflowBox

5.7 限制大小以允许子部件溢出

问题

您希望小部件有一个大小,并允许子小部件溢出。

解决办法

使用SizedOverflowBox

讨论

SizedOverflowBox是用尺寸创建的。小部件的实际大小符合其约束条件,并尽可能接近请求的大小。子布局仅使用SizedOverflowBox小部件的约束。

在清单 5-7 中,SizedOverflowBox小部件被放置在带有约束BoxConstraints.loose(Size(100, 100))ConstrainedBox小部件中。SizedOverflowBox小工具的请求大小为Size(50, 50)SizedOverflowBox的实际尺寸也是Size(50, 50)。子Image小部件只使用SizedOverflowBox的约束。结果是图像小部件的大小为Size(100, 100),溢出了它的父对象。

ConstrainedBox(
  constraints: BoxConstraints.loose(Size(100, 100)),
  child: SizedOverflowBox(
    size: Size(50, 50),
    child: Image.network('https://picsum.photos/400'),
  ),
)

Listing 5-7Example of SizedOverflowBox

5.8 无界时限制小部件的大小

问题

您有一个通常匹配其父级大小的小部件,但是您希望它用在需要大小约束的其他地方。

解决办法

使用LimitedBox

讨论

一些部件通常被设计得尽可能大,以匹配它们父母的尺寸。但是这些小部件需要在其他地方进行约束。例如,当这些小部件被添加到垂直列表中时,需要限制高度。LimitedBox构造函数已经命名了参数maxWidthmaxHeight来指定限制。如果LimitedBox小部件的最大宽度没有限制,那么其子部件的宽度限制为maxWidth。如果这个LimitedBox的最大高度是无界的,那么它的子级高度被限制为maxHeigth

在清单 5-8 中,一个LimitedBox小部件的maxHeight被设置为 100,所以孩子的最大高度是 100px。

LimitedBox(
  maxHeight: 100,
  child: Image.network('https://picsum.photos/400'),
)

Listing 5-8Example of LimitedBox

5.9 缩放和定位小部件

问题

您想要缩放和定位一个小部件。

解决办法

使用不同装配模式和校准的FittedBox

讨论

配方 5-3 中的对齐部件可以使用不同的对齐方式来定位其子部件。FittedBox小部件支持其子部件的缩放和定位。使用BoxFit类型的参数fit指定适合模式。BoxFit是枚举类型,其值如表 5-4 所示。

表 5-4

BoxFit 值

|

名字

|

描述

|
| --- | --- |
| fill | 填充目标框。源的纵横比被忽略。 |
| contain | 尽可能大以将源完全包含在目标框中。 |
| cover | 尽可能小以覆盖整个目标框。 |
| fitWidth | 仅确保显示源的整个宽度。 |
| fitHeight | 仅确保显示源的完整高度。 |
| none | 在目标框内对齐源,并丢弃框外的任何内容。 |
| scaleDown | 将源与目标框对齐,必要时缩小以确保源适合目标框。如果源被收缩,这与 contain 相同;否则和没有一样。 |

FittedBox通常在显示图像时使用。清单 5-9 显示了一个示例,演示了BoxFit的不同值。ImageBox小部件使用SizedBox小部件来限制其大小,并将图像放在FittedBox小部件中。DecoratedBox小部件创建一个红色边框来显示ImageBox小部件的边界。

class FitPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Fit'),
      ),
      body: Center(
        child: Wrap(
          spacing: 20,
          runSpacing: 20,
          alignment: WrapAlignment.spaceAround,
          children: <Widget>[
            ImageBox(fit: BoxFit.fill),
            ImageBox(fit: BoxFit.contain),
            ImageBox(fit: BoxFit.cover),
            ImageBox(fit: BoxFit.fitWidth),
            ImageBox(fit: BoxFit.fitHeight),
            ImageBox(fit: BoxFit.none),
            ImageBox(fit: BoxFit.scaleDown),
          ],
        ),
      ),
    );
  }
}

class ImageBox extends StatelessWidget {
  const ImageBox({
    Key key,
    this.boxWidth = 150,
    this.boxHeight = 170,
    this.imageWidth = 200,
    this.fit,
  });

  final double boxWidth;
  final double boxHeight;
  final double imageWidth;
  final BoxFit fit;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(border: Border.all(color: Colors.red)),
      child: SizedBox(
        width: boxWidth,
        height: boxHeight,
        child: FittedBox(
          fit: fit,
          child: SizedBox(
            width: imageWidth,
            height: imageWidth,
            child: Image.network('https://dummyimage.com/${imageWidth.toInt()}'
                '&text=${fit.toString().substring(7)}'),
          ),
        ),
      ),
    );
  }
}

Listing 5-9Different values of BoxFit

图 5-1 显示了清单 5-9 中的代码截图。图像中的文本显示了这个ImageBox小部件中使用的BoxFit值。

img/479501_1_En_5_Fig1_HTML.jpg

图 5-1

BoxFit 的不同值

5.10 旋转部件

问题

你想旋转一个部件。

解决办法

使用RotatedBox

讨论

RotatedBox小组件在布局前旋转其子组件。旋转由带有quarterTurns参数的int类型的顺时针四分之一圈指定。quarterTurns参数值 1 表示顺时针旋转 90 度。

在清单 5-10 中,Text小部件旋转了四分之一圈。

RotatedBox(
  quarterTurns: 1,
  child: Text(
    'Hello World',
    textScaleFactor: 2,
  ),
)

Listing 5-10Example of RotatedWidget

5.11 显示部件时添加填充

问题

您希望在小部件周围添加填充。

解决办法

使用Padding

讨论

填充小部件在其子部件周围创建空白空间。传递给其子级的布局约束是小部件通过填充缩小后的约束,这会导致子级以较小的尺寸布局。填充是在类型EdgeInsetsGeometry的必需padding参数中指定的。

AlignmentGeometry类似,EdgeInsetsGeometry有两个子类EdgeInsetsEdgeInsetsDirectionalEdgeInsets类表示视觉坐标中的偏移量。偏移值是针对leftrighttopbottom边缘指定的。表 5-5 显示了EdgeInsets类的构造函数。

表 5-5

EdgeInsets 构造函数

|

名字

|

描述

|
| --- | --- |
| EdgeInsets.all() | 所有的偏移量都有给定值。 |
| EdgeInsets.fromLTRB() | 指定左、上、右和下边缘的偏移值。 |
| EdgeInsets.only() | 它具有命名参数 left、top、right 和 bottom,默认值为 0.0。 |
| EdgeInsets.symmetric() | 它已命名参数垂直和水平,以创建对称的偏移。 |

考虑到文字方向,应该用EdgeInsetsDirectional类代替EdgeInsetsEdgeInsetsDirectional级用startend代替leftright。它有EdgeInsetsDirectional.fromSTEB()构造器来从starttopendbottom的偏移量创建 insets。EdgeInsetsDirectional.only()构造器与EdgeInsets.only()类似。

清单 5-11 展示了一个Padding小部件的例子。

Padding(
  padding: EdgeInsets.all(20),
  child: Image.network('https://picsum.photos/200'),
)

Listing 5-11Example of Padding

5.12 根据纵横比确定部件的大小

问题

您希望调整小部件的大小以保持特定的纵横比。

解决办法

使用AspectRatio

讨论

AspectRatio构造函数有必需的参数aspectRatio来指定宽高比的值。例如,4:3 的纵横比使用 4.0/3.0 的值。AspectRatio widget 试图找到最佳的尺寸来保持纵横比,同时遵守其布局约束。

该过程从将宽度设置为约束的最大宽度开始。如果最大宽度是有限的,那么高度由width / aspectRatio计算。否则,高度设置为约束的最大高度,宽度设置为height * aspectRatio。可能需要额外的步骤来确保结果的宽度和高度符合布局约束。例如,如果高度小于约束的最小高度,则高度设置为该最小值,宽度根据高度和纵横比计算。一般规则是先检查宽度再检查高度,先检查最大值再检查最小值。最终尺寸可能不满足比例要求,但必须满足布局限制。

在清单 5-12 中,AspectRatio小部件被放在一个ConstrainedBox中,带有一个Size(200, 200)的松散约束。长宽比是4.0/3.0,所以高度是根据200 / (4.0 / 3.0) = 150.0计算的。ApsectRatio的结果大小为Size(200.0, 150.0)

ConstrainedBox(
  constraints: BoxConstraints.loose(Size(200, 200)),
  child: AspectRatio(
    aspectRatio: 4.0 / 3.0,
    child: Image.network('https://picsum.photos/400/300'),
  ),
)

Listing 5-12Example of AspectRatio

5.13 转换小部件

问题

您想要在小部件上应用变换。

解决办法

使用Transform

讨论

变换小部件可以在绘制之前对其子部件应用变换。使用Matrix4实例来表示转换。Transform构造器有命名参数,如表 5-6 所示。

表 5-6

转换的命名参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| transform | Matrix4 | 矩阵来变换子对象。 |
| origin | Offset | 要应用变换的坐标系的原点。 |
| alignment | AlignmentGeometry | 原点对齐。 |
| transformHitTests | bool | 执行点击测试时是否应应用转换。 |

Transform类有其他构造函数来创建公共转换:

  • Tranform.rotate()–通过旋转指定角度来变换孩子。

  • Transform.scale()–用指定的比例因子均匀缩放,变换子对象。

  • Transform.translate()–通过平移指定的偏移量来变换子对象。

清单 5-13 展示了使用Transform的命名构造函数的例子。

Transform.rotate(
  angle: pi / 4.0,
  origin: Offset(10, 10),
  child: Text('Hello World'),
)

Transform.translate(
  offset: Offset(50, 50),
  child: Text('Hello World'),
)

Listing 5-13Examples of Transform

5.14 控制小部件上不同的布局方面

问题

您希望为一个小部件定义不同的布局方面。

解决办法

使用容器。

讨论

Flutter 有许多小部件来控制布局的不同方面。比如SizedBox widget 控制大小,Align widget 控制对齐。如果您想在同一个小部件上控制不同的布局,您可以用嵌套的方式包装这些小部件。实际上,Flutter 提供了一个Container小部件,使得定义不同的布局方面变得更加容易。

表 5-7 显示了Container构造器的命名参数。您不能同时为colordecoration提供非空值,因为颜色只是用值BoxDecoration(color: color)创建装饰的一种速记。如果widthheight不为空,它们的值用于收紧约束。

表 5-7

容器的命名参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| alignment | 对齐几何图形 | 子对象的对齐方式。 |
| padding | EdgeInsetsGeometry | 装饰内部的空白空间。 |
| color | Color | 背景颜色。 |
| decoration | Decoration | 装饰要画在孩子背后。 |
| foregroundDecoration | Decoration | 装饰在孩子面前画画。 |
| width | double | 子对象的宽度。 |
| height | double | 孩子的身高。 |
| constraints | BoxConstraints | 附加约束。 |
| margin | EdgeInsetsGeometry | 装饰周围的空白空间。 |
| transform | Matrix4 | 应用于容器的转换。 |

Container是基于参数值的不同小部件的组合。清单 5-14 显示了Container使用的不同部件的嵌套结构以及这些部件可能使用的参数。如果参数的值为 null,那么相应的小部件可能不存在。

Transform (transform)
  - Padding (margin)
    - ConstrainedBox (constraints, width, height)
      - DecoratedBox (foregroundDecoration)
        - DecoratedBox (decoration, color)
          - Padding (padding, decoration)
            - Align (alignment)
              - child

Listing 5-14Structure of Container

清单 5-15 展示了一个使用所有命名参数的Container小部件的例子。

Container(
  alignment: Alignment.bottomRight,
  padding: EdgeInsets.all(16),
  color: Colors.red.shade100,
  foregroundDecoration: BoxDecoration(
    image: DecorationImage(
      image: NetworkImage('https://picsum.photos/100'),
    ),
  ),
  width: 300,
  height: 300,
  constraints: BoxConstraints.loose(Size(400, 400)),
  margin: EdgeInsets.all(32),
  transform: Matrix4.rotationZ(0.1),
  child: Text(
    'Hello World',
    textScaleFactor: 3,
  ),
)

Listing 5-15Example of Container

图 5-2 显示了清单 5-15 中容器小部件的结构。您可以清楚地看到这些小部件是如何嵌套的。

img/479501_1_En_5_Fig2_HTML.jpg

图 5-2

容器的结构

5.15 实施柔性框布局

问题

您有多个小部件要布局,并且您希望它们能够占据额外的空间。

解决办法

使用 Flex、Column、Row、Flexible 和 Expanded。

讨论

要使用 flex box 模型布局多个小部件,可以使用 Flutter 提供的一组小部件,包括 flex、Column、Row、Flexible、Expanded 和 Spacer。事实上,只有 Flex 和灵活的小部件才是理解的重点。Flex 小部件用作布局容器,而 Flexible 小部件用于将子小部件包装在容器内。Flex 小部件在一维数组中显示其子部件。它支持水平和垂直两个方向的子布局。Row 和 Column 是 Flex 的子类,分别只在水平和垂直方向放置子元素。Flex 容器的灵活小部件可以控制子容器如何伸缩以占用额外的空间。Flex 小部件的子部件可以是灵活的,也可以不是。如果你想让一个孩子变得灵活,你可以简单地把它包在一个灵活的小部件里。

与 CSS flex 框布局相同,flex 小部件使用两个轴进行布局。儿童被放置的轴是主轴。另一根轴是横轴。使用轴类型的方向参数配置主轴。如果值是 Axis.horizontal,则主轴是水平轴,而横轴是垂直轴。如果值是 Axis.vertical,则主轴是垂直轴,而横轴是水平轴。Row 小工具总是以横轴为主轴,Column 小工具总是以纵轴为主轴。如果主轴是已知的,那么应该使用行或列小部件,而不是 Flex 小部件。

柔性框布局算法

Flex 子项的布局很复杂,需要多个步骤。第一步是用 null 或零伸缩因子来布局每个子元素。这些是不灵活的孩子。用于布局这些子项的约束取决于 crossAxisAlignment 的值。如果 crossAxisAlignment 的值为 CrossAxisAlignment.stretch,则约束将是横轴上最大尺寸的紧横轴约束。否则,约束仅设置横轴的最大值。例如,如果方向是 Axis.horizontal,而 crossAxisAlignment 是 CrossAxisAlignment.stretch,则这些非柔性子级的约束会将 minHeight 和 maxHeight 都设置为 Flex 约束的 maxHeight。这使得这些孩子占据了横轴上的所有空间。在第一步中,记录为这些子节点分配的总大小和跨轴大小的最大值。

第二步是用一个弹性系数来布置每个孩子。这些是灵活的孩子。从第一步开始,主轴的分配尺寸是已知的。可以根据主轴的最大大小和分配大小来计算空闲空间。自由空间根据弹性系数在所有弹性子节点之间分配。弹性系数为 2.0 的孩子将获得两倍于弹性系数为 1.0 的孩子的可用空间。假设有三个伸缩因子为 1.0、2.0 和 3.0 的子节点,如果可用空间为 120px,那么这些子节点将分别获得 20px、40px 和 60px 的空间。基于每个子项的弹性系数计算的值将是主轴上的最大约束。主轴上的最小约束取决于子项的 FlexFit 值。如果配合值为 FlexFit.tight,则最小值与最大值相同,这将在主轴上创建紧密约束。如果拟合值为 FlexFit.loose,则最小值为 0.0,这将在主轴上创建松散约束。横轴上的约束与 Flex 小部件的约束相同。最终约束用于布局这些 flex 子元素。

第三步是确定主轴和横轴的范围。如果 mainAxisSize 的值为 MainAxisSize.max,则主轴范围是当前 Flex 小部件的最大约束。否则,主轴范围就是为所有子级分配的大小。横轴的范围是所有子级的横轴约束的最大值。

最后一步是根据 mainAxisAlignment 和 crossAxisAlignment 的值确定每个子元素的位置。

表 5-8 显示了 enum MainAxisAlignment 的值。

表 5-8

MainAxisAlignment 值

|

名字

|

描述

|
| --- | --- |
| start | 将孩子放在靠近主轴起点的地方。水平方向的起始位置由 TextDirection 确定,垂直方向的起始位置由 VerticalDirection 确定。 |
| end | 把孩子们放在靠近主轴末端的地方。使用与开始相同的方式确定结束位置。 |
| center | Place the children close to the middle. |
| spaceBetween | 在孩子之间平均分配空闲空间。 |
| spaceAround | 在第一个和最后一个孩子前后各一半的空间,在孩子之间平均分配可用空间。 |
| spaceEvenly | 在子节点之间均匀分布可用空间,包括第一个和最后一个子节点的前后。 |

表 5-9 显示了枚举交叉轴分配的值。

表 5-9

交叉轴对齐值

|

名字

|

描述

|
| --- | --- |
| start | 放置子对象,使起始边与横轴的起始边对齐。水平方向的起始位置由 TextDirection 确定,垂直方向的起始位置由 VerticalDirection 确定。 |
| end | 放置子对象,使其端边与横轴的端边对齐。使用与开始相同的方式确定结束位置。 |
| center | 将孩子放在与横轴中心对齐的位置。 |
| stretch | 要求孩子们填写横轴。 |
| baseline | 在横轴上匹配子项的基线。 |

灵活的

Flexible 使用 flex 参数来指定伸缩因子,使用 fit 参数来指定 BoxFit 值。flex 参数的默认值为 1,而 Fit 的默认值为 box fit . loose。Expanded 是 Flexible 的子类,其 fit 参数设置为 BoxFit.tight。

在清单 5-16 中,Column 小部件被放在一个 LimitedBox 小部件中以限制其高度。Column 小部件的所有子部件都是非灵活的。

LimitedBox(
  maxHeight: 320,
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.end,
    mainAxisAlignment: MainAxisAlignment.spaceAround,
    children: <Widget>[
      Image.network('https://picsum.photos/50'),
      Image.network('https://picsum.photos/70'),
      Image.network('https://picsum.photos/90'),
    ],
  ),
)

Listing 5-16Flex widget with non-flexible children

在清单 5-17 中,列小部件既有灵活的子部件,也有非灵活的子部件。可以通过包装灵活或扩展的小部件来创建灵活的小部件。

LimitedBox(
  maxHeight: 300,
  child: Column(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    children: <Widget>[
      Flexible(
        child: Image.network('https://picsum.photos/50'),
      ),
      Image.network('https://picsum.photos/40'),
      Expanded(
        child: Image.network('https://picsum.photos/50'),
      ),
      Expanded(
        flex: 2,
        child: Image.network('https://picsum.photos/50'),
      ),
    ],
  ),
)

Listing 5-17Flex widget with flexible and non-flexible children

5.16 显示重叠的小部件

问题

您希望布局可能相互重叠的小部件。

解决办法

使用 Stack 或 IndexedStack。

讨论

堆栈小部件的子部件可以是定位的,也可以是非定位的。定位的子控件包装在一个定位的小部件中,至少有一个非空属性。堆栈小部件的大小由所有未定位的子部件决定。布局过程有两个阶段。

第一阶段是布局所有未定位的子节点。用于未定位子级的约束取决于 StackFit 类型的 fit 属性值:

  • stack fit . Loose–由 constraints.loosen()创建的松散约束

  • stack filt . expand–由 box constraints . Tight(constraints . maximum)创建的紧密约束

  • Stack filt . pass through–与堆栈小部件具有相同的约束

堆栈小部件的大小由所有未定位子部件的最大大小决定。

在第二阶段,根据对齐属性定位所有未定位的子对象。用于定位子对象的约束由堆栈小部件的大小及其属性决定。定位的小部件有六个属性:左、上、右、下、宽度和高度。属性 left、right 和 width 用于确定紧密宽度约束。“顶部”、“底部”和“高度”属性用于确定紧密高度约束。例如,如果 left 和 right 值都不为 null,则紧宽度约束为 width of stack–right–left。然后,基于两个轴上的左、右、上、下值定位定位的子对象。如果所有这些值都为空,则基于对齐方式对其进行定位。

Stack 的子元素按顺序绘制,第一个子元素在底部。子数组中的顺序决定了子数组如何相互重叠。

IndexedStack 类是 Stack 的子类。IndexedStack 实例仅显示子级列表中的单个子级。IndexedStack 构造函数不仅具有与 Stack 构造函数相同的参数,还包括一个 int 类型的参数 index,用于指定要显示的子级的索引。如果参数 index 的值为 null,则不会显示任何内容。IndexedStack 的布局与 Stack 相同。IndexedStack 类只是用不同的方式来绘制自己。这意味着即使只显示了一个子元素,所有的子元素仍然需要像 Stack 一样进行布局。

清单 5-18 显示了一个带有定位子控件的堆栈小部件的例子。

Stack(
  children: <Widget>[
    Image.network('https://picsum.photos/200'),
    Image.network('https://picsum.photos/100'),
    Positioned(
      right: 0,
      bottom: 0,
      child: Image.network('https://picsum.photos/150'),
    ),
  ],
)

Listing 5-18Example of Stack

5.17 在多次运行中显示小部件

问题

您希望在多个水平或垂直方向上显示小部件。

解决办法

使用包装。

问题

Flex widget 不允许子项的大小超过主轴的大小。包装部件创建新的运行;没有足够的空间容纳孩子们。表 5-10 显示了包装构造器的命名参数。

表 5-10

Wrap 的命名参数

|

名字

|

价值

|

缺省值

|

描述

|
| --- | --- | --- | --- |
| direction | Axis | 轴.水平 | 主轴方向。 |
| alignment | WrapAlignment | 环绕对齐. start | 在主轴上对齐一个行程内的子件。 |
| spacing | Double | Zero | 在主轴上跑步的孩子之间的距离。 |
| runAlignment | WrapAlignment | 环绕对齐. start | 横轴上的运行对齐。 |
| runSpacing | Double | Zero | 横轴上运行之间的空间。 |
| crossAxisAlignment | WrapCrossAlignment | WrapCrossAlignment.start | 在横轴上排列一个行程内的子件。 |
| textDirection | TextDirection |   | 水平排列子项的顺序。 |
| verticalDirection | VerticalDirection | 垂直方向.向下 | 垂直排列子对象的顺序。 |
| children | List<Widget> | [] | 孩子们。 |

WrapAlignment 枚举与 MainAxisAlignment 具有相同的值。WrapCrossAlignment 枚举只有 start、end 和 center 值。

清单 5-19 展示了一个通过包装十个图像部件来包装部件的例子。

Wrap(
  spacing: 10,
  runSpacing: 5,
  crossAxisAlignment: WrapCrossAlignment.center,
  children: List.generate(
    10,
    (index) => Image.network('https://picsum.photos/${50 + index * 10}'),
  ),
)

Listing 5-19Example of Wrap

5.18 创建自定义单个子布局

问题

您希望为一个孩子创建自定义布局。

解决办法

使用 CustomSingleChildLayout。

讨论

如果那些针对单个子节点的内置布局小部件不能满足您的要求,您可以使用 CustomSingleChildLayout 创建一个自定义布局。CustomSingleChildLayout 小部件只是将布局委托给一个 SingleChildLayoutDelegate 实例。您需要创建自己的 SingleChildLayoutDelegate 子类来实现表 5-11 中所示的方法。

表 5-11

SingleChildLayoutDelegate 的方法

|

名字

|

描述

|
| --- | --- |
| getConstraintsForChild(BoxConstraints constraints) | 获取子对象的约束。 |
| getPositionForChild(Size size, Size childSize) | 根据这个小部件和子部件的大小获取子部件的位置。 |
| getSize(BoxConstraints constraints) | 获取此小部件的大小。 |
| shouldRelayout() | 应该重新布局。 |

这个小部件的大小是应用约束后 delegate 的 getSize()方法返回的大小的结果。使用委托的 getConstraintsForChild()方法返回的约束完成子元素的布局。最后,用委托的 getPositionForChild()方法返回的值更新 child 的位置。

在清单 5-20 中,FixedPositionLayoutDelegate 类覆盖 getSize()方法来提供父小部件的大小。它还重写 getPositionForChild()方法来提供子级的位置。getConstraintsForChild()方法也被重写以返回紧缩约束。

class FixedPositionLayoutDelegate extends SingleChildLayoutDelegate {
  @override
  bool shouldRelayout(SingleChildLayoutDelegate oldDelegate) {
    return false;
  }

  @override
  Size getSize(BoxConstraints constraints) {
    return constraints.constrain(Size(300, 300));
  }

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return constraints.tighten(width: 300, height: 300);
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return Offset(50, 50);
  }
}

Listing 5-20Custom single child layout delegate

清单 5-21 展示了如何使用 FixedPositionLayoutDelegate。

CustomSingleChildLayout(
  delegate: FixedPositionLayoutDelegate(),
  child: Image.network('https://picsum.photos/100'),
)

Listing 5-21Example of FixedPositionLayoutDelegate

5.19 创建自定义多个子布局

问题

您想要为多个子项创建自定义布局。

解决办法

请使用 CustomMultiChildLayout 和 MultiChildLayoutDelegate。

讨论

如果这些用于多个孩子的内置小部件不能满足您的要求,您可以使用 CustomMultiChildLayout 创建一个自定义布局。与 CustomSingleChildLayout 类似,CustomMultiChildLayout 将布局逻辑委托给 MultiChildLayoutDelegate 实例。CustomMultiChildLayout 的所有子级必须包装在 LayoutId 小部件中,以便为它们提供唯一的 Id。在表 5-12 中显示的所有方法中,必须实现 performLayout()和 shouldRelayout()方法。所有其他方法都有默认实现。在 performLayout()方法的实现中,layoutChild()方法必须为每个子级调用一次。

表 5-12

MultiChildLayoutDelegate 的方法

|

名字

|

描述

|
| --- | --- |
| hasChild(Object childId) | 检查具有给定 id 的子级是否存在。 |
| layoutChild(Object childId, BoxConstraints constraints) | 使用提供的约束布局子对象。 |
| positionChild(Object childId, Offset offset) | 用给定的偏移量定位子对象。 |
| getSize(BoxConstraints constraints) | 获取此小部件的大小。 |
| performLayout(Size size) | 实际布局逻辑。 |
| shouldRelayout() | 应该重新布局。 |

清单 5-22 显示了一个定制的多子布局委托。此委托使用递增的 int 值作为布局 id。子项的布局 id 必须从 0 开始。在 performLayout()方法中,对每个子元素调用 layoutChild()方法,从具有宽松约束的第一个子元素开始,这允许第一个子元素采用自然大小。记录第一个孩子的实际大小。然后用 Offset.zero 调用 positionChild()方法,将第一个孩子放在左上角。在第一个子级之后,对所有其他子级调用 layoutChild()和 positionChild()方法,分别增加大小和位置偏移量。

class GrowingSizeLayoutDelegate extends MultiChildLayoutDelegate {
  @override
  void performLayout(Size size) {
    int index = 0;
    Size childSize = layoutChild(index, BoxConstraints.loose(size));
    Offset offset = Offset.zero;
    positionChild(index, offset);
    index++;

    while (hasChild(index)) {
      double sizeFactor = 1.0 + index * 0.1;
      double offsetFactor = index * 10.0;
      childSize = layoutChild(
          index,
          BoxConstraints.tight(Size(
              childSize.width * sizeFactor, childSize.height * sizeFactor)));
      offset = offset.translate(offsetFactor, offsetFactor);
      positionChild(index, offset);
      index++;
    }
  }

  @override
  bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) {
    return false;
  }

  @override
  Size getSize(BoxConstraints constraints) =>
      constraints.constrain(Size(400, 400));
}

Listing 5-22Custom multiple children layout delegate

清单 5-23 显示了 GrowingSizeLayoutDelegate 的用法。CustomMultiChildLayout 的子级是 SizedBox 中嵌套的六个图像。包装 LayoutId 小部件需要将布局 Id 传递给委托。

CustomMultiChildLayout(
  delegate: GrowingSizeLayoutDelegate(),
  children: List.generate(
    6,
    (index) => LayoutId(
          id: index,
          child: DecoratedBox(
            decoration:
                BoxDecoration(border: Border.all(color: Colors.red)),
            child: SizedBox(
              width: 70,
              height: 70,
              child: Image.network(
                  'https://dummyimage.com/${50 + index * 10}'),
            ),
          ),
        ),
  ),
)

Listing 5-23Example of GrowingSizeLayoutDelegate

图 5-3 显示了使用 GrowingSizeLayoutDelegate 的结果。

img/479501_1_En_5_Fig3_HTML.jpg

图 5-3

使用 GrowingSizeLayoutDelegate 的结果

5.20 摘要

有了 Flutter 中的布局小部件,很容易满足构建 Flutter 应用的常见布局需求。本章涵盖了许多针对单个子节点和多个子节点的布局小部件。在下一章,我们将讨论表单小部件。

六、表单小部件

在移动应用中,表单控件对于与用户进行交互非常重要。Flutter 提供了一组用于材质设计和 iOS 风格的表单小部件。这些表单小部件通常没有内部状态。它们的外观和行为完全由构造函数参数定义。有了祖先小部件中维护的状态,表单小部件被重新呈现以反映状态变化。本章介绍了表单小部件的基本用法。

6.1 收集文本输入

问题

您希望收集文本输入。

解决办法

材质设计使用 TextField,iOS 风格使用 CupertinoTextField。

讨论

要在 Flutter 应用中收集用户输入,可以使用 TextField widget 进行材质设计,或者使用 CupertinoTextField widget 进行 iOS 风格设计。这两种小部件具有相似的使用模式和行为。事实上,这两个小部件包装了相同的可编辑文本,该文本提供了基本的文本输入功能,并支持滚动、选择和光标移动。EditableText 是一个高度可定制的小部件,具有许多命名参数。这个菜谱重点介绍如何设置 TextField 或 CupertinoTextField 小部件的初始值,并从中获取文本。

可编辑文本小部件的文本由 TextEditingController 实例控制。创建新的 EditableText 小部件时,可以使用 controller 参数设置 TextEditingController 实例。控制器维护与相应的 EditableText 小部件的双向数据绑定。控制器有一个 text 属性来跟踪当前编辑的文本,还有一个 TextSelection 类型的 selection 属性来跟踪当前选定的文本。每当用户修改或选择 EditableText 小部件中的文本时,关联的 TextEditingController 实例的 text 和 selection 属性都会更新。如果修改 TextEditingController 实例的文本或选择属性,EditableText 小部件将会自我更新。TextEditingController 类是 ValueNotifier 的子类,因此您可以向控制器添加侦听器,以便在文本或选择发生变化时获得通知。创建新的 TextEditingController 实例时,可以用 text 参数传递一些文本,这些文本将成为相应的 EditableText 小部件的初始文本。

让我们看看从 EditableText 小部件获取文本的三种不同方式。

使用 TextEditingController

第一种方式是使用 TextEditingController。清单 6-1 中的 ReverseText 小部件用于反转输入字符串。使用初始文本“<输入>创建 TextEditingController 实例。当按下按钮时,_value 更新为从控制器中检索的文本。将显示反转的字符串。

class ReverseText extends StatefulWidget {
  @override
  _ReverseTextState createState() => _ReverseTextState();
}

class _ReverseTextState extends State<ReverseText> {
  final TextEditingController _controller = TextEditingController(
    text: "<input>",
  );
  String _value;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Row(
          children: <Widget>[
            Expanded(
              child: TextField(
                controller: _controller,

              ),
            ),
            RaisedButton(
              child: Text('Go'),
              onPressed: () {
                this.setState(() {
                  _value = _controller.text;
                });
              },
            ),
          ],
        ),
        Text( (_value ?? "). split(").reversed.join()),
      ],
    );

  }
}

Listing 6-1Use TextEditingController to get text

图 6-1 显示了清单 6-1 中的代码截图。

img/479501_1_En_6_Fig1_HTML.jpg

图 6-1

使用 TextEditingController

使用 TextEditingController 的侦听器

TextEditingController 实例也是 ValueNotifier 的实例,因此您可以向它添加侦听器并对通知做出反应。在清单 6-2 中,监听器 function _handleTextChanged 在收到变更通知时调用 setState()函数来更新状态。侦听器在 initState()函数中添加,在 dispose()函数中删除,这确保资源得到正确清理。

class ReverseTextWithListener extends StatefulWidget {
  @override
  _ReverseTextWithListenerState createState() =>
      _ReverseTextWithListenerState();
}

class _ReverseTextWithListenerState extends State<ReverseTextWithListener> {
  TextEditingController _controller;
  String _value;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController(
      text: "<input>",
    );
    _controller.addListener(_handleTextChanged);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        TextField(
          controller: _controller,
        ),
        Text( (_value ?? "). split(").reversed.join()),
      ],
    );
  }

  @override

  void dispose() {
    _controller.removeListener(_handleTextChanged);
    super.dispose();
  }

  void _handleTextChanged() {
    this.setState(() {
      this._value = _controller.text;
    });
  }
}

Listing 6-2Use TextEditingController listener

图 6-2 显示了清单 6-2 中的代码截图。

img/479501_1_En_6_Fig2_HTML.jpg

图 6-2

使用 TextEditingController 侦听器

使用回调

从 EditableText 小部件获取文本的最后一种方法是使用回调。与文本编辑相关的回调有三种类型;见表 6-1 。

表 6-1

可编辑文本回调

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| onChanged | 值已更改 | 当文本改变时调用。 |
| onEditingComplete | 无效回拨 | 当用户提交文本时调用。 |
| onSubmitted | 值已更改 | 当用户完成编辑文本时调用。 |

如果你想主动观察文本的变化,你应该使用 onChanged 回调。当用户完成编辑文本时,onEditingComplete 和 onSubmitted 回调都将被调用。区别在于 onEditingComplete 回调不提供对提交文本的访问。

在清单 6-3 中,不同的消息被记录在不同的回调中。所有日志消息都显示在 RichText 小部件中。

class TextFieldCallbacks extends StatefulWidget {
  @override
  _TextFieldCallbacksState createState() => _TextFieldCallbacksState();
}

class _TextFieldCallbacksState extends State<TextFieldCallbacks> {
  List<String> _logs = List();

  void _log(String value) {
    this.setState(() {
      this._logs.add(value);
    });
  }

  @override

  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        TextField(
          onChanged: (text) => _log('changed: $text'),
          onEditingComplete: () => _log('completed'),
          onSubmitted: (text) => _log('submitted: $text'),
        ),
        Text.rich(TextSpan(
          children: this._logs.map((log) => TextSpan(text: '$log\n')).toList(),
        )),
      ],
    );
  }
}

Listing 6-3EditableText callbacks

图 6-3 显示了清单 6-3 中的代码截图。

img/479501_1_En_6_Fig3_HTML.jpg

图 6-3

可编辑文本回调

尽管清单 6-1 、 6-2 和 6-3 中的例子使用了 TextField,但同样的模式也可以应用于 CupertinoTextField。

6.2 自定义文本输入键盘

问题

您想要自定义用于编辑文本的键盘。

解决办法

使用 keyboardType、textInputAction 和 keyboardAppearance 参数。

讨论

EditableText 小部件允许定制用于编辑文本的键盘。可以使用 TextInputType 类的 keyboardType 参数来设置适合文本的键盘类型。例如,如果 EditableText 小部件用于编辑电话号码,那么 TextInputType.phone 是 keyboardType 参数的更好选择。表 6-2 显示了 TextInputType 中的常量。TextInputType.number 常量用于不带小数点的无符号数字。对于其他类型的数字,可以使用 textinputtype . numberwithoptions({ bool signed:false,bool decimal: false })构造函数来设置数字是应该带符号还是应该包含小数点。

表 6-2

TextInputType 常量

|

名字

|

描述

|
| --- | --- |
| text | 纯文本。 |
| multiline | 多行文本。 |
| number | 不带小数点的无符号数。 |
| phone | 电话号码。 |
| datetime | 日期和时间。 |
| emailAddress | 电子邮件地址。 |
| url | 网址。 |

TextInputAction 枚举类型的 textInputAction 参数设置用户提交文本时要执行的逻辑操作。例如,如果文本字段用于输入搜索查询,那么 TextInputAction.search 值使键盘显示文本“search”。用户可以期望在点击动作按钮之后执行搜索动作。TextInputAction 枚举定义了一组操作。这些操作的按钮在不同平台或同一平台的不同版本上可能有不同的外观。Android 和 iOS 都支持这些操作。它们被映射到 Android 上的 IME 输入类型和 iOS 上的键盘返回类型。表 6-3 显示了 TextInputAction 的值及其在 Android 和 iOS 上的映射。某些操作可能仅在 Android 或 iOS 上受支持。使用不支持的操作将导致在调试模式下引发错误。但是在发布模式下,不支持的动作会分别映射到 Android 上的 IME _ 动作 _ 未指定和 iOS 上的 UIReturnKeyDefault。

表 6-3

TextInputAction 值

|

名字

|

安卓 IME 输入类型

|

iOS 键盘返回类型

|
| --- | --- | --- |
| none | IME_ACTION_NONE(无) | 不适用的 |
| unspecified | IME _ 行动 _ 未指明 | UIReturnKeyDefault 默认 |
| done | IME _ 行动 _ 完成 | UIReturnKeyDone |
| search | IME _ 行动 _ 搜索 | UIReturnKeySearch |
| send | IME _ 行动 _ 发送 | UIReturnKeySend |
| next | IME _ 行动 _ 下一步 | UIReturnKeyNext |
| previous | IME _ 行动 _ 先前 | 不适用的 |
| continueAction | 不适用的 | UIReturnKeyContinue |
| join | 不适用的 | UIReturnKeyJoin |
| route | 不适用的 | UIReturnKeyRoute |
| emergencyCall | 不适用的 | UIReturnKeyEmergencyCall |
| newline | IME_ACTION_NONE(无) | UIReturnKeyDefault 默认 |

Brightness 类型的最后一个 keyboardAppearance 参数设置键盘的外观。亮度枚举有两个值,暗和亮。该参数仅用于 iOS。

清单 6-4 显示了 textInputAction 和 last keyboardAppearance 参数的用法。

清单 6-4。键盘类型和键盘外观参数

TextField(
  keyboardType: TextInputType.phone,
)

TextField(
  keyboardType: TextInputType.numberWithOptions(
    signed: true,
    decimal: true,
  ),
)

TextField(
  textInputAction: TextInputAction.search,
  keyboardAppearance: Brightness.dark,
)

6.3 在材质设计中为文本输入添加装饰

问题

您希望在材质设计中为文本字段添加前缀和后缀等装饰。

解决办法

使用 InputDecoration 类型的装饰参数。

讨论

TextField widget 支持添加不同的装饰来向用户呈现各种信息。例如,如果文本输入的值无效,您可以在文本输入下方添加红色边框和一些文本来表明这一点。您还可以添加文本或图标作为前缀或后缀。如果 TextField 小部件用于编辑货币值,您可以添加一个货币符号作为前缀。TextField 的 InputDecoration 类型的修饰参数用于添加此信息。InputDecoration 类有许多命名参数,我们将在接下来查看这些参数。

边界

让我们从给文本输入小部件添加边框开始。InputDecoration 构造函数有几个与边框相关的 InputBorder 类型的参数,包括 errorBorder、disabledBorder、focusedBorder、focusedErrorBorder 和 enabledBorder。这些参数的名称表示这些边界何时会根据状态显示。还有一个边框参数,但是这个参数只用来提供边框的形状。

InputBorder 类是抽象的,因此应该使用它的一个子类 UnderlineInputBorder 或 OutlineInputBorder。UnderlineInputBorder 类只有底边有边框。UnderlineInputBorder 构造函数具有 borderSide 类型的参数 BorderSide 和 borderRadius 类型的参数 BorderRadius。BorderSide 类定义边框一边的颜色、宽度和样式。边框样式由 BorderStyle 枚举定义,其值为 none 和 solid。具有样式 BorderStyle.none 的 BorderSide 将不被呈现。BorderRadius 类为矩形的每个角定义了一组半径。拐角的半径是使用半径类创建的。半径的形状可以是圆形或椭圆形。可以分别使用构造函数 Radius.circular(double radius)和 radius . elliptic(double x,double y)创建圆形或椭圆形半径。BorderRadius 具有 Radius 类型的 topLeft、topRight、bottomLeft 和 bottomRight 属性来表示这四个角的半径。可以使用 BorderRadius.only()为每个角指定不同的 Radius 实例,或者使用 BorderRadius.all()为所有角使用单个 Radius 实例。

OutlineInputBorder 类在小部件周围绘制一个矩形。OutlineInputBorder 构造函数也有参数 borderSide 和 borderRadius。它还具有 gapPadding 参数,用于指定在边框间隙中显示的标签文本的水平填充。

在清单 6-5 中,两个 TextField 小部件都声明了当它们使用 focusedBorder 参数获得焦点时呈现的边框。

TextField(
  decoration: InputDecoration(
    enabledBorder: UnderlineInputBorder(
      borderSide: BorderSide(color: Colors.red),
      borderRadius: BorderRadius.all(Radius.elliptical(5, 10)),
    ),
  ),
)

TextField(
  decoration: InputDecoration(
    labelText: 'Username',
    focusedBorder: OutlineInputBorder(
      borderSide: BorderSide(color: Colors.blue),
      borderRadius: BorderRadius.circular(10),
      gapPadding: 2,
    ),
  ),
)

Listing 6-5Examples of InputDecoration

图 6-4 显示了清单 6-5 中的代码截图。第二个 TextField 被聚焦,因此显示聚焦的边框。

img/479501_1_En_6_Fig4_HTML.jpg

图 6-4

边界

前缀和后缀

文本输入中的前缀和后缀可以提供在编辑文本时有用的信息和动作。前缀和后缀都可以是纯文本或小部件。使用文本时,您可以自定义文本的样式。InputDecoration 构造函数具有参数 prefix、prefixIcon、prefixText 和 prefixStyle 来自定义前缀。它还有参数 suffix、suffixIcon、suffixText 和 suffixStyle 来自定义后缀。不能同时为 prefix 和 prefixText 指定非空值。此限制也适用于后缀和后缀 Text。您只能提供一个小部件或文本,但不能同时提供两者。

TextField(
  decoration: InputDecoration(
    prefixIcon: Icon(Icons.monetization_on),
    prefixText: 'Pay ',
    prefixStyle: TextStyle(fontStyle: FontStyle.italic),
    suffixText: '.00',
  ),
)

Listing 6-6Example of prefix and suffix

图 6-5 是清单 6-6 的截图。

img/479501_1_En_6_Fig5_HTML.jpg

图 6-5

前缀和后缀

文本

您可以添加不同类型的文本作为装饰,并自定义它们的样式。表 6-4 中显示了五种类型的文本。

表 6-4

不同类型的文本

|

类型

|

文本

|

风格

|

描述

|
| --- | --- | --- | --- |
| 标签 | 标签文本 | 标签样式 | 标签显示在输入字段的上方。 |
| 助手 | 帮助文本 | 帮助者风格 | 帮助文本显示在输入字段下方。 |
| 暗示 | 提示文本 | dintstyle | 当输入字段为空时,会在其中显示提示。 |
| 错误 | error text-错误文字 | 错误类型 | 错误显示在输入字段下方。 |
| 计数器 | 对抗文本 | 反风格 | 计数器显示在输入字段的下方,但向右对齐。 |

如果 errorText 值不为空,则输入字段被设置为错误状态。

TextField(
  keyboardType: TextInputType.emailAddress,
  decoration: InputDecoration(
    labelText: 'Email',
    labelStyle: TextStyle(fontWeight: FontWeight.bold),
    hintText: 'Email address for validation',
    helperText: 'For receiving validation emails',
    counterText: '10',
  ),
)

Listing 6-7Example of text

图 6-6 显示了清单 6-7 中的代码截图。

img/479501_1_En_6_Fig6_HTML.jpg

图 6-6

文本字段的文本

6.4 设置文本限制

问题

你想要控制文本的长度。

解决办法

使用 maxLength 参数。

讨论

要设置 TextField 和 CupertinoTextField 中文本的最大长度,可以使用 maxLength 参数。maxLength 参数的默认值为 null,这意味着对字符数没有限制。如果设置了 maxLength 参数,文本输入下方会显示一个字符计数器,显示输入的字符数和允许的字符数。如果 maxLength 参数设置为 TextField.noMaxLength,则只显示输入的字符数。设置 maxLength 时,如果字符数达到限制,则行为取决于 maxLengthEnforced 参数的值。如果 maxLengthEnforced 为 true(默认值),则不能再输入任何字符。如果 maxLengthEnforced 为 false,则可以输入额外的字符,但小部件会切换到错误样式。

TextField(
  maxLength: TextField.noMaxLength,
)

TextField(
  maxLength: 10,
  maxLengthEnforced: false,
)

CupertinoTextField(
  maxLength: 10,
)

Listing 6-8Examples of maxLength

图 6-7 显示了清单 6-8 中两个 TextField 小部件的截图。

img/479501_1_En_6_Fig7_HTML.jpg

图 6-7

文本限制

6.5 选择文本

问题

您希望在文本输入中选择一些文本。

解决办法

使用 TextEditingController 的 selection 属性。

讨论

在 Recipe 6-1 中,你已经看到了使用 TextEditingController 来获取和设置使用 EditableText 的小部件的文本的例子。TextEditingController 也可用于获取用户选择的文本并选择文本。这是通过获取或设置 TextSelection 类型的 selection 属性值来实现的。

TextSelection 是 TextRange 的子类。您可以使用 TextRange.textInside()来获取选定的文本。TextSelection 类使用 baseOffset 和 extentOffset 属性分别表示选定内容的起始和终止位置。baseOffset 的值可能大于、小于或等于 extentOffset。如果 baseOffset 等于 extentOffset,则选定内容将被折叠。折叠的文本选择包含零个字符,但它们用于表示文本插入点。TextSelection.collapsed()构造函数可以在指定的偏移量处创建一个折叠的选择。

在清单 6-9 中,当文本选择改变时,显示选中的文本。第一个按钮选择了[0,5]范围内的文本,而 thp7e 第二个按钮将光标移动到偏移 1。

class TextSelectionExample extends StatefulWidget {
  @override
  _TextSelectionExampleState createState() => _TextSelectionExampleState();
}

class _TextSelectionExampleState extends State<TextSelectionExample> {
  TextEditingController _controller;
  String _selection;

  @override
  void initState() {
    super.initState();
    _controller = new TextEditingController();
    _controller.addListener(_handleTextSelection);
  }

  @override
  void dispose() {
    _controller.removeListener(_handleTextSelection);
    super.dispose();
  }

  @override

  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        TextField(
          controller: _controller,
        ),
        Row(
          children: <Widget>[
            RaisedButton(
              child: Text('Select text [0, 5]'),
              onPressed: () {
                setState(() {
                  _controller.selection =
                      TextSelection(baseOffset: 0, extentOffset: 5);
                });
              },
            ),
            RaisedButton(
              child: Text('Move cursor to offset 1'),
              onPressed: () {
                setState(() {
                  _controller.selection = TextSelection.collapsed(offset: 1);
                });
              },
            ),
          ],
        ),
        Text.rich(TextSpan(
          children: [
            TextSpan(
              text: 'Selected:',
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
            TextSpan(text: _selection ?? "),
          ],
        )),
      ],
    );
  }

  _handleTextSelection() {
    TextSelection selection = _controller.selection;
    if (selection != null) {
      setState(() {
        _selection = selection.textInside(_controller.text);
      });
    }
  }
}

Listing 6-9
Text selection

图 6-8 显示了清单 6-9 中的代码截图。

img/479501_1_En_6_Fig8_HTML.jpg

图 6-8

文本选择

6.6 格式化文本

问题

您想要格式化文本。

解决办法

将 TextInputFormatter 与 EditableText 一起使用。

讨论

当用户键入文本输入时,您可能希望验证和格式化输入的文本。一个常见的要求是删除黑名单中的字符。这是通过提供 TextInputFormatter 实例的列表作为 TextField 和 CupertinoTextField 的 inputFormatters 参数来实现的。

TextInputFormatter 是一个抽象类,只需实现 formatEditUpdate(TextEditingValue old value,TextEditingValue newValue)即可。oldValue 和 newValue 参数分别表示以前的文本和新文本。返回值是另一个表示格式化文本的 TextEditingValue 实例。可以链接 TextInputFormatter 实例。链接时,调用 formatEditUpdate 方法的 oldValue 的值始终是前一个文本,而 newValue 的值是调用链中前一个 TextInputFormatter 实例的 formatEditUpdate 方法的返回值。

TextInputFormatter 已经有三个内置的实现类,如表 6-5 所示。这些类用于实现 TextField 和 CupertinoTextField。例如,当 maxLines 参数的值为 1 时,会将 blacklingtextinputformatter . singleline formatter 添加到 TextInputFormatter 实例的列表中,以过滤掉“\n”字符。

表 6-5

TextInputFormatter 的实现

|

名字

|

描述

|
| --- | --- |
| LengthLimitingTextInputFormatter | 限制可以输入的字符数。 |
| BlacklistingTextInputFormatter | 用给定字符串替换匹配正则表达式模式的字符。 |
| WhitelistingTextInputFormatter | 只允许匹配给定正则表达式模式的字符。 |

不需要声明 TextInputFormatter 的新子类,更简单的方法是将 TextInputFormatter . with function()方法与 formatEditUpdate()方法类型匹配的函数一起使用。

在清单 6-10 中,输入文本被格式化为使用大写字母。

TextField(
  inputFormatters: [
    TextInputFormatter.withFunction((oldValue, newValue) {
      return newValue.copyWith(text: newValue.text?.toUpperCase());
    }),

  ],
)

Listing 6-10Format text

6.7 选择单个值

问题

您希望从值列表中选择一个值。

解决办法

使用一组单选小部件。

讨论

单选按钮通常用于需要单项选择的情况。一个组中只能选择一个单选按钮。Radio 类有一个表示值的类型的类型参数 T。创建 Radio 实例时,需要提供必需的参数,包括 value、groupValue 和 onChanged。单选小部件不维护任何状态。它的外观完全由 value 和 groupValue 参数决定。当单选按钮组的选择发生变化时,onChanged listener 将使用所选的值进行调用。表 6-6 显示了无线电构造器的命名参数。

表 6-6

无线电的命名参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| value | T | 此单选按钮的值。 |
| groupValue | T | 这组单选按钮的选定值。groupValue 单选按钮处于选中状态。 |
| onChanged | ValueChanged<T> | 选择改变时的监听器功能。 |
| activeColor | Color | 选中此单选按钮时的颜色。 |

在清单 6-11 中,Fruit.allFruits 变量是所有水果实例的列表。_selectedFruit 是当前选择的水果实例。对于每个水果实例,创建一个单选按钮<水果>小部件,并将 groupValue 设置为 _selectedFruit。

class FruitChooser extends StatefulWidget {
  @override
  _FruitChooserState createState() => _FruitChooserState();
}

class _FruitChooserState extends State<FruitChooser> {
  Fruit _selectedFruit;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Column(
          children: Fruit.allFruits.map((fruit) {
            return Row(
              children: <Widget>[
                Radio<Fruit>(
                  value: fruit,
                  groupValue: _selectedFruit,
                  onChanged: (value) {
                    setState(() {
                      _selectedFruit = value;

                    });
                  },
                ),
                Expanded(
                  child: Text(fruit.name),
                ),
              ],
            );
          }).toList(),
        ),
        Text(_selectedFruit != null ? _selectedFruit.name : ")
      ],
    );
  }
}

Listing 6-11Example of using Radio

图 6-9 显示了清单 6-11 中示例的截图。

img/479501_1_En_6_Fig9_HTML.jpg

图 6-9

收音机部件

6.8 从下拉列表中选择单个值

问题

您希望从下拉列表中选择一个值。

解决办法

使用下拉按钮。

讨论

一个 DropdownButton 小部件在点击时会显示一个项目列表。DropdownButton 类是泛型的,其类型参数表示值的类型。使用 List < DropdownMenuItem>类型的 items 参数指定项目列表。DropdownMenuItem 小部件是一个简单的包装器,带有值和一个子小部件。当选择被改变时,onChanged 回调将使用所选项的值被调用。选定项的值作为值参数传递。如果值为 null,则显示提示小部件。

在清单 6-12 中,每个水果实例都被映射到一个 DropdownMenuItem 小部件。

class FruitChooser extends StatefulWidget {
  @override
  _FruitChooserState createState() => _FruitChooserState();
}

class _FruitChooserState extends State<FruitChooser> {
  Fruit _selectedFruit;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        DropdownButton(
          value: _selectedFruit,
          items: Fruit.allFruits.map((fruit) {
            return DropdownMenuItem(
              value: fruit,
              child: Text(fruit.name),
            );
          }).toList(),
          onChanged: (fruit) {
            setState(() {
              _selectedFruit = fruit;
            });
          },
          hint: Text('Select a fruit'),
        ),
      ],
    );
  }
}

Listing 6-12Example of DropdownButton

图 6-10 显示了一个展开的下拉按钮的截图。

img/479501_1_En_6_Fig10_HTML.jpg

图 6-10

展开的下拉按钮

6.9 选择多个值

问题

您想要选择多个值。

解决办法

使用复选框小工具。

讨论

复选框通常用于允许多重选择。如果创建复选框时将参数 tristate 设置为 true,则复选框可以显示三个值:true、false 和 null。否则,只允许值 true 和 false。如果该值为空,则显示一个破折号。复选框本身不维护任何状态。它的外观完全由 value 参数决定。当 checkbox 的值改变时,onChanged 回调用新状态的值调用。

在清单 6-13 中,选择的水果被维护在一个清单<水果>实例中。每个水果实例都映射到一个 Checkbox 小部件。复选框的值取决于相应的水果实例是否在 _selectedFruits 列表中。

class FruitSelector extends StatefulWidget {
  @override
  _FruitSelectorState createState() => _FruitSelectorState();
}

class _FruitSelectorState extends State<FruitSelector> {
  List<Fruit> _selectedFruits = List();

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Column(
          children: Fruit.allFruits.map((fruit) {
            return Row(
              children: <Widget>[
                Checkbox(
                  value: _selectedFruits.contains(fruit),
                  onChanged: (selected) {
                    setState(() {
                      if (selected) {
                        _selectedFruits.add(fruit);
                      } else {
                        _selectedFruits.remove(fruit);
                      }
                    });
                  },
                ),
                Expanded(
                  child: Text(fruit.name),
                )
              ],
            );
          }).toList(),
        ),
        Text(_selectedFruits.join(', ')),
      ],
    );
  }
}

Listing 6-13Example of Checkbox

图 6-10 显示了清单 6-13 中示例的截图。

img/479501_1_En_6_Fig11_HTML.jpg

图 6-11

检验盒

6.10 切换开/关状态

问题

您想要切换开/关状态。

解决办法

材质设计使用 Switch,iOS 风格使用 CupertinoSwitch。

讨论

Switch 是一个常用的 UI 控件,用于切换设置的开/关状态。开关部件用于材质设计。开关小部件可以有两种状态,活动和非活动。开关部件本身不维护任何状态。它的行为和外观完全由构造函数参数的值决定。如果 value 参数为真,则开关部件处于活动状态;否则,它处于非活动状态。当 Switch 小部件的开/关状态改变时,调用 onChanged 回调函数,新状态为。您可以使用参数 activeColor、activeThumbImage、activeTrackColor、inactiveThumbColor、inactiveThumbImage 和 inactiveTrackColor 自定义不同状态下的 Switch 小部件的外观。

在清单 6-14 中,Switch 小部件用于控制另一个 TextField 小部件的状态。

class NameInput extends StatefulWidget {
  @override
  _NameInputState createState() => _NameInputState();
}

class _NameInputState extends State<NameInput> {
  bool _useCustomName = false;

  _buildNameInput() {
    return TextField(
      decoration: InputDecoration(labelText: 'Name'),
    );
  }

  _buildToggle() {
    return Row(
      children: <Widget>[
        Switch(
          value: _useCustomName,
          onChanged: (value) {
            setState(() {
              _useCustomName = value;
            });
          },
          activeColor: Colors.green,
          inactiveThumbColor: Colors.grey.shade200,
        ),
        Expanded(
          child: Text('Use custom name'),
        ),
      ],
    );
  }

  @override

  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: _useCustomName
          ? [_buildToggle(), _buildNameInput()]
          : [_buildToggle()],
    );
  }
}

Listing 6-14Example of Switch

图 6-12 为清单 6-14 中示例的截图。

img/479501_1_En_6_Fig12_HTML.jpg

图 6-12

转换

CupertinoSwitch 小部件创建了一个 iOS 风格的开关,其工作方式与 Switch 相同,但它只支持活动颜色的定制。Switch 小部件具有构造函数 Switch.adaptive()来创建 Switch 小部件或 CupertinoSwitch 小部件,这取决于目标平台。当使用 Switch.adaptive()创建 CupertinoSwitch 小部件时,只使用 CupertinoSwitch()接受的构造函数参数;其他参数被忽略。

清单 6-15 展示了使用 CupertinoSwitch 和 Switch.adaptive()的例子。

CupertinoSwitch(
  value: true,
  onChanged: (value) => {},
  activeColor: Colors.red.shade300,
)

Switch.adaptive(
  value: true,
  onChanged: (value) => {},
)

Listing 6-15Example of CupertinoSwitch

6.11 从一系列值中选择

问题

您希望从一组连续或离散的值中进行选择。

解决办法

使用滑块进行材质设计,或使用 CupertinoSlider 进行 iOS 风格设计。

讨论

滑块通常用于从一组连续或离散的值中进行选择。可以使用 Slider widget 进行材质设计,或者使用 CupertinoSlider 进行 iOS 风格设计。这两个小部件具有相同的行为,但视觉外观不同。创建滑块时,需要使用最小和最大参数提供有效的值范围。如果非空值用于分割参数,将选择一组离散值。否则,将选择连续的值范围。例如,如果“最小值”中的值为 0.0,“最大值”为 10.0,且“分段”设置为 5,则选择的值为 0.0、2.0、4.0、6.0、8.0 和 10.0。滑块小部件不保持任何状态。它的行为和外观完全由构造函数参数决定。当滑块的值发生变化时,onChanged 回调函数将使用选定的值进行调用。还可以使用 onChangeStart 和 onChangeEnd 回调分别在值开始改变和改变完成时获得通知。您可以使用 label、activeColor 和 inactiveColor 进一步自定义滑块的外观。CupertinoSlider 仅支持 activeColor 参数。如果 onChanged 为 null 或范围为空,则 slider 小部件将被禁用。

在清单 6-16 中,使用给定的 divisions 参数值创建一个 Slider 小部件,并显示当前值。

class SliderValue extends StatefulWidget {
  SliderValue({Key key, this.divisions}) : super(key: key);

  final int divisions;

  @override
  _SliderValueState createState() => _SliderValueState(divisions);
}

class _SliderValueState extends State<SliderValue> {
  _SliderValueState(this.divisions);

  final int divisions;
  double _value = 0.0;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Expanded(
          child: Slider(
            value: _value,
            min: 0.0,
            max: 10.0,
            divisions: divisions,
            onChanged: (value) {
              setState(() {
                _value = value;
              });
            },
          ),
        ),
        Text(_value.toStringAsFixed(2)),
      ],
    );
  }
}

Listing 6-16Example of Slider

CupertinoSlider 的用法与 Slider 类似。您可以简单地用清单 6-16 中的 CupertinoSlider 替换 Slider。图 6-13 显示了滑块和 CupertinoSlider 的截图。

img/479501_1_En_6_Fig13_HTML.jpg

图 6-13

滑块和吸盘滑块

6.12 使用芯片

问题

您希望有简洁的替代方案来表示不同类型的实体。

解决办法

使用不同类型的芯片。

讨论

当空间有限时,按钮、单选按钮和复选框等传统小部件可能不适合。在这种情况下,可以使用材质设计中的芯片来表示相同的语义,但使用较少的空间。

芯片小部件是通用的芯片实现,它有一个必需的标签和一个可选的头像。在设置非空 onDeleted 回调时,它还可以包含一个 delete 按钮。

InputChip 小部件比 Chip 小部件更强大。InputChip 小部件可以通过设置 onSelected 回调来选择,也可以通过设置 onPressed 回调来按压。但是,不能对 onSelected 和 onPressed 回调都设置非 null 值。当使用 onSelected 时,InputChip 小部件的行为类似于复选框。您可以使用选定的参数来设置状态。当使用 onPressed 时,InputChip 小部件的行为就像一个按钮。

ChoiceChip 小部件的行为就像一个单选按钮,用选定的参数来设置其状态,用选定的回调来通知状态变化。但是,ChoiceChip 小部件没有与 Radio 小部件中的 groupValue 类似的参数,所以您必须手动设置选择的状态。

FilterChip 小部件的行为类似于复选框。FilterChip 构造函数与 ChoiceChip 构造函数具有相同的参数。

ActionChip 小部件的行为类似于带有 onPressed 参数的按钮。动作芯片和按钮的区别在于,不能通过将 onPressed 参数设置为 null 来禁用动作芯片。如果行动芯片的行动不适用,则应将其移除。这种行为与使用芯片减少空间的目标是一致的。

事实上,所有这些芯片小部件都通过仅使用 RawChip 构造函数支持的参数子集来包装 RawChip 小部件。

在清单 6-17 中,ChoiceChip 小部件用于实现单选。

class FruitChooser extends StatefulWidget {
  @override
  _FruitChooserState createState() => _FruitChooserState();
}

class _FruitChooserState extends State<FruitChooser> {
  Fruit _selectedFruit;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Wrap(
          spacing: 5,
          children: Fruit.allFruits.map((fruit) {
            return ChoiceChip(
              label: Text(fruit.name),
              selected: _selectedFruit == fruit,
              onSelected: (selected) {
                setState(() {
                  _selectedFruit = selected ? fruit : null;
                });
              },
              selectedColor: Colors.red.shade200,
            );
          }).toList(),

        ),
        Text(_selectedFruit != null ? _selectedFruit.name : ")
      ],
    );
  }
}

Listing 6-17Example of ChoiceChip

在清单 6-18 中,FilterChip 小部件用于实现多重选择。

class FruitSelector extends StatefulWidget {
  @override
  _FruitSelectorState createState() => _FruitSelectorState();
}

class _FruitSelectorState extends State<FruitSelector> {
  List<Fruit> _selectedFruits = List();

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Wrap(
          spacing: 5,
          children: Fruit.allFruits.map((fruit) {
            return FilterChip(
              label: Text(fruit.name),
              selected: _selectedFruits.contains(fruit),
              onSelected: (selected) {
                setState(() {
                  if (selected) {
                    _selectedFruits.add(fruit);
                  } else {
                    _selectedFruits.remove(fruit);
                  }
                });
              },
              selectedColor: Colors.blue.shade200,
            );
          }).toList(),

        ),
        Text(_selectedFruits.join(', ')),
      ],
    );
  }
}

Listing 6-18Example of FilterChip

图 6-14 为清单 6-17 和 6-18 中实例的截图。

img/479501_1_En_6_Fig14_HTML.jpg

图 6-14

选择芯片和滤波器芯片

6.13 选择日期和时间

问题

您想要选择日期和时间。

解决办法

使用 showDatePicker()和 showTimePicker()函数进行材质设计,或使用 CupertinoDatePicker 和 CupertinoTimerPicker 进行 iOS 样式设计。

讨论

对于材质设计,可以使用 YearPicker、MonthPicker、DayPicker 或 showDatePicker()函数等小部件来允许用户选择日期。showTimePicker()函数用于选择时间。小部件很少用于选择日期。大多数情况下,showDatePicker()和 showTimePicker()函数用于显示对话框。

YearPicker 小部件显示了要选择的年份列表。创建 YearPicker 小部件时,需要分别使用 selected date、firstDate 和 lastDate 参数为所选日期、最早日期和最晚日期提供 DateTime 实例。当选择被更改时,onChanged 回调将使用选定的 DateTime 实例调用。

MonthPicker 小部件显示要选择的月份列表。MonthPicker 构造函数与 YearPicker 具有相同的参数 selectedDate、firstDate、lastDate 和 onChanged。它还有一个谓词函数 selectableDayPredicate,用于定制哪些天是可选的。

DayPicker 小部件显示给定月份中要选择的日期。DayPicker 构造函数具有 MonthPicker 的所有参数和 displayedMonth 参数,用于设置要选取的月份。

如果你想显示一个对话框让用户选择日期,showDatePicker()函数比创建你自己的对话框更容易使用。您需要为参数 initialDate、firstDate 和 lastDate 传递 DateTime 实例。BuildContext 类型的上下文参数也是必需的。该函数可以在 DatePickerMode 枚举中定义的两种模式下工作。DatePickerMode.day 表示每天选择一个月,DatePickerMode.year 表示选择一年。showDatePicker()函数的返回值是代表所选日期的未来日期

在清单 6-19 中,TextField 小部件有一个 IconButton 作为后缀。当按钮被按下时,showDatePicker()函数被调用以显示日期选择器对话框。选定的日期显示在 TextField 小工具中。

class PickDate extends StatefulWidget {
  @override
  _PickDateState createState() => _PickDateState();
}

class _PickDateState extends State<PickDate> {
  DateTime _selectedDate = DateTime.now();
  TextEditingController _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: InputDecoration(
        labelText: 'Date',
        suffix: IconButton(
          icon: Icon(Icons.date_range),
          onPressed: () {
            showDatePicker(
              context: context,
              initialDate: _selectedDate,
              firstDate: DateTime.now().subtract(Duration(days: 30)),
              lastDate: DateTime.now().add(Duration(days: 30)),)
            .then((selectedDate) {
              if (selectedDate != null) {
                _selectedDate = selectedDate;
                _controller.text = DateFormat.yMd().format(_selectedDate);
              }
            });
          },
        ),
      ),
    );
  }
}

Listing 6-19Pick date

函数的作用是:显示一个对话框来选择时间。您需要传递 TimeOfDay 类型的 initialTime 参数作为要显示的初始时间。返回值是代表所选时间的未来实例。清单 6-20 中的代码使用与清单 6-19 相似的模式来显示时间选择器对话框。

class PickTime extends StatefulWidget {
  @override
  _PickTimeState createState() => _PickTimeState();
}

class _PickTimeState extends State<PickTime> {
  TimeOfDay _selectedTime = TimeOfDay.now();
  TextEditingController _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: InputDecoration(
          labelText: 'Time',
          suffix: IconButton(
            icon: Icon(Icons.access_time),
            onPressed: () {
              showTimePicker(
                context: context,
                initialTime: _selectedTime,
              ).then((selectedTime) {
                if (selectedTime != null) {
                  _selectedTime = selectedTime;
                  _controller.text = _selectedTime.format(context);
                }
              });
            },
          )),
    );
  }
}

Listing 6-20Pick time

对于 iOS 风格,您可以使用 CupertinoDatePicker 和 CupertinoTimerPicker 小部件分别选择日期和时间。根据枚举 CupertinoDatePickerMode 的模式参数,CupertinoDatePicker 可以有不同的模式,包括日期、时间以及日期和时间。与材质设计中的小部件类似,CupertinoDatePicker 构造函数具有参数 initialDateTime、minimumDate、maximumDate 和 onDateTimeChanged。根据 enum CupertinoTimerPickerMode 的 mode 参数,CupertinoTimerPicker 还可以有不同的模式,包括 hm、ms 和 hms。不同之处在于,CupertinoTimerPicker 使用 Duration 实例来设置初始值和作为 onTimerDurationChanged 回调中的值。

6.14 包装表单字段

问题

您希望将表单小部件包装成表单字段。

解决办法

使用 FormField 或 TextFormField。

讨论

表单小部件可以像普通小部件一样使用。但是,这些表单小部件不维护任何状态;您总是需要将它们包装在有状态的小部件中来保持状态。典型的使用模式是使用 onChanged 回调来更新状态并触发表单小部件的重建。因为这是使用表单小部件的典型模式,所以 Flutter 有一个内置的 FormField 小部件来维护表单小部件的当前状态。它处理更新和验证错误。

FormField 类是泛型的,类型参数 T 表示值的类型。FormField 可以用作独立的小部件,也可以作为表单小部件的一部分。本食谱仅讨论独立使用。表 6-7 显示了 FormField 构造函数的命名参数。

表 6-7

表单域的命名参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| builder | FormFieldBuilder<T> | 构建表示该表单字段的小部件。 |
| onSaved | FormFieldSetter<T> | 保存表单时回调。 |
| validator | FormFieldValidator<T> | 表单域的验证器。 |
| initialValue | T | 初始值。 |
| autovalidate | boolean | 每次更改后是否自动验证。 |
| enabled | boolean | 此表单域是否已启用。 |

FormFieldBuilder 类型是一个小部件形式的 typedef(formfield state字段)。FormFieldState 类从 State 类扩展而来,代表表单域的当前状态。FormFieldBuilder 负责根据状态构建小部件。从 FormFieldState 中,可以获得表单域的当前值和错误文本。也可以使用表 6-8 中 FormFieldState 的方法。FormFieldValidator < T >也是字符串(T 值)形式的 typedef。它将当前值作为输入,如果验证失败,则返回一个非空字符串作为错误消息。FormFieldSetter < T > type 是一个 void(T newValue)形式的 typedef。

表 6-8

FormFieldState

|

名称

|

描述

|
| --- | --- |
| save() | 用当前值调用 onSaved()方法。 |
| validate() | 如果验证失败,调用验证器并设置 errorText。 |
| didChange(T value) | 将字段的状态更新为新值。 |
| reset() | 将字段重置为初始值。 |

当在 FormFields 中包装 TextFields 时,最好使用内置的 TextFormField。TextFormField 小部件已经使用 TextEditingController 处理设置文本,并使用 FormFieldValidator 返回的错误文本更新输入装饰。TextFormField 构造函数支持来自 TextField 和 FormField 构造函数的参数。清单 6-21 中的 TextFormField 有一个验证器来验证文本长度。

class NameInput extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return TextFormField(
      decoration: InputDecoration(
        labelText: 'Name',
      ),
      validator: (value) {
        if (value == null || value.isEmpty) {
          return 'Name is required.';
        } else if (value.length < 6) {
          return 'Minimum length is 6.';
        } else {
          return null;
        }
      },
      autovalidate: true,
    );
  }
}

Listing 6-21
TextFormField

图 6-15 显示了清单 6-21 中的代码截图。

img/479501_1_En_6_Fig15_HTML.jpg

图 6-15

TextFormField

FormFieldState 实例只能在 FormField 的 builder 函数中访问。如果需要从其他地方访问状态,可以传递一个 GlobalKey 作为 FormField 的 Key 参数,然后使用 currentState 属性访问当前状态。

在清单 6-22 中,FormField 的状态是一个列表< PizzaTopping >实例。使用 GlobalKey,当按下按钮时,可以检索当前值。

class PizzaToppingsSelector extends StatelessWidget {
  final GlobalKey<FormFieldState<List<PizzaTopping>>> _formFieldKey =
      GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        FormField<List<PizzaTopping>>(
          key: _formFieldKey,
          initialValue: List(),
          builder: (state) {
            return Wrap(
              spacing: 5,
              children: PizzaTopping.allPizzaToppings.map((topping) {
                return ChoiceChip(
                  label: Text(topping.name),
                  selected: state.value.contains(topping),
                  onSelected: state.value.length < 2 ||
                          state.value.contains(topping)
                      ? (selected) {
                          List<PizzaTopping> newValue = List.of(state.value);
                          if (selected) {
                            newValue.add(topping);
                          } else {
                            newValue.remove(topping);
                          }
                          state.didChange(newValue);
                        }
                      : null,

                );
              }).toList(),
            );
          },
        ),
        RaisedButton(
          child: Text('Get toppings'),
          onPressed: () => print(_formFieldKey.currentState?.value),
        ),
      ],
    );
  }
}

Listing 6-22FormField

6.15 创建表单

问题

您希望创建一个包含多个表单域的表单。

解决办法

使用表单。

讨论

使用表单域时,通常您会尝试构建一个包含多个表单域的表单。当处理多个表单域时,单独管理表单域是一项繁琐的任务。Form 是多个表单域的方便包装器。您需要将所有表单域包装在表单域小部件中,并使用一个表单小部件作为所有这些表单域小部件的共同祖先。表单小部件是一个有状态的小部件,其状态由关联的 FormState 实例管理。FormState 类有 save()、validate()和 reset()方法。这些方法调用后代 FormField 小部件的所有 FormFieldState 实例上的相应函数。

有两种方法可以获得 FormState 实例,这取决于小部件想要使用 FormState 的位置。如果小部件是 Form 小部件的后代,使用 Form.of(BuildContext context)是获得最接近的 FormState 实例的简单方法。第二种方法是在创建表单小部件时使用 GlobalKey 实例,然后使用 GlobalKey.currentState 获取表单状态。

清单 6-23 显示了一个登录表单的代码。使用 GlobalKey 实例创建了两个 TextFormField 小部件。

class LoginForm extends StatefulWidget {
  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final GlobalKey<FormFieldState<String>> _usernameFormFieldKey = GlobalKey();
  final GlobalKey<FormFieldState<String>> _passwordFormFieldKey = GlobalKey();

  _notEmpty(String value) => value != null && value.isNotEmpty;

  get _value => ({
        'username': _usernameFormFieldKey.currentState?.value,
        'password': _passwordFormFieldKey.currentState?.value
      });

  @override

  Widget build(BuildContext context) {
    return Form(
      child: Column(
        children: <Widget>[
          TextFormField(
            key: _usernameFormFieldKey,
            decoration: InputDecoration(
              labelText: 'Username',
            ),
            validator: (value) =>
                !_notEmpty(value) ? 'Username is required' : null,
          ),
          TextFormField(
            key: _passwordFormFieldKey,
            obscureText: true,
            decoration: InputDecoration(
              labelText: 'Password',
            ),
            validator: (value) =>
                !_notEmpty(value) ? 'Password is required' : null,
          ),
          Builder(builder: (context) {
            return Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: <Widget>[
                RaisedButton(
                  child: Text('Log In'),
                  onPressed: () {
                    if (Form.of(context).validate()) {
                      print(_value);
                    }
                  },
                ),
                FlatButton(
                  child: Text('Reset'),
                  onPressed: () => Form.of(context).reset(),
                )
              ],
            );
          }),
        ],
      ),
    );
  }
}

Listing 6-23
Login form

图 6-16 显示了登录表单的截图。

img/479501_1_En_6_Fig16_HTML.jpg

图 6-16

登录表单

6.16 摘要

表单小部件对于与用户交互很重要。本章涵盖了材质设计和 iOS 风格的表单小部件,包括文本输入、单选按钮、复选框、下拉菜单、开关、芯片和滑块。在下一章,我们将讨论应用脚手架的部件。

七、常见小部件

在 Flutter 应用中,一些小部件被广泛用于不同的目的。本章讨论一些常见的小部件。

7.1 显示项目列表

问题

您希望显示一个可滚动的项目列表。

解决办法

使用 ListView 小部件作为项目的容器。

讨论

像 Flex、Row 和 Column 这样的 Flutter 布局小部件不支持滚动,并且这些小部件不是设计用来在需要滚动时显示项目的。如果你想显示大量的条目,你应该使用 ListView 小部件。您可以将 ListView 视为 Flex 小部件的可滚动对应物。

使用不同的构造函数创建 ListView 小部件有三种不同的方法:

  • 从子部件的静态列表中创建。

  • 通过基于滚动位置按需构建子项来创建。

  • 创建自定义实现。

    这个食谱着重于前两种方法。

带有静态子视图的 ListView

如果您有一个可能超过其父小部件大小的子部件静态列表,您可以将它们包装在一个 ListView 小部件中以支持滚动。这是通过使用 Widget[]类型的 children 参数调用 ListView()构造函数来实现的。滚动方向由 Axis 类型的 scrollDirection 参数确定。默认的滚动方向是 Axis.vertical。如果要以相反的顺序显示子项,可以将 reverse 参数设置为 true。清单 7-1 显示了一个带有三个子控件的 ListView 小部件。

ListView(
  children: <Widget>[
    ExampleWidget(name: 'Box 1'),
    ExampleWidget(name: 'Box 2'),
    ExampleWidget(name: 'Box 3'),
  ],
)

Listing 7-1ListView with static children

默认的 ListView()构造函数应该只在你有少量孩子的时候使用。将创建所有子对象,即使其中一些子对象在视口中不可见。这可能会对性能产生影响。

带有项目生成器的 ListView

如果你有大量的条目或者条目需要动态创建,你可以使用 ListView.builder()和 ListView.separated()构造函数。您需要提供 IndexedWidgetBuilder 类型的构建器函数来按需构建项目,而不是静态的小部件列表。IndexedWidgetBuilder 是小部件的 typedef(build context 上下文,int index)。index 参数是要生成的项的索引。ListView 小部件确定视窗中项目的索引,并调用构建器函数来构建要呈现的项目。如果项目总数是已知的,您应该将这个数字作为 itemCount 参数传递。如果 itemCount 为非空,则只在索引大于或等于零且小于 itemCount 的情况下调用构建器函数。如果 itemCount 为 null,则构建器函数需要返回 null,以表明没有更多的项目可用。

使用 ListView.builder()构造函数时,只需要提供 IndexedWidgetBuilder 类型的 itemBuilder 参数。对于 ListView.separated()构造函数,除了 itemBuilder 参数之外,还需要提供 IndexedWidgetBuilder 类型的 separatorBuilder 参数来构建项之间的分隔符。使用 ListView.separated()时,itemCount 参数是必需的。清单 7-2 展示了使用 ListView.builder()和 ListView.separated()的例子。

ListView.builder(
  itemCount: 20,
  itemBuilder: (context, index) {
    return ExampleWidget(name: 'Dynamic Box ${index + 1}');
  },
);

ListView.separated(
  itemBuilder: (context, index) {
    return ExampleWidget(name: 'Separated Box ${index + 1}');
  },
  separatorBuilder: (context, index) {
    return Divider(
      height: 8,
    );

  },
  itemCount: 20,
);

Listing 7-2ListView with item builders

如果项在滚动方向上的范围是已知的,则应将该值作为 itemExtent 参数传递。itemExtent 参数的非空值使滚动更有效。

listfile(列表文件)

您可以使用任何小部件作为 ListView 的子部件。如果你的项目包括文本、图标和其他控件,你可以使用 ListTile 及其子类。列表框包含一到三行文本以及文本周围的前导和尾随小部件。表 7-1 显示

表 7-2

CheckboxListTile 参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| 副手 | 小部件 | 显示在磁贴另一侧的小部件。 |
| 控制亲和力 | ListTileControlAffinity | 在图块中放置控件的位置。 |

表 7-1

列表文件的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| 标题 | 小部件 | 列表框的标题。 |
| 小标题 | 小部件 | 标题下方显示的可选内容。 |
| 是三线 | 弯曲件 | 列表框是否有三行文本。 |
| 主要的 | 小部件 | 标题前显示的小部件。 |
| 蔓延的 | 小部件 | 标题后显示的小部件。 |
| 使能够 | 弯曲件 | 列表框是否已启用。 |
| 挑选 | 弯曲件 | 列表框是否被选中。选中时,图标和文本以相同的颜色呈现。 |
| 数据库 | GestureTapCallback | 点击标题时回调。 |
| 又没有长按 | gesturelongpressscallback | 长按标题时回调。 |
| 稠密的 | 弯曲件 | 为 true 时,平铺的大小会减小。 |
| 内容填充 | 边缘镶嵌几何学 | 瓷砖内部的填充。 |

清单 7-3 展示了一个使用 ListTile 的例子。

ListTile(
  title: Text('Title'),
  subtitle: Text('Description'),
  leading: Icon(Icons.shop),
  trailing: Icon(Icons.arrow_right),
)

Listing 7-3Example of ListTile

如果您想在列表框中有一个复选框,您可以使用 checkboxListTile 小部件,它结合了 list tile 和 Checkbox。CheckboxListTile 构造函数与 ListTile 构造函数具有相同的参数 title、subtitle、isThreeLine、selected 和 dense。它还有用于复选框构造函数的参数 value、onChanged 和 activeColor。

ListTileControlAffinity 枚举定义列表框中控件的位置。它有三个值,前导、尾随和平台。当指定了控件的位置时,辅助小部件总是放在对面。

class CheckboxInListTile extends StatefulWidget {
  @override
  _CheckboxInListTileState createState() => _CheckboxInListTileState();
}

class _CheckboxInListTileState extends State<CheckboxInListTile> {
  bool _value = false;

  @override
  Widget build(BuildContext context) {
    return CheckboxListTile(
      title: Text('Checkbox'),
      subtitle: Text('Description'),
      value: _value,
      onChanged: (value) {
        setState(() {
          _value = value;
        });
      },
      secondary: Icon(_value ? Icons.monetization_on : Icons.money_off),
    );
  }
}

Listing 7-4Example of CheckboxListTile

如果想在列表框中添加单选按钮,可以使用 RadioListTile 小部件。对于 RadioListTile 构造函数的参数,value、groupValue、onChanged 和 activeColor 与 Radio 构造函数中的含义相同;title、subtitle、isThreeLine、dense、secondary、selected 和 controlAffinity 与 CheckboxListTile 构造函数中的含义相同。清单 7-5 显示了一个使用放射性同位素的例子。

enum CustomColor { red, green, blue }

class RadioInListTile extends StatefulWidget {
  @override
  _RadioInListTileState createState() => _RadioInListTileState();
}

class _RadioInListTileState extends State<RadioInListTile> {
  CustomColor _selectedColor;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: CustomColor.values.map((color) {
        return RadioListTile<CustomColor>(
          title: Text(color.toString()),
          value: color,
          groupValue: _selectedColor,
          onChanged: (value) {
            setState(() {
              _selectedColor = value;
            });
          },
        );
      }).toList(),
    );
  }
}

Listing 7-5Example of RadioListTile

如果您想将开关添加到列表框中,您可以使用 SwitchListTile。SwitchListTile 构造函数的一些参数来自 Switch 构造函数,另一些参数来自 ListTile 构造函数。清单 7-6 展示了一个使用 SwitchListTile 的例子。

class SwitchInListTile extends StatefulWidget {
  @override
  _SwitchInListTileState createState() => _SwitchInListTileState();
}

class _SwitchInListTileState extends State<SwitchInListTile> {
  bool _value = false;

  @override
  Widget build(BuildContext context) {
    return SwitchListTile(
      title: Text('Switch'),
      subtitle: Text('Description'),
      value: _value,
      onChanged: (value) {
        setState(() {
          _value = value;
        });
      },
    );
  }
}

Listing 7-6Example of SwitchListTile

图 7-1 显示了不同 ListTiles 的截图。

img/479501_1_En_7_Fig1_HTML.jpg

图 7-1

列表文件

7.2 在网格中显示项目

问题

您希望在网格中显示项目。

解决办法

使用 GridView。

讨论

ListView 小工具以线性数组的形式显示项目。要在二维数组中显示小部件,可以使用 GridView。GridView 子级的实际布局被委托给 SliverGridDelegate 的一个实现。Flutter 提供了 SliverGridDelegate 的两个内置实现,slivergriddelegatewithfixedcrosaxiscount 和 slivergriddelegatewithmxcrosaxisextent。您还可以创建自己的 SliverGridDelegate 实现。

有三种方法可以提供 GridView 的子视图。您可以提供一个静态的小部件列表,或者使用 IndexedWidgetBuilder 类型的构建器函数,或者提供 SliverChildDelegate 的实现。

根据 SliverGridDelegate 和提供子级的选择,可以使用不同的 GridView 构造函数。表 7-3 显示了不同构造器的用法。

表 7-3

GridView 构造函数

|

名字

|

代表

|

孩子们

|
| --- | --- | --- |
| GridView() | silvergriddelegate | 小部件[] |
| GridView.builder() | silvergriddelegate | IndexedWidgetBuilder |
| GridView.count() | slivergriddelegatewithfixedcrosaxiscount | 小部件[] |
| GridView.extent() | silvergriddelegatewithmxcrosaxisextent | 小部件[] |
| GridView.custom() | silvergriddelegate | silverchilddelegate |

slivergriddelegatewithfixedcrosaxiscount 类使用 CrossAxisCount 参数来指定横轴中的固定平铺数。例如,如果 GridView 的滚动方向是垂直的,则 crossAxisCount 参数指定列数。清单 7-7 展示了一个使用 GridView.count()创建三列网格的例子。

GridView.count(
  crossAxisCount: 3,
  children: List.generate(10, (index) {
    return ExampleWidget(
      name: 'Fixed Count ${index + 1}',
    );
  }),
);

Listing 7-7Example of using Gridview.count()

slivergriddelegatewithmacrossaxisextent 类使用 maxCrossAxisExtent 参数指定横轴的最大范围。图块的实际横轴范围将尽可能大,以均匀划分 GridView 的横轴范围,并且不会超过指定的最大值。例如,如果 GridView 的横轴范围是 400,maxCrossAxisExtent 的值是 120,则图块的横轴范围是 100。如果 GridView 的滚动方向是垂直的,它将有四列。清单 7-8 展示了一个使用 GridView.extent()的例子。

GridView.extent(
  maxCrossAxisExtent: 250,
  children: List.generate(10, (index) {
    return ExampleWidget(
      name: 'Max Extent ${index + 1}',
    );
  }),
);

Listing 7-8Example of using GridView.extent()

要使用 builder 函数创建子级,需要使用 GridView.builder()构造函数和 SliverGridDelegate 实现。清单 7-9 展示了一个使用 GridView.builder()和 slivergriddelegatewithfixedcrosaxiscount 的例子。

GridView.builder(
  itemCount: 32,
  gridDelegate:
      SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
  itemBuilder: (context, index) {
    return ExampleWidget(
      name: 'Builder ${index + 1}',
    );
  },
);

Listing 7-9Example of using GridView.builder()

slivergriddelegatewithfixedcrosaxiscount 和 SliverGridDelegateWithMaxCrossAxisExtent 类都有其他命名参数来配置布局;参见表 7-4 。

表 7-4

内置 SliverGridDelegate 实现的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| 主轴空间 | 两倍 | 瓷砖沿主轴的间距。 |
| 交叉轴间距 | 两倍 | 瓷砖沿横轴的间距。 |
| 儿童保护 | 两倍 | 切片横轴与主轴范围的比率。 |

使用这两个 SliverGridDelegate 实现时,首先确定每个图块的横轴范围,然后由 childAspectRatio 参数确定主轴范围。如果 GridView 用于显示具有所需纵横比的图像,则可以使用与 childAspectRatio 参数的值相同的纵横比。GridView.count()和 GridView.extent()构造函数在表 7-4 中具有相同的命名参数,以将这些参数传递给底层 SliverGridDelegate 实现。清单 7-10 显示了显示图像时使用 childAspectRatio 参数的示例。

GridView.count(
  crossAxisCount: 3,
  childAspectRatio: 4 / 3,
  children: List.generate(10, (index) {
    return Image.network('https://picsum.photos/400/300');
  }),
);

Listing 7-10Using childAspectRatio parameter

就像在 ListView 中使用 ListTiles 一样,在 GridView 中也可以使用 GridTiles。grid tile 有一个必需的子部件和可选的 header 和 footer 部件。对于 grid tiles 的页眉和页脚,通常使用 GridTileBar 小部件。GridTileBar 与 ListTile 类似。GridTileBar 构造函数有 title、subtitle、leading、trailing 和 backgroundColor 参数。

GridView.count(
  crossAxisCount: 2,
  children: <Widget>[
    GridTile(
      child: ExampleWidget(name: 'Simple'),
    ),
    GridTile(
      child: ExampleWidget(name: 'Header & Footer'),
      header: GridTileBar(
        title: Text('Header'),
        backgroundColor: Colors.red,
      ),
      footer: GridTileBar(
        title: Text('Footer'),
        subtitle: Text('Description'),
        backgroundColor: Colors.blue,
      ),
    )
  ],
);

Listing 7-11Example of GridTile and GridTileBar

图 7-2 显示了清单 7-11 中的代码截图。

img/479501_1_En_7_Fig2_HTML.png

图 7-2

GridTile 和 GridTileBar

7.3 显示表格数据

问题

您希望显示表格数据或对孩子使用表格布局。

解决办法

使用表格小部件。

讨论

如果您想显示表格数据,使用数据表是一个自然的选择。表格也可以用于布局目的,以组织孩子。对于这两种使用场景,您可以使用表格小部件。

表格小部件可以有多行。表格行用 table row 小部件表示。表格小部件构造函数有 List 类型的子参数来提供行列表。TableRow 构造函数也有 List 类型的 children 参数来提供该行中的单元格列表。表中的每一行都必须有相同数量的子代。

表格的边框是使用 TableBorder 类定义的。TableBorder 与 Border 类似,但 TableBorder 有两条额外的边:

  • horizontal inside–行与行之间的内部水平边框

  • vertical inside–列之间的内部垂直边框

清单 7-12 显示了一个三行四列的简单表格的例子。

Table(
  border: TableBorder.all(color: Colors.red.shade200),
  children: [
    TableRow(children: [Text('A'), Text('B'), Text('C'), Text('D')]),
    TableRow(children: [Text('E'), Text('F'), Text('G'), Text('H')]),
    TableRow(children: [Text('I'), Text('J'), Text('K'), Text('L')]),
  ],
);

Listing 7-12Simple table

表中列的宽度由 TableColumnWidth 实现配置。类型 Map 的 columnWidths 参数定义了列索引与其 TableColumnWidth 实现之间的映射。表 7-5 显示了内置的 TableColumnWidth 实现。MinColumnWidth 和 MaxColumnWidth 类结合了其他 TableColumnWidth 实现。如果没有为列找到 TableColumnWidth 实现,则使用 defaultColumnWidth 参数来获取默认的 TableColumnWidth 实现。defaultColumnWidth 的默认值是 FlexColumnWidth(1.0),这意味着所有列共享相同的宽度。

表 7-5

表列宽实现

|

名称

|

性能

|

描述

|
| --- | --- | --- |
| 固定列宽 | 高 |
| flex column width | Medium | 一旦调整完所有其他非灵活列的大小,就使用伸缩因子来划分剩余空间。 |
| FractionColumnWidth | Medium | 使用表格最大宽度的一部分作为列宽。 |
| IntrinsicColumnWidth | Low | 使用一列中所有单元格的内在尺寸来确定列宽。 |
| min column width | | 最小的两个 TableColumnWidth 对象。 |
| max column width | | 最多两个 TableColumnWidth 对象。 |

清单 7-13 显示了一个具有不同列宽的表格示例。

Table(
  border: TableBorder.all(color: Colors.blue.shade200),
  columnWidths: {
    0: FixedColumnWidth(100),
    1: FlexColumnWidth(1),
    2: FlexColumnWidth(2),
    3: FractionColumnWidth(0.2),
  },
  children: [
    TableRow(children: [Text('A'), Text('B'), Text('C'), Text('D')]),
    TableRow(children: [Text('E'), Text('F'), Text('G'), Text('H')]),
    TableRow(children: [Text('I'), Text('J'), Text('K'), Text('L')]),
  ],
);

Listing 7-13Table with different column width

单元格的垂直对齐是用 TableCellVerticalAlignment 枚举的值配置的。TableCellVerticalAlignment 枚举具有值 top、middle、bottom、baseline 和 fill。表构造函数的 defaultVerticalAlignment 参数指定默认的 TableCellVerticalAlignment 值。如果希望自定义单个单元格的垂直对齐,可以将单元格小部件包装在 TableCell 小部件中,并指定 vertical alignment 参数。清单 7-14 展示了一个为单元格指定垂直对齐的例子。

class VerticalAlignmentTable extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Table(
      border: TableBorder.all(color: Colors.green.shade200),
      defaultVerticalAlignment: TableCellVerticalAlignment.bottom,
      children: [
        TableRow(children: [
          TextCell('A'),
          TableCell(
            verticalAlignment: TableCellVerticalAlignment.middle,
            child: Text('B'),
          ),
          Text('C'),
          Text('D'),
        ]),
        TableRow(children: [Text('E'), Text('F'), Text('G'), Text('H')]),
        TableRow(children: [Text('I'), Text('J'), Text('K'), Text('L')]),
      ],
    );
  }
}

class TextCell extends StatelessWidget {
  TextCell(this.text, {this.height = 50});

  final String text;
  final double height;

  @override
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: BoxConstraints(
        minHeight: height,
      ),
      child: Text(text),
    );
  }
}

Listing 7-14Vertical alignment of table cells

图 7-3 为不同表格的截图。

img/479501_1_En_7_Fig3_HTML.png

图 7-3

桌子

7.4 脚手架材质设计页面

问题

你要脚手架材质设计页面。

解决办法

使用脚手架和其他相关部件。

讨论

材质设计应用具有通用的布局结构。Scaffold 小部件将其他常见的小部件放在一起,创建基本的页面结构。表 7-6 显示了可以包含在 Scaffold 小部件中的元素。指定为 drawer 和 endDrawer 的小部件最初是隐藏的,可以通过滑动来显示。滑动方向取决于文本方向。drawer 小部件使用与文本方向相同的方向,而 endDrawer 小部件使用相反的方向。例如,如果文本方向是从左到右,则通过从左到右滑动来打开抽屉小部件,通过从右到左滑动来打开 endDrawer 小部件。

表 7-6

脚手架元件

|

参数

|

小部件

|

描述

|
| --- | --- | --- |
| 打电话给我 | 打电话给我 | 显示在顶部的应用栏。 |
| 浮动操作按钮 | 浮动操作按钮 | 一个按钮浮在身体上方的右下角。 |
| 抽屉 | 抽屉 | 显示在机身侧面的隐藏面板。 |
| 抽屉末端 | 抽屉 | 显示在机身侧面的隐藏面板。 |
| 底部导航栏 | BottomAppBar 底部导航栏 | 导航栏显示在底部。 |
| 底板 | 底板 | 持久的底层。 |
| persistentFooterButtons | 列表 | 显示在底部的一组按钮。 |
| 身体 | 小部件 | 主要内容。 |

表 7-6 中的第二列仅列出了这些元素的首选小部件类型。Scaffold 构造器实际上接受任何类型的小部件。例如,您可以使用 ListView 小部件作为抽屉。然而,这些首选的小部件更合适。

App Bar(应用栏)

AppBar 小工具显示当前屏幕的基本信息。它由一个工具栏和其他小部件组成。表 7-7 显示了 AppBar 小部件的元素。这些元素也是 AppBar 构造函数的命名参数。

表 7-7

AppBar 的参数

|

名字

|

描述

|
| --- | --- |
| 标题 | 工具栏中的主要小部件。 |
| 主要的 | 在标题前显示的小工具。 |
| 行动 | 标题后显示的小部件列表。 |
| 底部 | 显示在底部的小部件。 |
| 灵活的空间 | 要堆叠在工具栏和底部后面的小工具。 |

如果前导小部件为空,并且 automaticallyImplyLeading 参数为真,则从状态中推导出实际的前导小部件。如果脚手架有一个抽屉,那么主要的小部件是一个打开抽屉的按钮。如果最近的导航器有以前的路线,领先的小部件是返回到以前路线的后退按钮。

动作列表中的小部件通常是图标按钮。如果没有足够的空间来放置这些图标按钮,您可以使用 PopupMenuButton 作为最后一个操作,并将其他操作放在弹出菜单中。TabBar 小部件通常用作底部小部件。清单 7-15 展示了一个使用 AppBar 的例子。

AppBar(
  title: Text('Scaffold'),
  actions: <Widget>[
    IconButton(
      icon: Icon(Icons.search),
      onPressed: () {},
    ),
  ],
);

Listing 7-15Example of AppBar

浮动操作按钮

FloatingActionButton 小部件是一种特殊的按钮,用于提供对主要操作的快速访问。浮动操作按钮是一个圆形图标,通常显示在屏幕的右下角。在 Gmail 应用中,电子邮件列表屏幕有一个浮动的操作按钮,用于编写新邮件。

有两种浮动操作按钮。使用 FloatingActionButton()构造函数时,只需要提供子 widget 和 onPressed 回调。使用 FloatingActionButton.extend()构造函数时,需要提供图标和标签小部件以及 onPressed 回调。对于这两个构造函数,foregroundColor 和 backgroundColor 参数都可以自定义颜色。清单 7-16 展示了一个使用 FloatingActionButton 的例子。

FloatingActionButton(
  child: Icon(Icons.create),
  onPressed: () {},
);

Listing 7-16Example of FloatingActionButton

抽屉

Drawer 小部件是一个方便的面板包装器,滑动时显示在支架小部件的边缘。虽然你可以使用抽屉来包装任何小部件,但通常会在抽屉中显示应用徽标、当前用户的信息以及应用页面的链接。ListView 小部件通常用作 Drawer 小部件的子部件,以支持在抽屉中滚动。

要显示应用徽标和当前用户的信息,可以使用提供的 DrawerHeader 小部件及其子类 UserAccountsDrawerHeader。DrawerHeader 小部件包装了一个子小部件,并具有预定义的样式。UserAccountsDrawerHeader 是一个显示用户详细信息的特定小部件。表 7-8 显示了可以添加到 UserAccountsDrawerHeader 小部件中的部分。您还可以使用 onDetailsPressed 参数来添加在点击带有帐户名称和电子邮件的区域时的回拨。

表 7-8

UserAccountsDrawerHeader 中的节

|

名字

|

描述

|
| --- | --- |
| curreniaccountpicture | 当前用户帐户的图片。 |
| 其他账户 | 当前用户的其他帐户的图片列表。你最多只能有三张这样的照片。 |
| 帐户名 | 当前用户的帐户名称。 |
| 帐户电子邮件 | 当前用户帐户的电子邮件。 |

清单 7-17 展示了一个使用 Drawer 和 UserAccountsDrawerHeader 的例子。

Drawer(
  child: ListView(
    children: <Widget>[
      UserAccountsDrawerHeader(
        currentAccountPicture: CircleAvatar(
          child: Text('JD'),
        ),
        accountName: Text('John Doe'),
        accountEmail: Text('john.doe@example.com'),
      ),
      ListTile(
        leading: Icon(Icons.search),
        title: Text('Search'),
      ),
      ListTile(
        leading: Icon(Icons.history),
        title: Text('History'),
      ),
    ],
  ),
);

Listing 7-17Example of Drawer

底部应用栏

BottomAppBar 小部件是 AppBar 的简化版本,显示在脚手架的底部。只在底部的应用栏中添加图标按钮是很常见的。如果脚手架也有一个浮动的动作按钮,底部的应用栏也会创建一个按钮停靠的凹口。清单 7-18 展示了一个使用 BottomAppBar 的例子。

BottomAppBar(
  child: Text('Bottom'),
  color: Colors.red,
);

Listing 7-18Example of BottomAppBar

底部导航栏

BottomNavigationBar 小部件提供了在不同视图之间导航的额外链接。表 7-9 显示了 BottomNavigationBar 构造函数的参数。

表 7-9

BottomNavigationBar 的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| 项目 | 列表< BottomNavigationBarItem> | 项目列表。 |
| 当前值的索引 | (同 Internationalorganizations)国际组织 | 所选项目的索引。 |
| 数据库 | 值已更改 | 当选择的项目改变时回调。 |
| 类型 | BottomNavigationBarType | 导航栏的类型。 |
| 固定颜色 | 颜色 | 键入 if bottomnavigationbartype . fixed 时选定项的颜色 |
| 图标大小 | 两倍 | 图标的大小。 |

点击某个项目时,会调用带有所点击项目索引的 onTap 回调。根据项目的数量,可以有不同的方式来显示这些项目。项的布局由 BottomNavigationBarType 枚举的值定义。如果该值是固定的,则这些项目具有固定的宽度,并且总是显示文本标签。如果值正在移动,项目的位置可能会根据选定的项目而改变,并且仅显示选定项目的文本标签。BottomNavigationBar 有一个默认的策略来选择类型。当项目少于四个时,使用 BottomNavigationBarType.fixed 否则,将使用 BottomNavigationBarType.shifting。您可以使用 type 参数来重写默认行为。

表 7-10 显示了 BottomNavigationBarItem 构造器的参数。图标和标题参数都是必需的。如果 BottomNavigationBar 的类型是 BottomNavigationBarType.shifting,则导航栏的背景由所选项的背景颜色决定。您应该指定 backgroundColor 参数来区分各项。

表 7-10

BottomNavigationBarItem 的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| 图标 | 小部件 | 项目的图标。 |
| 标题 | 小部件 | 项目的标题。 |
| 激活 | 小部件 | 选择项目时显示的图标。 |
| 背景颜色 | 颜色 | 项目的背景色。 |

清单 7-19 显示了一个使用 BottomNavigationBar 和 BottomNavigationBarItem 的例子。

 BottomNavigationBar(
  currentIndex: 1,
  type: BottomNavigationBarType.shifting,
  items: [
    BottomNavigationBarItem(
      icon: Icon(Icons.cake),
      title: Text('Cake'),
      backgroundColor: Colors.red.shade100,
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.map),
      title: Text('Map'),
      backgroundColor: Colors.green.shade100,
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.alarm),
      title: Text('Alarm'),
      backgroundColor: Colors.blue.shade100,
    ),
  ],

);

Listing 7-19Example of BottomNavigationBar

底部薄板

BottomSheet 微件显示在应用的底部,以提供附加信息。系统共享表是底层表的一个典型例子。有两种类型的底板:

  • 持久的底层总是可见的。可以使用 ScaffoldState.showBottomSheet 函数和 Scaffold 构造函数的 BottomSheet 参数创建持久的底部工作表。

  • 模态底层表单的行为类似于模态对话框。可以使用 showModalBottomSheet 函数创建模态底板。

BottomSheet 构造函数使用 WidgetBuilder 函数来创建实际内容。您还需要提供一个 onClosing 回调函数,当底部的工作表开始关闭时会调用这个回调函数。清单 7-20 显示了一个使用 BottomSheet 的例子。

BottomSheet(
  onClosing: () {},
  builder: (context) {
    return Text('Bottom');
  },
);

Listing 7-20Example of BottomSheet

脚手架状态

Scaffold 是一个有状态的小部件。您可以使用 Scaffold.of()方法从构建上下文中获取最近的 ScaffoldState 对象。ScaffoldState 有不同的方法与其他组件交互;参见表 7-11 。

表 7-11

脚手架搭设方法

|

名字

|

描述

|
| --- | --- |
| openDrawer() | 打开抽屉。 |
| openEndDrawer() | 打开末端的抽屉。 |
| 表演用酒吧(谈话用酒吧) | 展示零食吧。 |
| hideCurrentSnackBar() | 隐藏当前的 SnackBar。 |
| removeCurrentSnackBar() | 删除当前的 SnackBar。 |
| showBottomSheet() | 显示持久的底层。 |

小吃吧

SnackBar 小部件在屏幕底部显示一条带有可选操作的消息。要创建 SnackBar 小部件,构造函数需要 content 参数来指定内容。duration 参数控制小吃店显示多长时间。要向小吃店添加操作,可以使用 SnackBarAction 类型的操作参数。当提供一个动作时,当按下该动作时,小吃店被解散。

要创建 SnackBarAction 实例,需要提供标签和 onPressed 回调。您可以使用 textColor 参数自定义按钮标签颜色。小吃店动作的按钮只能按一次。

ScaffoldState 的 showSnackBar()方法显示一个 SnackBar 小部件。一次最多只能显示一个小吃店。如果在另一个小吃店仍然可见时调用 ScaffoldState()方法,则给定的小吃店将被添加到一个队列中,并将在其他小吃店消失后显示。showSnackBar()方法的返回类型是 ScaffoldFeatureController 。SnackBarClosedReason 是一个定义小吃店可能关闭的原因的枚举。

清单 7-21 展示了一个开小吃店的例子。

Scaffold.of(context).showSnackBar(SnackBar(
  content: Text('This is a message.'),
  action: SnackBarAction(label: 'OK', onPressed: () {}),
));

Listing 7-21Example of SnackBar

7.5 搭建 iOS 页面

问题

你想搭建 iOS 页面。

解决办法

用 cupertinopagescaffold。

讨论

对于 iOS 应用,您可以使用 CupertinoPageScaffold 小部件来创建页面的基本布局。与材质设计中的支架相比,CupertinoPageScaffold 提供的定制是有限的。您只能指定导航栏、子级和背景色。

CupertinoNavigationBar 小部件在材质设计上与 AppBar 类似,但 CupertinoNavigationBar 只能有前导、中间和尾随小部件。中间小部件位于前导小部件和尾随小部件的中间。当 automaticallyImplyLeading 参数为 true 时,可以基于导航状态自动暗示前导小部件。当 automaticallyImplyMiddle 参数为真时,也可以自动隐含中间小部件。

清单 7-22 展示了一个使用 CupertinoPageScaffold 和 CupertinoNavigationBar 的例子。

CupertinoPageScaffold(
  navigationBar: CupertinoNavigationBar(
    middle: Text('App'),
    trailing: CupertinoButton(
      child: Icon(CupertinoIcons.search),
      onPressed: () {},
    ),

  ),
  child: Container(),
);

Listing 7-22Example of CupertinoPageScaffold

7.6 在材质设计中创建选项卡布局

问题

您想要创建标签栏和标签。

解决办法

使用 TabBar、Tab 和 TabController。

讨论

移动应用中广泛使用选项卡布局来组织一个页面中的多个部分。要在材质设计中实现选项卡布局,您需要使用几个小部件。TabBar 小部件是选项卡小部件的容器。TabController 小部件负责协调 TabBar 和 TabView。

选项卡小部件必须至少有一些文本、图标或子小部件,但不能同时有文本和子小部件。要创建 TabBar,您需要提供一个选项卡列表。您可以选择使用显式创建的 TabController 实例或使用共享的 DefaultTabController 实例。DefaultTabController 是一个继承的小部件。如果没有提供 TabController,TabBar 将尝试查找祖先 DefaultTabController 实例。

您可以选择提供 TabController 实例或使用继承的 DefaultTabController。要创建 TabController,您需要提供选项卡的数量和 TickerProvider 实例。

在清单 7-23 中,_TabPageState 的 mixin singletickerproviderstatemix in 是 TickerProvider 的一个实现,所以 _TabPageState 的当前实例作为 TabController 构造函数的 vsync 参数传递。TabController 实例由 TabBar 和 TabBarView 共享。

class TabPage extends StatefulWidget {
  @override
  _TabPageState createState() => _TabPageState();
}

class _TabPageState extends State<TabPage> with SingleTickerProviderStateMixin {
  final List<Tab> _tabs = [
    Tab(text: 'List', icon: Icon(Icons.list)),
    Tab(text: 'Map', icon: Icon(Icons.map)),
  ];
  TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: _tabs.length, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Tab'),
        bottom: TabBar(
          tabs: _tabs,
          controller: _tabController,
        ),
      ),
      body: TabBarView(
        children: _tabs.map((tab) {
          return Center(
            child: Text(tab.text),
          );
        }).toList(),
        controller: _tabController,
      ),
    );
  }
}

Listing 7-23TabBar with provided TabController

如果不需要和 TabController 交互,使用 DefaultTabController 是更好的选择。清单 7-24 中的代码使用 DefaultTabController 来实现与清单 7-23 中的代码相同的功能。

class DefaultTabControllerPage extends StatelessWidget {
  final List<Tab> _tabs = [
    Tab(text: 'List', icon: Icon(Icons.list)),
    Tab(text: 'Map', icon: Icon(Icons.map))
  ];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: _tabs.length,
      child: Scaffold(
        appBar: AppBar(
          bottom: TabBar(tabs: _tabs),
        ),
        body: TabBarView(
          children: _tabs.map((tab) {
            return Center(
              child: Text(tab.text),
            );
          }).toList(),
        ),
      ),
    );
  }

}

Listing 7-24DefaultTabController

7.7 在 iOS 中实现选项卡布局

问题

你想在 iOS 应用中实现标签布局。

解决办法

请使用 CupertinoTabScaffold、CupertinoTabBar 和 CupertinoTabView。

讨论

还可以使用小部件 CupertinoTabScaffold、CupertinoTabBar 和 CupertinoTabView 为 iOS 应用实现选项卡布局。创建 CupertinoTabScaffold 时,应该使用 CupertinoTabBar 作为 TabBar 参数的值。CupertinoTabBar 中的选项卡表示为 BottomNavigationBarItem 小部件。tabBuilder 参数指定为每个选项卡构建视图的构建器函数。清单 7-25 显示了一个实现标签布局的例子。

class CupertinoTabPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoTabScaffold(
      tabBar: CupertinoTabBar(items: [
        BottomNavigationBarItem(icon: Icon(CupertinoIcons.settings)),
        BottomNavigationBarItem(icon: Icon(CupertinoIcons.info)),
      ]),
      tabBuilder: (context, index) {
        return CupertinoTabView(
          builder: (context) {
            return Center(
              child: Text('Tab $index'),
            );
          },
        );
      },
    );
  }

}

Listing 7-25Tab layout for iOS style

7.8 摘要

本章讨论了 Flutter 中常见的小部件,包括列表视图、网格视图、表格布局、页面搭建和选项卡布局。这些小部件创建了 Flutter 中页面的基本结构。在下一章,我们将讨论 Flutter 应用中的页面导航。

八、页面导航

Flutter 应用可能有多个屏幕或页面。页面是一组功能。用户在不同的页面之间导航以使用不同的功能。像页面这样的概念在 Flutter 中被称为路由。路由不仅包括全屏页面,还包括模态对话框和弹出窗口。路线由Navigator小工具管理。本章讨论与 Flutter 中页面导航相关的方法。

8.1 实现基本页面导航

问题

您需要基本的页面导航支持。

解决办法

使用Navigator.push()导航到新路线,使用Navigator.pop()导航到以前的路线。

讨论

路线由Navigator小工具管理。导航器管理一堆路线。可以使用push()方法将路由推入堆栈,使用pop()方法将路由弹出堆栈。堆栈中的顶部元素是当前活动的路由。Navigator是一个有状态小部件,其状态为NavigatorState。要与 navigator 交互,可以使用 Navigator 的静态方法或获取一个NavigatorState的实例。通过使用Navigator.of()方法,您可以获得给定构建上下文的最近的封闭NavigatorState实例。您可以显式创建Navigator小部件,但是大多数时候您将使用由WidgetsAppMaterialAppCupertinoApp小部件创建的Navigator小部件。

使用抽象Route类的实现来表示路线。例如,PageRoute代表全屏模式路线,PopupRoute代表在当前路线上叠加一个小工具的模式路线。PageRoutePopupRoute类都是ModalRoute类的子类。对于材质设计应用,创建全屏页面最简单的方法是使用MaterialPageRoute类。MaterialPageRoute使用WidgetBuilder函数构建路线的内容。

在清单 8-1 中,Navigator.of(context)获取要使用的NavigatorState实例。推送给导航器的新路线是一个MaterialPageRoute实例。新路线有一个按钮,使用NavigatorState.pop()方法将当前路线弹出导航器。其实在使用Scaffold widget 的时候,应用栏里自动添加了一个后退按钮,所以不需要使用显式的后退按钮。

class SimpleNavigationPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Simple Navigation'),
      ),
      body: Center(
        child: RaisedButton(
          child: Text('Show page'),
          onPressed: () {
            Navigator.of(context).push(MaterialPageRoute(builder: (context) {
              return Scaffold(
                appBar: AppBar(
                  title: Text('New Page'),
                ),
                body: Center(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: <Widget>[
                      Text('A new page'),
                      RaisedButton(
                        child: Text('Go back'),
                        onPressed: () {
                          Navigator.of(context).pop();
                        },
                      ),
                    ],
                  ),
                ),
              );
            }));
          },
        ),
      ),
    );
  }
}

Listing 8-1Page navigation using Navigator

Navigator类有像push()pop()这样的静态方法,它们和NavigatorState类中的相同方法做同样的事情,但是这些静态方法需要一个额外的BuildContext参数。Navigator.push(context)其实和Navigator.of(context).push()一样。你可以选择使用任何一种方法。

8.2 使用命名路线

问题

您想要从不同的地方导航到相同的路线。

解决办法

使用带有Navigator.pushNamed()方法的命名路线。

讨论

当使用Navigator.push()方法将新路线推送到导航器时,使用构建器函数按需构建新路线。当路线可以从不同的地方导航时,这种方法不太适用,因为我们不想重复构建路线的代码。在这种情况下,使用命名路由是更好的选择。命名路由具有唯一的名称。Navigator.pushNamed()方法使用名称来指定要推送到导航器的路线。

命名的路由需要先注册,然后才能导航到。注册命名路径最简单的方法是使用WidgetsAppMaterialAppCupertinoApp构造函数的routes参数。routes参数是一个Map<String, WidgetBuilder>对象,以关键字作为路线名称。路由名称通常采用类似路径的格式,以“/”开头。这类似于 web 应用组织页面的方式。例如,您可以使用像/log_in/orders/orders/1234这样的路线名称。

在清单 8-2 中,按下“注册”按钮将指定的路线/sign_up推送到导航器。

class LogInPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Log In'),
      ),
      body: Center(
        child: RaisedButton(
          child: Text('Sign Up'),
          onPressed: () {
            Navigator.pushNamed(context, '/sign_up');
          },
        ),
      ),
    );
  }
}

Listing 8-2Use named route

在清单 8-3 中,在routes参数中注册了两条命名路径。

class PageNavigationApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Page Navigation',
      home: IndexPage(),
      routes: {
        '/sign_up': (context) => SignUpPage(),
        '/log_in': (context) => LogInPage(),
      },
    );
  }
}

Listing 8-3
Register named routes

8.3 在路线之间传递数据

问题

您希望在不同的路由之间传递数据。

解决办法

使用构造函数参数或RouteSettings对象将数据传递给路由,使用Navigator.pop()方法的result参数从路由传递数据。

讨论

构建路径内容时,路径可能需要额外的数据。弹出时,路由也可能返回一些数据。例如,编辑用户详细信息的路由可能需要当前的详细信息作为输入,并返回更新的详细信息作为输出。根据导航路线的方式,有不同的方法在路线之间传递数据。

使用Navigator.push()方法推送新路线时,最简单的方法是将数据作为WidgetBuilder函数返回的 widget 的构造函数参数传递。使用Navigator.pop()方法时,可以使用可选的result参数将返回值传递给之前的路径。Navigator.push()方法的返回值是一个Future<T>对象。这个Future对象将在弹出新推送的路线时被解析。解析的值是调用Navigator.pop()方法时传递的返回值。如果使用后退按钮弹出路线,则解析值为null

在清单 8-4 中,UserDetails类包含用户的名和姓。UserDetailsPage显示用户的详细信息。当按下编辑按钮时,一条新路线被推送到导航器。新路由的内容是一个EditUserDetailsPage小部件,将UserDetails对象作为构造函数参数。新路由的返回值也是一个UserDetails对象,用于更新UserDetailsPage的状态。

class UserDetails {
  UserDetails(this.firstName, this.lastName);

  final String firstName;
  final String lastName;
}

class UserDetailsPage extends StatefulWidget {
  @override
  _UserDetailsPageState createState() => _UserDetailsPageState();
}

class _UserDetailsPageState extends State<UserDetailsPage> {
  UserDetails _userDetails = UserDetails('John', 'Doe');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('User Details'),
      ),

      body: Column(
        children: <Widget>[
          Text('First name: ${_userDetails.firstName}'),
          Text('Last name: ${_userDetails.lastName}'),
          RaisedButton.icon(
            label: Text('Edit (route builder)'),
            icon: Icon(Icons.edit),
            onPressed: () async {
              UserDetails result = await Navigator.push(
                context,
                MaterialPageRoute<UserDetails>(
                  builder: (BuildContext context) {
                    return EditUserDetailsPage(_userDetails);
                  },
                ),
              );
              if (result != null) {
                setState(() {
                  _userDetails = result;
                });
              }
            },
          ),
        ],
      ),
    );
  }
}

Listing 8-4
User details page

在清单 8-5 中,EditUserDetailsPage使用两个TextFormField小部件来编辑用户详细信息。当按下保存按钮时,使用Navigator.pop()方法返回更新后的UserDetails对象。

class EditUserDetailsPage extends StatefulWidget {
  EditUserDetailsPage(this.userDetails);
  final UserDetails userDetails;

  @override
  _EditUserDetailsPageState createState() =>
      _EditUserDetailsPageState(userDetails);
}

class _EditUserDetailsPageState extends State<EditUserDetailsPage> {
  _EditUserDetailsPageState(this._userDetails);

  UserDetails _userDetails;
  final GlobalKey<FormFieldState<String>> _firstNameKey = GlobalKey();
  final GlobalKey<FormFieldState<String>> _lastNameKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Edit User Details'),
      ),
      body: Column(
        children: <Widget>[
          TextFormField(
            key: _firstNameKey,
            decoration: InputDecoration(
              labelText: 'First name',
            ),
            initialValue: _userDetails.firstName,
          ),
          TextFormField(
            key: _lastNameKey,
            decoration: InputDecoration(
              labelText: 'Last name',
            ),
            initialValue: _userDetails.lastName,
          ),
          RaisedButton(
            child: Text('Save'),
            onPressed: () {
              Navigator.pop(
                  context,
                  UserDetails(_firstNameKey.currentState?.value,
                      _lastNameKey.currentState?.value));
            },
          )
        ],
      ),
    );
  }
}

Listing 8-5Edit user details page

如果使用命名路由,可以使用Navigator.pushNamed()方法的arguments参数将数据传递给路由。在清单 8-6 中,使用pushNamed()方法导航到当前UserDetails对象的/edit_user路线。

UserDetails result = await Navigator.pushNamed(
  context,
  '/edit_user',
  arguments: _userDetails,
);

Listing 8-6Pass data to named route

被命名的路线/edit_user被登记在MaterialApp中。不能使用route参数,因为您不能访问在构建器函数中传递给路线的数据。应使用WidgetsAppMaterialAppCupertinoApponGenerateRoute参数。onGenerateRoute参数的类型为RouteFactory,是函数类型Route (RouteSettings settings)的 typedef。RouteSettings类包含创建Route对象时可能需要的数据。表 8-1 显示了RouteSettings类的属性。

表 8-1

路由设置的属性

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| name | String | 路线的名称。 |
| arguments | Object | 传递给路由的数据。 |
| isInitialRoute | bool | 此路线是否是推送到导航器的第一条路线。 |

当实现onGenerateRoute函数时,需要根据提供的RouteSettings对象返回路线。在清单 8-7 中,首先检查name属性,然后返回一个内容为EditUserDetailsPageMeterialPageRouteRouteSettingsarguments属性在EditUserDetailsPage构造函数中使用。arguments属性的值是清单 8-6 中传递的UserDetails对象。

MaterialApp(
  onGenerateRoute: (RouteSettings settings) {
    if (settings.name == '/edit_user') {
      return MaterialPageRoute<UserDetails>(
        settings: settings,
        builder: (context) {
          return EditUserDetailsPage(settings.arguments);
        },
      );
    }
  },
);

Listing 8-7Use onGenerateRoute

8.4 实现动态路径匹配

问题

您希望使用复杂的逻辑来匹配路由名称。

解决办法

使用onGenerateRoute参数。

讨论

当使用WidgetsApproutes参数注册命名路线时,只有整个路线名称可用于匹配Route对象。如果想用复杂的逻辑将Route对象与路线名称匹配,可以使用onGenerateRoute参数和RouteSettings对象。例如,您可以将所有以/order开头的路线名称匹配到一个Route对象。

在清单 8-8 中,所有以/order开头的路线名称将使用OrderPage导航到一条路线。

MaterialApp(
  onGenerateRoute: (RouteSettings settings) {
    if (settings.name.startsWith('/order')) {
      return MaterialPageRoute(
        settings: settings,
        builder: (context) {
          return OrderPage();
        },
      );
    }
  },
);

Listing 8-8Route matching

8.5 处理未知路线

问题

您希望处理导航到未知路线的情况。

解决办法

使用NavigatorWidgetsAppMaterialAppCupertinoApponUnknownRoute参数。

讨论

可能会要求导航员导航到未知的路线。这可能是由于应用中的编程错误或外部路线导航请求造成的。如果onGenerateRoute函数为给定的RouteSettings对象返回null,则onUnknownRoute函数被调用以提供一条回退路线。这个onUnknownRoute函数通常用于错误处理,就像 web 应用中的 404 页面一样。onUnknownRoute的类型也是RouteFactory

在清单 8-9 中,onUnknownRoute函数返回显示NotFoundPage小部件的路线。

MaterialApp(
  onUnknownRoute: (RouteSettings settings) {
    return MaterialPageRoute(
      settings: settings,
      builder: (BuildContext context) {
        return NotFoundPage(settings.name);
      },
    );
  },
);

Listing 8-9Use onUnknownRoute

8.6 显示材质设计对话框

问题

您希望显示材质设计对话框。

解决办法

使用showDialog()功能和DialogSimpleDialogAlertDialog控件。

讨论

要使用材质设计对话框,你需要创建对话框部件并显示它们。Dialog类及其子类SimpleDialogAlertDialog可以用来创建对话框。

SimpleDialog小工具为用户提供了几个选项。选项使用SimpleDialogOption类表示。一个SimpleDialogOption小部件可以有一个子小部件和一个onPressed回调。当创建SimpleDialog时,你可以提供一个孩子列表和一个可选的标题。AlertDialog widget 向用户呈现内容和动作列表。AlertDialog用于确认用户或要求确认。

要显示对话框,应该使用showDialog()功能。调用此函数会将对话路由推送到导航器。使用Navigator.pop()方法关闭对话框。showDialog()函数使用WidgetBuilder函数构建对话内容。showDialog()函数的返回值是一个Future<T>对象,它实际上是Navigator.push()方法的返回值。

在清单 8-10 中,按下按钮会显示一个带有两个选项的简单对话框。

RaisedButton(
  child: Text('Show SimpleDialog'),
  onPressed: () async {
    String result = await showDialog<String>(
        context: context,
        builder: (BuildContext context) {
          return SimpleDialog(
            title: Text('Choose Color'),
            children: <Widget>[
              SimpleDialogOption(
                child: Text('Red'),
                onPressed: () {
                  Navigator.pop(context, 'Red');
                },
              ),
              SimpleDialogOption(
                child: Text('Green'),
                onPressed: () {
                  Navigator.pop(context, 'Green');
                },
              ),
            ],
          );
        });
    print(result);
  },
);

Listing 8-10Show simple dialogs

图 8-1 显示了清单 8-10 中的代码截图。

img/479501_1_En_8_Fig1_HTML.jpg

图 8-1

材质设计简单对话框

在清单 8-11 中,按下按钮会显示一个带有两个动作的警告对话框。

RaisedButton(
  child: Text('Show AlertDialog'),
  onPressed: () async {
    bool result = await showDialog<bool>(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('Delete'),
          content: Text('Delete this item?'),
          actions: <Widget>[
            FlatButton(
              child: Text('Yes'),
              onPressed: () {
                Navigator.pop(context, true);
              },
            ),
            FlatButton(
              child: Text('No'),
              onPressed: () {
                Navigator.pop(context, false);
              },
            ),
          ],
        );
      },
    );
    print(result);
  },
);

Listing 8-11Show alert dialog

图 8-2 显示了清单 8-11 中的代码截图。

img/479501_1_En_8_Fig2_HTML.jpg

图 8-2

材质设计警告对话框

8.7 显示 iOS 对话框

问题

您想要显示 iOS 对话框。

解决办法

使用showCupertinoDialog()功能和CupertinoAlertDialogCupertinoPopupSurface控件。

讨论

对于 iOS 应用,你可以使用showCupertinoDialog()功能和CupertinoAlertDialogCupertinoPopupSurface等小工具来显示对话框。showCupertinoDialog()功能与材质设计的showDialog()功能类似。该函数也使用Navigator.push()方法将对话路径推送到导航器。CupertinoAlertDialog是一个内置的对话框实现,用于确认用户或要求确认。一个CupertinoAlertDialog可能有标题、内容和动作列表。使用CupertinoDialogAction小部件表示动作。表 8-2 显示了CupertinoDialogAction构造器的参数。

表 8-2

CupertinoDialogAction 参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| child | Widget | 行动的内容。 |
| onPressed | VoidCallback | 操作按了回拨。 |
| isDefaultAction | bool | 此操作是否为默认操作。 |
| isdstructural action | bool | 这个动作是否具有破坏性。破坏性的行为有不同的风格。 |
| textStyle | TextStyle | 应用于操作的文本样式。 |

在清单 8-12 中,按下按钮会显示一个 iOS 风格的警告对话框。

CupertinoButton(
  child: Text('Show Alert Dialog'),
  onPressed: () async {
    bool result = await showCupertinoDialog<bool>(
      context: context,
      builder: (BuildContext context) {
        return CupertinoAlertDialog(
          title: Text('Delete'),
          content: Text('Delete this item?'),
          actions: <Widget>[
            CupertinoDialogAction(
              child: Text('Delete'),
              onPressed: () {
                Navigator.pop(context, true);
              },
              isDestructiveAction: true,
            ),
            CupertinoDialogAction(
              child: Text('Cancel'),
              onPressed: () {
                Navigator.pop(context, false);
              },
            ),

          ],
        );
      },
    );
    print(result);
  },
);

Listing 8-12Show iOS alert dialog

图 8-3 显示了清单 8-12 中的代码截图。

img/479501_1_En_8_Fig3_HTML.jpg

图 8-3

iOS 警报对话框

如果你想创建一个自定义对话框,你可以使用CupertinoPopupSurface小部件来创建圆角矩形表面。

8.8 显示 iOS 行动表

问题

您想要在 iOS 应用中呈现一组操作供用户选择。

解决办法

使用showCupertinoModalPopup()功能和CupertinoActionSheet控件。

讨论

如果想在 iOS 应用中呈现一组动作供用户选择,可以使用showCupertinoModalPopup()函数显示CupertinoActionSheet widgets。一个CupertinoActionSheet可以有标题、消息、取消按钮和动作列表。动作被表示为CupertinoActionSheetAction小部件。CupertinoActionSheetAction构造器有参数childonPressedisDefaultActionisDestructiveAction,与表 8-2 中的CupertinoDialogAction构造器含义相同。

在清单 8-13 中,按下按钮会显示一个带有三个动作和一个取消按钮的动作表。

CupertinoButton(
  child: Text('Show Action Sheet'),
  onPressed: () async {
    String result = await showCupertinoModalPopup<String>(
      context: context,
      builder: (BuildContext context) {
        return CupertinoActionSheet(
          title: Text('What to do'),
          message: Text('Please select an action'),
          actions: <Widget>[
            CupertinoActionSheetAction(
              child: Text('Duplicate'),
              isDefaultAction: true,
              onPressed: () {
                Navigator.pop(context, 'duplicate');
              },
            ),
            CupertinoActionSheetAction(
              child: Text('Move'),
              onPressed: () {
                Navigator.pop(context, 'move');
              },
            ),
            CupertinoActionSheetAction(
              isDestructiveAction: true,
              child: Text('Delete'),
              onPressed: () {
                Navigator.pop(context, 'delete');
              },
            ),
          ],
          cancelButton: CupertinoActionSheetAction(
            child: Text('Cancel'),
            onPressed: () {
              Navigator.pop(context);
            },
          ),
        );
      },
    );
    print(result);
  },
);

Listing 8-13Show iOS action sheet

图 8-4 显示了清单 8-13 中的代码截图。

img/479501_1_En_8_Fig4_HTML.jpg

图 8-4

iOS 行动表

8.9 显示材质设计菜单

问题

你想在材质设计应用中显示菜单。

解决办法

使用showMenu()函数和PopupMenuEntry类的实现。

讨论

要使用showMenu()函数,你需要有一个PopupMenuEntry对象的列表。有不同类型的PopupMenuEntry实现:

  • PopupMenuItem–单值菜单项

  • CheckedPopupMenuItem–带勾号的菜单项

  • PopupMenuDivider–菜单项之间的水平分隔线

PopupMenuItem是具有其值的类型的类属。表 8-3 显示了PopupMenuItem构造器的参数。CheckedPopupMenuItemPopupMenuItem的子类。CheckedPopupMenuItemchecked属性来指定是否显示复选标记。

表 8-3

PopupMenuItem 构造函数的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| child | Widget | 菜单项的内容。 |
| value | T | 菜单项的值。 |
| enabled | bool | 是否可以选择此菜单项。 |
| height | double | 菜单项的高度。默认为48。 |

showMenu()函数返回一个Future<T>对象,该对象解析为所选菜单项的值。该功能也使用Navigator.push()方法来显示菜单。表 8-4 显示了showMenu()功能的主要参数。当指定initialValue时,具有匹配值的第一个项目被高亮显示。

表 8-4

showMenu()的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| items | List<PopupMenuEntry<T>> | 菜单项列表。 |
| initialValue | T | 突出显示菜单项的初始值。 |
| position | RelativeRect | 显示菜单的位置。 |

清单 8-14 中的菜单包含一个PopupMenuItem,一个PopupMenuDivider,和一个CheckedPopupMenuItem

RaisedButton(
  child: Text('Show Menu'),
  onPressed: () async {
    String result = await showMenu<String>(
      context: context,
      position: RelativeRect.fromLTRB(0, 0, 0, 0),
      items: [
        PopupMenuItem(
          value: 'red',
          child: Text('Red'),
        ),
        PopupMenuDivider(),
        CheckedPopupMenuItem(
          value: 'green',
          checked: true,
          child: Text('Green'),
        )
      ],
      initialValue: 'green',
    );
    print(result);
  },
);

Listing 8-14Show menu

使用showMenu()函数的主要困难是为position参数提供合适的值。如果菜单是按下按钮触发的,使用PopupMenuButton是更好的选择,因为菜单位置是根据按钮的位置自动计算的。表 8-5 显示了PopupMenuButton构造器的主要参数。PopupMenuItemBuilder函数将一个BuildContext对象作为参数,并返回一个List<PopupMenuEntry<T>>对象。

表 8-5

弹出菜单按钮的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| itemBuilder | PopupMenuItemBuilder<T> | 用于创建菜单项的生成器函数。 |
| initialValue | T | 初始值。 |
| onSelected | PopupMenuItemSelected<T> | 选择菜单项时回调。 |
| onCanceled | PopupMenuCanceled | 当菜单没有选择就被关闭时回调。 |
| tooltip | String | 按钮的工具提示。 |
| child | Widget | 按钮的内容。 |
| icon | Icon | 按钮的图标。 |

清单 8-15 展示了如何使用PopupMenuButton来实现与清单 8-14 中相同的菜单。

PopupMenuButton(
  itemBuilder: (BuildContext context) {
    return <PopupMenuEntry<String>>[
      PopupMenuItem(
        value: 'red',
        child: Text('Red'),
      ),
      PopupMenuDivider(),
      CheckedPopupMenuItem(
        value: 'green',
        checked: true,
        child: Text('Green'),
      )
    ];
  },
  initialValue: 'green',
  child: Text('Select color'),
  onSelected: (String value) {
    print(value);
  },
  onCanceled: () {
    print('no selections');
  },
);

Listing 8-15Use PopupMenuButton

图 8-5 显示了在清单 8-14 和 8-15 中创建的菜单截图。

img/479501_1_En_8_Fig5_HTML.jpg

图 8-5

材质设计菜单

8.10 使用嵌套导航器管理复杂的页面流

问题

你想要复杂的页面流。

解决办法

使用嵌套的Navigator实例。

讨论

一个Navigator实例管理它自己的路由栈。对于简单的 app,一个Navigator实例一般就够了,可以简单使用WidgetsAppMaterialApp或者CupertinoApp创建的Navigator实例。如果你的应用有复杂的页面流,你可能需要使用嵌套导航器。由于Navigator本身也是一个小部件,Navigator实例可以像普通小部件一样创建。由WidgetsAppMaterialAppCupertinoApp创建的Navigator实例成为根导航器。所有导航器都是按层次结构组织的。要获得根导航器,可以在调用Navigator.of()方法时将rootNavigator参数设置为true。表 8-6 显示了Navigator构造器的参数。

表 8-6

导航器参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| onGenerateRoute | RouteFactory | 为给定的RouteSettings对象生成路线。 |
| onUnknownRoute | RouteFactory | 处理未知路线。 |
| initialRoute | String | 第一条路线的名称。 |
| observers | List<NavigatorObserver> | 导航器中状态变化的观察者。 |

让我们用一个具体的例子来解释如何使用嵌套导航器。假设您正在构建一个社交新闻阅读应用,在新用户注册后,您希望向用户显示一个可选的登录页面。这个入门页面有几个步骤需要用户完成。用户可以来回移动,只完成感兴趣的步骤。用户也可以跳过这个页面,返回到应用的主页。入门页面有自己的导航器来处理步骤导航。

在清单 8-16 中,导航器有两条命名的路线。初始路线设置为on_boarding/topic,所以先显示UserOnBoardingTopicPage

class UserOnBoardingPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Get Started'),
      ),
      body: Navigator(
        initialRoute: 'on_boarding/topic',
        onGenerateRoute: (RouteSettings settings) {
          WidgetBuilder builder;
          switch (settings.name) {
            case 'on_boarding/topic':
              builder = (BuildContext context) {
                return UserOnBoardingTopicPage();
              };
              break;
            case 'on_boarding/follower':
              builder = (BuildContext context) {
                return UserOnBoardingFollowPage();
              };
              break;
          }
          return MaterialPageRoute(
            builder: builder,
            settings: settings,
          );
        },
      ),
    );

  }
}

Listing 8-16User on-boarding page

在清单 8-17 中,按下“下一步”按钮导航到路线名称为on_boarding/follower的下一步。按下“完成”按钮,使用根导航器弹出登机页面。

class UserOnBoardingTopicPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('Select interested topics'),
        RaisedButton.icon(
          icon: Icon(Icons.arrow_forward),
          label: Text('Next'),
          onPressed: () {
            Navigator.pushNamed(context, 'on_boarding/follower');
          },
        ),
        RaisedButton.icon(
          icon: Icon(Icons.check),
          label: Text('Done'),
          onPressed: () {
            Navigator.of(context, rootNavigator: true).pop();
          },
        )
      ],
    );
  }
}

Listing 8-17Step to select topics

图 8-6 显示了清单 8-17 中的代码截图。

img/479501_1_En_8_Fig6_HTML.jpg

图 8-6

选择主题的步骤

CupertinoTabView有自己的导航器实例。创建CupertinoTabView时,可以提供routesonGenerateRouteonUnknownRoutenavigatorObservers参数。这些参数用于配置Navigator实例。当使用CupertinoTabScaffold创建选项卡布局时,每个选项卡视图都有自己的导航状态和历史。

使用嵌套导航器时,确保使用正确的导航器实例很重要。如果要显示和关闭全屏页面或模态对话框,应该使用Navigator.of(context, rootNavigator: true)获得的根导航器。调用Navigator.of(context)只能获得最近的封闭Navigator实例。没有办法获得层次结构中的中间Navigator实例。您需要在窗口小部件树的正确位置使用BuildContext对象。像showDialog()showMenu()这样的函数总是在内部使用Navigator.of(context)。您只能使用传入的BuildContext对象来控制这些函数使用哪个Navigator实例。

8.11 观察导航器状态变化

问题

您希望在导航状态改变时得到通知。

解决办法

使用NavigatorObserver

讨论

有时,您可能希望在导航器状态改变时得到通知。例如,您希望分析使用应用的用户的页面流量,以改善用户体验。当创建Navigator实例时,您可以提供一个NavigatorObserver对象的列表,作为导航器状态变化的观察者。表 8-7 显示了NavigatorObserver接口的方法。

表 8-7

导航观测方法

|

名字

|

描述

|
| --- | --- |
| didPop(Route route, Route previousRoute) | 弹出route并且previousRoute是新激活的路由。 |
| didPush(Route route, Route previousRoute) | 按下route,而previousRoute是先前激活的路线。 |
| didRemove(Route route, Route previousRoute) | route被删除,previousRoute是被删除路线的下一条路线。 |
| didReplace(Route newRoute, Route oldRoute) | 将oldRoute替换为newRoute。 |
| didStartUserGesture(Route route, Route previousRoute) | 用户开始使用手势移动route。路线正下方的路线是previousRoute。 |
| didStopUserGesture() | 用户使用手势停止移动路线。 |

在清单 8-18 中,LoggingNavigatorObserver类记录路由推送和弹出时的消息。

class LoggingNavigatorObserver extends NavigatorObserver {
  @override
  void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
    print('push: ${_routeName(previousRoute)} -> ${_routeName(route)}');
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
    print(' pop: ${_routeName(route)} -> ${_routeName(previousRoute)}');
  }

  String _routeName(Route<dynamic> route) {
    return route != null
        ? (route.settings?.name ?? route.runtimeType.toString())
        : 'null';
  }

}

Listing 8-18Logging navigator observer

当你想用一个全局处理程序来处理导航器中的所有状态变化时,接口是很有用的。如果您只对与特定路由相关的状态变化感兴趣,那么使用RouteObserver类是更好的选择。RouteObserver类也是NavigatorObserver接口的一个实现。

为了获得与一个Route对象相关的状态变化的通知,您的类需要实现RouteAware接口。表 8-8 显示了RouteAware接口的方法。

表 8-8

路由软件的方法

|

名字

|

描述

|
| --- | --- |
| didPop() | 弹出当前路径时回调。 |
| didPopNext() | 当顶层路由弹出后当前路由变为活动时调用。 |
| didPush() | 当当前路线被推送时调用。 |
| didPushNext() | 推送新路由后,当前路由不再活动时调用。 |

要真正得到一个Route对象的通知,您需要使用RouteObserversubscribe()方法将一个RouteAware对象订阅给一个Route对象。当不再需要订阅时,您应该使用unsubscribe()取消订阅RouteAware对象。

在清单 8-19 中,_ObservedPageState类实现了RouteAware接口并覆盖了didPush()didPop()方法来打印出一些消息。ModalRoute.of(context)从构建上下文中获取最近的封闭ModalRoute对象,这是ObservedPage所在的路径。通过使用ModalRoute.of(context),不需要显式传递Route对象。当前_ObservedPageState对象使用传入的RouteObserver对象的subscribe()方法订阅当前路由中的状态变化。当_ObservedPageState对象被释放时,订阅被删除。

class ObservedPage extends StatefulWidget {
  ObservedPage(this.routeObserver);
  final RouteObserver<PageRoute<dynamic>> routeObserver;

  @override
  _ObservedPageState createState() => _ObservedPageState(routeObserver);
}

class _ObservedPageState extends State<ObservedPage> with RouteAware {
  _ObservedPageState(this._routeObserver);
  final RouteObserver<PageRoute<dynamic>> _routeObserver;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _routeObserver.subscribe(this, ModalRoute.of(context));
  }

  @override
  void dispose() {
    _routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPush() {
    print('pushed');
  }

  @override
  void didPop() {
    print('popped');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Observed (Stateful)'),
      ),
    );
  }
}

Listing 8-19Use RouteObserver

8.12 阻止路线弹出

问题

您希望阻止路线弹出导航器。

解决办法

WillPopCallbackModalRoute对象一起使用。

讨论

当一条路线被推送到导航器时,可以使用Scaffold中的后退按钮或 Android 中的系统后退按钮弹出该路线。有时,您可能想要阻止路线被弹出。例如,如果页面中有未保存的更改,您可能希望首先显示一个警告对话框来要求确认。当使用Navigator.maybePop()方法而不是Navigator.pop()方法时,您有机会决定弹出路线的请求是否应该继续。

ModalRoute类有addScopedWillPopCallback()方法来添加WillPopCallback来决定是否弹出路线。WillPopCallback是函数类型Future<bool> ()的 typedef。如果返回的Future<bool>对象解析为true,则可以弹出路线。否则,无法弹出路线。您可以向一个 ModalRoute 对象添加多个WillPopCallback函数。如果WillPopCallback函数中的任何一个否决了该请求,则不会弹出该路线。

在清单 8-20 中,一个WillPopCallback功能被添加到当前路线中。WillPopCallback函数的返回值是showDialog()返回的Future<bool>对象。

class VetoPopPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    ModalRoute.of(context).addScopedWillPopCallback(() {
      return showDialog<bool>(
        context: context,
        builder: (BuildContext context) {
          return AlertDialog(
            title: Text('Exit?'),
            actions: <Widget>[
              FlatButton(
                child: Text('Yes'),
                onPressed: () {
                  Navigator.pop(context, true);
                },
              ),
              FlatButton(
                child: Text('No'),
                onPressed: () {
                  Navigator.pop(context, false);
                },
              ),

            ],
          );
        },
      );
    });
    return Scaffold(
      appBar: AppBar(
        title: Text('Veto Pop'),
      ),
      body: Container(),
    );
  }
}

Listing 8-20Veto route popping request

8.13 摘要

在 Flutter 应用中有多个页面是很常见的。本章讨论在 Flutter 中实现页面导航的基本概念。本章还包括对话框、菜单和动作表。在下一章,我们将讨论 Flutter 中的后端服务交互。

九、服务交互

许多重要的移动应用需要与后端服务进行交互。本章涵盖了与 Flutter 中的服务交互相关的基本概念。

9.1 使用期货

问题

你想处理Future物体。

解决办法

使用then()catchError()方法处理Future对象的结果。

讨论

当使用来自 Flutter 和 Dart 库的代码时,您可能会遇到返回Future对象的函数。来自dart:async库的Future<T>类是延迟计算的代表。一个Future对象代表一个将来可能出现的潜在值或错误。当给定一个Future对象时,可以注册回调来处理可用的值或错误。Future类是 Dart 中异步编程的基本构建块之一。

给定一个Future对象,关于其结果有三种不同的情况:

  • 计算永远不会完成。不会调用任何回调。

  • 计算以一个值结束。使用值调用值回调。

  • 计算完成时出现错误。错误回调与错误一起被调用。

要注册对Future对象的回调,可以使用then()方法注册一个值回调和一个可选的错误回调,或者使用catchError()方法只注册一个错误回调。建议使用then()方法只注册一个值回调。这是因为如果一个错误回调是使用then()方法的onError参数注册的,这个错误回调不能处理在值回调中抛出的错误。大多数情况下,您希望错误回调处理所有可能的错误。如果一个Future对象的错误没有被它的错误回调函数处理,这个错误将被全局处理程序处理。

在清单 9-1 中,Future对象可能以值1或一个Error对象结束。值和错误回调都被注册来处理结果。

Future.delayed(
  Duration(seconds: 1),
  () {
    if (Random().nextBool()) {
      return 1;
    } else {
      throw Error();
    }
  },
).then((value) {
  print(value);
}).catchError((error) {
  print('error: $error');
});

Listing 9-1Use then() and catchError() methods to handle result

then()和 catchError()方法的返回值也是Future对象。给定一个Future对象 A,调用A.then(func)的结果是另一个Future对象 B,如果func回调运行成功,Future B 将以调用func函数的返回值完成。否则,Future B 将会以调用func函数时抛出的错误完成。调用B.catchError(errorHandler)返回一个新的Future对象 c。错误处理程序可以处理在Future B 中抛出的错误,这些错误可能会在未来的 A 本身或其值处理程序中抛出。通过使用then()catchError()方法,Future对象形成了一个处理异步计算的链。

在清单 9-2 中,多个then()方法被链接在一起按顺序处理结果。

Future.value(1)
  .then((value) => value + 1)
  .then((value) => value * 10)
  .then((value) => value + 2)
  .then((value) => print(value));

Listing 9-2
Chained then() methods

如果你想在未来完成时调用函数,你可以使用whenComplete()方法。当这个 future 完成时,使用whenComplete()添加的函数被调用,不管它完成时是有值还是有错误。whenComplete()方法相当于其他编程语言中的finally块。then().catchError().whenComplete()的链条相当于“尝试-捕捉-最终”。

清单 9-3 展示了一个使用whenComplete()方法的例子。

Future.value(1).then((value) {
  print(value);
}).whenComplete(() {
  print('complete');
});

Listing 9-3Using whenComplete()

Future对象的计算可能需要很长时间才能完成。您可以使用timeout()方法来设置计算的时间限制。当调用timeout()方法时,需要提供一个Duration对象作为时间限制,并提供一个可选的onTimeout函数在超时发生时提供值。timeout()方法的返回值是一个新的Future对象。如果当前的Future对象没有在时限前完成,调用onTimeout函数的结果就是新的Future对象的结果。如果没有提供onTimeout函数,当当前未来超时时,新的Future对象将以TimeoutException结束。

在清单 9-4 中,Future对象将用值1在 5 秒内完成,但是时间限制被设置为 2 秒。将使用由onTimeout函数返回的值10

Future.delayed(Duration(seconds: 5), () => 1)
  .timeout(
    Duration(seconds: 2),
    onTimeout: () => 10,
  )
  .then((value) => print(value));

Listing 9-4Use timeout() method

9.2 使用异步和等待来处理期货

问题

你想要处理Future对象,就像它们是同步的一样。

解决办法

使用asyncawait

讨论

对象代表异步计算。使用Future对象的通常方式是注册回调来处理结果。这种基于回调的风格可能会给习惯同步操作的开发人员造成障碍。使用asyncawait是 Dart 中的一个语法糖,可以像普通同步操作一样处理Future对象。

给定一个Future对象,await可以等待其完成并返回其值。await之后的代码可以直接使用返回值,就像它是同步调用的结果一样。使用await时,其封闭功能必须标记为async。这意味着该函数返回一个Future对象。

在清单 9-5 中,getValue()函数的返回值是一个Future对象。在calculate()函数中,await用于获取getValue()函数的返回值并赋给value变量。由于使用了await,所以calculate()功能被标记为async

Future<int> getValue() {
  return Future.value(1);
}

Future<int> calculate() async {
  int value = await getValue();
  return value * 10;
}

Listing 9-5Use async/await

当使用await处理Future对象时,可以使用 try-catch-finally 处理Future对象中抛出的错误。这允许像普通同步操作一样使用Future对象。清单 9-6 展示了一起使用 try-catch-finally 和await / async的例子。

Future<int> getErrorValue() {
  return Future.error('invalid value');
}

Future<int> calculateWithError() async {
  try {
    return await getErrorValue();
  } catch (e) {
    print(e);
    return 1;
  } finally {
    print('done');
  }
}

Listing 9-6Use try-catch-finally and await/async

9.3 创造未来

问题

你想要创建Future对象。

解决办法

使用Future构造函数Future()Future.delayed()Future.sync()Future.value()Future.error()创建Future对象。

讨论

如果需要创建Future对象,可以使用它的构造函数Future()Future.delayed()Future.sync()Future.value()Future.error():

  • Future()构造函数创建一个Future对象,异步运行计算。

  • Future.delayed()构造函数创建一个Future对象,该对象在使用Duration对象指定的延迟之后运行计算。

  • Future.sync()构造函数创建一个Future对象,它立即运行计算。

  • Future.value()构造函数创建一个Future对象,用给定的值完成。

  • Future.error()构造函数创建一个Future对象,该对象以给定的错误和可选的堆栈跟踪完成。

清单 9-7 展示了使用不同Future构造函数的例子。

Future(() => 1).then(print);
Future.delayed(Duration(seconds: 3), () => 1).then(print);
Future.sync(() => 1).then(print);
Future.value(1).then(print);
Future.error(Error()).catchError(print);

Listing 9-7Create Future objects

9.4 使用流

问题

你想处理一连串的事件。

解决办法

使用Stream<T>类及其子类。

讨论

使用Future类,我们可以表示将来可能可用的单个值。然而,我们可能还需要处理一系列事件。dart:async库中的Stream<T>类表示异步事件的来源。为了帮助解决这个问题,Future类有asStream()方法来创建一个包含当前Future对象结果的Stream

如果您有使用反应流( www.reactive-streams.org/ )的经验,您可能会发现 Dart 中的Stream是一个类似的概念。流中可以有三种类型的事件:

  • 数据事件表示流中的实际数据。这些事件也称为流中的元素。

  • Error 事件表示发生了错误。

  • Done 事件表示已到达流的末尾。不会发出更多事件。

要从流中接收事件,可以使用listen()方法来设置监听器。listen()方法的返回值是一个代表活动订阅的StreamSubscription对象。根据流上允许的订阅数量,有两种类型的流:

  • 单订阅流在流的整个生命周期中只允许一个侦听器。它仅在侦听器建立时开始发出事件,在侦听器取消订阅时停止发出事件。

  • 广播流允许任意数量的听众。即使没有订阅的侦听器,事件也会在准备就绪时发出。

给定一个Stream对象,属性isBroadcast可以用来检查它是否是一个广播流。您可以使用asBroadcastStream()方法从单一订阅流创建广播流。

流订阅

表 9-1 显示了listen()方法的参数。您可以为不同的事件提供任意数量的处理程序,并忽略那些不感兴趣的事件。

表 9-1

listen()方法的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| onData | void (T event) | 数据事件的处理程序。 |
| onError | Function | 错误事件的处理程序。 |
| onDone | void () | done 事件的处理程序。 |
| cancelOnError | bool | 发出第一个错误事件时是否取消订阅。 |

在清单 9-8 中,提供了三种类型事件的处理程序。

Stream.fromIterable([1, 2, 3]).listen(
  (value) => print(value),
  onError: (error) => print('error: $error'),
  onDone: () => print('done'),
  cancelOnError: true,
);

Listing 9-8Use listen() method

使用由listen()方法返回的StreamSubscription对象,您可以管理订阅。表 9-2 展示了StreamSubscription类的方法。

表 9-2

流订阅的方法

|

名字

|

描述

|
| --- | --- |
| cancel() | 取消此订阅。 |
| pause([Future resumeSignal]) | 请求流暂停事件发出。如果提供了 resumeSignal,流将在未来完成时恢复。 |
| resume() | 暂停后继续流。 |
| onData() | 替换数据事件处理程序。 |
| onError() | 替换错误事件处理程序。 |
| onDone() | 替换 done 事件处理程序。 |
| asFuture([E futureValue]) | 返回处理流完成的未来值。 |

当您想要处理流的完成时,asFuture()方法很有用。因为一个流可以正常完成,也可以出错,所以使用这个方法会覆盖现有的onDoneonError回调。在发生错误事件的情况下,订阅被取消,返回的Future对象完成时出现错误。在完成事件的情况下,Future对象以给定的futureValue结束。

流转换

stream 的强大之处在于对流应用各种转换来获得另一个流或值。表 9-3 显示了返回另一个Stream对象的Stream类中的方法。

表 9-3

流转换

|

名字

|

描述

|
| --- | --- |
| asyncExpand<E>(Stream<E> convert(T event)) | 将每个元素转换成一个流,并将这些流中的元素连接成新的流。 |
| asyncMap<E>(FutureOr<E> convert(T event)) | 将每个元素转换成一个新事件。 |
| distinct([bool equals(T previous, T next) ]) | 跳过重复的元素。 |
| expand<S>(Iterable<S> convert(T element)) | 将每个元素转换为一系列元素。 |
| handleError(Function onError, { bool test(dynamic error) }) | 处理流中的错误。 |
| map<S>(S convert(T event)) | 将每个元素转换成一个新事件。 |
| skip(int count) | 跳过流中的元素。 |
| skipWhile(bool test(T element)) | 跳过与谓词匹配的元素。 |
| take(int count) | 仅从流中获取前 count 个元素。 |
| takeWhile(bool test(T element)) | 获取与谓词匹配的元素。 |
| timeout(Duration timeLimit, { void onTimeout(EventSink<T> sink) }) | 当两个事件之间的时间超过时间限制时处理错误。 |
| transform<S>(StreamTransformer<T, S> streamTransformer) | 转换流。 |
| where(bool test(T event)) | 过滤流中的元素。 |

清单 9-9 展示了使用流转换的例子。每个语句下面的代码显示了执行的结果。

Stream.fromIterable([1, 2, 3]).asyncExpand((int value) {
  return Stream.fromIterable([value * 5, value * 10]);
}).listen(print);
// -> 5, 10, 10, 20, 15, 30

Stream.fromIterable([1, 2, 3]).expand((int value) {
  return [value * 5, value * 10];
}).listen(print);
// -> 5, 10, 10, 20, 15, 30

Stream.fromIterable([1, 2, 3]).asyncMap((int value) {
  return Future.delayed(Duration(seconds: 1), () => value * 10);
}).listen(print);
// -> 10, 20, 30

Stream.fromIterable([1, 2, 3]).map((value) => value * 10).listen(print);
// -> 10, 20, 30

Stream.fromIterable([1, 1, 2]).distinct().listen(print);
// -> 1, 2

Stream.fromIterable([1, 2, 3]).skip(1).listen(print);
// -> 2, 3

Stream.fromIterable([1, 2, 3])
    .skipWhile((value) => value % 2 == 1)
    .listen(print);
// -> 2, 3

Stream.fromIterable([1, 2, 3]).take(1).listen(print);
// -> 1

Stream.fromIterable([1, 2, 3])
    .takeWhile((value) => value % 2 == 1)
    .listen(print);
// -> 1

Stream.fromIterable([1, 2, 3]).where((value) => value % 2 == 1).listen(print);
// -> 1, 3

Listing 9-9Stream transformations

Stream类中有其他方法返回一个Future对象;见表 9-4 。这些操作返回单个值,而不是流。

表 9-4

单一值的方法

|

名字

|

描述

|
| --- | --- |
| any(bool test(T element)) | 检查流中是否有任何元素与谓词匹配。 |
| every(bool test(T element)) | 检查流中的所有元素是否都与谓词匹配。 |
| contains(Object needle) | 检查流中是否包含给定的元素。 |
| drain<E>([E futureValue ]) | 丢弃流中的所有元素。 |
| elementAt(int index) | 获取给定索引处的元素。 |
| firstWhere(bool test(T element), { T orElse() }) | 查找与谓词匹配的第一个元素。 |
| lastWhere(bool test(T element), { T orElse() }) | 查找与谓词匹配的最后一个元素。 |
| singleWhere(bool test(T element), { T orElse() }) | 查找与谓词匹配的单个元素。 |
| fold<S>(S initialValue, S combine(S previous, T element)) | 将流中的元素组合成一个值。 |
| forEach(void action(T element)) | 对流中的每个元素运行操作。 |
| join([String separator = "" ]) | 将元素组合成一个字符串。 |
| pipe(StreamConsumer<T> streamConsumer) | 将事件通过管道传输到 StreamConsumer。 |
| reduce(T combine(T previous, T element)) | 将流中的元素组合成一个值。 |
| toList() | 将元素收集到一个列表中。 |
| toSet() | 将元素收集到一个集合中。 |

清单 9-10 显示了使用表 9-4 中方法的例子。每个语句下面的代码显示了执行的结果。

Stream.fromIterable([1, 2, 3]).forEach(print);
// -> 1, 2, 3

Stream.fromIterable([1, 2, 3]).contains(1).then(print);
// -> true

Stream.fromIterable([1, 2, 3]).any((value) => value % 2 == 0).then(print);
// -> true

Stream.fromIterable([1, 2, 3]).every((value) => value % 2 == 0).then(print);
// -> false

Stream.fromIterable([1, 2, 3]).fold(0, (v1, v2) => v1 + v2).then(print);
// -> 6

Stream.fromIterable([1, 2, 3]).reduce((v1, v2) => v1 * v2).then(print);
// -> 6

Stream.fromIterable([1, 2, 3])
    .firstWhere((value) => value % 2 == 1)
    .then(print);

// -> 1

Stream.fromIterable([1, 2, 3])
    .lastWhere((value) => value % 2 == 1)
    .then(print);
// -> 3

Stream.fromIterable([1, 2, 3])
    .singleWhere((value) => value % 2 == 1)
    .then(print);
// -> Unhandled exception: Bad state: Too many elements

Listing 9-10Methods return Future objects

9.5 创建流

问题

你想要创建Stream对象。

解决办法

使用不同的Stream构造函数。

讨论

有不同的Stream构造函数来创建Stream对象:

  • Stream.empty()构造器创建一个空的广播流。

  • Stream.fromFuture()构造函数从一个Future对象创建一个单一订阅流。

  • Stream.fromFutures()构造器从一列Future对象中创建一个流。

  • Stream.fromInterable()构造函数从一个Iterable对象的元素中创建一个单一订阅流。

  • Stream.periodic()构造器创建一个流,它以给定的时间间隔周期性地发出数据事件。

清单 9-11 展示了不同Stream构造函数的例子。

Stream.fromIterable([1, 2, 3]).listen(print);
Stream.fromFuture(Future.value(1)).listen(print);
Stream.fromFutures([Future.value(1), Future.error('error'), Future.value(2)])
    .listen(print);
Stream.periodic(Duration(seconds: 1), (int count) => count * 2)
    .take(5)
    .listen(print);

Listing 9-11Use Stream constructors

另一种创建流的方法是使用StreamController类。一个StreamController对象可以向它控制的流发送不同的事件。默认的StreamController()构造器创建一个单一订阅流,而StreamController.broadcast()构造器创建一个广播流。使用StreamController,您可以以编程方式在流中生成元素。

在清单 9-12 中,不同的事件被发送到由StreamController对象控制的流中。

StreamController<int> controller = StreamController();
controller.add(1);
controller.add(2);
controller.stream.listen(print, onError: print, onDone: () => print('done'));
controller.addError('error');
controller.add(3);
controller.close();

Listing 9-12Use StreamController

9.6 基于流和未来构建小部件

问题

您希望构建一个基于流或未来数据更新其内容的小部件。

解决办法

使用StreamBuilder<T>FutureBuilder<T>小工具。

讨论

给定一个SteamFuture对象,您可能想要构建一个基于其中的数据更新其内容的小部件。您可以使用StreamBuilder<T>小部件处理Stream对象,使用FutureBuilder<T>小部件处理Future对象。表 9-5 显示了StreamBuilder<T>构造器的参数。

表 9-5

StreamBuilder 的参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| stream | Stream<T> | 建造者的河流。 |
| builder | AsyncWidgetBuilder<T> | 小部件的构建器函数。 |
| initialData | T | 构建小部件的初始数据。 |

AsyncWidgetBuilder是函数类型Widget (BuildContext context, AsyncSnapshot<T> snapshot)的 typedef。AsyncSnapshot类表示与异步计算交互的快照。表 9-6 显示了AsyncSnapshot<T>类的属性。

表 9-6

异步快照的属性

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| connectionState | ConnectionState | 异步计算的连接状态。 |
| data | T | 异步计算接收的最新数据。 |
| error | Object | 异步计算收到的最新错误对象。 |
| hasData | bool | 数据属性是否不是null。 |
| hasError | bool | 错误属性是否不是null。 |

您可以使用connectionState的值来确定连接状态。表 9-7 显示了ConnectionState枚举的值。

表 9-7

ConnectionState 的值

|

名字

|

描述

|
| --- | --- |
| none | 未连接到异步计算。 |
| waiting | 连接到异步计算并等待交互。 |
| active | 连接到活动的异步计算。 |
| done | 连接到终止的异步计算。 |

使用StreamBuilder widget 构建 UI 时,典型的方式是根据连接状态返回不同的 widget。例如,如果连接状态正在等待,则可以返回进程指示符。

在清单 9-13 中,流有五个每秒生成的元素。如果连接状态为nonewaiting,则返回一个CircularProgressIndicator小工具。如果状态为activedone,则根据dataerror属性的值返回一个Text小工具。

class StreamBuilderPage extends StatelessWidget {
  final Stream<int> _stream =
      Stream.periodic(Duration(seconds: 1), (int value) => value * 10).take(5);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Stream Builder'),
      ),
      body: Center(
        child: StreamBuilder(
          stream: _stream,

          initialData: 0,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
            switch (snapshot.connectionState) {
              case ConnectionState.none:
              case ConnectionState.waiting:
                return CircularProgressIndicator();
              case ConnectionState.active:
              case ConnectionState.done:
                if (snapshot.hasData) {
                  return Text('${snapshot.data ?? "}');
                } else if (snapshot.hasError) {
                  return Text(
                    '${snapshot.error}',
                    style: TextStyle(color: Colors.red),
                  );
                }
            }
            return null;
          },
        ),
      ),
    );
  }
}

Listing 9-13Use StreamBuilder

FutureBuilder控件的用法与StreamBuilder控件类似。当使用带有Future对象的FutureBuilder时,可以先使用asStream()方法将Future对象转换为Stream对象,然后对转换后的Stream对象使用StreamBuilder

在清单 9-14 中,我们使用了一种不同的方式来构建 UI。使用hasDatahasError属性来检查状态,而不是检查连接状态。

class FutureBuilderPage extends StatelessWidget {
  final Future<int> _future = Future.delayed(Duration(seconds: 1), () {
    if (Random().nextBool()) {
      return 1;
    } else {
      throw 'invalid value';
    }
  });

  @override

  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Future Builder'),
      ),
      body: Center(
        child: FutureBuilder(
          future: _future,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot) {
            if (snapshot.hasData) {
              return Text('${snapshot.data}');
            } else if (snapshot.hasError) {
              return Text(
                '${snapshot.error}',
                style: TextStyle(color: Colors.red),
              );
            } else {
              return CircularProgressIndicator();
            }

          },
        ),
      ),
    );
  }
}

Listing 9-14Use FutureBuilder

9.7 处理简单的 JSON 数据

问题

您希望有一种简单的方法来处理 JSON 数据。

解决办法

使用dart:convert库中的jsonEncode()jsonDecode()函数。

讨论

JSON 是一种流行的 web 服务数据格式。为了与后端服务交互,您可能需要在两种情况下处理 JSON 数据:

  • JSON 数据序列化将 Dart 中的对象转换为 JSON 字符串。

  • JSON 数据反序列化将 JSON 字符串转换为 Dart 中的对象。

对于这两种场景,如果您只是偶尔需要处理简单的 JSON 数据,那么使用dart:convert库中的jsonEncode()jsonDecode()函数是一个不错的选择。jsonEncode()函数将镖对象转换成字符串,而jsonDecode()函数将字符串转换成镖对象。在清单 9-15 中,数据对象首先被序列化为 JSON 字符串,然后 JSON 字符串再次被反序列化为 Dart 对象。

var data = {
  'name': 'Test',
  'count': 100,
  'valid': true,
  'list': [
    1,
    2,
    {
      'nested': 'a',
      'value': 123,
    },
  ],
};
String str = jsonEncode(data);
print(str);
Object obj = jsonDecode(str);
print(obj);

Listing 9-15Handle JSON data

dart:convert库中的 JSON 编码器只支持有限数量的数据类型,包括数字、字符串、布尔、null、列表和带字符串键的映射。要对其他类型的对象进行编码,您需要使用toEncodable参数来提供一个函数,该函数首先将对象转换为可编码的值。默认的toEncodable函数调用对象上的toJson()方法。向需要序列化为 JSON 字符串的自定义类添加toJson()方法是一种常见的做法。

在清单 9-16 中,ToEncode类的toJson()方法返回一个列表,该列表将作为 JSON 序列化的输入。

class ToEncode {
  ToEncode(this.v1, this.v2);

  final String v1;
  final String v2;

  Object toJson() {
    return [v1, v2];
  }
}

print(jsonEncode(ToEncode('v1', 'v2')));

Listing 9-16Use toJson() function

如果想在序列化的 JSON 字符串中有缩进,需要直接使用JsonEncoder类。在清单 9-17 中,两个空格被用作缩进。

String indentString = JsonEncoder.withIndent('  ').convert(data);
print(indentString);

Listing 9-17Add indent

9.8 处理复杂 JSON 数据

问题

您希望有一种类型安全的方法来处理 JSON 数据。

解决办法

使用json_annotationjson_serializable库。

讨论

使用dart:convert库中的jsonEncode()jsonDecode()函数可以轻松处理简单的 JSON 数据。当 JSON 数据具有复杂的结构时,使用这两个函数不是很方便。当反序列化 JSON 字符串时,结果通常是列表或映射。如果 JSON 数据有嵌套结构,那么从列表或映射中提取值就不容易了。当序列化对象时,您需要向这些类添加toJson()方法来构建列表或映射。这些任务可以通过使用json_annotationjson_serializable库的代码生成来简化。

json_annotation库提供注释来定制 JSON 序列化和反序列化行为。json_serializable库提供了生成处理 JSON 数据的代码的构建过程。要使用这两个库,您需要将它们添加到pubspec.yaml文件中。在清单 9-18 中,json_serializable库被添加到dependencies,而json_serializable库被添加到dev_dependencies

dependencies:
  json_annotation: ².0.0

dev_dependencies:
  build_runner: ¹.0.0
  json_serializable: ².0.0

Listing 9-18Add json_annotation and json_serializable

在清单 9-19 中,Person类在json_serialize.dart文件中。注释@JsonSerializable()意味着为Person类生成代码。生成的代码在json_serialize.g.dart文件中。清单 9-19 中使用的函数_$PersonFromJson()_$PersonToJson()来自生成的文件。_$PersonFromJson()函数用于Person.fromJson()构造函数,而_$PersonToJson()函数用于toJson()方法。

import 'package:json_annotation/json_annotation.dart';

part 'json_serialize.g.dart';

@JsonSerializable()
class Person {
  Person({this.firstName, this.lastName, this.email});

  final String firstName;
  final String lastName;
  final String email;

  factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);

  Map<String, dynamic> toJson() => _$PersonToJson(this);
}

Listing 9-19Use json_serializable

要生成代码,需要运行flutter packages pub run build_runner build命令。清单 9-20 显示了生成的文件。

part of 'json_serialize.dart';

Person _$PersonFromJson(Map<String, dynamic> json) {
  return Person(
      firstName: json['firstName'] as String,
      lastName: json['lastName'] as String,
      email: json['email'] as String);
}

Map<String, dynamic> _$PersonToJson(Person instance) => <String, dynamic>{
      'firstName': instance.firstName,
      'lastName': instance.lastName,
      'email': instance.email
    };

Listing 9-20Generated code to handle JSON data

JsonSerializable标注有不同的属性来定制行为;见表 9-8 。

表 9-8

JsonSerializable 的属性

|

名字

|

缺省值

|

描述

|
| --- | --- | --- |
| anyMap | false | 如果为 true,则使用 Map 作为地图类型;否则,使用地图 |
| checked | false | 是否添加额外的检查来验证数据类型。 |
| createFactory | true | 是否生成将地图转换为对象的函数。 |
| createToJson | true | 是否生成可用作 toJson()函数的函数。 |
| disallowUnrecognizedKeys | false | 为 true 时,无法识别的键被视为错误;否则,它们将被忽略。 |
| explicitToJson | false | 为 true 时,生成的 toJson()函数对嵌套对象使用 toJson。 |
| fieldRename | FieldRename.none | 将类字段的名称转换成 JSON 映射键的策略。 |
| generateToJsonFunction | true | 为真时,生成顶层函数;否则,用 toJson()函数生成一个 mixin 类。 |
| includeIfNull | true | 是否包含具有空值的字段。 |
| nullable | true | 是否优雅地处理空值。 |
| useWrappers | false | 是否使用包装类在序列化过程中最大限度地减少 Map 和 List 实例的使用。 |

generateToJsonFunction属性决定了如何生成toJson()函数。当值为true时,会生成类似清单 9-20 中_$PersonToJson()的顶级函数。在清单 9-21 中,User类的generateToJsonFunction属性被设置为false

@JsonSerializable(
  generateToJsonFunction: false,
)
class User extends Object with _$UserSerializerMixin {
  User(this.name);

  final String name;
}

Listing 9-21
User class

在清单 9-22 中,用toJson()方法生成了_$UserSerializerMixin类,而不是函数。清单 9-21 中的User类只需要使用这个 mixin 类。

User _$UserFromJson(Map<String, dynamic> json) {
  return User(json['name'] as String);
}

abstract class _$UserSerializerMixin {
  String get name;
  Map<String, dynamic> toJson() => <String, dynamic>{'name': name};
}

Listing 9-22Generated code for User class

JsonKey注解指定了一个字段如何被序列化。表 9-9 显示了JsonKey的属性。

表 9-9

JsonKey 的属性

|

名字

|

描述

|
| --- | --- |
| name | JSON 映射键。如果为 null,则使用字段名称。 |
| nullable | 是否优雅地处理空值。 |
| includeIfNull | 如果值为空,是否包括此字段。 |
| ignore | 是否忽略该字段。 |
| fromJson | 反序列化该字段的函数。 |
| toJson | 序列化该字段的函数。 |
| defaultValue | 用作默认值的值。 |
| required | JSON 映射中是否需要该字段。 |
| disallowNullValue | 是否不允许空值。 |

清单 9-23 展示了一个使用JsonKey的例子。

@JsonKey(
  name: 'first_name',
  required: true,
  includeIfNull: true,
)
final String firstName;

Listing 9-23Use JsonKey

JsonValue注释指定用于序列化的枚举值。在清单 9-24 中,JsonValue注释被添加到Color的所有枚举值中。

enum Color {
  @JsonValue('R')
  Red,
  @JsonValue('G')
  Green,
  @JsonValue('B')
  Blue
}

Listing 9-24Use JsonValue

JsonLiteral annotation 从文件中读取 JSON 数据,并将内容转换成对象。它允许轻松访问静态 JSON 数据文件的内容。在清单 9-25 中,JsonLiteral注释被添加到data getter 中。_$dataJsonLiteral是 JSON 文件中数据的生成变量。

@JsonLiteral('data.json', asConst: true)
Map get data => _$dataJsonLiteral;

Listing 9-25
Use JsonLiteral

9.9 处理 XML 数据

问题

您希望在 Flutter 应用中处理 XML 数据。

解决办法

使用xml库。

讨论

XML 是一种流行的数据交换格式。你可以使用xml库来处理 Flutter 应用中的 XML 数据。您需要先将xml: ³.3.1添加到pubspec.yaml文件的dependencies中。与 JSON 数据类似,XML 数据有两种使用场景:

  • 解析 XML 文档和查询数据。

  • 构建 XML 文档。

解析 XML 文档

要解析 XML 文档,您需要使用parse()函数,该函数将一个 XML 字符串作为输入,并返回解析后的XmlDocument对象。使用XmlDocument对象,可以查询和遍历 XML 文档树,从中提取数据。

要查询文档树,可以使用findElements()findAllElements()方法。这两个方法接受一个标记名和一个可选的名称空间作为参数,并返回一个Iterable<XmlElement>对象。不同的是,findElements()方法只搜索直系子代,而findAllElements()方法搜索所有后代子代。要遍历文档树,您可以使用表 9-10 中所示的属性。

表 9-10

XmlParent 的属性

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| children | XmlNodeList<XmlNode> | 此节点的直接子节点。 |
| ancestors | Iterable<XmlNode> | 文档顺序相反的此节点的祖先。 |
| descendants | Iterable<XmlNode> | 按文档顺序排列的此节点的后代。 |
| attributes | List<XmlAttribute> | 按文档顺序排列的该节点的属性节点。 |
| preceding | Iterable<XmlNode> | 按文档顺序位于此节点开始标记之前的节点。 |
| following | Iterable<XmlNode> | 按照文档顺序,此节点的结束标记后面的节点。 |
| parent | XmlNode | 此节点的父节点可以为空。 |
| firstChild | XmlNode | 此节点的第一个子节点可以为空。 |
| lastChild | XmlNode | 此节点的最后一个子节点可以为空。 |
| nextSibling | XmlNode | 此节点的下一个同级可以为空。 |
| previousSibling | XmlNode | 此节点的上一个同级可以为空。 |
| root | XmlNode | 树根。 |

在清单 9-26 中,输入的 XML 字符串(摘自 https://msdn.microsoft.com/en-us/windows/desktop/ms762271 )被解析并查询第一个book元素。然后提取title元素的文本和id属性的值。

String xmlStr = "'
  <?xml version="1.0"?>
  <catalog>
    <book id="bk101">
      <Author>Gambardella, Matthew</author>
      <title>XML Developer's Guide</title>
      <genre>Computer</genre>
      <price>44.95</price>
      <publish_date>2000-10-01</publish_date>
      <description>An in-depth look at creating applications
        with XML.</description>
    </book>
    <book id="bk102">
      <Author>Ralls, Kim</author>
      <title>Midnight Rain</title>
      <genre>Fantasy</genre>
      <price>5.95</price>
      <publish_date>2000-12-16</publish_date>
      <description>A former architect battles corporate zombies, an evil sorceress, and her own childhood to become queen of the world.</description>
    </book>
  </catalog>
"';
XmlDocument document = parse(xmlStr);
XmlElement firstBook = document.rootElement.findElements('book').first;
String title = firstBook.findElements('title').single.text;
String id = firstBook.attributes

    .firstWhere((XmlAttribute attr) => attr.name.local == 'id')
    .value;
print('$id => $title');

Listing 9-26XML document parsing and querying

构建 XML 文档

要构建 XML 文档,可以使用XmlBuilder类。XmlBuilder类提供了构建 XML 文档不同组件的方法;见表 9-11 。使用这些方法,我们可以以自顶向下的方式构建 XML 文档,从根元素开始,一层一层地构建嵌套内容。

表 9-11

XmlBuilder 的方法

|

名字

|

描述

|
| --- | --- |
| element() | 用指定的标记名、名称空间、属性和嵌套内容创建一个XmlElement节点。 |
| attribute() | 用指定的名称、值、命名空间和类型创建一个XmlAttribute节点。 |
| text() | 用指定的文本创建一个XmlText节点。 |
| namespace() | 将名称空间prefix绑定到uri。 |
| cdata() | 用指定的文本创建一个XmlCDATA节点。 |
| comment() | 用指定的文本创建一个XmlComment节点。 |
| processing() | 用指定的targettext创建一个XmlProcessing节点。 |

构建完成后,可以使用XmlBuilderbuild()方法来构建XmlNode作为结果。在清单 9-27 中,根元素是一个具有id属性的note元素。nest参数的值是一个使用构建器方法构建节点元素内容的函数。

XmlBuilder builder = XmlBuilder();
builder.processing('xml', 'version="1.0"');
builder.element(
  'note',
  attributes: {
    'id': '001',
  },
  nest: () {
    builder.element('from', nest: () {
      builder.text('John');
    });
    builder.element('to', nest: () {
      builder.text('Jane');
    });
    builder.element('message', nest: () {
      builder
        ..text('Hello!')
        ..comment('message to send');
    });
  },
);
XmlNode xmlNode = builder.build();
print(xmlNode.toXmlString(pretty: true));

Listing 9-27Use XmlBuilder

清单 9-28 显示了清单 9-27 中代码构建的 XML 文档。

<?xml version="1.0"?>
<note id="001">
  <from>John</from>
  <to>Jane</to>
  <message>Hello!
    <!--message to send-->
  </message>
</note>

Listing 9-28Built XML document

9.10 处理 HTML 数据

问题

你想在 Flutter 应用中解析 HTML 文档。

解决办法

使用html库。

讨论

尽管 JSON 和 XML 数据格式在 Flutter 应用中很流行,但您可能仍然需要解析 HTML 文档来提取数据。这个过程称为屏幕抓取。你可以使用html库来解析 HTML 文档。要使用这个库,需要将html: ⁰.13.4+1添加到pubspec.yaml文件的dependencies中。

parse()函数将 HTML 字符串解析成Document对象。这些Document对象可以使用 W3C DOM API 进行查询和操作。在清单 9-29 中,首先解析 HTML 字符串,然后使用getElementsByTagName()方法获取li元素,最后从li元素中提取id属性和文本。

import 'package:html/dom.dart';
import 'package:html/parser.dart' show parse;

void main() {
  String htmlStr = "'
  <ul>
    <li id="001">John</li>
    <li id="002">Jane</li>
    <li id="003">Mary</li>
  </ul>
  "';
  Document document = parse(htmlStr);
  var users = document.getElementsByTagName('li').map((Element element) {
    return {
      'id': element.attributes['id'],
      'name': element.text,
    };
  });
  print(users);

}

Listing 9-29Parse HTML document

9.11 发送 HTTP 请求

问题

您希望向后端服务发送 HTTP 请求。

解决办法

使用dart:io库中的HttpClient

讨论

HTTP 协议是公开 web 服务的流行选择。表示可以是 JSON 或 XML。通过使用来自dart:io库的HttpClient类,您可以轻松地通过 HTTP 与后端服务进行交互。

要使用HttpClient类,首先需要选择一个 HTTP 方法,然后为请求准备HttpClientRequest对象,为响应处理HttpClientResponse对象。HttpClient类有不同的方法对,对应不同的 HTTP 方法。例如,get()getUrl()方法都用于发送 HTTP GET 请求。不同的是,get()方法接受hostportpath参数,而getUrl()方法接受Uri类型的url参数。你可以看到其他对,如post()postUrl()put()putUrl()patch()patchUrl()delete()deleteUrl()head()headUrl()

这些方法返回Future<HttpClientRequest>对象。你需要用then()方法链接返回的Future对象来准备HttpClientRequest对象。例如,您可以修改 HTTP 请求头或编写请求体。then()方法需要返回HttpClientRequest.close()方法的值,这是一个Future<HttpClientResponse>对象。在Future<HttpClientResponse>对象的then()方法中,您可以使用该对象获取响应正文、标题、cookies 和其他信息。

在清单 9-30 ,request.close()方法在第一个then()方法中被直接调用,因为我们不需要对HttpClientRequest对象做任何事情。_handleResponse()函数将 HTTP 响应解码为 UTF-8 字符串并打印出来。HttpClientResponse类实现了Stream<List<int>>,所以响应体可以理解为流。

void _handleResponse(HttpClientResponse response) {
  response.transform(utf8.decoder).listen(print);
}

HttpClient httpClient = HttpClient();
httpClient
    .getUrl(Uri.parse('https://httpbin.org/get'))
    .then((HttpClientRequest request) => request.close())
    .then(_handleResponse);

Listing 9-30Send HTTP GET request

如果需要用 body 发送 HTTP POST、PUT、PATCH 请求,可以用HttpClientRequest.write()方法写 body;参见清单 9-31 。

httpClient
    .postUrl(Uri.parse('https://httpbin.org/post'))
    .then((HttpClientRequest request) {
  request.write('hello');
  return request.close();
}).then(_handleResponse);

Listing 9-31Write HTTP request body

如果需要修改 HTTP 请求头,可以使用HttpClientRequest.headers属性修改HttpHeaders对象;见清单 9-32 。

httpClient
    .getUrl(Uri.parse('https://httpbin.org/headers'))
    .then((HttpClientRequest request) {
  request.headers.set(HttpHeaders.userAgentHeader, 'my-agent');
  return request.close();
}).then(_handleResponse);

Listing 9-32Modify HTTP request headers

如果需要支持 HTTP 基本认证,可以使用HttpClient.addCredentials()方法添加HttpClientBasicCredentials对象;见清单 9-33 。

String username = 'username', password = 'password';
Uri uri = Uri.parse('https://httpbin.org/basic-auth/$username/$password');
httpClient.addCredentials(
    uri, null, HttpClientBasicCredentials(username, password));
httpClient

    .getUrl(uri)
    .then((HttpClientRequest request) => request.close())
    .then(_handleResponse);

Listing 9-33Basic authentication

9.12 连接到 WebSocket

问题

你想在 Flutter 应用中连接到 WebSocket 服务器。

解决办法

使用dart:io库中的WebSocket类。

讨论

WebSockets 广泛用于 web 应用中,以提供浏览器和服务器之间的双向通信。他们还可以提供后台数据的实时更新。如果您已经有了一个 WebSocket 服务器,它可以与浏览器中运行的 web 应用进行交互,您可能还希望在 Flutter 应用中提供相同的功能。dart:io库中的WebSocket类可以用来实现 WebSocket 连接。

静态WebSocket.connect()方法连接到 WebSocket 服务器。您需要提供带有方案wswss的服务器 URL。您可以选择提供子协议列表和标题映射。connect()方法的返回值是一个Future<WebSocket>对象。WebSocket类实现了Stream类,所以你可以读取从服务器发送的数据流。要向服务器发送数据,可以使用add()addStream()方法。

在清单 9-34 中,WebSocket 连接到演示 echo 服务器。通过使用listen()方法订阅WebSocket对象,我们可以处理从服务器发送的数据。两个add()方法调用向服务器发送两条消息。

WebSocket.connect('ws://demos.kaazing.com/echo').then((WebSocket webSocket) {
  webSocket.listen(print, onError: print);
  webSocket.add('hello');
  webSocket.add('world');
  webSocket.close();
}).catchError(print);

Listing 9-34Connect to WebSocket

9.13 连接插座

问题

您想要连接到套接字服务器。

解决办法

使用dart:io库中的Socket类。

讨论

如果你想在 Flutter 应用中连接 socket 服务器,可以使用dart:io库中的Socket类。静态的Socket.connect()方法在指定的hostport连接到一个套接字服务器,并返回一个Future<Socket>对象。Socket类实现了Stream<List<int>>,所以你可以通过订阅流从服务器读取数据。要向服务器发送数据,可以使用add()addStream()方法。

在清单 9-35 中,一个套接字服务器在端口10080上启动。该服务器将接收到的字符串转换成大写字母,并发回结果。

import 'dart:io';
import 'dart:convert';

void main() {
  ServerSocket.bind('127.0.0.1', 10080).then((serverSocket) {
    serverSocket.listen((socket) {
      socket.addStream(socket
          .transform(utf8.decoder)
          .map((str) => str.toUpperCase())
          .transform(utf8.encoder));
    });
  });
}

Listing 9-35Simple socket server

在清单 9-36 中,Socket.connect()方法用于连接清单 9-35 中所示的 socket 服务器。从服务器接收的数据被打印出来。两个字符串被发送到服务器。

void main() {
  Socket.connect('127.0.0.1', 10080).then((socket) {
    socket.transform(utf8.decoder).listen(print);
    socket.write('hello');
    socket.write('world');
    socket.close();
  });
}

Listing 9-36Connect to socket server

9.14 基于 JSON 的交互式 REST 服务

问题

您希望使用基于 JSON 的 REST 服务。

解决办法

使用HttpClientjson_serialize库和FutureBuilder控件。

讨论

移动应用后端通过以 JSON 为代表的 HTTP 协议来公开服务是一种流行的选择。通过使用HttpClientjson_serialize库和FutureBuilder小部件,您可以构建 UI 来使用这些 REST 服务。这个菜谱提供了一个具体的例子,它结合了清单 9-6 、 9-8 和 9-11 中的内容。

这个例子使用 GitHub Jobs API ( https://jobs.github.com/api )获取 GitHub 网站上的工作列表。在清单 9-37 中,Job类代表一个工作清单。在JsonSerializable注释中,createToJson属性被设置为false,因为我们只需要解析来自 API 的 JSON 响应。_parseDate函数解析 JSON 对象的created_at字段中的字符串。你需要添加intl库来使用DateFormat类。

part 'github_jobs.g.dart';

DateFormat _dateFormat = DateFormat('EEE MMM dd HH:mm:ss yyyy');
DateTime _parseDate(String str) =>
    _dateFormat.parse(str.replaceFirst(' UTC', "), true);

@JsonSerializable(
  createToJson: false,
)
class Job {
  Job();

  String id;

  String type;
  String url;
  @JsonKey(name: 'created_at', fromJson: _parseDate)
  DateTime createdAt;
  String company;
  @JsonKey(name: 'company_url')
  String companyUrl;
  @JsonKey(name: 'company_logo')
  String companyLogo;
  String location;
  String title;
  String description;
  @JsonKey(name: 'how-to-apply')
  String howToApply;

  factory Job.fromJson(Map<String, dynamic> json) => _$JobFromJson(json);

}

Listing 9-37
Job class

在清单 9-38 中,HttpClient对象用于向 GitHub Jobs API 发送 HTTP GET 请求,并使用jsonDecode()函数解析 JSON 响应。类型为Future<List<Job>>Future对象被FutureBuilder小部件用来构建 UI。JobsList小部件接受一个List<Job>对象,并使用ListView小部件显示列表。

class GitHubJobsPage extends StatelessWidget {
  final Future<List<Job>> _jobs = HttpClient()
      .getUrl(Uri.parse('https://jobs.github.com/positions.json'
          '?description=java&location=new+york'))
      .then((HttpClientRequest request) => request.close())
      .then((HttpClientResponse response) {
    return response.transform(utf8.decoder).join(").then((String content) {
      return (jsonDecode(content) as List<dynamic>)
          .map((json) => Job.fromJson(json))
          .toList();

    });
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('GitHub Jobs'),
      ),
      body: FutureBuilder<List<Job>>(
        future: _jobs,
        builder: (BuildContext context, AsyncSnapshot<List<Job>> snapshot) {
          if (snapshot.hasData) {
            return JobsList(snapshot.data);
          } else if (snapshot.hasError) {
            return Center(
              child: Text(
                '${snapshot.error}',
                style: TextStyle(color: Colors.red),
              ),
            );
          } else {
            return Center(
              child: CircularProgressIndicator(),
            );
          }
        },
      ),
    );
  }
}

class JobsList extends StatelessWidget {
  JobsList(this.jobs);
  final List<Job> jobs;

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemBuilder: (BuildContext context, int index) {
        Job job = jobs[index];
        return ListTile(
          title: Text(job.title),
          subtitle: Text(job.company),
        );
      },
      separatorBuilder: (BuildContext context, int index) {
        return Divider();
      },
      itemCount: jobs.length,

    );
  }
}

Listing 9-38Widget to show jobs

9.15 与 gRPC 服务互动

问题

您希望与 gRPC 服务进行交互。

解决办法

使用grpc库。

讨论

gRPC ( https://grpc.io/ )是一个高性能、开源的通用 RPC 框架。这个菜谱展示了如何与 gRPC 服务交互。要交互的 gRPC 服务是来自 gRPC 官方示例的 greeter 服务( https://github.com/grpc/grpc/tree/master/examples/node )。您需要首先启动 gRPC 服务器。

要在 Flutter 应用中使用这个 gRPC 服务,需要先安装协议缓冲编译器( https://github.com/protocolbuffers/protobuf )。在为您的平台下载发布文件并提取其内容后,您需要将提取的bin目录添加到PATH环境变量中。您可以运行protoc --version命令来验证安装。这个配方使用的版本是3.7.1

还需要安装 Dart protoc 插件( https://github.com/dart-lang/protobuf/tree/master/protoc_plugin )。最简单的安装方法是运行以下命令。

$ flutter packages pub global activate protoc_plugin

因为我们使用flutter packages来运行安装,所以二进制文件放在 Flutter SDK 的.pub-cache/bin目录下。你需要把这个路径添加到PATH环境变量中。插件要求dart命令可用,所以你还需要将 Flutter SDK 的bin/cache/dart-sdk/bin目录添加到 PATH 环境变量中。现在我们可以使用protoc来生成 Dart 文件,以便与欢迎服务进行交互。在下面的命令中,lib/grpc/generated是生成文件的输出路径。proto_file_path是 proto 文件的路径。helloworld.proto文件包含迎宾服务的定义。库protobufgrpc也需要添加到pubspec.yaml文件的dependencies中。

$ protoc --dart_out=grpc:lib/grpc/generated --proto_path=<proto_file_path> <proto_file_path>/helloworld.proto

生成的helloworld.pbgrpc.dart文件提供了GreeterClient类来与服务交互。在清单 9-39 中,创建了一个ClientChannel来连接 gRPC 服务器。创建GreeterClient对象时需要通道。sayHello()方法向服务器发送请求并接收响应。

import 'package:grpc/grpc.dart';

import 'generated/helloworld.pbgrpc.dart';

void main() async {
  final channel = new ClientChannel('localhost',
      port: 50051,
      options: const ChannelOptions(
          credentials: const ChannelCredentials.insecure()));
  final stub = new GreeterClient(channel);

  try {
    var response = await stub.sayHello(new HelloRequest()..name = 'John');
    print('Received: ${response.message}');
  } catch (e) {
    print('Caught error: $e');
  }
  await channel.shutdown();
}

Listing 9-39Interact with gRPC service

9.16 摘要

本章主要关注与后端服务交互的不同方式,包括 HTTP、WebSocket、Socket 和 gRPC。期货和流在异步计算中起着重要的作用。本章还讨论了如何处理 JSON、XML 和 HTML 数据。在下一章,我们将讨论 Flutter 应用中的状态管理。

十、状态管理

在构建 Flutter 应用时,您需要管理应用运行时的状态。状态可能会因用户交互或后台任务而改变。本章介绍了在 Flutter 中使用不同的状态管理解决方案的方法。

10.1 使用有状态小部件管理状态

问题

您希望有一种简单的方法来管理 UI 中的状态。

解决办法

创建自己的StatefulWidget子类。

讨论

StatefulWidget类是旋舞管理状态的基本方式。有状态小部件在其状态改变时会重建自身。如果要管理的状态很简单,使用有状态小部件通常就足够了。不需要使用其他菜谱中讨论的第三方库。

有状态小部件使用State对象来存储状态。当创建自己的StatefulWidget子类时,需要覆盖createState()方法来返回一个State对象。对于每个子类StatefulWidget,都会有一个相应的子类State class 来管理状态。createState()方法返回State的相应子类的一个对象。实际状态通常作为State子类的私有变量保存。

State的子类中,需要实现build()方法来返回一个Widget对象。当状态改变时,将调用build()方法来获取新的小部件以更新 UI。要触发 UI 的重建,您需要显式调用setState()方法来通知框架。setState()方法的参数是一个VoidCallback函数,包含更新内部状态的逻辑。重建时,build()方法使用最新的状态来创建小部件配置。小部件不会更新,但会在必要时替换。

清单 10-1 中的小部件是有状态小部件的典型例子。_SelectColorState类是SelectColor小部件的State实现。_selectedColor是保持当前所选颜色的内部变量。_selectedColor的值由DropdownButton小部件用来确定要呈现的选定选项,由Text小部件用来确定要显示的文本。在DropdownButtononChanged处理程序中,调用setState()方法更新_selectedColor变量的值,通知框架再次运行 _SelectColorState.build()方法获取新的 widget 配置来更新 UI。

class SelectColor extends StatefulWidget {
  @override
  _SelectColorState createState() => _SelectColorState();
}

class _SelectColorState extends State<SelectColor> {
  final List<String> _colors = ['Red', 'Green', 'Blue'];
  String _selectedColor;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        DropdownButton(
          value: _selectedColor,
          items: _colors.map((String color) {
            return DropdownMenuItem(
              value: color,
              child: Text(color),
            );
          }).toList(),
          onChanged: (value) {
            setState(() {
              _selectedColor = value;
            });
          },
        ),
        Text('Selected: ${_selectedColor ?? "}'),
      ],
    );
  }
}

Listing 10-1Example of stateful widget

对象有自己的生命周期。您可以在State的子类中覆盖不同的生命周期方法,以在不同的阶段执行操作。表 10-1 显示了这些生命周期方法。

表 10-1

状态的生命周期方法

|

名字

|

描述

|
| --- | --- |
| initState() | 当该对象被插入小部件树时调用。应该用于执行状态初始化。 |
| didChangeDependencies() | 当此对象的依赖项更改时调用。 |
| didUpdateWidget(T oldWidget) | 当这个对象的小部件改变时调用。旧部件作为参数传递。 |
| reassemble() | 在调试期间重新组装应用时调用。此方法仅在开发过程中调用。 |
| build(BuildContext context) | 当状态改变时调用。 |
| deactivate() | 当该对象从小部件树中移除时调用。 |
| dispose() | 当该对象从小部件树中永久删除时调用。这个方法在deactivate()之后调用。 |

在表 10-1 、initState()dispose()所列的方法中,方法比较容易理解。这两个方法在生命周期中只会被调用一次。然而,也可以多次调用其他方法。

当状态对象使用继承的小部件时,通常使用didChangeDependencies()方法。当继承的小部件改变时,调用这个方法。大多数时候,你不需要覆盖这个方法,因为框架会在一个依赖关系改变后自动调用build()方法。有时,在依赖关系改变后,您可能需要执行一些昂贵的任务。在这种情况下,您应该将逻辑放到didChangeDependencies()方法中,而不是在build()方法中执行任务。

reassemble()方法仅在开发期间使用,例如,在热重装期间。在发布版本中不调用此方法。大多数情况下,您不需要重写此方法。

当状态的小部件改变时,调用didUpdateWidget()方法。如果您需要在旧部件上执行清理任务或重用旧部件的某些状态,您应该重写此方法。例如,TextField小部件的_TextFieldState类覆盖了didUpdateWidget()方法,根据旧小部件的值初始化TextEditingController对象。

当状态对象从部件树中移除时,调用deactivate()方法。该状态对象可以被插回到窗口小部件树的不同位置。如果构建逻辑依赖于小部件的位置,您应该重写此方法。例如,FormField小部件的FormFieldState类覆盖了deactivate()方法,从封闭表单中注销当前表单字段。

在清单 10-1 中,小部件的全部内容都构建在build()方法中,所以你可以简单地在DropdownButtononPressed回调中调用setState()方法。如果小部件具有复杂的结构,您可以传递一个函数来更新子小部件的状态。在清单 10-2 中,RaisedButtononPressed回调由CounterButton的构造函数参数设置。当CounterButtonCounter小部件中使用时,提供的处理函数使用setState()来更新状态。

class Counter extends StatefulWidget {
  @override
  _CounterState createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int count = 0;
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        CounterButton(() {
          setState(() {
            count++;
          });
        }),
        CounterText(count),
      ],
    );
  }
}

class CounterText extends StatelessWidget {
  CounterText(this.count);
  final int count;

  @override
  Widget build(BuildContext context) {
    return Text('Value: ${count ?? "}');
  }
}

class CounterButton extends StatelessWidget {
  CounterButton(this.onPressed);
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      child: Text('+'),
      onPressed: onPressed,
    );
  }

}

Listing 10-2Pass state change function to descendant widget

10.2 使用继承的小部件管理状态

问题

您希望沿着部件树向下传播状态。

解决办法

创建你自己的 I nheritedWidget的子类。

讨论

当使用有状态小部件管理状态时,状态存储在State对象中。如果一个后代小部件需要访问状态,状态需要从子树的根传递给它,就像清单 10-2 中count状态是如何传递的一样。当小部件具有相对较深的子树结构时,添加构造函数参数来传递状态是不方便的。在这种情况下,使用InheritedWidget是更好的选择。

当使用 I nheritedWidget时,方法BuildContext.inheritFromWidgetOfExactType()可以从构建上下文中获得特定类型的继承小部件的最近实例。后代小部件可以轻松地访问存储在继承的小部件中的状态数据。当调用inheritFromWidgetOfExactType()方法时,构建上下文将自己注册到继承的小部件中。当继承的小部件发生变化时,构建上下文会自动重建,以便从继承的小部件中获取新值。这意味着使用继承部件状态的后代部件不需要手动更新。

清单 10-3 中的Config类表示状态。它有colorfontSize属性。Config类覆盖==操作符和hashCode属性来实现正确的相等检查。通过更新属性的一部分,copyWith()方法可以用来创建Config类的新实例。Config.fallback()构造函数用默认值创建一个Config对象。

class Config {
  const Config({this.color, this.fontSize});

  const Config.fallback()
      : color = Colors.red,
        fontSize = 12.0;

  final Color color;
  final double fontSize;

  Config copyWith({Color color, double fontSize}) {
    return Config(
      color: color ?? this.color,
      fontSize: fontSize ?? this.fontSize,
    );
  }

  @override
  bool operator ==(other) {
    if (other.runtimeType != runtimeType) return false;
    final Config typedOther = other;
    return color == typedOther.color && fontSize == typedOther.fontSize;
  }

  @override
  int get hashCode => hashValues(color, fontSize);
}

Listing 10-3Config class for inherited widget

清单 10-4 中的ConfigWidget是一个继承的小部件。它保持一个Config对象作为它的内部状态。调用updateShouldNotify()方法来检查在继承的小部件改变后是否应该通知注册的构建上下文。这是为了避免不必要的更新而进行的性能优化。静态of()方法是获取继承的小部件或与继承的小部件相关联的状态的常见做法。ConfigWidgetof()方法使用 i nheritFromWidgetOfExactType()从构建上下文中获取最近的封闭ConfigWidget实例,并从小部件中获取config属性。如果没有找到ConfigWidget对象,则返回默认的Config实例。

class ConfigWidget extends InheritedWidget {
  const ConfigWidget({
    Key key,
    @required this.config,
    @required Widget child,
  }) : super(key: key, child: child);

  final Config config;

  static Config of(BuildContext context) {
    final ConfigWidget configWidget =
        context.inheritFromWidgetOfExactType(ConfigWidget);
    return configWidget?.config ?? const Config.fallback();
  }

  @override
  bool updateShouldNotify(ConfigWidget oldWidget) {
    return config != oldWidget.config;
  }
}

Listing 10-4ConfigWidget as inherited widget

在清单 10-5 中,ConfiguredTextConfiguredBox小部件都使用ConfigWidget.of(context)来获取Config对象,并在构建 UI 时使用其属性。

class ConfiguredText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Config config = ConfigWidget.of(context);
    return Text(
      'Font size: ${config.fontSize}',
      style: TextStyle(
        color: config.color,
        fontSize: config.fontSize,
      ),
    );
  }
}

class ConfiguredBox extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Config config = ConfigWidget.of(context);
    return Container(
      decoration: BoxDecoration(color: config.color),
      child: Text('Background color: ${config.color}'),
    );
  }
}

Listing 10-5Use ConfigWidget to get the Config object

清单 10-6 中的ConfigUpdater小部件用于更新Config对象。它还使用ConfigWidget.of(context)来获得要更新的Config对象。onColorChangedonFontSizeIncreased回调用于触发Config对象的更新。

typedef SetColorCallback = void Function(Color color);

class ConfigUpdater extends StatelessWidget {
  const ConfigUpdater({this.onColorChanged, this.onFontSizeIncreased});

  static const List<Color> _colors = [Colors.red, Colors.green, Colors.blue];
  final SetColorCallback onColorChanged;
  final VoidCallback onFontSizeIncreased;

  @override
  Widget build(BuildContext context) {
    Config config = ConfigWidget.of(context);
    return Column(
      children: <Widget>[
        DropdownButton(
          value: config.color,
          items: _colors.map((Color color) {
            return DropdownMenuItem(
              value: color,
              child: Text(color.toString()),
            );
          }).toList(),
          onChanged: onColorChanged,
        ),
        RaisedButton(
          child: Text('Increase font size'),
          onPressed: onFontSizeIncreased,
        )
      ],
    );
  }
}

Listing 10-6ConfigUpdater to update Config object

现在我们可以将这些小部件放在一起构建整个 UI。在清单 10-7 中,ConfiguredPage是一个有状态的小部件,其状态为Config对象。ConfigUpdater小部件是ConfiguredPage的子部件,用来更新Config对象。ConfiguredPage构造函数也有child参数来提供使用ConfigWidget.of(context)获得正确的Config对象的子部件。对于ConfigWidgetonColorChangedonFontSizeIncreased回调,setState()方法用于更新ConfiguredPage widget 的状态并触发ConfigWidget的更新。框架通知ConfigUpdater和其他小部件用Config对象的最新值更新。

class ConfiguredPage extends StatefulWidget {
  ConfiguredPage({Key key, this.child}) : super(key: key);
  final Widget child;

  @override
  _ConfiguredPageState createState() => _ConfiguredPageState();
}

class _ConfiguredPageState extends State<ConfiguredPage> {
  Config _config = Config(color: Colors.green, fontSize: 16);

  @override
  Widget build(BuildContext context) {
    return ConfigWidget(
      config: _config,
      child: Column(
        children: <Widget>[
          ConfigUpdater(
            onColorChanged: (Color color) {
              setState(() {
                _config = _config.copyWith(color: color);
              });
            },
            onFontSizeIncreased: () {
              setState(() {
                _config = _config.copyWith(fontSize: _config.fontSize + 1.0);
              });
            },
          ),
          Container(
            decoration: BoxDecoration(border: Border.all()),
            padding: EdgeInsets.all(8),
            child: widget.child,
          ),
        ],
      ),
    );
  }
}

Listing 10-7ConfiguredPage to use ConfigWidget

在清单 10-8 中,ConfigWidgetPage小部件使用ConfiguredPage小部件包装ConfiguredTextConfiguredBox小部件。

class ConfigWidgetPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Inherited Widget'),
      ),
      body: ConfiguredPage(
        child: Column(
          children: <Widget>[
            ConfiguredText(),
            ConfiguredBox(),
          ],
        ),
      ),
    );
  }
}

Listing 10-8ConfigWidgetPage to build the UI

10.3 使用继承模型管理状态

问题

您希望得到通知并根据变化的各个方面重新构建 UI。

解决办法

创建自己的InheritedModel子类。

讨论

如果我们仔细查看菜谱 10-2 的清单 10-5 中的ConfiguredTextConfiguredBox小部件,我们可以看到ConfiguredBox小部件只依赖于Config对象的color属性。如果fontSize属性改变,那么ConfiguredBox小部件不需要重新构建。这些不必要的重建可能会导致性能问题,尤其是在小部件很复杂的情况下。

InheritedModel widget 允许你将一个状态分成多个方面。构建上下文可以注册,以便仅针对特定方面获得通知。当InheritedModel小部件中的状态改变时,只有注册到匹配方面的依赖构建上下文会被通知。

InheritedModel类从InheritedWidget类扩展而来。它有一个类型参数来指定方面的类型。清单 10-9 中的ConfigModel类是Config对象的InheritedModel子类。方面的类型是String。当实现InheritedModel类时,您仍然需要覆盖updateShouldNotify()方法来决定是否应该通知依赖者。updateShouldNotifyDependent()方法根据依赖的方面集合确定是否应该通知依赖者。只有当updateShouldNotify()方法返回true时,才会调用updateShouldNotifyDependent()方法。对于ConfigModel,仅定义了“颜色”和“字体大小”方面。如果依赖项依赖于“颜色”方面,那么只有当Config对象的color属性改变时才会被通知。这也适用于fontSize属性的“fontSize”特征。

静态的of()方法有一个额外的aspect参数来指定构建上下文所依赖的方面。静态的InheritedModel.inheritFrom()方法用于使构建上下文依赖于指定的方面。当纵横比为null时,该方法与使用BuildContext.inheritFromWidgetOfExactType()方法相同。

class ConfigModel extends InheritedModel<String> {
  const ConfigModel({
    Key key,
    @required this.config,
    @required Widget child,
  }) : super(key: key, child: child);

  final Config config;

  static Config of(BuildContext context, String aspect) {
    ConfigModel configModel =
        InheritedModel.inheritFrom(context, aspect: aspect);
    return configModel?.config ?? Config.fallback();
  }

  @override
  bool updateShouldNotify(ConfigModel oldWidget) {
    return config != oldWidget.config;
  }

  @override
  bool updateShouldNotifyDependent(
      ConfigModel oldWidget, Set<String> dependencies) {
    return (config.color != oldWidget.config.color &&
            dependencies.contains('color')) ||
        (config.fontSize != oldWidget.config.fontSize &&
            dependencies.contains('fontSize'));
  }
}

Listing 10-9ConfigModel as InheritedModel

在清单 10-10 中,ConfiguredModelText widget 使用null作为方面,因为它同时依赖于“color”和“fontSize”方面。ConfiguredModelBox widget 使用color作为方面。如果字体大小被更新,只有ConfiguredModelText部件被重建。

class ConfiguredModelText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Config config = ConfigModel.of(context, null);
    return Text(
      'Font size: ${config.fontSize}',
      style: TextStyle(
        color: config.color,
        fontSize: config.fontSize,
      ),
    );
  }
}

class ConfiguredModelBox extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Config config = ConfigModel.of(context, 'color');
    return Container(
      decoration: BoxDecoration(color: config.color),
      child: Text('Background color: ${config.color}'),
    );
  }

}

Listing 10-10Use ConfigModel to get Config object

10.4 使用继承的通知程序管理状态

问题

您希望依赖小部件基于来自Listenable对象的通知进行重建。

解决办法

创建自己的InheritedNotifier小部件的子类。

讨论

类通常用于管理监听器和通知客户端更新。您可以使用相同的模式来通知依赖者使用InheritedNotifier进行重建。InheritedNotifier widget 也从InheritedWidget类扩展而来。创建InheritedNotifier小部件时,需要提供Listenable对象。当Listenable对象发送通知时,这个InheritedNotifier小部件的依赖项被通知进行重建。

在清单 10-11 ,ConfigNotifier使用ValueNotifier<Config>作为Listenable的类型。静态的of()方法从ConfigNotifier对象中获取Config对象。

class ConfigNotifier extends InheritedNotifier<ValueNotifier<Config>> {
  ConfigNotifier({
    Key key,
    @required notifier,
    @required Widget child,
  }) : super(key: key, notifier: notifier, child: child);

  static Config of(BuildContext context) {
    final ConfigNotifier configNotifier =
        context.inheritFromWidgetOfExactType(ConfigNotifier);
    return configNotifier?.notifier?.value ?? Config.fallback();
  }
}

Listing 10-11ConfigNotifier as InheritedNotifier

要使用ConfigNotifier小部件,您需要创建一个新的ValueNotifier<Config>实例。要更新Config对象,只需将value属性设置为一个新值。ValueNotifier对象会发送通知,通知依赖的小部件重新构建。

class ConfiguredNotifierPage extends StatelessWidget {
  ConfiguredNotifierPage({Key key, this.child}) : super(key: key);
  final Widget child;
  final ValueNotifier<Config> _notifier =
      ValueNotifier(Config(color: Colors.green, fontSize: 16));

  @override
  Widget build(BuildContext context) {
    return ConfigNotifier(
      notifier: _notifier,
      child: Column(
        children: <Widget>[
          ConfigUpdater(
            onColorChanged: (Color color) {
              _notifier.value = _notifier.value.copyWith(color: color);
            },
            onFontSizeIncreased: () {
              Config oldConfig = _notifier.value;
              _notifier.value =
                  oldConfig.copyWith(fontSize: oldConfig.fontSize + 1.0);
            },
          ),
          Container(
            decoration: BoxDecoration(border: Border.all()),
            padding: EdgeInsets.all(8),
            child: child,
          ),
        ],
      ),
    );
  }

}

Listing 10-12ConfiguredNotifierPage to use ConfigNotifier

10.5 使用作用域模型管理状态

问题

您希望有一个简单的解决方案来处理模型变更。

解决办法

使用scoped_model包。

讨论

在菜谱 10-1、10-2、10-3 和 10-4 中,您已经看到了使用StatefulWidgetInheritedWidgetInheritedModelInheritedNotifier小部件来管理状态。这些小部件是由 Flutter framework 提供的。这些小部件都是底层 API,所以不方便在复杂的 app 中使用。scoped_model包( https://pub.dev/packages/scoped_model )是一个库,允许轻松地将数据模型从父窗口小部件向下传递到其子窗口小部件。它构建在InheritedWidget之上,但是有一个易于使用的 API。要使用这个包,你需要将scoped_model: ¹.0.1添加到pubspec.yaml文件的dependencies中。我们将使用与配方 10-2 中相同的例子来演示scoped_model包的用法。

清单 10-13 显示了使用scoped_model包的Config模型。Config类从Model类扩展而来。它有私有字段来存储状态。setColor()increaseFontSize()方法分别更新_color_fontSize字段。这两个方法在内部使用notifyListeners()来通知后代小部件进行重建。

import 'package:scoped_model/scoped_model.dart';

class Config extends Model {
  Color _color = Colors.red;
  double _fontSize = 16.0;

  Color get color => _color;
  double get fontSize => _fontSize;

  void setColor(Color color) {
    _color = color;
    notifyListeners();
  }

  void increaseFontSize() {
    _fontSize += 1;
    notifyListeners();
  }
}

Listing 10-13Config model as scoped model

在清单 10-14 ,ScopedModelText小部件展示了如何在后代小部件中使用模型。ScopedModelDescendant widget 用于获取最近的封闭模型对象。type 参数确定要获取的模型对象。builder参数指定了构建小部件的构建函数。构建函数有三个参数。类型BuildContext的第一个参数对于构建函数是通用的。最后一个参数是模型对象。如果小部件 UI 的一部分不依赖于模型,并且不应该在模型改变时重新构建,您可以将其指定为ScopedModelDescendant小部件的child参数,并在构建函数的第二个参数中访问它。

class ScopedModelText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ScopedModelDescendant<Config>(
      builder: (BuildContext context, Widget child, Config config) {
        return Text(
          'Font size: ${config.fontSize}',
          style: TextStyle(
            color: config.color,
            fontSize: config.fontSize,
          ),
        );
      },
    );
  }
}

Listing 10-14ScopedModelText uses ScopedModelDescendant

在清单 10-15 中,ScopedModelUpdater小部件简单地使用setColor()increaseFontSize()方法来更新状态。

class ScopedModelUpdater extends StatelessWidget {
  static const List<Color> _colors = [Colors.red, Colors.green, Colors.blue];

  @override
  Widget build(BuildContext context) {
    return ScopedModelDescendant<Config>(
      builder: (BuildContext context, Widget child, Config config) {
        return Column(
          children: <Widget>[
            DropdownButton(
              value: config.color,
              items: _colors.map((Color color) {
                return DropdownMenuItem(
                  value: color,
                  child: Text(color.toString()),
                );
              }).toList(),
              onChanged: (Color color) {
                config.setColor(color);
              },
            ),
            RaisedButton(
              child: Text('Increase font size'),
              onPressed: () {
                config.increaseFontSize();
              },
            )
          ],
        );
      },
    );
  }
}

Listing 10-15ScopedModelUpdater to update Config object

清单 10-16 中的小部件是将ModelScopedModelDescendant放在一起的最后一块。model参数指定由ScopedModel对象管理的模型对象。ScopedModel对象下的所有ScopedModelDescendant小部件都获得相同的模型对象。

class ScopedModelPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Scoped Model'),
      ),
      body: ScopedModel(
        model: Config(),
        child: Column(
          children: <Widget>[
            ScopedModelUpdater(),
            ScopedModelText()
          ],
        ),
      ),
    );
  }
}

Listing 10-16ScopedModelPage uses ScopedModel

您还可以使用静态的ScopedModel.of()方法来获取ScopedModel对象,然后使用其model属性来获取模型对象。

10.6 使用 Bloc 管理状态

问题

您希望使用阻塞模式来管理状态。

解决办法

使用blocflutter_bloc包。

讨论

Bloc(业务逻辑组件)是一种将表示与业务逻辑分离架构模式。Bloc 被设计成简单、强大和可测试的。让我们从 Bloc 中的核心概念开始。

状态代表应用状态的一部分。当状态改变时,UI 小部件被通知根据最新的状态进行重建。每个应用都有自己定义状态的方式。通常,您将使用 Dart 类来描述状态。

事件是状态变化的来源。事件可以由用户交互或后台任务生成。例如,按下按钮可以生成描述预期动作的事件。当 HTTP 请求的响应就绪时,还可以生成一个事件来包含响应体。事件通常被描述为 Dart 类。事件也可能带有负载。

调度事件时,处理这些事件可能会导致当前状态转换到新状态。然后通知 UI 小部件使用新状态进行重建。事件转换由当前状态、事件和下一个状态组成。如果所有的状态转换都被记录下来,我们就可以很容易地跟踪所有的用户交互和状态变化。我们还可以实现时间旅行调试。

现在我们可以有一个集团的定义。阻塞组件将事件流转换成状态流。块具有初始状态,作为接收任何事件之前的状态。对于每个事件,Bloc 都有一个mapEventToState()函数,该函数接收一个接收到的事件并返回一个状态流供表示层使用。Bloc 还有一个向其分派事件的dispatch()方法。

在这个菜谱中,我们将使用 GitHub Jobs API ( https://jobs.github.com/api )来获取 GitHub 上的工作列表。用户可以输入关键字进行搜索并查看结果。为了消费这个,我们将使用 http 包( https://pub.dev/packages/http )。将这个包添加到您的 pubspec.yaml 文件中。

让我们从美国开始。清单 10-17 显示了不同状态的类。JobsState是所有状态类的抽象基类。JobsState类从equatable包中的Equatable类扩展而来。Equatable类用于为==操作符和hashCode属性提供移植。JobsEmpty是初始状态。JobsLoading表示作业列表数据仍在加载中。JobsLoaded表示加载了作业列表数据。JobsLoaded事件的有效载荷类型为List<Job>JobsError表示取数据时出现错误。

import 'package:http/http.dart' as http;

abstract class JobsState extends Equatable {
  JobsState([List props = const []]) : super(props);
}

class JobsEmpty extends JobsState {}

class GetJobsEvent extends JobsEvent {
  GetJobsEvent({@required this.keyword})
      : assert(keyword != null),
        super([keyword]);

  final String keyword;
}

class GitHubJobsClient {
  Future<List<Job>> getJobs(keyword) async {
    final response = await http.get('https://jobs.github.com/positions.json?description=${keyword}');
    if (response.statusCode != 200) {
      throw new Exception("Unable to fetch data");
    }else{
      var result = new List<Job>();
      final rawResult = json.decode(response.body);
      for(final jsonJob in rawResult){
        result.add(Job.fromJson(jsonJob));
      }
    }
  }
}

class JobsLoading extends JobsState {}

class JobsLoaded extends JobsState {
  JobsLoaded({@required this.jobs})
      : assert(jobs != null),
        super([jobs]);

  final List<Job> jobs;
}

class JobsError extends JobsState {}

Listing 10-17Bloc states

清单 10-18 显示了这些事件。JobsEvent是事件类的抽象基类。GetJobsEvent类表示获取作业数据的事件。

abstract class JobsEvent extends Equatable {
  JobsEvent([List props = const []]) : super(props);
}

class GetJobsEvent extends JobsEvent {
  GetJobsEvent({@required this.keyword})
      : assert(keyword != null),
        super([keyword]);

  final String keyword;
}

Listing 10-18Bloc events

清单 10-19 显示了该块。JobsBloc类从Bloc<JobsEvent, JobsState>类扩展而来。Bloc的类型参数是事件和状态类。JobsEmpty是初始状态。在mapEventToState()方法中,如果事件是GetJobsEvent,则首先向流发出一个JobsLoading状态。然后使用GitHubJobsClient对象获取数据。如果数据提取成功,则发出一个带有加载数据的JobsLoaded状态。否则,发出一个JobsError状态。

class JobsBloc extends Bloc<JobsEvent, JobsState> {
  JobsBloc({@required this.jobsClient}) : assert(jobsClient != null);

  final GitHubJobsClient jobsClient;

  @override
  JobsState get initialState => JobsEmpty();

  @override
  Stream<JobsState> mapEventToState(JobsEvent event) async* {
    if (event is GetJobsEvent) {
      yield JobsLoading();
      try {
        List<Job> jobs = await jobsClient.getJobs(event.keyword);
        yield JobsLoaded(jobs: jobs);
      } catch (e) {
        yield JobsError();
      }
    }
  }
}

Listing 10-19Bloc

清单 10-20 中的GitHubJobs类是使用清单 10-19 中的JobsBloc类的小部件。JobsBloc对象在initState()方法中创建,在dispose()方法中处理。在KeywordInput小部件中,当用户在文本字段中输入关键字并按下搜索按钮时,一个GetJobsEvent被分派给JobsBloc对象。在JobsView小部件中,BlocBuilder小部件用于基于块中的状态构建 UI。这里我们检查了JobsState的实际类型并返回不同的小部件。

class GitHubJobs extends StatefulWidget {
  GitHubJobs({Key key, @required this.jobsClient})
      : assert(jobsClient != null),
        super(key: key);

  final GitHubJobsClient jobsClient;

  @override
  _GitHubJobsState createState() => _GitHubJobsState();
}

class _GitHubJobsState extends State<GitHubJobs> {
  JobsBloc _jobsBloc;

  @override
  void initState() {
    super.initState();
    _jobsBloc = JobsBloc(jobsClient: widget.jobsClient);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: KeywordInput(
            jobsBloc: _jobsBloc,
          ),
        ),
        Expanded(
          child: JobsView(
            jobsBloc: _jobsBloc,
          ),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _jobsBloc.dispose();
    super.dispose();
  }
}

class KeywordInput extends StatefulWidget {
  KeywordInput({this.jobsBloc});

  final JobsBloc jobsBloc;

  @override
  _KeywordInputState createState() => _KeywordInputState();
}

class _KeywordInputState extends State<KeywordInput> {
  final GlobalKey<FormFieldState<String>> _keywordFormKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Expanded(
          child: TextFormField(
            key: _keywordFormKey,
          ),
        ),
        IconButton(
          icon: Icon(Icons.search),
          onPressed: () {
            String keyword = _keywordFormKey.currentState?.value ?? ";
            if (keyword.isNotEmpty) {
              widget.jobsBloc.dispatch(GetJobsEvent(keyword: keyword));
            }
          },
        ),
      ],
    );
  }
}

class JobsView extends StatelessWidget {
  JobsView({this.jobsBloc});

  final JobsBloc jobsBloc;

  @override
  Widget build(BuildContext context) {
    return BlocBuilder(
      bloc: jobsBloc,
      builder: (BuildContext context, JobsState state) {
        if (state is JobsEmpty) {
          return Center(
            child: Text('Input keyword and search'),
          );
        } else if (state is JobsLoading) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else if (state is JobsError) {
          return Center(
            child: Text(
              'Failed to get jobs',
              style: TextStyle(color: Colors.red),
            ),
          );
        } else if (state is JobsLoaded) {
          return JobsList(state.jobs);
        }
      },
    );
  }
}

Listing 10-20GitHub jobs widget using Bloc

10.7 使用 Redux 管理状态

问题

您希望使用 Redux 作为状态管理解决方案。

解决办法

使用reduxflux_redux包。

讨论

Redux ( https://redux.js.org/ )是一个管理应用状态的流行库。Redux 起源于 React,现已移植到不同语言。redux包是 Redux 的 Dart 实现。flux_redux包允许在构建 Flutter 小部件时使用 Redux store。如果你以前用过 Redux,那么在 Flutter 中也会用到相同的概念。

Redux 使用单个全局对象作为状态。这个对象是应用的唯一真实来源,它被称为商店。动作被分派给存储以更新状态。Reducer 函数接受当前状态和一个动作作为参数,并返回下一个状态。下一个状态成为 reducer 函数下一次运行的输入。UI 小部件可以从商店中选择部分数据来构建内容。

要使用flutter_redux包,需要将flutter_redux: ⁰.5.3添加到pubspec.yaml文件的dependencies中。我们将使用 GitHub 上列出作业的相同示例来演示 Redux 在 Flutter 中的用法。

先从状态说起。清单 10-21 中的JobsState类代表全局状态。状态有三个属性,loading表示数据是否仍在加载,error表示加载数据时是否出错,data表示数据列表。通过使用copyWith()方法,我们可以通过更新一些属性来创建新的JobsState对象。

class JobsState extends Equatable {
  JobsState({bool loading, bool error, List<Job> data})
      : _loading = loading,
        _error = error,
        _data = data,
        super([loading, error, data]);

  final bool _loading;
  final bool _error;
  final List<Job> _data;

  bool get loading => _loading ?? false;
  bool get error => _error ?? false;
  List<Job> get data => _data ?? [];
  bool get empty => _loading == null && _error == null && _data == null;

  JobsState copyWith({bool loading, bool error, List<Job> data}) {
    return JobsState(
      loading: loading ?? this._loading,
      error: error ?? this._error,
      data: data ?? this._data,
    );
  }
}

Listing 10-21JobsState for Redux

清单 10-22 显示了这些动作。这些动作触发状态改变。

abstract class JobsAction extends Equatable {
  JobsAction([List props = const []]) : super(props);
}

class LoadJobAction extends JobsAction {
  LoadJobAction({@required this.keyword})
      : assert(keyword != null),
        super([keyword]);

  final String keyword;
}

class JobLoadedAction extends JobsAction {
  JobLoadedAction({@required this.jobs})
      : assert(jobs != null),
        super([jobs]);

  final List<Job> jobs;
}

class JobLoadErrorAction extends JobsAction {}

Listing 10-22Actions for Redux

清单 10-23 显示了根据动作更新状态的 reducer 函数。

JobsState jobsReducers(JobsState state, dynamic action) {
  if (action is LoadJobAction) {
    return state.copyWith(loading: true);
  } else if (action is JobLoadErrorAction) {
    return state.copyWith(loading: false, error: true);
  } else if (action is JobLoadedAction) {
    return state.copyWith(loading: false, data: action.jobs);
  }
  return state;
}

Listing 10-23Reducer function for Redux

清单 10-22 中定义的动作只能用于同步操作。例如,如果您想要分派JobLoadedAction,您需要首先准备好List<Job>对象。但是,加载作业数据的操作是异步的。您需要使用 thunk 函数作为 Redux store 的中间件。thunk 函数将商店作为唯一的参数。它使用存储来分派动作。thunk 操作可以被分派到存储区,就像其他普通操作一样。

清单 10-24 中的getJobs()函数将一个GitHubJobsClient对象和一个搜索关键字作为参数。这个函数返回一个类型为ThunkAction<JobsState>的 thunk 函数。ThunkAction来自redux_thunk包。在 thunk 函数中,首先调度一个LoadJobAction。然后使用GitHubJobsClient对象获取作业数据。根据数据加载的结果,调度JobLoadedActionJobLoadErrorAction

ThunkAction<JobsState> getJobs(GitHubJobsClient jobsClient, String keyword) {
  return (Store<JobsState> store) async {
    store.dispatch(LoadJobAction(keyword: keyword));
    try {
      List<Job> jobs = await jobsClient.getJobs(keyword);
      store.dispatch(JobLoadedAction(jobs: jobs));
    } catch (e) {
      store.dispatch(JobLoadErrorAction());
    }
  };
}

Listing 10-24Thunk function for Redux

现在我们可以使用 Redux 商店来构建小部件。您可以使用两个助手小部件来访问商店中的数据。在清单 10-25 ,StoreBuilder小部件用于提供对商店的直接访问。商店可以作为构建函数的第二个参数。StoreBuilder widget 通常用在需要调度动作的时候。StoreConnector widget 允许使用一个转换器函数先转换状态。当按下搜索图标时,首先调用清单 10-24 中的getJobs()函数来创建 thunk 函数,然后将 thunk 函数分派给商店。当使用StoreConnector小部件时,转换器函数只是从存储中获取当前状态。然后在构建函数中使用状态对象。

class GitHubJobs extends StatefulWidget {
  GitHubJobs({
    Key key,
    @required this.store,
    @required this.jobsClient,
  })  : assert(store != null),
        assert(jobsClient != null),
        super(key: key);

  final Store<JobsState> store;
  final GitHubJobsClient jobsClient;

  @override
  _GitHubJobsState createState() => _GitHubJobsState();
}

class _GitHubJobsState extends State<GitHubJobs> {
  @override
  Widget build(BuildContext context) {
    return StoreProvider<JobsState>(
      store: widget.store,
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: KeywordInput(
              jobsClient: widget.jobsClient,
            ),
          ),
          Expanded(
            child: JobsView(),
          ),
        ],
      ),
    );
  }
}

class KeywordInput extends StatefulWidget {
  KeywordInput({this.jobsClient});

  final GitHubJobsClient jobsClient;

  @override
  _KeywordInputState createState() => _KeywordInputState();
}

class _KeywordInputState extends State<KeywordInput> {
  final GlobalKey<FormFieldState<String>> _keywordFormKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Expanded(
          child: TextFormField(
            key: _keywordFormKey,
          ),
        ),
        StoreBuilder<JobsState>(
          builder: (BuildContext context, Store<JobsState> store) {
            return IconButton(
              icon: Icon(Icons.search),
              onPressed: () {
                String keyword = _keywordFormKey.currentState?.value ?? ";
                if (keyword.isNotEmpty) {
                  store.dispatch(getJobs(widget.jobsClient, keyword));
                }
              },
            );
          },
        ),
      ],
    );
  }

}

class JobsView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreConnector<JobsState, JobsState>(
      converter: (Store<JobsState> store) => store.state,
      builder: (BuildContext context, JobsState state) {
        if (state.empty) {
          return Center(
            child: Text('Input keyword and search'),
          );
        } else if (state.loading) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else if (state.error) {
          return Center(
            child: Text(
              'Failed to get jobs',
              style: TextStyle(color: Colors.red),
            ),
          );
        } else {
          return JobsList(state.data);
        }
      },
    );
  }

}

Listing 10-25GitHub jobs widget using Redux store

最后一步是创建商店。清单 10-26 中的商店是用 reducer 函数、初始状态和来自redux_thunk包的 thunk 中间件创建的。

final store = new Store<JobsState>(
  jobsReducers,
  initialState: JobsState(),
  middleware: [thunkMiddleware],
);

Listing 10-26Create the store

10.8 使用 Mobx 管理状态

问题

您希望使用 Mobx 来管理状态。

解决办法

使用mobxflutter_mobx包。

讨论

Mobx ( https://mobx.js.org )是一个状态管理库,将反应式数据与 UI 连接起来。MobX 起源于使用 JavaScript 开发 web 应用。也移植到 Dart ( https://mobx.pub )。在 Flutter 应用中,我们可以使用mobxflutter_mobx包,用 Mobx 构建应用。Mobx for Flutter 使用build_runner包为商店生成代码。build_runnermobx_codegen包需要作为dev_dependencies添加到pubspec.yaml文件中。

Mobx 使用 observables 来管理状态。应用的整体状态由核心状态和派生状态组成。派生状态是从核心状态计算出来的。动作使可观察对象变异以更新状态。反应是状态的观察者,只要它们跟踪的可观察对象发生变化,它们就会得到通知。在 Flutter 应用中,反应用于更新小部件。

与 Redux 的 Flutter 相比,Mobx 使用代码生成来简化存储的使用。您不需要编写样板代码来创建动作。Mobx 提供了几个注释。您只需用这些注释对代码进行注释。这与json_annotationjson_serialize包的工作方式类似。我们将使用在 GitHub 上显示工作列表的相同示例来演示 Mobx 的用法。将这个包添加到您的 pubspec.yaml 文件中,如果它还不存在的话。

清单 10-27 显示了 Mobx 商店的jobs_store.dart文件的基本代码。该文件使用生成的零件文件jobs_store.g.dart_JobsStore是乔布斯的商店的抽象类。它实现了来自 Mobx 的Store类。这里,我们使用@observable注释定义了两个可观测量。第一个可观察到的keyword是一个管理当前搜索关键字的简单字符串。getJobsFuture observable 是一个ObservableFuture<List<Job>>对象,它管理异步操作以使用 API 获得作业。使用@computed注释标记的属性是派生的可观察值,用于检查数据加载的状态。我们还使用@action注释定义了两个动作。setKeyword()动作将getJobsFuture可观察值设置为空状态,将keyword可观察值设置为提供的值。getJobs()动作使用GitHubJobsClient.getJobs()方法加载数据。将getJobsFuture可观察对象更新为包装返回的未来的ObservableFuture对象。

import 'package:meta/meta.dart';
import 'package:mobx/mobx.dart';

part 'jobs_store.g.dart';

class JobsStore = _JobsStore with _$JobsStore;

abstract class _JobsStore implements Store {
  _JobsStore({@required this.jobsClient}) : assert(jobsClient != null);

  final GitHubJobsClient jobsClient;

  @observable
  String keyword = ";

  @observable
  ObservableFuture<List<Job>> getJobsFuture = emptyResponse;

  @computed
  bool get empty => getJobsFuture == emptyResponse;

  @computed
  bool get hasResults =>
      getJobsFuture != emptyResponse &&
      getJobsFuture.status == FutureStatus.fulfilled;

  @computed
  bool get loading =>
      getJobsFuture != emptyResponse &&
      getJobsFuture.status == FutureStatus.pending;

  @computed
  bool get hasError =>
      getJobsFuture != emptyResponse &&
      getJobsFuture.status == FutureStatus.rejected;

  static ObservableFuture<List<Job>> emptyResponse = ObservableFuture.value([]);

  List<Job> jobs = [];

  @action
  Future<List<Job>> getJobs() async {
    jobs = [];
    final future = jobsClient.getJobs(keyword);
    getJobsFuture = ObservableFuture(future);

    return jobs = await future;
  }

  @action
  void setKeyword(String keyword) {
    getJobsFuture = emptyResponse;
    this.keyword = keyword;
  }
}

Listing 10-27Mobx store

生成代码需要使用flutter packages pub run build_runner build命令。JobsStore类是商店使用。清单 10-28 显示了使用商店的小部件。在搜索按钮的onPressed回调中,首先调用setKeyword()方法更新关键字,然后调用getJobs()方法触发数据加载。Observer小部件使用一个构建函数来构建 UI,该函数使用JobsStore对象中计算出的观察值和字段。每当这些可观察到的东西改变时,Observer widget 就会重建来更新 UI。

class GitHubJobs extends StatefulWidget {
  GitHubJobs({Key key, @required this.jobsStore})
      : assert(jobsStore != null),
        super(key: key);

  final JobsStore jobsStore;

  @override
  _GitHubJobsState createState() => _GitHubJobsState();
}

class _GitHubJobsState extends State<GitHubJobs> {
  @override
  Widget build(BuildContext context) {
    JobsStore jobsStore = widget.jobsStore;
    return Column(
      children: <Widget>[
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: KeywordInput(
            jobsStore: jobsStore,
          ),
        ),
        Expanded(
          child: JobsView(
            jobsStore: jobsStore,
          ),
        ),
      ],
    );
  }
}

class KeywordInput extends StatefulWidget {
  KeywordInput({this.jobsStore});

  final JobsStore jobsStore;

  @override
  _KeywordInputState createState() => _KeywordInputState();
}

class _KeywordInputState extends State<KeywordInput> {
  final GlobalKey<FormFieldState<String>> _keywordFormKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Expanded(
          child: TextFormField(
            key: _keywordFormKey,
          ),
        ),
        IconButton(
          icon: Icon(Icons.search),
          onPressed: () {
            String keyword = _keywordFormKey.currentState?.value ?? ";
            if (keyword.isNotEmpty) {
              widget.jobsStore.setKeyword(keyword);
              widget.jobsStore.getJobs();
            }
          },
        ),
      ],
    );
  }
}

class JobsView extends StatelessWidget {
  JobsView({this.jobsStore});

  final JobsStore jobsStore;

  @override
  Widget build(BuildContext context) {
    return Observer(
      builder: (BuildContext context) {
        if (jobsStore.empty) {
          return Center(
            child: Text('Input keyword and search'),
          );
        } else if (jobsStore.loading) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else if (jobsStore.hasError) {
          return Center(
            child: Text(
              'Failed to get jobs',
              style: TextStyle(color: Colors.red),
            ),
          );
        } else {
          return JobsList(jobsStore.jobs);
        }
      },
    );
  }
}

Listing 10-28GitHub jobs widget using Mobx store

10.9 摘要

本章讨论了 Flutter 应用的不同状态管理解决方案。在这些解决方案中,StatefulWidgetInheritedWidgetInheritedModelInheritedNotifier小部件由 Flutter 框架提供。作用域模型、Bloc、Redux 和 Mobx 库是第三方解决方案。您可以自由选择最适合您需求的解决方案。在下一章,我们将讨论 Flutter 中的动画。

十一、动画

动画在移动应用中扮演着重要的角色,为最终用户提供视觉反馈。本章涵盖了与 Flutter 中的动画相关的配方。

11.1 创建简单的动画

问题

你想要创建简单的动画。

解决办法

使用AnimationController类创建简单的动画。

讨论

Flutter 中的动画有一个值和一个状态。动画的价值可能会随着时间而改变。动画使用抽象的Animation<T>类来表示。Animation类从Listenable类扩展而来。您可以向Animation对象添加监听器,以便在值或状态发生变化时得到通知。

AnimationController类是Animation<double>类的子类。AnimationController类提供对它创建的动画的控制。要创建一个AnimationController对象,您可以提供一个下限、一个上限和一个持续时间。AnimationController对象的值随着持续时间从下限变化到上限。还需要一个TickerProvider对象。对于有状态的小部件,可以使用TickerProviderStateMixinSingleTickerProviderStateMixin类作为 state 类的 mixin。如果只有一个AnimationController对象用于状态,使用SingleTickerProviderStateMixin会更有效。

清单 11-1 展示了一个在有状态小部件中使用AnimationController来动画显示图像大小的例子。在initState()方法的主体中创建AnimationController对象,并在dispose()方法中对其进行处理。这是典型的使用AnimationController的模式。_GrowingImageState类有SingleTickerProviderStateMixin mixin,所以AnimationController构造函数使用这个对象作为vsync参数。在AnimationController对象的监听器中,调用setState()方法来触发小部件的重建。forward()方法开始向前播放动画。在build()方法中,AnimationController对象的当前值用于控制SizedBox小部件的大小。在运行时,SizedBox widget 的大小在 10 秒内从0增长到400

class GrowingImage extends StatefulWidget {
  @override
  _GrowingImageState createState() => _GrowingImageState();
}

class _GrowingImageState extends State<GrowingImage>
    with SingleTickerProviderStateMixin {
  AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      lowerBound: 0,
      upperBound: 400,
      duration: const Duration(seconds: 10),
      vsync: this,
    )
      ..addListener(() {
        setState(() {});
      })
      ..forward();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: controller.value,
      height: controller.value,
      child: Image.network('https://picsum.photos/400'),
    );
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

Listing 11-1Using AnimationController

表 11-1 显示了AnimationController控制动画进度的方法。

表 11-1

控制动画的方法

|

名字

|

描述

|
| --- | --- |
| forward() | 开始向前播放动画。 |
| reverse() | 开始反向播放动画。 |
| stop() | 停止动画的运行。 |
| repeat() | 开始运行动画,并在动画完成时重新启动。 |
| reset() | 将值设置为下限并停止动画。 |

动画可能处于不同的状态。AnimationStatus enum 代表动画的不同状态。表 11-2 显示了该枚举的所有值。您可以使用addStatusListener()方法添加一个监听器,以便在状态改变时得到通知。

表 11-2

动画状态的价值

|

名字

|

描述

|
| --- | --- |
| forward | 动画正向播放。 |
| reverse | 动画是反向播放的。 |
| dismissed | 动画在开始时停止。 |
| completed | 动画在结束时停止。 |

在清单 11-2 中,状态监听器被添加到AnimationController对象中。当动画处于completed状态时,它开始反向运行。

var controller = AnimationController(
  lowerBound: 0,
  upperBound: 300,
  duration: const Duration(seconds: 10),
  vsync: this,
)
  ..addListener(() {
    setState(() {});
  })
  ..addStatusListener((AnimationStatus status) {
    if (status == AnimationStatus.completed) {
      controller.reverse();
    }
  })
  ..forward();

Listing 11-2
Status listener

清单 11-1 展示了一个使用有状态窗口小部件动画的典型模式。widget 让动画的使用变得更加简单。AnimatedWidget构造函数需要一个Listenable对象。每当Listenable对象发出一个值时,小部件就重新构建自己。清单 11-3 显示了一个使用AnimatedWidget的例子。虽然AnimatedWidget类通常与Animation对象一起使用,但是你仍然可以将它与任何Listenable对象一起使用。

class AnimatedImage extends AnimatedWidget {
  AnimatedImage({Key key, this.animation})
      : super(key: key, listenable: animation);

  final Animation<double> animation;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: animation.value,
      height: animation.value,
      child: Image.network('https://picsum.photos/300'),
    );
  }

}

Listing 11-3Example of AnimatedWidget

11.2 使用线性插值创建动画

问题

您希望使用线性插值为其他数据类型创建动画。

解决办法

使用Tween类及其子类。

讨论

AnimationController类使用double作为它的值类型。双精度值对于有大小或位置的动画很有用。您可能仍然需要动画显示其他类型的数据。例如,您可以将背景颜色从红色变为绿色。对于这些场景,你可以使用Tween类及其子类。

Tween类表示开始值和结束值之间的线性插值。要创建一个Tween对象,您需要提供这两个值。Tween对象可以提供值给动画使用。通过对另一个Animation对象使用animate()方法,您可以创建一个新的Animation对象,它由提供的Animation对象驱动,但是使用来自Tween对象的值。Tween的子类需要实现lerp()方法,该方法获取动画值并返回插值。

在清单 11-4 中,AnimatedColor小部件使用Animation<Color>对象来更新背景颜色。ColorTween对象是用开始值Colors.red和结束值Colors.green创建的。

class AnimatedColorTween extends StatefulWidget {
  @override
  _AnimatedColorTweenState createState() => _AnimatedColorTweenState();
}

class _AnimatedColorTweenState extends State<AnimatedColorTween>
    with SingleTickerProviderStateMixin {
  AnimationController controller;
  Animation<Color> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 10),
      vsync: this,
    );
    animation =
        ColorTween(begin: Colors.red, end: Colors.green).animate(controller);
    controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedColor(
      animation: animation,
    );
  }

  @override

  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

class AnimatedColor extends AnimatedWidget {
  AnimatedColor({Key key, this.animation})
      : super(key: key, listenable: animation);

  final Animation<Color> animation;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 300,
      height: 300,
      decoration: BoxDecoration(color: animation.value),
    );
  }

}

Listing 11-4Example of ColorTween

对于不同的对象,Tween还有很多其他的子类,包括AlignmentTweenBorderTweenBoxConstraintsTweenDecorationTweenEdgeInsetsTweenSizeTweenTextStyleTween等等。

11.3 创建曲线动画

问题

你想创造曲线动画。

解决办法

使用CurvedAnimationCurveTween类。

讨论

除了线性动画之外,您还可以创建使用曲线来调整变化率的曲线动画。曲线是一个单位区间到另一个单位区间的映射。Curve类及其子类是曲线的内置类型。Curve类的transform()方法返回给定点的曲线映射值。曲线必须将输入0.0映射到0.0并将1.0映射到1.0。表 11-3 显示了不同类型的曲线。

表 11-3

不同类型的曲线

|

名字

|

描述

|
| --- | --- |
| Cubic | 由两个控制点定义的三次曲线。用四个 double 值作为这两个点的 x 和 y 坐标创建。 |
| ElasticInCurve | 振荡曲线,当超过其界限时,其幅度增加。随着振荡的持续时间而产生。 |
| ElasticOutCurve | 超越界限时幅度缩小的振荡曲线。随着振荡的持续时间而产生。 |
| ElasticInOutCurve | 振荡曲线增长,然后缩小幅度,同时超越其界限。随着振荡的持续时间而产生。 |
| Interval | 用beginend和一个curve创建。其值在begin之前为 0.0,在end之后为 1.0。开始和结束之间的值由曲线定义。 |
| SawTooth | 重复给定次数的锯齿曲线。 |
| Threshold | 在阈值之前为 0.0,然后跳到 1.0 的曲线。 |

您可以使用表 11-3 中Curve子类的构造函数来创建新曲线,或者使用Curves类中的常量。对于大多数情况来说,Curves类中的常量已经足够好了。对于一个Curve对象,你可以使用flipped属性得到一条新的曲线,它是这条曲线的反转。

使用Curve对象,你可以使用CurvedAnimation类创建曲线动画。表 11-4 显示了CurvedAnimation构造器的参数。如果reverseCurve参数为空,指定的曲线用于两个方向。

表 11-4

曲线动画参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| parent | Animation<double> | 应用曲线的动画。 |
| curve | Curve | 向前使用的曲线。 |
| reverseCurve | Curve | 向后使用的曲线。 |

在清单 11-5 中,AnimatedBox小部件使用动画值来确定盒子的左边位置。CurvedAnimation物体是用Curves.easeInOut曲线创建的。

class CurvedPosition extends StatefulWidget {
  @override
  _CurvedPositionState createState() => _CurvedPositionState();
}

class _CurvedPositionState extends State<CurvedPosition>
    with SingleTickerProviderStateMixin {
  AnimationController controller;
  Animation<double> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 5),
      vsync: this,
    )..forward();
    animation = CurvedAnimation(parent: controller, curve: Curves.easeInOut);
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBox(
      animation: animation,
    );
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

class AnimatedBox extends AnimatedWidget {
  AnimatedBox({Key key, this.animation})
      : super(key: key, listenable: animation);

  final Animation<double> animation;
  final double _width = 400;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: _width,
      height: 20,
      child: Stack(
        children: <Widget>[
          Positioned(
            left: animation.value * _width,
            bottom: 0,
            child: Container(
              width: 10,
              height: 10,
              decoration: BoxDecoration(color: Colors.red),
            ),
          )

        ],
      ),
    );
  }
}

Listing 11-5CurvedAnimation

CurveTween类使用一个Curve对象来转换动画的值。当需要用另一个Tween对象链接曲线动画时,可以使用CurveTween对象。

11.4 链接补间动画

问题

你想连锁补间。

解决办法

使用Animatable类的chain()方法或者动画类的drive()方法。

讨论

AnimatableTweenCurveTweenTweenSequence类的超类。给定一个Animatable对象,你可以用另一个Animatable对象作为父对象来使用chain()方法。对于给定的输入值,首先评估父Animatable对象,然后将结果用作当前Animatable对象的输入。你可以使用多种chain()方法来创建复杂的动画。

在清单 11-6 中,Tween对象与另一个CurveTween对象链接在一起。

var animation = Tween(begin: 0.0, end: 300.0)
  .chain(CurveTween(curve: Curves.easeOut))
  .animate(controller);

Listing 11-6Chain tweens

你也可以使用Animation类的drive()方法来链接一个Animatable对象。

11.5 创建补间序列

问题

您想要为不同的阶段创建补间序列。

解决办法

使用TweenSequence类。

讨论

通过使用TweenSequence类,你可以为动画的不同阶段使用不同的Animatable对象。一个TweenSequence对象由一列TweenSequenceItem对象定义。每个TweenSequenceItem对象都有一个Animatable对象和一个权重。权重定义了该TweenSequenceItem对象在其父TweenSequence对象的整个持续时间中的相对百分比。

在清单 11-7 中,动画是用 40%的线性补间和 60%的曲线补间创建的。

var animation = TweenSequence([
  TweenSequenceItem(
    tween: Tween(begin: 0.0, end: 100.0),
    weight: 40,
  ),
  TweenSequenceItem(
    tween: Tween(begin: 100.0, end: 300.0)
        .chain(CurveTween(curve: Curves.easeInOut)),
    weight: 60,
  )
]).animate(controller);

Listing 11-7Example of TweenSequence

11.6 运行同步动画

问题

您想要在AnimatedWidget中同时运行动画。

解决办法

使用Animatable类的evaluate()方法。

讨论

AnimatedWidget构造函数只支持一个Animation对象。如果你想在一个AnimatedWidget对象中使用多个动画,你需要在AnimatedWidget对象中创建多个Tween对象,并使用evaluate()方法获取Animation对象的值。

在清单 11-8 中,_leftTween_bottomTween对象分别决定左边和底部的属性。

class AnimatedBox extends AnimatedWidget {
  AnimatedBox({Key key, this.animation})
      : super(key: key, listenable: animation);

  final Animation<double> animation;
  final double _width = 400;
  final double _height = 300;
  static final _leftTween = Tween(begin: 0, end: 1.0);
  static final _bottomTween = CurveTween(curve: Curves.ease);

  @override
  Widget build(BuildContext context) {
    return Container(
      width: _width,
      height: _height,
      margin: EdgeInsets.all(10),
      decoration: BoxDecoration(border: Border.all()),
      child: Stack(
        children: <Widget>[
          Positioned(
            left: _leftTween.evaluate(animation) * _width,
            bottom: _bottomTween.evaluate(animation) * _height,
            child: Container(
              width: 10,
              height: 10,
              decoration: BoxDecoration(color: Colors.red),
            ),
          )

        ],
      ),
    );
  }
}

Listing 11-8Simultaneous animations

11.7 创建交错动画

问题

您想要创建连续或重叠的动画。

解决办法

使用Interval类。

讨论

使用TweenSequence类,你可以创建一个补间序列。然而,在TweenSequence对象中指定的补间不能重叠。要创建重叠动画,可以使用Interval曲线来指定动画的开始和结束时间。

在清单 11-9 中,三个Tween对象以Interval对象中指定的不同间隔进行动画。这些Tween对象由同一个Animation对象控制。

class AnimatedContainer extends StatelessWidget {
  AnimatedContainer({Key key, this.animation})
      : width = Tween(begin: 0.0, end: 300.0).animate(CurvedAnimation(
            parent: animation,
            curve: Interval(0.0, 0.5, curve: Curves.easeInOut))),
        height = Tween(begin: 0.0, end: 200.0).animate(CurvedAnimation(
            parent: animation,
            curve: Interval(0.2, 0.7, curve: Curves.bounceInOut))),
        backgroundColor = ColorTween(begin: Colors.red, end: Colors.green)
            .animate(CurvedAnimation(
                parent: animation,
                curve: Interval(0.3, 1.0, curve: Curves.elasticInOut))),
        super(key: key);

  final Animation<double> animation;
  final Animation<double> width;
  final Animation<double> height;
  final Animation<Color> backgroundColor;

  Widget _build(BuildContext context, Widget child) {
    return Container(
      width: width.value,
      height: height.value,
      decoration: BoxDecoration(color: backgroundColor.value),
      child: child,
    );
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: animation,
      builder: _build,
    );
  }
}

Listing 11-9Staggered animations

11.8 创建英雄动画

问题

您想要在两条路线上制作元素动画。

解决办法

使用Hero小工具。

讨论

当从当前路线切换到新路线时,最好在新路线中有一些元素来指示导航上下文。例如,当前路线显示项目列表。当用户点击一个项目来导航到详细路线时,新路线应该有一个小部件来显示所选项目的简要信息。

Hero微件在两条路线之间共享。用一个标签和一个子部件创建一个Hero部件。标签是Hero小部件的唯一标识符。如果源路线和目标路线都有一个带有相同标签的Hero小部件,那么在路线转换期间,源路线中的Hero小部件将被动画显示到目标路线中的位置。英雄小部件的标签在同一个小部件树中必须是唯一的。

在清单 11-10 中,ImageHero 类包装了一个在 SizedBox 小部件中显示图像的 Hero 小部件。标签被设置为图像的 URL。

class ImageHero extends StatelessWidget {
  ImageHero({Key key, this.imageUrl, this.width, this.height})
      : super(key: key);

  final String imageUrl;
  final double width;
  final double height;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: width,
      height: height,
      child: Hero(
        tag: imageUrl,
        child: Image.network(imageUrl),
      ),
    );
  }
}

Listing 11-10Hero widget

清单 11-11 显示了显示图像列表的当前路线。ImageHero小部件被包装在一个GridTile小部件中。用ImageView小工具点击图像导航到新路线。

class ImagesPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Images'),
      ),
      body: GridView.count(
        crossAxisCount: 2,
        children: List.generate(8, (int index) {
          String imageUrl = 'https://picsum.photos/300?random&$index';
          return GridTile(
            child: InkWell(
              onTap: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (BuildContext context) {
                    return ImageView(imageUrl: imageUrl);
                  }),
                );
              },
              child: ImageHero(
                imageUrl: imageUrl,
                width: 300,
                height: 300,
              ),
            ),
          );
        }),
      ),
    );
  }
}

Listing 11-11Current route with ImageHero

清单 11-12 显示了ImageView小部件。它还有一个与所选图像标签相同的ImageHero小部件。这是动画工作所必需的。

class ImageView extends StatelessWidget {
  ImageView({Key key, this.imageUrl}) : super(key: key);
  final String imageUrl;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Image'),
      ),
      body: Row(
        children: <Widget>[
          ImageHero(
            width: 50,
            height: 50,
            imageUrl: imageUrl,
          ),
          Expanded(
            child: Text('Image Detail'),
          ),
        ],
      ),
    );
  }
}

Listing 11-12New route with ImageHero

11.9 使用常见转换

问题

你想有一个简单的方法来使用不同类型的Tween对象来制作动画。

解决办法

使用不同类型的过渡。

讨论

通常使用不同类型的Tween对象来为部件的不同方面制作动画。你可以使用AnimatedWidgetAnimatedBuilder类来处理Tween对象。Flutter SDK 提供了几个过渡小部件,使某些动画易于使用。

ScaleTransition微件动画显示微件的比例。要创建一个ScaleTransition对象,您需要提供一个Animation<double>对象作为标尺。alignment参数指定缩放坐标原点相对于框的对齐。清单 11-13 显示了一个ScaleTransition的例子。

class ScaleBox extends StatelessWidget {
  ScaleBox({Key key, Animation<double> animation})
      : _animation = CurveTween(curve: Curves.ease).animate(animation),
        super(key: key);

  final Animation<double> _animation;

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _animation,
      alignment: Alignment.centerLeft,
      child: Container(
        height: 100,
        decoration: BoxDecoration(color: Colors.red),
      ),
    );
  }
}

Listing 11-13Example of ScaleTransition

过渡小部件的另一个例子是制作不透明度动画的FadeTransition小部件。清单 11-14 显示了一个FadeTransition的例子。

class FadeBox extends StatelessWidget {
  FadeBox({Key key, Animation<double> animation})
      : _animation = CurveTween(curve: Curves.ease).animate(animation),
        super(key: key);

  final Animation<double> _animation;

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _animation,
      child: Container(
        height: 100,
        decoration: BoxDecoration(color: Colors.red),
      ),
    );
  }

}

Listing 11-14Example of FadeTransition

11.10 创建物理模拟

问题

你想用物理模拟。

解决办法

使用physics库中的模拟。

讨论

animation库中的动画要么是线性的,要么是弯曲的。physics库提供物理模拟,包括弹簧、摩擦力和重力。Simulation class 是所有模拟的基类。模拟也随着时间而变化。对于一个时间点,方法x()返回位置,方法dx()返回速度,方法isDone()返回模拟是否完成。给定一个Simulation对象,你可以使用AnimationController类的animateWith()方法来驱动这个模拟动画。

SpringSimulation类代表一个附着在弹簧上的粒子的模拟。要创建一个SpringSimulation对象,你可以提供表 11-5 中列出的参数。

表 11-5

弹簧模拟参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| spring | SpringDescription | 对春天的描述。 |
| start | double | 开始距离。 |
| end | double | 终点距离。 |
| velocity | double | 初始速度。 |
| tolerance | Tolerance | 距离、持续时间和速度的差异幅度被视为相等。 |

要创建SpringDescription对象,您可以使用带有参数的SpringDescription()构造函数来指定质量、刚度和阻尼系数。SpringDescription.withDampingRatio()构造器用阻尼比代替阻尼系数。清单 11-15 显示了一个创建SpringSimulation对象的例子。

SpringSimulation _springSimulation = SpringSimulation(
  SpringDescription.withDampingRatio(
    mass: 1.0,
    stiffness: 50,
    ratio: 1.0,
  ),
  0.0,
  1.0,
  1.0)
..tolerance = Tolerance(distance: 0.01, velocity: double.infinity);

Listing 11-15Spring simulation

使用弹簧模拟的一个更简单的方法是使用AnimationController类的fling()方法。该方法使用临界阻尼弹簧驱动动画。

代表一个遵循牛顿第二运动定律的粒子的模拟。表 11-6 显示了GravitySimulation构造器的参数。

表 11-6

重力模拟参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| acceleration | double | 粒子的加速度。 |
| distance | double | 初始距离。 |
| endDistance | double | 要进行模拟的结束距离。 |
| velocity | double | 初始速度。 |

在清单 11-16 中,SimulationController widget 使用一个模拟对象来驱动动画。

typedef BuilderFunc = Widget Function(BuildContext, Animation<double>);

class SimulationController extends StatefulWidget {
  SimulationController({Key key, this.simulation, this.builder})
      : super(key: key);
  final Simulation simulation;
  final BuilderFunc builder;

  @override
  _SimulationControllerState createState() => _SimulationControllerState();
}

class _SimulationControllerState extends State<SimulationController>
    with SingleTickerProviderStateMixin {
  AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(

      vsync: this,
    )..animateWith(widget.simulation);
  }

  @override
  Widget build(BuildContext context) {
    return widget.builder(context, controller.view);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

Listing 11-16Use simulation with animation

11.11 摘要

本章涵盖了与 Flutter 中的动画相关的配方。AnimationController类用于控制动画。Tween类的子类为不同类型的数据创建线性动画。AnimatedWidgetAnimatedBuilder是使用动画的有用小部件。在下一章,我们将讨论在 Flutter 中与本地平台的集成。

十二、平台集成

在移动应用中,与原生平台集成是很常见的。您可以编写特定于平台的代码来使用本机平台 API。有大量的插件来执行不同的任务。

12.1 读写文件

问题

你想读写文件。

解决办法

使用File API。

讨论

在移动应用中,您可能需要在设备上保存文件。dart:io库提供文件 API 来读写文件。File类有读取内容、写入内容、查询文件元数据的方法。对文件系统的操作可以是同步的,也可以是异步的。大多数这些操作在File类中都有一对方法。异步方法返回一个Future对象,而同步方法使用Sync作为名称后缀并返回实际值。例如,readAsString()readAsStringSync()方法是返回字符串的读操作对。表 12-1 显示了File类的异步方法。

表 12-1

文件的异步方法

|

名字

|

描述

|
| --- | --- |
| copy(String newPath) | 将此文件复制到新路径。 |
| create({bool recursive: false}) | 创建此文件。如果recursive为真,将创建所有目录。 |
| open() | 用RandomAccessFile对象打开文件进行随机访问。 |
| readAsBytes() | 以字节列表的形式读取整个文件内容。 |
| readAsString({Encoding encoding: utf8}) | 使用指定的编码将整个文件内容作为字符串读取。 |
| readAsLines(({Encoding encoding: utf8}) | 使用指定的编码将整个文件内容作为文本行读取。 |
| writeAsBytes(List<int> bytes) | 将字节列表写入文件。 |
| writeAsString(String contents) | 向文件中写入一个字符串。 |
| rename(String newPath) | 将此文件重命名为新路径。 |
| delete({bool recursive: false}) | 删除此文件。 |
| exists() | 检查该文件是否存在。 |
| stat() | 返回一个描述文件的FileStat对象。 |
| lastAccessed() | 获取该文件的最后访问时间。 |
| lastModified() | 获取该文件的最后修改时间。 |
| length() | 获取这个文件的长度。 |

Directory类表示文件系统中的目录。给定一个Directory对象,list()listSync()方法可以用来列出文件和子目录。

要创建File对象,可以使用带有路径的默认构造函数。对于 Flutter 应用,路径可能是特定于平台的。有两个存储移动应用文件的常见位置:

  • 用于存储临时文件的临时目录,这些临时文件可以随时清除

  • 用于存储应用私有文件的文档目录,只有在删除应用时才会被清除

要获得这两个位置的特定于平台的路径,可以使用path_provider包( https://pub.dev/packages/path_provider )。这个包提供了获取临时目录路径的getTemporaryDirectory()函数和获取应用文档目录的getApplicationDocumentsDirectory()函数。

在清单 12-1 中,readConfig()方法从应用文档目录中读取config.txt文件,而writeConfig()方法将一个字符串写入同一个文件。

class ConfigFile {
  Future<File> get _configFile async {
    Directory directory = await getApplicationDocumentsDirectory();
    return File('${directory.path}/config.txt');
  }

  Future<String> readConfig() async {
    return _configFile
        .then((file) => file.readAsString())
        .catchError((error) => 'default config');
  }

  Future<File> writeConfig(String config) async {
    File file = await _configFile;
    return file.writeAsString(config);
  }
}

Listing 12-1Read and write files

12.2 存储键值对

问题

您希望存储类型安全的键值对。

解决办法

使用shared_preferences插件。

讨论

您可以使用文件 API 在设备上存储任何数据。使用通用文件 API 意味着您需要自己处理数据序列化和反序列化。如果需要存储的数据是简单的键值对,使用shared_preferences插件( https://pub.dev/packages/shared_preferences )是更好的选择。这个插件提供了一个基于映射的 API 来管理类型安全的键值对。键的类型总是String。只有几种类型可以用作值,包括StringbooldoubleintList<String>

为了管理键值对,您需要使用静态的SharedPreferences.getInstance()方法来获取SharedPreferences对象。表 12-2 显示了SharedPreferences类的方法。对于每种受支持的数据类型,都有一对获取和设置值的方法。例如,getBool()setBool()方法用于获取和设置bool值。

表 12-2

共享首选项的方法

|

名字

|

描述

|
| --- | --- |
| get(String key) | 读取指定键的值。 |
| containsKey(String key) | 检查指定的键是否存在。 |
| getKeys() | 拿一套钥匙。 |
| remove(String key) | 移除具有指定密钥的密钥对。 |
| clear() | 移除所有线对。 |
| setString(String key, String value) | 写一个字符串值。 |
| getString() | 读取一个字符串值。 |

在清单 12-2 中,SharedPreferences类用于读写一个键值对。

class AppConfig {
  Future<SharedPreferences> _getPrefs() async {
    return await SharedPreferences.getInstance();
  }

  Future<String> getName() async {
    SharedPreferences prefs = await _getPrefs();
    return prefs.getString('name') ?? ";
  }

  Future<bool> setName(String name) async {
    SharedPreferences prefs = await _getPrefs();
    return prefs.setString('name', name);
  }

}

Listing 12-2Use SharedPreferences

12.3 编写特定于平台的代码

问题

您希望编写特定于平台的代码。

解决办法

使用平台通道在 Flutter app 和底层主机平台之间传递消息。

讨论

在 Flutter 应用中,大多数代码都是用平台无关的 Dart 代码编写的。Flutter SDK 提供的功能有限。有时,您可能仍然需要编写特定于平台的代码来使用本机平台 API。一个生成的 Flutter 应用已经在androidios目录中有了特定于平台的代码。构建本机包需要这两个目录中的代码。

Flutter 使用消息传递来调用特定于平台的 API 并返回结果。消息通过平台通道传递。Flutter 代码通过平台通道向主机发送消息。宿主代码侦听平台通道并接收消息。然后,它使用特定于平台的 API 来生成响应,并通过相同的通道将其发送回 Flutter 代码。传递的消息实际上是异步方法调用。

在 Flutter 代码中,使用MethodChannel类创建平台通道。应用中的所有频道名称必须是唯一的。建议使用域名作为频道名称的前缀。要通过信道发送方法调用,这些方法调用在发送之前必须编码为二进制格式,接收到的结果解码为 Dart 值。使用MethodCodec类的子类完成编码和解码:

  • StandardMethodCodec类使用标准二进制编码。

  • JSONMethodCodec类使用 UTF-8 JSON 编码。

MethodChannel构造函数有name参数指定通道名,有codec参数指定MethodCodec对象。默认使用的MethodCodec对象是一个StandardMethodCodec对象。

给定一个MethodChannel对象,invokeMethod()方法使用指定的参数调用通道上的方法。返回值是一个Future<T>对象。此Future对象可能以不同的值完成:

  • 如果方法调用成功,它以结果结束。

  • 如果方法调用失败,它以一个PlatformException结束。

  • 如果该方法还没有实现,它以一个MissingPluginException结束。

invokeListMethod()方法也调用一个方法,但是返回一个Future<List<T>>对象。invokeMapMethod()方法调用一个方法并返回一个Future<Map<K, V>>对象。invokeListMethod()invokeMapMethod()方法都在内部使用invokeMethod(),但是增加了额外的类型转换。

在清单 12-3 中,getNetworkOperator方法在通道上被调用并返回网络操作符。

class NetworkOperator extends StatefulWidget {
  @override
  _NetworkOperatorState createState() => _NetworkOperatorState();
}

class _NetworkOperatorState extends State<NetworkOperator> {
  static const channel = const MethodChannel('flutter-recipes/network');

  String _networkOperator = ";

  @override
  void initState() {
    super.initState();
    _getNetworkOperator();
  }

  Future<void> _getNetworkOperator() async {
    String operator;
    try {
      operator = await channel.invokeMethod('getNetworkOperator') ?? 'unknown';
    } catch (e) {
      operator = 'Failed to get network operator: ${e.message}';
    }

    setState(() {
      _networkOperator = operator;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Center(
        child: Text(_networkOperator),
      ),
    );
  }
}

Listing 12-3
Get network operator

getNetworkOperator方法调用的 handler 需要在 Android 和 iOS 平台都实现。清单 12-4 显示了 Java 的实现。getNetworkOperator()方法使用 Android API 获取网络运营商。在通道的方法调用处理程序中,如果方法名为getNetworkOperator,则使用Result.success()方法将getNetworkOperator()方法的结果作为成功响应发回。如果要发回错误响应,可以使用Result.error()方法。如果方法未知,您应该使用Result.notImplemented()将该方法标记为未实现。

public class MainActivity extends FlutterActivity {
  private static final String CHANNEL = "flutter-recipes/network";

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    GeneratedPluginRegistrant.registerWith(this);

    new MethodChannel(getFlutterView(), CHANNEL)
        .setMethodCallHandler((methodCall, result) -> {
          if ("getNetworkOperator".equals(methodCall.method)) {
            result.success(getNetworkOperator());
          } else {
            result.notImplemented();
          }
        });
  }

  private String getNetworkOperator() {
    TelephonyManager telephonyManager =
        ((TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE));
    return telephonyManager.getNetworkOperatorName();
  }
}

Listing 12-4Android implementation of getNetworkOperator

清单 12-5 显示了 iOS 平台的AppDelegate.swift文件。receiveNetworkOperator()函数使用 iOS API 获取运营商名称,并使用 FlutterResult 作为响应发送回来。

import UIKit
import Flutter
import CoreTelephony

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?
  ) -> Bool {
    GeneratedPluginRegistrant.register(with: self)

    guard let controller = window?.rootViewController as? FlutterViewController else {
      fatalError("rootViewController is not type FlutterViewController")
    }
    let networkChannel = FlutterMethodChannel(name: "flutter-recipes/network", binaryMessenger: controller)
    networkChannel.setMethodCallHandler({
      [weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in
      guard call.method == "getNetworkOperator" else {
        result(FlutterMethodNotImplemented)
        return
      }
      self?.receiveNetworkOperator(result: result)
    })

    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func receiveNetworkOperator(result: FlutterResult) {
    let networkInfo = CTTelephonyNetworkInfo()
    let carrier = networkInfo.subscriberCellularProvider
    result(carrier?.carrierName)

  }
}

Listing 12-5Swift implementation of getNetworkOperator

12.4 创建插件

问题

您希望创建包含特定于平台的代码的可共享插件。

解决办法

使用插件模板创建 Flutter 项目。

讨论

菜谱 12-4 展示了如何为 Flutter 应用添加特定于平台的代码。添加到 Flutter 应用的代码不能在不同的应用之间共享。如果你想让特定平台的代码可重用,你可以创建 Flutter 插件。插件是 Flutter SDK 支持的另一类项目。插件可以像其他 Dart 包一样使用 Dart pub 工具( https://pub.dev/ )共享。

要创建一个新的 Flutter 插件,你可以使用flutter create --template=plugin命令。template=plugin参数意味着使用plugin模板创建一个 Flutter 项目。你可以选择在 Android 上使用 Java 或 Kotlin,在 iOS 上使用 Objective-C 或 Swift。默认情况下,Android 使用 Java,iOS 使用 Objective-C。您可以使用值为javakotlin-a参数来指定 Android 的语言,使用值为objcswift-i参数来指定 iOS 的语言。以下命令显示了如何使用 Swift for iOS 创建插件。

$ flutter create --template=plugin -i swift network

你也可以使用 Android Studio 或者 VS 代码来创建新的插件。

新创建的插件已经有了获取平台版本的框架代码。我们可以使用配方 12-3 中的代码,用新方法实现插件,以获得网络运营商。在生成插件的目录中,有几个子目录:

  • lib目录包含插件的公共 Dart API。

  • android目录包含公共 API 的 Android 实现。

  • ios目录包含公共 API 的 iOS 实现。

  • example目录包含一个使用这个插件的示例 Flutter 应用。

  • test目录包含测试代码。

我们首先在lib/network_plugin.dart文件中定义公共 Dart API。在清单 12-6 中,通过使用方法通道调用getNetworkOperator方法来检索networkOperator属性的值。

class NetworkPlugin {
  static const MethodChannel _channel =
    const MethodChannel('network_plugin');

  static Future<String> get networkOperator async {
    return await _channel.invokeMethod('getNetworkOperator');
  }
}

Listing 12-6Plugin Dart API

清单 12-7 中的NetworkPlugin.java文件是插件的 Android 实现。NetworkPlugin类实现了MethodCallHandler接口来处理从平台通道接收的方法调用。

public class NetworkPlugin implements MethodCallHandler {

  public static void registerWith(Registrar registrar) {
    final MethodChannel channel = new MethodChannel(registrar.messenger(), "network_plugin");
    channel.setMethodCallHandler(new NetworkPlugin(registrar));
  }

  NetworkPlugin(Registrar registrar) {
    this.registrar = registrar;
  }

  private final PluginRegistry.Registrar registrar;

  @Override
  public void onMethodCall(MethodCall call, Result result) {
    if (call.method.equals("getNetworkOperator")) {
      result.success(getNetworkOperator());
    } else {
      result.notImplemented();
    }
  }

  private String getNetworkOperator() {
    Context context = registrar.context();
    TelephonyManager telephonyManager =
        ((TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE));
    return telephonyManager.getNetworkOperatorName();
  }
}

Listing 12-7Android implementation

清单 12-8 中的SwiftNetworkPlugin.swift文件是插件的快速实现。

public class SwiftNetworkPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "network_plugin",
      binaryMessenger: registrar.messenger())
    let instance = SwiftNetworkPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall,
      result: @escaping FlutterResult) {
    if (call.method == "getNetworkOperator") {
      self.receiveNetworkOperator(result: result)
    } else {
      result(FlutterMethodNotImplemented)
    }
  }

  private func receiveNetworkOperator(result: FlutterResult) {
    let networkInfo = CTTelephonyNetworkInfo()
    let carrier = networkInfo.subscriberCellularProvider
    result(carrier?.carrierName)
  }
}

Listing 12-8Swift implementation

示例项目和测试代码也需要用新的 API 进行更新。

12.5 显示网页

问题

你想显示网页。

解决办法

使用webview_flutter插件。

讨论

如果你想在 Flutter 应用里面显示网页,可以使用webview_flutter插件( https://pub.dartlang.org/packages/webview_flutter )。将webview_flutter: ⁰.3.6添加到pubspec.yaml文件的依赖项后,您可以使用WebView小部件显示网页并与之交互。对于 iOS,需要将值为YESio.flutter.embedded_views_preview键添加到ios/Runner/Info.plist文件中。

表 12-3 显示了 WebView 构造器的参数。要控制 web 视图,需要使用onWebViewCreated回调来获取WebViewController对象。javascriptMode的值可以是JavascriptMode.disabledJavascriptMode.unrestricted。要在网页中启用 JavaScript 执行,应该将JavascriptMode.unrestricted设置为值。类型为NavigationDelegatenavigationDelegate是一个接受NavigationRequest对象并返回NavigationDecision枚举值的函数。如果返回值为NavigationDecision.prevent,导航请求被阻止。如果返回值为NavigationDecision.navigate,则导航请求可以继续。您可以使用导航委托来阻止用户访问受限页面。onPageFinished回调接收加载页面的 URL。

表 12-3

WebView 构造函数的参数

|

名字

|

描述

|
| --- | --- |
| initialUrl | 要加载的初始 URL。 |
| onWebViewCreated | 创建WebView时回调。 |
| javascriptMode | 是否启用了 JavaScript。 |
| javascriptChannels | 接收 web 视图中运行的 JavaScript 代码发送的消息的通道。 |
| navigationDelegate | 确定是否应该处理导航请求。 |
| onPageFinished | 页面加载完成时回调。 |
| gestureRecognizers | web 视图识别的手势。 |

获得WebViewController对象后,可以使用表 12-4 所示的方法与 web 视图进行交互。所有这些方法都是异步的,并且返回Future对象。例如,canGoBack()方法返回一个Future<bool>对象。

表 12-4

WebViewController 的方法

|

名字

|

描述

|
| --- | --- |
| evaluateJavascript(String javascriptString) | 在当前页面的上下文中评估 JavaScript 代码。 |
| loadUrl(String url, { Map<String, String> headers } | 加载指定的 URL。 |
| reload() | 重新加载当前 URL。 |
| goBack() | 回到导航历史中。 |
| canGoBack() | 追溯历史是否有效。 |
| goForward() | 在导航历史中前进。 |
| canGoForward() | 历史前进是否有效。 |
| clearCache() | 清除缓存。 |
| currentUrl() | 获取当前 URL。 |

清单 12-9 展示了一个使用WebView小部件与 Google 搜索页面交互的例子。因为WebView小部件的创建是异步的,所以使用Completer<WebViewController>对象来捕获WebViewController对象。在onWebViewCreated回调中,Completer<WebViewController>对象被创建的WebViewController对象完成。在onPageFinished回调中,WebViewController对象的evaluateJavascript()方法用于执行 JavaScript 代码,该代码为输入设置值并单击搜索按钮。这导致WebView小部件加载搜索结果页面。

创建的JavascriptChannel对象带有一个通道名和一个JavascriptMessageHandler函数,用于处理从网页中运行的 JavaScript 代码发送的消息。清单 12-9 中的消息处理器使用一个SnackBar小部件来显示接收到的消息。通道名“Messenger”成为一个全局对象,该对象有一个用于 JavaScript 代码的postMessage函数来发回消息。

class GoogleSearch extends StatefulWidget {
  @override
  _GoogleSearchState createState() => _GoogleSearchState();
}

class _GoogleSearchState extends State<GoogleSearch> {
  final Completer<WebViewController> _controller =
      Completer<WebViewController>();

  @override
  Widget build(BuildContext context) {
    return WebView(
      initialUrl: 'https://google.com',
      javascriptMode: JavascriptMode.unrestricted,
      javascriptChannels:
          <JavascriptChannel>[_javascriptChannel(context)].toSet(),
      onWebViewCreated: (WebViewController webViewController) {
        _controller.complete(webViewController);
      },
      onPageFinished: (String url) {
        _controller.future.then((WebViewController webViewController) {
          webViewController.evaluateJavascript(
              'Messenger.postMessage("Loaded in " + navigator.userAgent);');
          webViewController.evaluateJavascript(
              'document.getElementsByName("q")[0].value="flutter";'
              'document.querySelector("button[aria-label*=Search]").click();');
        })

;
      },
    );
  }

  JavascriptChannel _javascriptChannel(BuildContext context) {
    return JavascriptChannel(
        name: 'Messenger',
        onMessageReceived: (JavascriptMessage message) {
          Scaffold.of(context).showSnackBar(
            SnackBar(content: Text(message.message)),
          );
        });
  }

}

Listing 12-9Use WebView

12.6 播放视频

问题

你想玩视频。

解决办法

使用video_player插件。

讨论

如果您想要播放来自素材、文件系统或网络的视频,您可以使用video_player插件( https://pub.dev/packages?q=video_player )。要使用这个插件,你需要将video_player: ⁰.10.0+5添加到pubspec.yaml文件的依赖项中。对于 iOS,你需要使用真实的设备而不是模拟器进行开发和测试。如果您想从任意位置加载视频,您需要将清单 12-10 中的代码添加到ios/Runner/Info.plist文件中。使用 NSAllowsArbitraryLoads 会降低应用的安全性。网络安全最好查一下苹果的指南( https://developer.apple.com/documentation/security/preventing_insecure_network_connections )。

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

Listing 12-10
iOS HTTP security config

如果你需要在 Android 上从网络加载视频,你需要将清单 12-11 中的代码添加到android/app/src/main /AndroidManifest.xml文件中。

<uses-permission android:name="android.permission.INTERNET"/>

Listing 12-11
Android

要播放视频,需要使用表 12-5 所示的构造函数来创建VideoPlayerController对象。

表 12-5

VideoPlayerController 的构造函数

|

名字

|

描述

|
| --- | --- |
| VideoPlayerController.asset(String dataSource, { String package }) | 播放素材中的视频。 |
| VideoPlayerController.file(File file) | 播放本地文件系统中的视频。 |
| VideoPlayerController.network(String dataSource) | 播放从网络加载的视频。 |

创建一个VideoPlayerController对象后,可以使用表 12-6 所示的方法控制视频播放。所有这些方法都返回Future对象。必须首先调用initialize()方法来初始化控制器。只有在initialize()方法返回的Future对象成功完成后,才能调用其他方法。

表 12-6

视频播放器控制器的方法

|

名字

|

描述

|
| --- | --- |
| play() | 播放视频。 |
| pause() | 暂停视频。 |
| seekTo(Duration moment) | 寻找到指定的位置。 |
| setLooping(bool looping) | 是否循环播放视频。 |
| setVolume(double volume) | 设置音频的音量。 |
| initialize() | 初始化控制器。 |
| dispose() | 处置控制器并清理资源。 |

VideoPlayerController类从ValueNotifier<VideoPlayerValue>类扩展而来。通过向状态添加侦听器,您可以在状态改变时得到通知。VideoPlayerValue类包含不同的属性来访问视频的状态。VideoPlayer class 是显示视频的实际小部件。它需要一个VideoPlayerController对象。

清单 12-12 中的VideoPlayerView类是一个小部件,用于播放从指定 URL 加载的视频。在initState()方法中,VideoPlayerController.network()构造函数用于创建VideoPlayerController对象。FutureBuilder widget 使用initialize()方法返回的Future对象构建 UI。由于VideoPlayerController对象也是一个Listenable对象,我们可以将AnimatedBuilderVideoPlayerController对象一起使用。AspectRatio小部件使用aspectRatio属性来确保在播放视频时使用正确的纵横比。VideoProgressIndicator小工具显示进度条,指示视频播放进度。

class VideoPlayerView extends StatefulWidget {
  VideoPlayerView({Key key, this.videoUrl}) : super(key: key);

  final String videoUrl;

  @override
  _VideoPlayerViewState createState() => _VideoPlayerViewState();
}

class _VideoPlayerViewState extends State<VideoPlayerView> {
  VideoPlayerController _controller;
  Future<void> _initializedFuture;

  @override
  void initState() {
    super.initState();
    _controller = VideoPlayerController.network(widget.videoUrl);
    _initializedFuture = _controller.initialize();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _initializedFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          return AnimatedBuilder(
            animation: _controller,
            child: VideoProgressIndicator(_controller, allowScrubbing: true),
            builder: (context, child) {
              return Column(
                children: <Widget>[
                  AspectRatio(
                    aspectRatio: _controller.value.aspectRatio,
                    child: VideoPlayer(_controller),
                  ),
                  Row(
                    children: <Widget>[
                      IconButton(
                        icon: Icon(_controller.value.isPlaying
                            ? Icons.pause
                            : Icons.play_arrow),
                        onPressed: () {
                          if (_controller.value.isPlaying) {
                            _controller.pause();
                          } else {
                            _controller.play();
                          }
                        },
                      ),
                      Expanded(child: child),
                    ],
                  ),
                ],
              );
            },
          );
        } else {

          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

}

Listing 12-12Playing video

12.7 使用摄像机

问题

你想用相机拍照或者录视频。

解决办法

使用camera插件。

讨论

如果要访问设备上的摄像头,可以使用camera插件( https://pub.dev/packages/camera )。要安装这个插件,你需要将camera: ⁰.5.0添加到pubspec.yaml文件的依赖项中。对于 iOS,您需要将清单 12-13 中的代码添加到ios/Runner/Info.plist文件中。这两个键值对描述了访问摄像头和麦克风的目的。这是保护用户隐私所必需的。

<key>NSCameraUsageDescription</key>
<string>APPNAME requires access to your phone's camera.</string>
<key>NSMicrophoneUsageDescription</key>
<string>APPNAME requires access to your phone's microphone.</string>

Listing 12-13Privacy requirements for iOS

对于 Android,最低 Android SDK 版本需要在android/app/build.gradle文件中设置为 21。

要访问摄像机,您需要创建CameraController对象。CameraController构造函数需要CameraDescriptionResolutionPreset类型的参数。CameraDescription描述一种照相机。ResolutionPreset enum 描述屏幕分辨率的质量。ResolutionPreset是一个值为lowmediumhigh的枚举。要获取CameraDescription对象,您可以使用availableCameras()函数获取类型为List<CameraDescription>的可用摄像机列表。

表 12-7 显示了CameraController类的方法。所有这些方法都返回Future对象。一个CameraController对象需要首先被初始化。其他方法只能在initialize()返回的Future对象成功完成后调用。CameraController类从ValueNotifier<CameraValue>类扩展而来,因此您可以向它添加监听器以获得状态变化的通知。

表 12-7

摄像机控制器的方法

|

名字

|

描述

|
| --- | --- |
| takePicture(String path) | 拍摄照片并保存到文件中。 |
| prepareForVideoRecording() | 准备录像。 |
| startVideoRecording(String filePath) | 开始录像并保存到文件中。 |
| stopVideoRecording() | 停止当前的视频录制。 |
| startImageStream() | 开始图像流。 |
| stopImageStream() | 停止当前的图像流。 |
| initialize() | 初始化控制器。 |
| dispose() | 处置控制器并清理资源。 |

在清单 12-14 中,CameraController对象是用传入的CameraDescription对象创建的。FutureBuilder widget 在CameraController对象初始化后构建实际的 UI。CameraPreview小工具显示相机的实时预览。按下图标时,会拍摄一张照片并保存到临时目录中。

class CameraView extends StatefulWidget {
  CameraView({Key key, this.camera}) : super(key: key);
  final CameraDescription camera;

  @override
  _CameraViewState createState() => _CameraViewState();
}

class _CameraViewState extends State<CameraView> {
  CameraController _controller;
  Future<void> _initializedFuture;

  @override
  void initState() {
    super.initState();
    _controller = CameraController(widget.camera, ResolutionPreset.high);
    _initializedFuture = _controller.initialize();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<void>(
      future: _initializedFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          return Column(

            children: <Widget>[
              Expanded(child: CameraPreview(_controller)),
              IconButton(
                icon: Icon(Icons.photo_camera),
                onPressed: () async {
                  String path = join((await getTemporaryDirectory()).path,
                      '${DateTime.now()}.png');
                  await _controller.takePicture(path);
                  Scaffold.of(context).showSnackBar(
                      SnackBar(content: Text('Picture saved to $path')));
                },
              ),
            ],
          );
        } else {
          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Listing 12-14Use camera

在清单 12-15 中,availableCameras()函数获得了一个CameraDescription对象的列表,只有第一个用于创建CameraView小部件。

class CameraSelector extends StatelessWidget {
  final Future<CameraDescription> _cameraFuture =
      availableCameras().then((list) => list.first);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<CameraDescription>(
      future: _cameraFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasData) {
            return CameraView(camera: snapshot.data);
          } else {
            return Center(child: Text('No camera available!'));
          }
        } else {
          return Center(child: CircularProgressIndicator());
        }

      },
    );
  }
}

Listing 12-15Select camera

12.8 使用系统共享表

问题

您希望允许用户使用系统共享表共享项目。

解决办法

使用share插件。

讨论

如果您希望允许用户共享应用中的项目,您可以使用share插件( https://pub.dev/packages/share )来显示系统共享表。要使用这个插件,你需要将share: ⁰.6.1添加到pubspec.yaml文件的依赖项中。

share plugin 提供的 API 非常简单。它只有一个静态的share()方法来共享一些文本。您可以共享纯文本或 URL。清单 12-16 展示了如何使用share()方法共享一个 URL。

Share.share('https://flutter.dev');

Listing 12-16Share a URL

12.9 摘要

Flutter 应用可以使用特定于平台的代码来调用原生平台 API。有大量的社区插件可以在原生平台上使用不同的未来,包括摄像头、麦克风、传感器等等。在下一章,我们将讨论 Flutter 中的各种话题。

十三、杂项

这一章涵盖了 Flutter 的各种课题的食谱。

13.1 使用素材

问题

您希望在应用中捆绑静态素材。

解决办法

使用素材。

讨论

Flutter 应用可以包含代码和静态素材。有两种类型的素材:

  • 数据文件,包括 JSON、XML 和纯文本文件

  • 包括图像和视频的二进制文件

素材在pubspec.yaml文件的flutter/assets部分声明。在构建过程中,这些素材文件被捆绑到应用的二进制文件中。这些素材可以在运行时访问。将素材放在assets目录下是很常见的。在清单 13-1 中,有两个文件在pubspec.yaml文件中被声明为素材。

flutter:
  assets:
    - assets/dog.jpg
    - assets/data.json

Listing 13-1Assets in pubspec.yaml file

在运行时,AssetBundle类的子类用于从资源中加载内容。load()方法检索二进制内容,而loadString()方法检索字符串内容。使用这两种方法时,您需要提供素材键。密钥与pubspec.yaml文件中声明的素材路径相同。静态应用级的rootBundle属性指的是AssetBundle对象,它包含与应用打包在一起的素材。您可以直接使用此属性来加载素材。建议使用静态的DefaultAssetBundle.of()方法从构建上下文中获取AssetBundle对象。

在清单 13-2 中,JSON 文件assets/data.json使用loadString()方法作为字符串加载。

class TextAssets extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: DefaultAssetBundle.of(context)
          .loadString('assets/data.json')
          .then((json) {
        return jsonDecode(json)['name'];
      }),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.done) {
          return Center(child: Text(snapshot.data));
        } else {
          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }
}

Listing 13-2Load string assets

如果素材文件是一个图像,您可以使用带有Image小部件的AssetImage类来显示它。在清单 13-3 中,AssetImage类用于显示assets/dog.jpg图像。

Image(
  image: AssetImage('assets/dog.jpg'),
)

Listing 13-3Use AssetImage

对于图像资源,同一文件通常会有多个分辨率不同的变体。当使用AssetImage类加载素材图像时,将使用与当前设备像素比率最匹配的变量。

在清单 13-4 中,assets/2.0x/dog.jpg文件是assets/dog.jpg的变体,分辨率为2.0。如果设备像素比率为1.6,则使用assets/2.0x/dog.jpg文件。

flutter:
  assets:
    - assets/dog.jpg
    - assets/2.0x/dog.jpg
    - assets/3.0x/dog.jpg

Listing 13-4Image assets variants

13.2 使用手势

问题

您希望允许用户使用手势来执行操作。

解决办法

使用GestureDetector小工具检测手势。

讨论

手机应用的用户习惯于在执行动作时使用手势。例如,在查看图片库时,使用滑动手势可以轻松地在不同图片之间导航。在 Flutter 中,我们可以使用GestureDetector小部件来检测手势,并为手势调用指定的回调。GestureDetector构造函数有大量的参数,为不同的事件提供回调。一个手势可以在其生命周期中调度多个事件。例如,水平拖动的手势可以调度三个事件。以下是这三个事件的处理程序参数:

  • onHorizontalDragStart回调表示指针可能开始水平移动。

  • onHorizontalDragUpdate回调表示指针在水平方向移动。

  • onHorizontalDragEnd回调意味着指针与屏幕接触的时间更长。

不同事件的回调可以接收关于事件的详细信息。在清单 13-5 中,GestureDetector小部件包装了一个Container小部件。在onHorizontalDragEnd回调处理程序中,DragEndDetails对象的velocity属性是指针的移动速度。我们使用这个属性来确定拖动方向。

class SwipingCounter extends StatefulWidget {
  @override
  _SwipingCounterState createState() => _SwipingCounterState();
}

class _SwipingCounterState extends State<SwipingCounter> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('$_count'),
        Expanded(
          child: GestureDetector(
            child: Container(
              decoration: BoxDecoration(color: Colors.grey.shade200),
            ),
            onHorizontalDragEnd: (DragEndDetails details) {
              setState(() {
                double dx = details.velocity.pixelsPerSecond.dx;
                _count += (dx > 0 ? 1 : (dx < 0 ? -1 : 0));
              });
            },
          ),
        ),
      ]

,
    );
  }
}

Listing 13-5Use GestureDetector

13.3 支持多个语言环境

问题

您希望应用支持多种语言环境。

解决办法

使用Localizations小部件和LocalizationsDelegate类。

讨论

Flutter 内置了对内部化的支持。如果你想支持多种语言环境,你需要使用Localizations小部件。Localizations类使用一列LocalizationsDelegate对象来加载本地化资源。LocalizationsDelegate<T>类是T类型的一组本地化资源的工厂。本地化资源集通常是一个具有提供本地化值的属性和方法的类。

要创建一个Localizations对象,您需要提供一个Locale对象和一个LocalizationsDelegate对象列表。大多数时候,不需要显式创建一个Localizations对象。WidgetsApp小工具已经创建了一个Localizations对象。WidgetsApp构造函数有被Localizations对象使用的参数。当需要使用本地化值时,可以使用 static Localizations.of<T>(BuildContext context, Type type)方法来获取给定类型的最近的封闭本地化资源对象。

默认情况下,Flutter 只提供美国英语本地化。为了支持其他地区,您需要首先为这些地区添加 Flutter 自己的本地化版本。这是通过将flutter_localizations包添加到pubspec.yaml文件的dependencies中来实现的;见清单 13-6 。有了这个包,您可以使用在MaterialLocalizations类中定义的本地化值。

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

Listing 13-6
flutter_localizations

添加了flutter_localizations包之后,我们需要启用那些本地化的值。在清单 13-7 中,这是通过将GlobalMaterialLocalizations.delegateGlobalWidgetsLocalizations.delegate添加到MaterialApp构造函数的localizationsDelegates列表中来实现的。localizationsDelegates参数的值被传递给Localizations构造函数。supportedLocales参数指定支持的语言环境。

MaterialApp(
  localizationsDelegates: [
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
  ],
  supportedLocales: [
    const Locale('en'),
    const Locale('zh', 'CN'),
  ],
);

Listing 13-7Enable Flutter localized values

在清单 13-8 中,MaterialLocalizations.of()方法从构建上下文中获取MaterialLocalizations对象。copyButtonLabel属性是在MaterialLocalizations类中定义的本地化值。在运行时,按钮的标签取决于设备的区域设置。MaterialLocalizations.of()方法在内部使用Localizations.of()来查找MaterialLocalizations对象。

RaisedButton(
  child: Text(MaterialLocalizations.of(context).copyButtonLabel),
  onPressed: () {},
);

Listing 13-8Use localized values

MaterialLocalizations类只提供了一组有限的本地化值。对于您自己的应用,您需要创建自定义的本地化资源类。清单 13-9 中的AppLocalizations类是一个定制的本地化资源类。AppLocalizations类有appName属性作为简单可本地化字符串的例子。greeting()方法是需要参数的可本地化字符串的一个例子。AppLocalizationsEnAppLocalizationsZhCn类分别是enzh_CN地区的AppLocalizations类的实现。

abstract class AppLocalizations {
  String get appName;
  String greeting(String name);

  static AppLocalizations of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations);
  }
}

class AppLocalizationsEn extends AppLocalizations {
  @override
  String get appName => 'Demo App';

  @override
  String greeting(String name) {
    return 'Hello, $name';
  }
}

class AppLocalizationsZhCn extends AppLocalizations {
  @override
  String get appName => '示例应用';

  @override
  String greeting(String name) {
    return '你好, $name';
  }
}

Listing 13-9AppLocalizations and localized subclasses

我们还需要创建一个定制的LocalizationsDelegate类来加载AppLocalizations对象。有三种方法需要实现:

  • 方法检查一个区域是否被支持。

  • 方法加载给定地区的本地化资源对象。

  • shouldReload()方法检查是否应该调用load()方法来再次加载资源。

在清单 13-10 的load()方法中,根据给定的区域设置返回AppLocalizationsEnAppLocalizationsZhCn对象。

class _AppLocalizationsDelegate
    extends LocalizationsDelegate<AppLocalizations> {
  const _AppLocalizationsDelegate();

  static const List<Locale> _supportedLocales = [
    const Locale('en'),
    const Locale('zh', 'CN')
  ];

  @override
  bool isSupported(Locale locale) {
    return _supportedLocales.contains(locale);
  }

  @override
  Future<AppLocalizations> load(Locale locale) {
    return Future.value(locale == Locale('zh', 'CN')
        ? AppLocalizationsZhCn()
        : AppLocalizationsEn());
  }

  @override
  bool shouldReload(LocalizationsDelegate<AppLocalizations> old) {
    return false;
  }
}

Listing 13-10
Custom LocalizationsDelegate

_AppLocalizationsDelegate对象需要添加到清单 13-7 中的localizationsDelegates列表中。清单 13-11 展示了一个使用AppLocalizations类的例子。

Text(AppLocalizations.of(context).greeting('John'))

Listing 13-11Use AppLocalizations

13.4 生成翻译文件

问题

您希望从代码中提取可本地化的字符串,并集成翻译后的字符串。

解决办法

使用intl_translation包中的工具。

讨论

配方 13-3 描述了如何使用Localizations小部件和LocalizationsDelegate类支持多种语言环境。配方 13-3 中的解决方案的主要缺点是,您需要为所有支持的地区手动创建本地化的资源类。因为本地化字符串直接嵌入在源代码中,所以很难让翻译人员参与进来。更好的选择是使用intl_translation包提供的工具来自动化这个过程。您需要将intl_translation: ⁰.17.3添加到pubspec.yaml文件的dev_dependencies中。

清单 13-12 显示了新的AppLocalizations类,它具有与清单 13-9 相同的appName属性和greeting()方法。Intl.message()方法描述一个本地化的字符串。只有消息字符串是必需的。像namedescargsexamples这样的参数用于帮助翻译人员理解消息字符串。

class AppLocalizations {
  static AppLocalizations of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations);
  }

  String get appName {
    return Intl.message(
      'Demo App',
      name: 'appName',
      desc: 'Name of the app',
    );
  }

  String greeting(String name) {
    return Intl.message(
      'Hello, $name',
      name: 'greeting',
      args: [name],
      desc: 'Greeting message',
      examples: const {'name': 'John'},
    );
  }
}

Listing 13-12AppLocalizations using Intl.message()

现在我们可以使用intl_translation包提供的工具从源代码中提取本地化的消息。下面的命令从lib/app_intl.dart文件中提取用Intl.message()声明的消息,并保存到lib/l10n目录中。运行这个命令后,您应该会在lib/l10n目录中看到生成的intl_messages.arb文件。生成的文件是 ARB(应用资源包)格式( https://github.com/googlei18n/app-resource-bundle ),可以作为 Google Translator Toolkit 等翻译工具的输入。ARB 文件其实是 JSON 文件;您可以简单地使用文本编辑器来修改它们。

$ flutter packages pub run intl_translation:extract_to_arb --locale=en --output-dir=lib/l10n lib/app_intl.dart

现在,您可以为每个受支持的地区复制intl_messages.arb文件并翻译它们。例如,intl_messages_zh.arb文件是zh地区的翻译版本。翻译好文件后,您可以使用以下命令来生成 Dart 文件。运行这个命令后,您应该会看到每个地区的messages_all.dart文件和messages_*.dart文件。

$ flutter packages pub run intl_translation:generate_from_arb --output-dir=lib/l10n --no-use-deferred-loading lib/app_intl.dart lib/l10n/intl_*.arb

文件messages_all.dart中的initializeMessages()函数可以用来初始化给定地区的消息。清单 13-13 中的静态 load()方法首先使用initializeMessages()函数初始化消息,然后设置默认的区域设置。

class AppLocalizations {
  static Future<AppLocalizations> load(Locale locale) {
    final String name =
        locale.countryCode.isEmpty ? locale.languageCode : locale.toString();
    final String localeName = Intl.canonicalizedLocale(name);
    return initializeMessages(localeName).then((_) {
      Intl.defaultLocale = localeName;
      return AppLocalizations();
    });
  }
}

Listing 13-13
Load messages

这个静态的AppLocalizations.load()方法可以被LocalizationsDelegate类的load()方法用来加载AppLocalizations对象。

13.5 绘制自定义元素

问题

您想要绘制自定义元素。

解决办法

使用带有CustomPainterCanvas类的CustomPaint小部件。

讨论

如果想完全自定义一个 widget 的绘制,可以使用CustomPaint widget。CustomPaint widget 提供了一个画布,可以在上面绘制定制元素。表 13-1 显示了CustomPaint构造器的参数。在绘制过程中,painter先在画布上绘制,然后绘制子 widget,最后foregroundPainter在画布上绘制。

表 13-1

CustomPaint 参数

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| painter | CustomPainter | 在孩子面前画画的画家。 |
| foregroundPainter | CustomPainter | 跟在孩子后面画画的画家。 |
| size | Size | 要绘制的大小。 |
| child | Widget | 子部件。 |

要创建CustomPainter对象,您需要创建CustomPainter的子类并覆盖paint()shouldRepaint()方法。在paint()方法中,canvas参数可以用来绘制自定义元素。Canvas类有一套绘制不同元素的方法;见表 13-2 。

表 13-2

画布的方法

|

名字

|

描述

|
| --- | --- |
| drawArc() | 画一个弧线。 |
| drawCircle() | 用指定的圆心和半径画一个圆。 |
| drawImage() | 绘制一个Image对象。 |
| drawLine() | 在两点之间画一条线。 |
| drawOval() | 画一个椭圆形。 |
| drawParagraph() | 绘制文本。 |
| drawRect() | 用指定的Rect对象绘制一个矩形。 |
| drawRRect() | 画一个圆角矩形。 |

Canvas类中的大多数方法都有一个类型为Paint的参数,用来描述在画布上绘图时使用的样式。在清单 13-14 中,Shapes类在画布上绘制了一个矩形和一个圆形。在CustomShapes小部件中,Text小部件被绘制在Shapes画师的上方。

class CustomShapes extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 300,
      height: 300,
      child: CustomPaint(
        painter: Shapes(),
        child: Center(child: Text('Hello World')),
      ),
    );
  }
}

class Shapes extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Rect rect = Offset(5, 5) & (size - Offset(5, 5));
    canvas.drawRect(
      rect,
      Paint()
        ..color = Colors.red
        ..strokeWidth = 2
        ..style = PaintingStyle.stroke,
    );
    canvas.drawCircle(
      rect.center,
      (rect.shortestSide / 2) - 10,
      Paint()..color = Colors.blue,
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }

}

Listing 13-14Use CustomPaint

13.6 自定义主题

问题

你想在 Flutter 应用中自定义主题。

解决办法

ThemeData类用于材质设计,将CupertinoThemeData类用于 iOS。

讨论

定制应用的外观和感觉是一个常见的需求。对于 Flutter apps,如果使用材质设计,可以使用ThemeData类自定义主题。ThemeData类有大量的参数来配置主题的不同方面。MaterialApp类有theme参数来提供ThemeData对象。对于 iOS 风格,CupertinoThemeData类也有同样的目的来指定主题。CupertinoApp类也有CupertinoThemeData类型的theme参数来定制主题。

如果你需要访问当前的主题对象,你可以使用静态的Theme.of()方法来获得最近的封闭的ThemeData对象,用于材质设计中的构建上下文。类似的CupertinoTheme.of()方法可以用于 iOS 风格。

在清单 13-15 中,第一个Text小部件使用当前Theme对象的textTheme.headline属性作为样式。第二个Text小部件使用colorScheme.error属性作为显示错误文本的颜色。

class TextTheme extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text('Headline', style: Theme.of(context).textTheme.headline),
        Text('Error',
            style: TextStyle(color: Theme.of(context).colorScheme.error)),
      ],
    );
  }
}

Listing 13-15Use Theme

13.7 摘要

本章讨论了在不同场景中有用的各种 Flutter 主题。在下一章,我们将讨论 Flutter 的测试和调试。

十四、测试和调试

本章涵盖了与测试和调试 Flutter 应用相关的方法。

14.1 编写单元测试

问题

你想写单元测试。

解决办法

使用test包中的 API。

讨论

单元测试在应用开发中非常重要。要在 Flutter 应用中编写测试,您需要将test: ¹.5.3添加到pubspec.yaml文件的dev_dependencies部分。测试文件通常放在test目录中。清单 14-1 中的MovingBox类是要测试的类。move()方法更新内部_offset变量。

class MovingBox {
  MovingBox({Offset initPos = Offset.zero}) : _offset = initPos;
  Offset _offset;

  get offset => _offset;

  void move(double dx, double dy) {
    _offset += Offset(dx, dy);
  }
}

Listing 14-1Dart class to test

清单 14-2 显示了MovingBox类的测试。group()函数创建一个组来描述一组测试。test()函数用给定的描述和主体创建一个测试用例。主体是一个使用expect()函数声明期望来验证的函数。要调用expect()函数,您需要提供实际值和一个匹配器来检查该值。匹配器可以是来自matcher包的简单值或函数。常见的匹配器功能有contains()startsWith()endsWith()lessThan()greaterThan()inInclusiveRange()

void main() {
  group('MovingBox', () {
    test('position should be (0.0) by default', () {
      expect(MovingBox().offset, Offset.zero);
    });

    test('postion should be initial value', () {
      expect(MovingBox(initPos: Offset(10, 10)).offset, Offset(10, 10));
    });

    test('postion should be moved', () {
      final box = MovingBox();
      box.move(5, 5);
      expect(box.offset, Offset(5, 5));
      box.move(-1, -1);
      expect(box.offset, Offset(4, 4));
    });
  });
}

Listing 14-2Test of MovingBox

您可以使用async函数作为expect()函数的主体来编写异步测试。在清单 14-3 中,第一个测试用例使用一个带有awaitasync函数来获取一个Future对象的值。在第二个测试案例中,completion()函数等待一个Future对象的完成并验证该值。throwsA()函数验证Future对象抛出了给定的错误。在第三个测试用例中,expectAsync1()函数包装另一个函数来验证结果,并检查其调用次数。

void main() {
  test('future with async', () async {
    var value = await Future.value(1);
    expect(value, equals(1));
  });

  test('future', () {
    expect(Future.value(1), completion(equals(1)));
    expect(Future.error('error'), throwsA(equals('error')));
  });

  test('future callback', () {
    Future.error('error').catchError(expectAsync1((error) {
      expect(error, equals('error'));
    }, count: 1));
  });
}

Listing 14-3Asynchronous tests

您可以使用setUp()函数来添加一个在测试前运行的函数。类似地,tearDown()函数用于添加一个在测试后运行的函数。setUp()函数应该用来准备测试用例运行的环境。tearDown()函数应该用于运行清理任务。setUp()tearDown()函数通常成对出现。在清单 14-4 中,setUp()和 tearDown()函数会被调用两次。

void main() {
  setUp(() {
    print('setUp');
  });

  test('action1', () {
    print('action1');
  });

  test('action2', () {
    print('action2');
  });

  tearDown(() {
    print('tearDown');
  });
}

Listing 14-4setUp() and tearDown() functions

运行清单 14-4 中的测试用例后,输出应该如清单 14-5 所示。

setUp
action1
tearDown
setUp
action2
tearDown

Listing 14-5Output with setUp() and tearDown() functions

14.2 在测试中使用模拟对象

问题

您希望在测试用例中模仿依赖关系。

解决办法

使用mockito包。

讨论

当编写测试用例时,要测试的类可能具有需要外部资源的依赖性。例如,服务类需要访问后端 API 来获取数据。当测试这些类时,您不想使用真正的依赖项。依赖于外部资源,给测试用例的执行带来不确定性,并使它们不稳定。使用实时服务也很难测试所有可能的场景。

更好的方法是创建模拟对象来替换这些依赖关系。使用模拟对象,您可以很容易地模拟不同的场景。模拟对象是类的替代实现。您可以手动创建模拟对象或使用mockito包。要使用mockito包,需要在pubspec.yaml文件的dev_dependencies段添加mockito: ⁴.0.0

清单 14-6 中的GitHubJobsClient类使用http包中的Client类来访问 GitHub Jobs API。

class GitHubJobsClient {
  GitHubJobsClient({@required this.httpClient}) : assert(httpClient != null);

  final http.Client httpClient;

  Future<List<Job>> getJobs(String keyword) async {
    Uri url = Uri.https(
        'jobs.github.com', '/positions.json', {'description': keyword});
    http.Response response = await httpClient.get(url);
    if (response.statusCode != 200) {
      throw Exception('Failed to get job listings');
    }
    return (jsonDecode(response.body) as List<dynamic>)
        .map((json) => Job.fromJson(json))
        .toList();
  }
}

Listing 14-6GitHubJobsClient class to test

为了测试GitHubJobsClient类,我们可以为http.Client对象创建一个模拟对象。在清单 14-7 中,MockHttpClient类是http.Client类的模拟类。在第一个测试用例中,当用指定的Uri对象调用MockHttpClientget()方法时,一个带有 JSON 字符串的Future<Response>对象被用作结果。我们可以验证GitHubJobsClientgetJobs()方法可以解析响应并返回一个包含一个元素的List对象。在第二个测试用例中,MockHttpClientget()方法的返回结果被设置为带有 HTTP 500 错误的Future<Response>。然后,我们通过调用getJobs()方法来验证是否抛出了异常。

import 'package:mockito/mockito.dart';
class MockHttpClient extends Mock implements http.Client {}

void main() {
  group('getJobs', () {
    Uri url = Uri.https(
        'jobs.github.com', '/positions.json', {'description': 'flutter'});

    test('should return list of jobs', () {
      final httpClient = MockHttpClient();
      when(httpClient.get(url))
          .thenAnswer((_) async => http.Response('[{"id": "123"}]', 200));
      final jobsClient = GitHubJobsClient(httpClient: httpClient);
      expect(jobsClient.getJobs('flutter'), completion(hasLength(1)));
    });

    test('should throws an exception', () {
      final httpClient = MockHttpClient();
      when(httpClient.get(url))
          .thenAnswer((_) async => http.Response('error', 500));
      final jobsClient = GitHubJobsClient(httpClient: httpClient);
      expect(jobsClient.getJobs('flutter'), throwsException);
    });
  });
}

Listing 14-7GitHubJobsClient test with mock

14.3 编写小部件测试

问题

您希望编写测试用例来测试小部件。

解决办法

使用flutter_test包。

讨论

使用testmockito包足以编写 Dart 类的测试。然而,你需要使用flutter_test包来为小部件编写测试。flutter_test包已经包含在由flutter create命令创建的新项目的pubspec.yaml文件中。使用testWidgets()函数声明小部件的测试用例。调用testWidgets()时,需要提供一个描述和一个回调,在 Flutter 测试环境内部运行。回调接收一个WidgetTester对象来与小部件和测试环境交互。在创建了被测试的小部件之后,您可以使用Finder对象和匹配器来验证小部件的状态。

表 14-1 显示了WidgetTester类的方法。通过创建要测试的小部件,pumpWidget()方法通常是测试的入口点。当测试有状态小部件时,在改变状态后,需要调用pump()方法来触发重建。如果 widget 使用动画,您应该使用pumpAndSettle()方法等待动画结束。像enterText()ensureVisible()这样的方法使用Finder对象来寻找要交互的小部件。

表 14-1

WidgetTester 方法

|

名字

|

描述

|
| --- | --- |
| pumpWidget() | 呈现指定的小工具。 |
| pump() | 触发导致小部件重建的帧。 |
| pumpAndSettle() | 重复调用pump()方法,直到没有帧被调度。 |
| enterText() | 在文本输入小部件中输入文本。 |
| pageBack() | 关闭当前页面。 |
| runAsync() | 异步运行回调。 |
| dispatchEvent() | 调度事件。 |
| ensureVisible() | 通过滚动其祖先Scrollable小部件使小部件可见。 |
| drag() | 按照给定的偏移量拖动小工具。 |
| press() | 按下小工具。 |
| longPress() | 长按 widget。 |
| tap() | 轻按 widget。 |

清单 14-8 中的小部件是要测试的有状态小部件。它有一个TextField小部件来输入文本。当按下按钮时,使用Text小部件显示输入文本的大写字母。

class ToUppercase extends StatefulWidget {
  @override
  _ToUppercaseState createState() => _ToUppercaseState();
}

class _ToUppercaseState extends State<ToUppercase> {
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Row(
          children: <Widget>[
            Expanded(child: TextField(controller: _controller)),
            RaisedButton(
              child: Text('Uppercase'),
              onPressed: () {
                setState(() {});
              },
            ),
          ],
        ),
        Text((_controller.text ?? ").toUpperCase()),
      ],
    );
  }
}

Listing 14-8Widget to test

清单 14-9 显示了ToUppercase小部件的测试用例。在测试之前,_wrapInMaterial()函数将ToUppercase小部件包装在一个MaterialApp中。这是因为TextField小部件需要一个祖先Material小部件。在测试用例中,首先使用pumpWidget()呈现小部件。find对象是CommonFinders类的顶级常量。它有方便的方法来创建不同种类的Finder对象。这里我们找到了类型为TextField的小部件,并使用enterText()来输入文本“abc”。然后点击RaisedButton小部件,状态改变。触发重建需要pump()方法。最后,我们验证带有文本“ABC”的Text小部件是否存在。

表 14-2

共同点的方法

|

名字

|

描述

|
| --- | --- |
| byType() | 按类型查找小部件。 |
| byIcon() | 通过图标数据找到Icon widgets。 |
| byKey() | 通过特定的Key对象查找小部件。 |
| byTooltip() | 找到带有给定消息的Tooltip小部件。 |
| byWidget() | 通过给定的小部件实例查找小部件。 |
| text() | 找到带有给定文本的TextEditableText小部件。 |
| widgetWithIcon() | 查找包含带有图标的后代小部件的小部件。 |
| widgetWithText() | 查找包含带有给定文本的Text子体的小部件。 |

Widget _wrapInMaterial(Widget widget) {
  return MaterialApp(
    home: Scaffold(
      body: widget,
    ),
  );
}

void main() {
  testWidgets('ToUppercase', (WidgetTester tester) async {
    await tester.pumpWidget(_wrapInMaterial(ToUppercase()));
    await tester.enterText(find.byType(TextField), 'abc');
    await tester.tap(find.byType(RaisedButton));
    await tester.pump();
    expect(find.text('ABC'), findsOneWidget);
  });
}

Listing 14-9Test ToUppercase widget

对象与匹配器一起使用来验证状态。有四个匹配器来处理Finder对象:

  • findsOneWidget期望只找到一个小部件。

  • findsNothing期望找不到任何小部件。

  • findsNWidgets期望找到指定数量的小部件。

  • findsWidgets期望至少找到一个小部件。

14.4 编写集成测试

问题

您希望编写在模拟器或真实设备上运行的集成测试。

解决办法

使用flutter_driver包。

讨论

单元测试和小部件测试只能测试单独的类、函数或小部件。这些测试在开发或测试机器上运行。这些测试不能测试应用不同组件之间的集成。这个场景应该使用集成测试。

集成测试分为两部分。第一部分是部署到模拟器或真实设备的仪表化应用。第二部分是驱动应用和验证应用状态的测试代码。测试中的应用与测试代码相隔离,以避免干扰。

编写集成测试需要flutter_driver包。您需要将flutter_driver包添加到pubspec.yaml文件的dev_dependencies部分;见清单 14-10 。

dev_dependencies:
  flutter_driver:
    sdk: flutter

Listing 14-10Add flutter_driver package

集成测试文件通常放在test_driver目录中。测试的目标是在 GitHub 上搜索职位列表的页面。重要的是提供ValueKey对象作为集成测试需要使用的小部件的key参数。这使得在测试用例中更容易找到这些小部件。在清单 14-11 中,Key('keyword')创建一个名为“keyword”的ValueKey对象。

TextField(
  key: Key('keyword'),
  controller: _controller,
)

Listing 14-11Add key to widget

test_driver目录中的github_jobs.dart文件包含要测试的页面的检测版本。清单 14-12 显示了github_jobs.dart文件的内容。flutter_driver 包中的enableFlutterDriverExtension()函数使 Flutter Driver 能够连接到 app。

void main() {
  enableFlutterDriverExtension();
  runApp(SampleApp());
}

Listing 14-12App to test using Flutter Driver

清单 14-13 显示了github_jobs_test.dart文件的内容。通过在应用文件的名称后添加_test后缀来选择文件名。这是 Flutter 驱动程序用来查找 Dart 文件以运行测试中的应用的约定。在setUpAll()功能中,FlutterDriver.connect()用于连接 app。在测试用例中,findCommonFinders对象的顶层常量,它有方便的方法来创建SerializableFinder对象。byValueKey()方法通过指定的键找到清单 14-11 中的TextField小部件。FlutterDrivertap()方法点击TextField小部件使其获得焦点。然后使用enterText()方法向聚焦的TextField小部件输入搜索关键字。然后点击搜索按钮来触发数据加载。如果数据加载成功,带有jobsList键的ListView小部件可用。waitFor()方法等待ListView小部件出现。

void main() {
  group('GitHub Jobs', () {
    FlutterDriver driver;

    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    test('searches by keyword', () async {
      await driver.tap(find.byValueKey('keyword'));
      await driver.enterText('android');
      await driver.tap(find.byValueKey('search'));
      await driver.waitFor(find.byValueKey('jobsList'),
          timeout: Duration(seconds: 5));
    });

    tearDownAll(() {
      if (driver != null) {
        driver.close();
      }
    });
  });
}

Listing 14-13Test using Flutter Driver

现在我们可以使用下面的命令来运行集成测试。Flutter Driver 将应用部署到仿真器或真实设备上,并运行测试代码来验证结果。

$ flutter driver --target=test_driver/github_jobs.dart

表 14-3 显示了FlutterDriver类的方法,这些方法可用于在测试期间与应用交互。如果你想执行自定义动作,你可以在调用enableFlutterDriverExtension()函数时提供一个DataHandler函数。使用requestData()方法发送的消息将由DataHandler处理。

表 14-3

振动驱动器的方法

|

名字

|

描述

|
| --- | --- |
| enterText() | 在当前聚焦的文本输入中输入文本。 |
| getText() | 获取Text小部件中的文本。 |
| tap() | 轻敲小工具。 |
| waitFor() | 等待 finder 找到一个 widget。 |
| waitForAbsent() | 等到 finder 无法再找到 widget。 |
| scroll() | 按照给定的偏移量在小工具中滚动。 |
| scrollIntoView() | 滚动小部件的Scrollable祖先,直到它可见。 |
| scrollUntilVisible(SerializableFinder scrollable, SerializableFinder item) | 反复调用scrollable控件中的scroll(),直到item可见,然后调用item上的scrollIntoView()。 |
| traceAction() | 运行操作并返回其性能跟踪。 |
| startTracing() | 开始记录性能轨迹。 |
| stopTracingAndDownloadTimeline() | 停止记录性能跟踪并下载结果。 |
| forceGC() | 来运行垃圾收集。 |
| getRenderTree() | 返回当前渲染树的转储。 |
| requestData() | 向应用发送消息并接收响应。 |
| screenshot() | 截图吧。 |

FlutterDriver类中的方法使用SerializableFinder对象来定位小部件。表 14-4 显示了CommonFinders类创建SerializableFinder对象的方法。这些方法仅支持使用Stringint值作为参数。这是因为值在发送到应用时需要序列化。

表 14-4

flutter_driver 中的公共查找器方法

|

名字

|

描述

|
| --- | --- |
| byType() | 按类名查找小部件。 |
| byValueKey() | 按键查找小部件。 |
| byTooltip() | 查找带有给定消息的工具提示的小部件。 |
| text() | 找到带有给定文本的TextEditableText小部件。 |
| pageBack() | 找到后退按钮。 |

14.5 调试应用

问题

您想要调试应用中发现的问题。

解决办法

使用由 Flutter SDK 提供的 IDE 和实用程序。

讨论

当代码在运行时没有像你预期的那样工作时,你需要调试代码来找出原因。在 ide 的帮助下,调试 Flutter 应用非常简单。您可以在代码中添加断点,并在调试模式下启动应用。

调试代码的另一种常见方法是使用print()函数将输出写入系统控制台。可以使用flutter logs命令查看这些日志。Android Studio 也在控制台视图中显示这些日志。您还可以使用debugPrint()功能来节流输出,以避免日志被 Android 丢弃。

当创建您自己的小部件时,您应该覆盖debugFillProperties()方法来添加定制的诊断属性。这些属性可以在 Flutter 检查器中查看。在清单 14-14 中,DebugWidget具有nameprice属性。在debugFillProperties()方法中,使用DiagnosticPropertiesBuilder对象添加了两个DiagnosticsProperty对象。

class DebugWidget extends StatelessWidget {
  DebugWidget({Key key, this.name, this.price}) : super(key: key);

  final String name;
  final double price;

  @override
  Widget build(BuildContext context) {
    return Text('$name - $price');
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);

    properties.add(StringProperty('name', name));
    properties.add(DoubleProperty('price', price));
  }
}

Listing 14-14debugFillProperties()

基于属性类型,有不同类型的DiagnosticsProperty子类可供使用。表 14-5 显示了常见的DiagnosticsProperty子类。

表 14-5

共同点的方法

|

名字

|

描述

|
| --- | --- |
| StringProperty | 对于String属性。 |
| DoubleProperty | 对于double属性。 |
| PercentProperty | 将double属性格式化为百分比。 |
| IntProperty | 对于int属性。 |
| FlagProperty | 将bool属性格式化为标志。 |
| EnumProperty | 对于enum属性。 |
| IterableProperty | 对于Iterable属性。 |

14.6 摘要

本章涵盖了与测试和调试 Flutter 应用相关的主题。

posted @   绝不原创的飞龙  阅读(64)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示