使用 Flutter 构建精美的界面

Flutter 是 Google 的界面工具包,用于通过单一代码库针对移动设备、Web 和桌面设备构建经过原生编译的精美应用。在此 Codelab 中,您将针对 Android、iOS 和(可选)Web 创建一款简单的聊天应用。

编写您的第一个 Flutter 应用 - 第 1 部分第 2 部分相比,此 Codelab 更深入地介绍了 Flutter。如果您想简要了解 Flutter,请从那些 Codelab 入手。

学习内容

  • 如何编写在 Android 和 iOS 上看起来都非常自然的 Flutter 应用
  • 如何通过适用于 Android Studio 和 IntelliJ 的 Flutter 插件支持的多个快捷键来使用 Android Studio IDE
  • 如何调试您的 Flutter 应用
  • 如何在模拟器和设备上运行您的 Flutter 应用

您想通过此 Codelab 学习哪些内容?

我不熟悉这个主题,想好好了解一下。 我对这个主题有所了解,但想复习并深入了解一下。 我在寻找示例代码以用到我的项目中。 我在寻找有关特定内容的说明。

您需要使用两款软件才能完成此 Codelab:Flutter SDK(下载)和一款编辑器(配置)。此 Codelab 假定您使用的是 Android Studio,但您可以使用自己偏好的编辑器。

您可以使用以下任意设备运行此 Codelab:

  • 连接到计算机并设为开发者模式的实体设备(Android 或 iOS)
  • Android 模拟器
  • iOS 模拟器
  • Chrome 浏览器
  • Windows、macOS 或 Linux 桌面设备(如果需要启用 Flutter 的桌面设备支持

如果您要在 Android 上运行此 Codelab,必须在 Android Studio 中进行一些设置。如果您要在 iOS 上运行此 Codelab,还必须在 Mac 上安装 Xcode。如需了解详情,请参阅设置编辑器

创建一个简单的模板化 Flutter 应用。您将通过修改此起始应用来创建最终应用。

b2f84ff91b0e1396.png 启动 Android Studio。

  1. 如果您没有已打开的项目,请从欢迎页面中选择 Start a new Flutter app。否则,请依次选择 File > New > New Flutter Project
  2. 选择 Flutter Application 作为项目类型,然后点击 Next
  3. 验证 Flutter SDK 路径是否指定了相应 SDK 的位置(如果文本字段为空,请选择 Install SDK)。
  4. 输入 friendly_chat 作为项目名称,然后点击 Next
  5. 使用 Android Studio 建议的默认软件包名称,然后点击 Next
  6. 点击 Finish
  7. 等待 Android Studio 安装相应 SDK 并创建项目。

b2f84ff91b0e1396.png 或者,通过命令行创建一个 Flutter 应用。

$ flutter create friendly_chat
$ cd friendly_chat
$ dart migrate --apply-changes
$ flutter run

有问题?

如需详细了解如何创建简单的模板化应用,请参阅试运行页面。或者,请通过以下链接中提供的代码恢复正常状态。

在这一部分,您首先要修改默认示例应用,使其成为一款聊天应用。我们的目标是,使用 Flutter 构建一款可扩展且具有以下功能的简单聊天应用 FriendlyChat:

  • 该应用会实时显示短信。
  • 用户可以输入文本字符串消息,然后通过按回车键或 发送按钮发送消息。
  • 该界面可在 Android 和 iOS 设备及 Web 上运行。

在 DartPad 上试试最终应用吧

创建主应用 scaffold

您需要添加的第一个元素是一个简单的应用栏,用于显示该应用的静态标题。随着您继续学习此 Codelab 的后续部分,您将逐步向该应用添加更多自适应的有状态界面元素。

main.dart 文件位于 Flutter 项目的 lib 目录下,包含 main() 函数,该函数是您的应用的执行起点。

b2f84ff91b0e1396.pngmain.dart 中的所有代码替换为以下代码:

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'FriendlyChat',
      home: Scaffold(
        appBar: AppBar(
          title: Text('FriendlyChat'),
        ),
      ),
    ),
  );
}

cf1e10b838bf60ee.png 观察内容

  • 任何 Dart 程序(无论是命令行应用、AngularDart 应用还是 Flutter 应用)都始于 main() 函数。
  • main()runApp() 函数定义与自动生成的应用中的相同。
  • runApp() 函数将 Widget 作为参数,Flutter 框架会在运行时将其展开并显示在屏幕上。
  • 此聊天应用的界面使用 Material Design 元素,因此系统会创建 MaterialApp 对象并将其传递给 runApp() 函数。MaterialApp 微件将成为应用的微件树的根。
  • home 参数用于指定用户在您的应用中看到的默认屏幕。在本示例中,它包含一个 Scaffold 微件,后者有一个简单的 AppBar 作为其子微件。这是 Material 应用的典型结构。

b2f84ff91b0e1396.png 点击编辑器中的 Run 图标 6869d41b089cc745.png 来运行应用。第一次运行应用可能需要一些时间。在后续步骤中,应用的运行速度会变快。

febbb7a3b70462b7.png

您应看到类似下图的内容:

Pixel 3XL

iPhone 11

构建聊天屏幕

为了给互动式组件打下基础,您可以将这个简单的应用分成两个不同的微件子类:一个是保持不变的根级 FriendlyChatApp 微件,还有一个是会在消息发送后和内部状态更改后进行重新构建的子级 ChatScreen 微件。目前,这两个类都可以扩展 StatelessWidget。稍后,您需要将 ChatScreen 修改为有状态微件。这样,您就可以根据需要更改其状态。

b2f84ff91b0e1396.png 创建 FriendlyChatApp 微件:

  1. main() 中,将光标放在 MaterialApp 中的字母 M 之前。
  2. 右键点击,然后依次选择 Refactor > Extract > Extract Flutter Widget

a133a9648f86738.png

  1. ExtractWidget 对话框中输入 FriendlyChatApp,然后点击 Refactor 按钮。MaterialApp 代码放置在新的名为 FriendlyChatApp 的无状态微件中,而 main() 会在调用 runApp() 函数时更新为调用该类。
  2. 选择 home: 之后的文本块。这个文本块以 Scaffold( 开头,以 Scaffold 的英文右括号 ) 结尾。请勿包含结尾处的英文逗号。
  3. 开始输入 ChatScreen,,然后从弹出式列表中选择 ChatScreen()(请选择黄圈内标有等号的 ChatScreen 条目。这将提供一个带有空英文括号的类,而不是常量)。

b2f84ff91b0e1396.png 创建一个无状态微件 ChatScreen

  1. FriendlyChatApp 类下方,第 27 行左右的位置,开始输入 stless。编辑器会询问您是否要创建一个 Stateless 微件。按回车键接受。系统会显示样板代码,您可以在光标所在位置输入无状态微件的名称。
  2. 输入 ChatScreen

b2f84ff91b0e1396.png 更新 ChatScreen 微件:

  1. ChatScreen 微件内,选择 Container,然后开始输入 Scaffold。从弹出式列表中选择 Scaffold
  2. 光标应放置在英文括号内。按回车键另起一行。
  3. 开始输入 appBar,,然后从弹出式列表中选择 appBar:
  4. appBar: 之后,开始输入 AppBar,,然后从弹出式列表中选择 AppBar 类。
  5. 在英文括号内,开始输入 title,,然后从弹出式列表中选择 title:
  6. title: 之后,开始输入 Text,,然后选择 Text 类。
  7. Text 的样板代码包含 data 一词。删除 data 后的第一个英文逗号。选择 data, 并将其替换为 'FriendlyChat'(Dart 支持英文单引号或英文双引号,但更倾向于使用英文单引号,除非文本中已经包含英文单引号)。

查看代码窗格的右上角。如果您看到一个绿色对勾标记,则表示您的代码已通过分析。恭喜!

cf1e10b838bf60ee.png 观察内容

此步骤将介绍 Flutter 框架的几个关键概念:

  • 您需要在微件的 build() 方法中描述界面上由该微件表示的部分。在将这些微件插入微件层次结构时以及它们的依赖项发生变化时,框架会调用 FriendlyChatAppChatScreenbuild() 方法。
  • @override 是一种 Dart 注解,用于指明所标记的方法会替换父类的方法。
  • 某些微件(例如 ScaffoldAppBar)特定于 Material Design 应用。其他微件(例如 Text)则属于通用微件,可在任意应用中使用。来自 Flutter 框架中不同内容库的微件相互兼容,可在一个应用中协同工作。
  • 简化 main() 方法会启用热重载,因为热重载不会重新运行 main()

b2f84ff91b0e1396.png 点击热重载 48583acd5d1a5e12.png 按钮可以几乎立即看到相应更改。将界面分成单独的类并修改根微件后,界面中应该不会显示明显的更改。

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

在这一部分,您将学习如何构建支持用户输入和发送聊天消息的用户控件。

64fd9c97437a7461.png

在设备上,点击文本字段将调出一个软键盘。用户可以通过输入非空字符串并按软键盘上的回车键发送聊天消息。或者,用户可以通过按输入字段旁边的发送按钮来发送他们输入的消息。

目前,用于撰写消息的界面位于聊天屏幕的顶部,但在下一步中添加用于显示消息的界面后,您需要将其移至聊天屏幕的底部。

添加互动式文本输入字段

Flutter 框架提供了一个名为 TextField 的 Material Design 微件。它是一个 StatefulWidget(状态可变的微件),具有用于自定义输入字段行为的属性。State 是构建微件时可以同步读取的信息,在微件的生命周期中可能会发生变化。向 FriendlyChat 应用添加第一个有状态微件时,您需要进行一些修改。

b2f84ff91b0e1396.pngChatScreen 类更改为有状态类:

  1. class ChatScreen extends StatelessWidget 这一代码行中选择 ChatScreen
  2. Option+Return(在 macOS 上)或 Alt+Enter(在 Linux 和 Windows 上)打开相应菜单。
  3. 从菜单中选择 Convert to StatefulWidget。系统会使用有状态微件的样板代码自动更新该类(包括新的用于管理状态的 _ChatScreenState 类)。

如需管理与文本字段的交互,请使用 TextEditingController 对象,它用于读取输入字段的内容,然后在聊天消息发送完毕后清除该字段。

b2f84ff91b0e1396.pngTextEditingController 添加到 _ChatScreenState. 中。

将以下代码行添加为 _ChatScreenState 类的第一行:

final _textController = TextEditingController();

现在,您的应用拥有了状态管理功能,接下来,您可以用一个输入字段和一个发送按钮构建 _ChatScreenState 类。

b2f84ff91b0e1396.png_buildTextComposer 函数添加到 _ChatScreenState 中:

  Widget _buildTextComposer() {
    return  Container(
        margin: EdgeInsets.symmetric(horizontal: 8.0),
      child: TextField(
        controller: _textController,
        onSubmitted: _handleSubmitted,
        decoration: InputDecoration.collapsed(
            hintText: 'Send a message'),
      ),
    );
  }

cf1e10b838bf60ee.png 观察内容

  • 在 Flutter 中,微件的有状态数据封装在 State 对象中。然后,State 对象会与用于扩展 StatefulWidget 类的微件相关联。
  • 上述代码定义了一个名为 _buildTextComposer() 的私有方法,该方法会返回 Container 微件,其中包含一个配置好的 TextField 微件。
  • Container 微件用于在屏幕边缘与输入字段的每一侧之间增加水平边距。
  • 传递到 EdgeInsets.symmetric 的单位是逻辑像素,会根据设备的像素比转换为特定数量的物理像素。您可能很熟悉其在 Android 中的等效术语(密度无关像素)或在 iOS 中的等效术语(点)。
  • onSubmitted 属性提供了一种私有回调方法 _handleSubmitted()。最初,此方法只能清除字段,但之后,您可以将其扩展为发送聊天消息。
  • 您可以通过带有 TextEditingControllerTextField 来控制文本字段。此控制器将清除相应字段并读取其值。

b2f84ff91b0e1396.png_handleSubmitted 函数添加到 _ChatScreenState 中,以清除文本控制器:

  void _handleSubmitted(String text) {
    _textController.clear();
  }

添加文本编辑器微件

b2f84ff91b0e1396.png 更新 _ChatScreenState.build() 方法。

appBar: AppBar(...) 行之后,添加 body: 属性:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('FriendlyChat')),
      body: _buildTextComposer(),    // NEW
    );
  }

cf1e10b838bf60ee.png 观察内容

  • _buildTextComposer 方法会返回一个封装文本输入字段的微件。
  • _buildTextComposer 添加到 body 属性会使应用显示文本输入用户控件。

b2f84ff91b0e1396.png 对该应用执行热重载。您应该会看到类似如下所示的屏幕:

Pixel 3XL

iPhone 11

添加自适应发送按钮

接下来,您需要在文本字段右侧添加一个发送按钮。这需要向布局添加更多结构。

b2f84ff91b0e1396.png_buildTextComposer 函数中,将 TextField 封装在 Row: 内:

  1. _buildTextComposer 中选择 TextField
  2. Option+Return(在 macOS 上)或 Alt+Enter(在 Linux 和 Windows 上)打开相应菜单,然后选择 Wrap with widget。这样就添加了一个封装 TextField 的新微件。占位符名称处于选中状态,IDE 会等待您输入一个新的占位符名称。
  3. 开始输入 Row,,然后从显示的列表中选择 Row。界面上会显示一个弹出式窗口,其中包含 Row 构造函数的定义。child 属性具有红色边框,分析器会告知您缺少必需的 children 属性。
  4. 将鼠标悬停在 child 上,系统会显示一个弹出式窗口。在弹出式窗口中,系统会询问是否要将该属性更改为 children。选择相应选项。
  5. children 属性使用的是列表,而不是单个微件(目前列表中只有一项内容,但您稍后需要另添一项)。在 children: 文本之后输入一个英文左方括号 ([),将该微件转换成列表。编辑器还提供了英文右括号。删除那个英文右括号。往下几行,就在结束那行代码的英文右括号之前,输入英文右括号,后跟一个英文逗号 (],)。现在,分析器应该会显示一个绿色对勾标记。
  6. 现在,代码内容没问题,但格式不正确。右键点击代码窗格,然后选择 Reformat code with dartfmt

b2f84ff91b0e1396.pngTextField 封装在 Flexible 内:

  1. 选择 Row
  2. Option+Return(在 macOS 上)或 Alt+Enter(在 Linux 和 Windows 上)打开相应菜单,然后选择 Wrap with widget。这样就添加了一个封装 TextField 的新微件。占位符名称处于选中状态,IDE 会等待您输入一个新的占位符名称。
  3. 开始输入 Flexible,,然后从显示的列表中选择 Flexible。界面上会显示一个弹出式窗口,其中包含 Row 构造函数的定义。
Widget _buildTextComposer() {
  return  Container(
    margin: EdgeInsets.symmetric(horizontal: 8.0),
    child:  Row(                             // NEW
      children: [                            // NEW
         Flexible(                           // NEW
          child:  TextField(
            controller: _textController,
            onSubmitted: _handleSubmitted,
            decoration:  InputDecoration.collapsed(
                hintText: 'Send a message'),
          ),
        ),                                    // NEW
      ],                                      // NEW
    ),                                        // NEW
  );
}

cf1e10b838bf60ee.png 观察内容

  • 使用 Row 可以将发送按钮放在输入字段的旁边。
  • TextField 封装到 Flexible 微件内,会指示 Row 自动调整文本字段的大小,以使用该按钮未使用的剩余空间。
  • 在英文右方括号之后添加英文逗号,可告知格式设置工具如何设置相应代码的格式。

接下来,您需要添加发送按钮。这是一款 Material 应用,因此请使用相应的 Material 图标 2de111ba4b057a1e.png

b2f84ff91b0e1396.png发送按钮添加到 Row 中。

发送按钮将成为 Row 列表中的第二项。

  1. 将光标放在 Flexible 微件的英文右括号和英文逗号的末尾,然后按回车键另起一行。
  2. 开始输入 Container,,然后从弹出式列表中选择 Container。光标位于该容器的英文括号内。按回车键另起一行。
  3. 将以下代码行添加到容器中:
margin: EdgeInsets.symmetric(horizontal: 4.0),
child: IconButton(
    icon: const Icon(Icons.send),
    onPressed: () => _handleSubmitted(_textController.text)),

cf1e10b838bf60ee.png 观察内容

  • IconButton 会显示发送按钮。
  • icon 属性通过指定 Material 库中的 Icons.send 常量来创建新的 Icon 实例。
  • 通过将 IconButton 置于 Container 微件内,您可以自定义该按钮的外边距,使之位于输入字段旁边视觉效果更好的位置。
  • onPressed 属性使用匿名函数来调用 _handleSubmitted() 方法,并使用 _textController 传递消息的内容。
  • 在 Dart 中,箭头语法 ( => expression) 有时会在声明函数中使用。这是 { return expression; } 的简写形式,仅用于单行函数。如需简要了解 Dart 函数支持(包括匿名函数和嵌套函数),请参阅 Dart 语言导览

b2f84ff91b0e1396.png 对该应用执行热重载,以显示发送按钮:

Pixel 3XL

iPhone 11

该按钮的颜色为黑色,这来自默认的 Material Design 主题。如需让应用中的图标拥有强调色,请将相应的颜色参数传递给 IconButton,或应用其他主题。

b2f84ff91b0e1396.png_buildTextComposer() 中,将 Container 封装在 IconTheme: 内。

  1. 选择 _buildTextComposer() 函数顶部的 Container
  2. Option+Return(在 macOS 上)或 Alt+Enter(在 Linux 和 Windows 上)打开相应菜单,然后选择 Wrap with widget。这样就添加了一个封装 Container 的新微件。占位符名称处于选中状态,IDE 会等待您输入一个新的占位符名称。
  3. 开始输入 IconTheme,,然后从列表中选择 IconThemechild 属性周围有一个红色方框,分析器会告知您 data 属性是必需的。
  4. 添加 data 属性:
return IconTheme(
  data: IconThemeData(color: Theme.of(context).accentColor), // NEW
  child: Container(

cf1e10b838bf60ee.png 观察内容

  • 图标的颜色、不透明度和大小继承自 IconTheme 微件,后者使用 IconThemeData 对象定义这些特征。
  • IconThemedata 属性指定了当前主题的 ThemeData 对象。这会为该按钮(以及此部分微件树中的任何其他图标)添加当前主题的强调色。
  • BuildContext 对象是微件在应用的微件树中的位置的句柄。每个微件都有自己的 BuildContext,后者将成为由 StatelessWidget.buildState.build 函数返回的微件的父级。这意味着 _buildTextComposer() 可以从其封装 State 对象访问 BuildContext 对象。您无需将上下文显式传递给该方法。

b2f84ff91b0e1396.png 对该应用执行热重载。发送按钮现在应该会变为蓝色:

Pixel 3XL

iPhone 11

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

e57d18c5bb8f2ac7.png您发现了一些特别内容!

您可以通过多种方式调试应用。您可以直接使用 IDE 设置断点,也可以使用 Dart 开发者工具(请勿与 Chrome 开发者工具混淆)。此 Codelab 演示的是如何使用 Android Studio 和 IntelliJ 设置断点。如果您使用的是其他编辑器(例如 VS Code),请使用开发者工具进行调试。如需详细了解 Dart 开发者工具,请参阅编写您的第一个 Flutter Web 应用中的第 2.5 步。

您可以利用 Android Studio 和 IntelliJ IDE 调试在模拟器或设备上运行的 Flutter 应用。借助这些编辑器,您可以:

  • 选择用来调试应用的设备或模拟器。
  • 查看管理中心消息。
  • 在代码中设置断点。
  • 在运行时检查变量和对表达式求值。

Android Studio 和 IntelliJ 编辑器会在您的应用运行时显示系统日志,还提供一个用于使用断点和控制执行流的调试程序界面。

6ea611ca007eb43c.png

使用断点

b2f84ff91b0e1396.png 使用断点调试您的 Flutter 应用:

  1. 打开您要设置断点的源代码文件。
  2. 找到要设置断点的那一行代码,点击该行,然后选择 Run > Toggle Line Breakpoint。或者,您也可以点击间距区域(位于行号的右侧)来切换断点。
  3. 如果您未在调试模式下运行,请停止该应用。
  4. 使用 Run > Debug 或点击界面中的 Run debug 按钮重启应用。

编辑器会启动调试程序界面,并在遇到断点时暂停执行应用。然后,您可以使用调试程序界面中的控件来确定错误的原因。

请通过在 FriendlyChat 应用的 build() 方法中设置断点来练习使用该调试程序,然后运行和调试该应用。您可以检查堆栈帧,查看您的应用的方法调用历史记录。

准备好基本应用 scaffold 和屏幕后,现在,您已准备好定义显示聊天消息的区域。

de23b9bb7bf84592.png

实现聊天消息列表

在此部分,您将创建一个微件,以便使用合成功能(创建和合并多个较小的微件)显示聊天消息。首先,您需要创建表示单条聊天消息的微件。然后,您需要将该微件嵌套在可滚动的父级列表中。最后,您需要将这个可滚动的列表嵌套在基本应用 scaffold 中。

b2f84ff91b0e1396.png 添加 ChatMessage 无状态微件:

  1. 将光标置于 FriendlyChatApp 类之后,然后开始输入 stless(类的顺序无关紧要,但这种顺序更易于将代码与解决方案进行比较)。
  2. 输入 ChatMessage 作为类名称。

b2f84ff91b0e1396.pngRow 添加到 ChatMessagebuild() 方法中:

  1. 将光标放在 return Container() 中的英文括号内,然后按回车键另起一行。
  2. 添加 margin 属性:
margin: EdgeInsets.symmetric(vertical: 10.0),
  1. Container' 的子级将是 RowRow 的列表包含两个微件:一个头像和一列文本。
return Container(
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Container(
        margin: const EdgeInsets.only(right: 16.0),
        child: CircleAvatar(child: Text(_name[0])),
      ),
      Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(_name, style: Theme.of(context).textTheme.headline4),
          Container(
            margin: EdgeInsets.only(top: 5.0),
            child: Text(text),
          ),
        ],
      ),
    ],
  ),
);
  1. text 变量和构造函数添加到 ChatMessage 的顶部:
class ChatMessage extends StatelessWidget {
  ChatMessage({required this.text}); // NEW
  final String text;                 // NEW

此时,分析器应该只会指出 _name 未定义。您将在下一步解决这个问题。

b2f84ff91b0e1396.png 定义 _name 变量。

如下所示,定义 _name 变量,并将 Your Name 替换为您自己的姓名。此变量用于为每条聊天消息标注发信人姓名。在此 Codelab 中,为简单起见,您对该值进行了硬编码,但大多数应用会通过身份验证来检索发信人的姓名。在 main() 函数后,添加以下代码行:

String _name = 'Your Name';

cf1e10b838bf60ee.png 观察内容

  • ChatMessagebuild() 方法会返回一个 Row(显示简单的图形头像来代表发送相应聊天消息的用户),还会返回一个 Column 微件(包含发信人姓名和消息文本)。
  • CircleAvatar 的个性化设置方式如下:通过将 _name 变量值的第一个字符传递给子级 Text 微件,用用户的姓名首字母来标记它。
  • crossAxisAlignment 参数在 Row 构造函数中指定 CrossAxisAlignment.start,用来确定头像和消息相对于其父级微件的位置。对于头像而言,其父级是 Row 微件,其主轴是水平的,因此 CrossAxisAlignment.start 提供了沿垂直轴的最高位置。对于消息而言,其父级是 Column 微件,其主轴是垂直的,因此 CrossAxisAlignment.start 将文本排列在了沿水平轴的最左边位置。
  • 头像旁边有两个垂直排列的 Text 微件,用于在顶部显示发信人的姓名,在下方显示消息文本。
  • Theme.of(context) 用于为应用提供默认的 Flutter ThemeData 对象。在稍后的步骤中,您将替换此默认主题,面向 Android 和 iOS 为该应用设置不同的样式。
  • 借助 ThemeDatatextTheme 属性,您可以访问 headline4 等文本的 Material Design 逻辑样式,从而避免对字体大小和其他文本属性进行硬编码。在此示例中,发信人姓名的样式已调整为大于消息文本。

b2f84ff91b0e1396.png 对该应用执行热重载。

在文本字段中输入消息。按发送按钮发出该消息。在文本字段中输入一条较长的消息,看看文本字段溢出时会发生什么情况。稍后,在第 9 步中,您需要将该列封装在 Expanded 微件中,从而封装 Text 微件。

在界面中实现聊天消息列表

下一项优化内容是获取聊天消息的列表并在界面中显示。您希望此列表可以滚动,以便用户查看消息历史记录。该列表还应按时间顺序显示消息,最新消息显示在可见列表的最下面一行。

b2f84ff91b0e1396.png_messages 列表添加到 _ChatScreenState 中。

_ChatScreenState 定义中,添加一个名为 _messagesList 成员来表示每条聊天消息:

class _ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = [];      // NEW
  final _textController = TextEditingController();

b2f84ff91b0e1396.png 修改 _ChatScreenState. 中的 _handleSubmitted() 方法。

当用户从文本字段发送聊天消息时,该应用应将新消息添加到消息列表中。通过修改 _handleSubmitted() 方法实现此行为:

void _handleSubmitted(String text) {
  _textController.clear();
  ChatMessage message = ChatMessage(    //NEW
    text: text,                         //NEW
  );                                    //NEW
  setState(() {                         //NEW
    _messages.insert(0, message);       //NEW
  });                                   //NEW
 }

b2f84ff91b0e1396.png 提交内容后,将焦点重新置于文本字段上。

  1. FocusNode 添加到 _ChatScreenState 中:
class _ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = [];
  final _textController = TextEditingController();
  final FocusNode _focusNode = FocusNode();    // NEW
  1. _buildTextComposer() 中,将 focusNode 属性添加到 TextField 内:
child: TextField(
  controller: _textController,
  onSubmitted: _handleSubmitted,
  decoration: InputDecoration.collapsed(hintText: 'Send a message'),
  focusNode: _focusNode,  // NEW
),
  1. _handleSubmitted() 中,在对 setState() 的调用后,请求系统将焦点置于 TextField 上:
    setState(() {
      _messages.insert(0, message);
    });
    _focusNode.requestFocus();  // NEW

cf1e10b838bf60ee.png 观察内容

  • 列表中的每项内容都是一个 ChatMessage 实例。
  • 该列表被初始化为空白列表。
  • 通过调用 setState() 来修改 _messages,可让框架知道此部分微件树已发生变化,并且它需要重新构建界面。setState() 中应该仅执行同步操作,否则框架可能会在操作完成之前重新构建微件。
  • 通常,在此方法调用之外的一些私有数据发生变化后,可以用一个空的闭包调用 setState()。不过,最好更新 setState 的闭包中的数据,这样您就不会忘记在事后调用它。

b2f84ff91b0e1396.png 对该应用执行热重载。

在文本字段中输入文本,然后按 Return。文本字段将再次获得焦点。

放置消息列表

现在,您已准备好显示聊天消息列表。从 _messages 列表获取 ChatMessage 微件,并将其放入 ListView 微件中,以获得一个可滚动列表。

b2f84ff91b0e1396.png_ChatScreenStatebuild() 方法中,在 Column 内添加 ListView

Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: Text ('FriendlyChat')),
    body: Column(                                            // MODIFIED
      children: [                                            // NEW
        Flexible(                                            // NEW
          child: ListView.builder(                           // NEW
            padding: EdgeInsets.all(8.0),                    // NEW
            reverse: true,                                   // NEW
            itemBuilder: (_, int index) => _messages[index], // NEW
            itemCount: _messages.length,                     // NEW
          ),                                                 // NEW
        ),                                                   // NEW
        Divider(height: 1.0),                                // NEW
        Container(                                           // NEW
          decoration: BoxDecoration(
            color: Theme.of(context).cardColor),             // NEW
          child: _buildTextComposer(),                       // MODIFIED
        ),                                                   // NEW
      ],                                                     // NEW
    ),                                                       // NEW
  );
}

cf1e10b838bf60ee.png 观察内容

  • ListView.builder 工厂方法通过提供针对列表中的每项内容都调用一次的参数,按需构建了一个列表。每次调用时,该函数都会返回一个新微件。构建器还会自动检测其 children 参数的变更并启动重建过程。
  • 传递给 ListView.builder 构造函数的参数可自定义列表内容和外观:
  • padding 用于在消息文本周围创建空格。
  • itemCount 用于指定列表中消息的数量。
  • itemBuilder 提供了用于在 [index] 中构建每个微件的函数。由于您不需要当前的构建上下文,因此可以忽略 IndexedWidgetBuilder 的第一个参数。使用下划线 (_) 而不使用其他任何内容为参数命名是一种惯例,表示该参数不会再投入使用。
  • 现在,Scaffold 微件的 body 属性包含收到的消息列表以及输入字段和发送按钮。布局使用以下微件:
  • Column:垂直排列其直接子级。Column 微件会接受一个包含多个子级微件的列表(与 Row 一样),然后转换为一个滚动列表和一行输入字段。
  • FlexibleListView 的父级):指示框架让收到的消息列表扩展为填满 Column 高度,而 TextField 保持固定大小。
  • Divider:在用于显示消息的界面和用于撰写消息的文本输入字段之间绘制一条水平线。
  • Container(文本编辑器的父级):定义背景图片、内边距、外边距和其他常见的布局详情。
  • decoration:创建一个新的用于定义背景颜色的 BoxDecoration。在这种情况下,您使用的是由默认主题的 ThemeData 对象定义的 cardColor。这就给用于撰写消息的界面提供了与消息列表不同的背景。

b2f84ff91b0e1396.png 对该应用执行热重载。您应该会看到类似如下所示的屏幕:

Pixel 3XL

iPhone 11

b2f84ff91b0e1396.png 尝试使用您刚刚构建的用于撰写和显示消息的界面发送几条聊天消息!

Pixel 3XL

iPhone 11

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

您可以向微件添加动画效果,让应用的用户体验更加流畅和直观。在这一部分,您将学习如何向聊天消息列表添加基本动画效果。

您可以为用户发送的新聊天信息添加动画效果,使其从屏幕底部垂直缓缓上升,而不是简单地将其显示在信息列表中。

Flutter 中的动画被封装为 Animation 对象,其中包含一个类型化的值和一个状态(如“forward”、“reverse”、“completed”和“dismissed”)。您可以将动画对象附加到微件,或监听对动画对象的更改。根据对动画对象的属性进行的更改,框架可以修改微件的显示方式,还可以重新构建微件树。

指定动画控制器

您可以使用 AnimationController 类指定动画应当如何运行。借助 AnimationController,您可以定义动画的重要特征,如时长和播放方向(正向或反向)。

b2f84ff91b0e1396.png 更新 _ChatScreenState 类定义,以包含 TickerProviderStateMixin

class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {   // MODIFIED
  final List<ChatMessage> _messages = [];
  final _textController = TextEditingController();
  final FocusNode _focusNode = FocusNode();
  ...

b2f84ff91b0e1396.pngChatMessage 类定义中,添加一个用于存储动画控制器的变量:

class ChatMessage extends StatelessWidget {
  ChatMessage({required this.text, required this.animationController}); // MODIFIED
  final String text;
  final AnimationController animationController;      // NEW
  ...

b2f84ff91b0e1396.png_handleSubmitted() 方法添加动画控制器:

void _handleSubmitted(String text) {
  _textController.clear();
  var message = ChatMessage(
    text: text,
    animationController: AnimationController(      // NEW
      duration: const Duration(milliseconds: 700), // NEW
      vsync: this,                                 // NEW
    ),                                             // NEW
  );                                               // NEW
  setState(() {
    _messages.insert(0, message);
  });
  _focusNode.requestFocus();
  message.animationController.forward();           // NEW
}

cf1e10b838bf60ee.png 观察内容

  • AnimationController 指定动画的运行时时长为 700 毫秒(这段时长较长,会减慢动画效果,因此您可以看到转换逐渐发生。实际上,您可能需要在运行应用时设置一个较短的时长)。
  • 动画控制器会附加到一个新的 ChatMessage 实例,并指定每当有消息被添加到聊天列表时,动画都应正向播放。
  • 创建 AnimationController 时,您必须向其传递 vsync 参数。vsync 是推动动画正向播放的检测信号源 (Ticker)。此示例使用 _ChatScreenState 作为 vsync,因此它向 _ChatScreenState 类定义添加了 TickerProviderStateMixin mixin。
  • 在 Dart 中,mixin 支持在多个类层次结构中重复使用类主体。如需了解详情,请参阅 Dart 语言导览中的向类添加函数:mixin 这部分内容。

添加 SizeTransition 微件

在动画中添加 SizeTransition 微件可以为 ClipRect 添加在文本滑入时放大其字号的动画效果。

b2f84ff91b0e1396.pngSizeTransition 微件添加到 ChatMessagebuild() 方法中:

  1. ChatMessagebuild() 方法中,选择第一个 Container 实例。
  2. Option+Return(在 macOS 上)或 Alt+Enter(在 Linux 和 Windows 上)打开相应菜单,然后选择 Wrap with widget
  3. 输入 SizeTransitionchild: 属性周围会显示一个红色方框。这表示微件类缺少必需的属性。将光标悬停在 SizeTransition, 上,会出现一条提示,指明必须使用 sizeFactor 并提出要执行创建操作。选择相应选项,该属性便会显示 null 值。
  4. null 替换为实例 CurvedAnimation。这会为如下两个属性添加样板代码:parent(必需)和 curve
  5. 对于 parent 属性,请将 null 替换为 animationController
  6. 对于 curve 属性,请将 null 替换为 Curves.easeOutCurves 类中的一个常量)。
  7. sizeFactor 之后(但在同一级别)添加一行,然后为 SizeTransition 输入 axisAlignment 属性,将其值设置为 0.0。
@override
Widget build(BuildContext context) {
  return SizeTransition(             // NEW
    sizeFactor:                      // NEW
        CurvedAnimation(parent: animationController, curve: Curves.easeOut),  // NEW
    axisAlignment: 0.0,              // NEW
    child: Container(                // MODIFIED
    ...

cf1e10b838bf60ee.png 观察内容

  • CurvedAnimation 对象与 SizeTransition 类结合使用可产生缓出动画效果。缓出效果会使消息在动画开始时快速向上滑动,然后慢下来,直至停止。
  • SizeTransition 微件充当具有动画效果的 ClipRect,会在文本滑入时显示更多文本。

处置动画

当不再需要动画时,最好处置动画控制器以释放资源。

b2f84ff91b0e1396.pngdispose() 方法添加到 _ChatScreenState. 中。

将以下方法添加到 _ChatScreenState 的底部:

@override
void dispose() {
  for (var message in _messages){
    message.animationController.dispose();
  }
  super.dispose();
}

b2f84ff91b0e1396.png 现在,代码内容没问题,但格式不正确。右键点击代码窗格,然后选择 Reformat code with dartfmt

b2f84ff91b0e1396.png 对该应用执行热重载(如果正在运行的应用包含聊天消息,则执行热重启),然后输入一些消息以观察动画效果。

如果您想进一步尝试动画,可以尝试以下几种操作:

  • 通过修改 _handleSubmitted() 方法中指定的 duration 值,加快或减慢动画播放速度。
  • 使用 Curves 类中定义的常量指定不同的动画曲线。
  • 通过将 Container 封装到 FadeTransition 微件(而不是 SizeTransition)中,创建淡入动画效果。

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

在这个可选步骤中,您需要为应用提供一些复杂的细节,如仅在有文本需要发送时才启用发送按钮,封装较长的消息,以及面向 Android 和 iOS 添加原生样式的自定义功能。

让发送按钮能够感知情境

目前,即使输入字段中没有文本,发送按钮也会处于启用状态。您可能希望该按钮的外观根据字段中是否有文本需要发送而发生变化。

b2f84ff91b0e1396.png 定义私有变量 _isComposing,只要用户在输入字段中输入内容,其值就为 true:

class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
  final List<ChatMessage> _messages = [];
  final _textController = TextEditingController();
  final FocusNode _focusNode = FocusNode();
  bool _isComposing = false;            // NEW

b2f84ff91b0e1396.pngonChanged() 回调方法添加到 _ChatScreenState. 中。

_buildTextComposer() 方法中,将 onChanged 属性添加到 TextField,并更新 onSubmitted 属性:

Flexible(
  child: TextField(
    controller: _textController,
    onChanged: (String text) {            // NEW
      setState(() {                       // NEW
        _isComposing = text.isNotEmpty;   // NEW
      });                                 // NEW
    },                                    // NEW
    onSubmitted: _isComposing ? _handleSubmitted : null, // MODIFIED
    decoration:
        InputDecoration.collapsed(hintText: 'Send a message'),
    focusNode: _focusNode,
  ),
),

b2f84ff91b0e1396.png 更新 _ChatScreenState. 中的 onPressed() 回调方法。

还是在 _buildTextComposer() 方法中,更新 IconButtononPressed 属性:

Container(
  margin: EdgeInsets.symmetric(horizontal: 4.0),
  child: IconButton(
      icon: const Icon(Icons.send),
      onPressed: _isComposing                            // MODIFIED
          ? () => _handleSubmitted(_textController.text) // MODIFIED
          : null,                                        // MODIFIED
      )
      ...
)

b2f84ff91b0e1396.png 修改 _handleSubmitted,将 _isComposing 设置为当文本字段为空时变为 false:

void _handleSubmitted(String text) {
  _textController.clear();
  setState(() {                             // NEW
    _isComposing = false;                   // NEW
  });                                       // NEW

  ChatMessage message = ChatMessage(
  ...

cf1e10b838bf60ee.png 观察内容

  • onChanged 回调会告知 TextField 用户修改了其文本。每当 TextField 的值从字段的当前值变为其他值时,它就会调用这个方法。
  • 当字段中包含某些文本时,onChanged 回调会调用 setState(),以将 _isComposing 的值更改为 true。
  • _isComposing 为 false 时,onPressed 属性会设置为 null
  • onSubmitted 属性也经过修改,不会在消息列表中添加空字符串。
  • _isComposing 变量现在控制着发送按钮的行为和视觉外观。
  • 如果用户在文本字段中输入一个字符串,那么 _isComposingtrue,,且发送按钮的颜色会设置为 Theme.of(context).accentColor。在用户按发送按钮时,框架会调用 _handleSubmitted()
  • 如果用户没有在文本字段中输入任何内容,那么 _isComposingfalse,,而相应微件的 onPressed 属性会设置为 null,同时停用发送按钮。框架会自动将该按钮的颜色更改为 Theme.of(context).disabledColor

b2f84ff91b0e1396.png 对应用执行热重载,试试效果吧!

让较长的文本行换行

当用户发送的聊天消息的长度超出消息显示界面的宽度时,相应文本行应换行,以使整条消息显示出来。目前,溢出的行会被截断,并且系统会显示视觉溢出错误。如需确保文本正确换行,一种简单方式是将其放在 Expanded 微件内。

b2f84ff91b0e1396.png 使用 Expanded 微件封装 Column 微件:

  1. ChatMessagebuild() 方法中,选择 ContainerRow 内的 Column
  2. Option+Return(在 macOS 上)或 Alt+Enter(在 Linux 和 Windows 上)打开相应菜单。
  3. 开始输入 Expanded,,然后从一系列可能的对象中选择 Expanded

以下代码示例展示了 ChatMessage 类在进行此项更改后是什么样子:

...
Container(
  margin: const EdgeInsets.only(right: 16.0),
  child: CircleAvatar(child: Text(_name[0])),
),
Expanded(            // NEW
  child: Column(     // MODIFIED
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(_name, style: Theme.of(context).textTheme.headline4),
      Container(
        margin: EdgeInsets.only(top: 5.0),
        child: Text(text),
      ),
    ],
  ),
),                    // NEW
...

cf1e10b838bf60ee.png 观察内容

Expanded 微件允许其子级微件(例如 Column)对子级微件施加布局限制(在本示例中为 Column 的宽度)。在这里,它限制了 Text 微件的宽度(这通常由其内容决定)。

面向 Android 和 iOS 进行自定义

如需使应用的界面呈现自然的外观和风格,您可以添加主题,为 FriendlyChatApp 类的 build() 方法添加一些简单的逻辑。在这一步中,您需要定义一个平台主题,用于应用一组不同的主色和强调色。您还可以自定义发送按钮,以便在 Android 上使用 Material Design IconButton,在 iOS 上使用 CupertinoButton

b2f84ff91b0e1396.png 将以下代码添加到 main() 方法后的 main.dart 中:

final ThemeData kIOSTheme = ThemeData(
  primarySwatch: Colors.orange,
  primaryColor: Colors.grey[100],
  primaryColorBrightness: Brightness.light,
);

final ThemeData kDefaultTheme = ThemeData(
  primarySwatch: Colors.purple,
  accentColor: Colors.orangeAccent[400],
);

cf1e10b838bf60ee.png 观察内容

  • kDefaultTheme ThemeData 对象指定了面向 Android 的颜色(紫色以及橙色强调色)。
  • kIOSTheme ThemeData 对象指定了面向 iOS 的颜色(浅灰以及橙色强调色)。

b2f84ff91b0e1396.png 修改 FriendlyChatApp 类,借助应用的 MaterialApp 微件的 theme 属性改变主题。

  1. 在文件顶部导入 foundation 软件包:
import 'package:flutter/foundation.dart';  // NEW
import 'package:flutter/material.dart';
  1. 修改 FriendlyChatApp 类以选择适当的主题:
class FriendlyChatApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FriendlyChat',
      theme: defaultTargetPlatform == TargetPlatform.iOS // NEW
        ? kIOSTheme                                      // NEW
        : kDefaultTheme,                                 // NEW
      home: ChatScreen(),
    );
  }
}

b2f84ff91b0e1396.png 修改 AppBar 微件(应用界面顶部的横幅)的主题。

  1. _ChatScreenStatebuild() 方法中,找到以下代码行:
      appBar: AppBar(title: Text('FriendlyChat')),
  1. 将光标放在两个英文右括号 ())) 之间,输入一个英文逗号,然后按回车键另起一行。
  2. 添加以下两行代码:
elevation:
   Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0, // NEW
  1. 右键点击代码窗格,然后选择 Reformat code with dartfmt

cf1e10b838bf60ee.png 观察内容

  • 顶级 defaultTargetPlatform 属性和有条件运算符用于选择主题。
  • elevation 属性用于定义 AppBar 的 z 坐标。在 Android 中,z 坐标值为 4.0,表示有一个已定义的阴影;在 iOS 中,这个值为 0.0,表示没有阴影。

b2f84ff91b0e1396.png 自定义 Android 上和 iOS 上的发送图标。

  1. 将以下 import 添加到 main.dart 的顶部:
import 'package:flutter/cupertino.dart';   // NEW
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
  1. _ChatScreenState_buildTextComposer() 方法中,修改将 IconButton 分配为 Container 的子级的那一行。将分配改为以平台为条件进行调整。对于 iOS 设备,使用 CupertinoButton;否则继续使用 IconButton
Container(
   margin: EdgeInsets.symmetric(horizontal: 4.0),
   child: Theme.of(context).platform == TargetPlatform.iOS ? // MODIFIED
   CupertinoButton(                                          // NEW
     child: Text('Send'),                                    // NEW
     onPressed: _isComposing                                 // NEW
         ? () =>  _handleSubmitted(_textController.text)     // NEW
         : null,) :                                          // NEW
   IconButton(                                               // MODIFIED
       icon: const Icon(Icons.send),
       onPressed: _isComposing ?
           () =>  _handleSubmitted(_textController.text) : null,
       )
   ),

b2f84ff91b0e1396.png 将顶级 Column 封装在 Container 微件中,并为其上边缘提供浅灰色边框。

此边框有助于在 iOS 上从视觉角度将应用栏与应用主体区分开来。如需在 Android 上隐藏此边框,请应用上一个代码示例中的应用栏所用的逻辑:

  1. _ChatScreenStatebuild() 方法中,选择 body: 之后出现的 Column
  2. Option+Return(在 macOS 上)或 Alt+Enter(在 Linux 和 Windows 上)打开相应菜单,然后选择 Wrap with Container
  3. 在该 Column 结束之后,但在 Container 结束之前,添加用于根据平台有条件地添加相应按钮的代码,如下所示。
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text('FriendlyChat'),
      elevation:
          Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0, // NEW
    ),
    body: Container(
        child: Column(
          children: [
            Flexible(
              child: ListView.builder(
                padding: EdgeInsets.all(8.0),
                reverse: true,
                itemBuilder: (_, int index) => _messages[index],
                itemCount: _messages.length,
              ),
            ),
            Divider(height: 1.0),
            Container(
              decoration: BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ],
        ),
        decoration: Theme.of(context).platform == TargetPlatform.iOS // NEW
            ? BoxDecoration(                                 // NEW
                border: Border(                              // NEW
                  top: BorderSide(color: Colors.grey[200]!), // NEW
                ),                                           // NEW
              )                                              // NEW
            : null),                                         // MODIFIED
  );
}

b2f84ff91b0e1396.png 对该应用执行热重载。您应该会看到面向 Android 和 iOS 的不同颜色、阴影和图标按钮。

Pixel 3XL

iPhone 11

有问题?

如果您的应用运行不正常,请检查是否存在拼写错误。如果需要,请通过以下链接中提供的代码恢复正常状态。

恭喜!

现在,您已掌握使用 Flutter 框架构建跨平台移动应用的基础知识。

我们的学习内容

  • 如何从头开始构建 Flutter 应用
  • 如何使用 Android Studio 和 IntelliJ 中提供的一些快捷键
  • 如何在模拟器和设备上运行、热重载和调试您的 Flutter 应用
  • 如何使用微件和动画自定义界面
  • 如何面向 Android 和 iOS 自定义界面

后续步骤

尝试学习其他 Flutter Codelab

继续了解 Flutter:

如需详细了解键盘快捷键,请参阅:

不妨下载示例代码作为参考,或在特定部分启动此 Codelab。如需获取此 Codelab 的示例代码副本,请从您的终端运行以下命令:

 git clone https://github.com/flutter/codelabs

此 Codelab 的示例代码位于 friendly_chat 文件夹中。每个编号步骤文件夹都与此 Codelab 相应编号步骤结尾处的代码一致。您也可以把任意步骤的 lib/main.dart 文件内的代码拖到 DartPad 实例中,在那里运行代码。