构建您的第一个 Flutter 应用

1. 简介

Flutter 是 Google 的界面工具包,用于通过单一代码库针对移动设备、Web 和桌面设备构建应用。在此 Codelab 中,您将构建以下 Flutter 应用。

1d26af443561f39c.gif

该应用可以生成好听的英文名,例如“newstay”“lightstream”“mainbrake”或“graypine”。用户可以请求生成下一个英文名,收藏当前英文名,以及在单独的页面上查看收藏的英文名列表。该应用可自适用不同的屏幕尺寸。

学习内容

  • Flutter 的基本工作原理
  • 在 Flutter 中创建布局
  • 关联用户互动(如按下按钮)与应用行为
  • 让 Flutter 代码井然有序
  • 让应用能够自适用不同的屏幕尺寸
  • 让应用具有一致的外观和风格

您将从一个基本的基架开始构建应用,这样您就可以直接跳到感兴趣的部分。

d6e3d5f736411f13.png

Filip 将指引您完成整个 Codelab!

点击“下一步”开始此 Codelab。

2. 设置您的 Flutter 环境

编辑器

为了尽可能让此 Codelab 保持简单易懂,我们假设您将使用 Visual Studio Code (VS Code) 作为开发环境。该开发环境是免费的,适用于所有主要平台。

当然,您也可以使用任何喜欢的编辑器:Android Studio、其他 IntelliJ IDE、Emacs、Vim 或 Notepad++。它们均支持 Flutter。

我们建议在此 Codelab 中使用 VS Code,因为相关说明默认使用 VS Code 专有的快捷键。与“在编辑器中执行适当的操作以执行 X”相比,“点击此处”或“按下此键”这样的描述要更加简单直观。

15961a28a4500ac1.png

选择目标开发平台

Flutter 是一个多平台工具包。您的应用可以在以下任何操作系统上运行:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • Web

不过,一种常见的做法是选择一个操作系统作为所开发应用的主要运行平台。这是您的“目标开发平台”。在开发过程中,您的应用将在此操作系统上运行。

d105428cb3aae7d5.png

例如,假设您开发 Flutter 应用使用的是 Windows 笔记本电脑。如果选择 Android 作为目标开发平台,您通常会使用 USB 线将 Android 设备连接到 Windows 笔记本电脑,并且您在开发的应用将在该连接的 Android 设备上运行。但您也可以选择 Windows 作为目标开发平台,这意味着您在开发的应用将作为 Windows 应用与编辑器一起运行。

您可能会想要选择 Web 作为目标开发平台。但这种选择有不利的一面,即您将失去 Flutter 最实用的一项开发功能:有状态热重载。Flutter 无法热重载 Web 应用。

您现在可以做出选择了。请记住,您以后始终可以在其他操作系统上运行应用。只是在明确目标开发平台之后,后续步骤才会更加顺利。

安装 Flutter

如需获取关于如何安装 Flutter SDK 的最新说明,请随时访问 docs.flutter.dev

Flutter 网站上的说明不仅介绍了 SDK 安装步骤,而且还涵盖与目标开发平台相关的工具和编辑器插件。请记住,对于此 Codelab,您只需安装以下内容:

  1. Flutter SDK
  2. 随带 Flutter 插件的 Visual Studio Code
  3. 您选择的目标开发平台所需的软件(例如:以 Windows 为目标开发平台时,需要 Visual Studio;以 macOS 为目标开发平台时,需要 Xcode

在下一节中,您将创建您的第一个 Flutter 项目。

如果您到目前为止遇到了问题,以下来自 StackOverflow 的一些问题解答可能有助于您排查问题。

常见问题解答

3. 创建项目

创建您的第一个 Flutter 项目

启动 Visual Studio Code 并打开命令面板(使用 F1Ctrl+Shift+PShift+Cmd+P)。开始输入“flutter new”。选择 Flutter: New Project 命令。

58e8487afebfc1dd.gif

接下来,选择 Application,然后选择要在哪个文件夹中创建项目。这可以是您的主目录,或类似于 C:\src\ 的目录。

最后,为项目命名。比如说,命名为 namer_appmy_awesome_namer

260a7d97f9678005.png

现在,Flutter 会创建项目文件夹,然后在 VS Code 中打开该文件夹。

您现在将使用应用的基本基架来覆盖 3 个文件的内容。

复制并粘贴初始应用

在 VS Code 的左侧窗格中,确保已选中 Explorer,然后打开 pubspec.yaml 文件。

e2a5bab0be07f4f7.png

将此文件的内容替换为以下内容。

pubspec.yaml

name: namer_app
description: A new Flutter project.

publish_to: 'none' # Remove this line if you wish to publish to pub.dev

version: 0.0.1+1

environment:
  sdk: '>=2.19.4 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  english_words: ^4.0.0
  provider: ^6.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^2.0.0

flutter:
  uses-material-design: true

pubspec.yaml 文件指定与您的应用相关的基本信息,例如其当前版本、依赖项以及其随附的资源。

接下来,在项目中打开另一个配置文件 analysis_options.yaml

a781f218093be8e0.png

将其内容替换为以下内容:

analysis_options.yaml

include: package:flutter_lints/flutter.yaml

linter:
  rules:
    prefer_const_constructors: false
    prefer_final_fields: false
    use_key_in_widget_constructors: false
    prefer_const_literals_to_create_immutables: false
    prefer_const_constructors_in_immutables: false
    avoid_print: false

此文件决定了 Flutter 在分析代码时的严格程度。由于这是您第一次使用 Flutter,您可以让分析器不用太严格。此后,您可以随时进行调整。事实上,在邻近发布实际正式版应用的阶段,您几乎肯定会希望分析器更加严格。

最后,打开 lib/ 目录下的 main.dart 文件。

e54c671c9bb4d23d.png

将此文件的内容替换为以下内容。

lib/main.dart

import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => MyAppState(),
      child: MaterialApp(
        title: 'Namer App',
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();

    return Scaffold(
      body: Column(
        children: [
          Text('A random idea:'),
          Text(appState.current.asLowerCase),
        ],
      ),
    );
  }
}

到目前为止,这 50 行代码是应用的全部。

在下一节中,您将以调试模式运行应用并开始开发工作。

4. 添加按钮

此步骤将添加一个 Next 按钮,可用于生成新的单词对。

启动应用

首先,打开 lib/main.dart 并确保选择了目标设备。在 VS Code 的右下方,您会找到一个显示当前目标设备的按钮。点击以更改该按钮。

6c4474b4b5e92ffb.gif

lib/main.dart 处于打开状态时,在 VS Code 窗口的右上方找到“播放”b0a5d0200af5985d.png 按钮,然后点击该按钮。

9b7598a38a6412e6.gif

大约一分钟后,您的应用将以调试模式启动。看起来效果还不太好:

f96e7dfb0937d7f4.png

第一次热重载

lib/main.dart 的底部,向第一个 Text 对象中的字符串添加一些内容,然后保存文件(使用 Ctrl+SCmd+S)。例如:

lib/main.dart

// ...

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),  // ← Example change.
          Text(appState.current.asLowerCase),
        ],
      ),
    );

// ...

请注意应用会立即发生更改,但随机单词保持不变。这正是 Flutter 广为人知的有状态热重载功能在发挥作用。当您将更改保存到源文件时,系统会触发热重载。

1b05b00515b3ecec.gif

常见问题解答

添加按钮

接下来,在 Column 底部添加一个按钮,也就是第二个 Text 实例的正下方。

lib/main.dart

// ...

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),
          Text(appState.current.asLowerCase),

          // ↓ Add this.
          ElevatedButton(
            onPressed: () {
              print('button pressed!');
            },
            child: Text('Next'),
          ),

        ],
      ),
    );

// ...

当您保存更改时,应用会再次更新:其中会显示一个按钮,当您点击该按钮时,VS Code 中的调试控制台会显示 button pressed! 消息。

8d86426a01e28011.gif

5 分钟 Flutter 速成课程

尽管显示调试控制台很有趣,但您希望按钮执行更有意义的操作。不过,在开始之前,请仔细查看 lib/main.dart 中的代码,了解其工作原理。

lib/main.dart

// ...

void main() {
  runApp(MyApp());
}

// ...

在文件的最顶部,您可以找到 main() 函数。目前,该函数只是告知 Flutter 运行 MyApp 中定义的应用。

lib/main.dart

// ...

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => MyAppState(),
      child: MaterialApp(
        title: 'Namer App',
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        ),
        home: MyHomePage(),
      ),
    );
  }
}

// ...

MyApp 类扩展 StatelessWidget。在构建每一个 Flutter 应用时,widget 都是一个基本要素。如您所见,应用本身也是一个 widget。

MyApp 中的代码设置了整个应用,包括创建应用级状态(稍后会详细介绍)、命名应用、定义视觉主题以及设置“主页” widget,即应用的起点。

lib/main.dart

// ...

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();
}

// ...

接下来,MyAppState 类定义应用的状态。这是您第一次使用 Flutter。因此,在此 Codelab 中,我们让该类保持简单和专注。在 Flutter 中,可以采用许多有效的方法来管理应用状态。其中最容易理解的一种方法就是 ChangeNotifier,也是此应用所采用的方法。

  • MyAppState 定义应用运行所需的数据。现在,其中仅包含一个变量,即通过随机函数生成当前的随机单词对。您稍后将在其中添加代码。
  • 状态类扩展 ChangeNotifier,这意味着它可以向其他人通知自己的更改。例如,如果当前单词对发生变化,应用中的一些 widget 需要知晓此变化。
  • 使用 ChangeNotifierProvider 创建状态并将其提供给整个应用(参见上面 MyApp 中的代码)。这样一来,应用中的任何 widget 都可以获取状态。d9b6ecac5494a6ff.png

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {           // ← 1
    var appState = context.watch<MyAppState>();  // ← 2

    return Scaffold(                             // ← 3
      body: Column(                              // ← 4
        children: [
          Text('A random AWESOME idea:'),        // ← 5
          Text(appState.current.asLowerCase),    // ← 6
          ElevatedButton(
            onPressed: () {
              print('button pressed!');
            },
            child: Text('Next'),
          ),
        ],                                       // ← 7
      ),
    );
  }
}

// ...

最后是 MyHomePage,这是您已经修改过的 widget。下面每个带编号的行均映射到上面代码中相应行编号的注释:

  1. 每个 widget 均定义了一个 build() 方法,每当 widget 的环境发生变化时,系统都会自动调用该方法,以便 widget 始终保持最新状态。
  2. MyHomePage 使用 watch 方法跟踪对应用当前状态的更改。
  3. 每个 build 方法都必须返回一个 widget 或(更常见的)嵌套 widget 树。在本例中,顶层 widget 是 Scaffold。您不会在此 Codelab 中使用 Scaffold,但它是一个有用的 widget。在绝大多数真实的 Flutter 应用中都可以找到该 widget。
  4. Column 是 Flutter 中最基础的布局 widget 之一。它接受任意数量的子项并将这些子项从上到下放在一列中。默认情况下,该列会以可视化形式将其子项置于顶部。您很快就会对其进行更改,使该列居中。
  5. 您在第一步中更改了此 Text widget。
  6. 第二个 Text widget 接受 appState,并访问该类的唯一成员 current(这是一个 WordPair)。WordPair 提供了一些有用的 getter,例如 asPascalCaseasSnakeCase。此处,我们使用了 asLowerCase。但如果您希望选择其他选项,您现在可以对其进行更改。
  7. 请注意,Flutter 代码大量使用了尾随逗号。此处并不需要这种特殊的逗号,因为 children 是此特定 Column 参数列表的最后一个(也是唯一一个)成员。不过,在一般情况下,使用尾随逗号是一种不错的选择。尾随逗号可大幅减小添加更多成员的必要性,并且还可以在 Dart 的自动格式化程序中作为添加换行符的提示。如需了解详细信息,请参阅代码格式

接下来,您会将按钮关联至状态。

您的第一个行为

滚动至 MyAppState 并添加 getNext 方法。

lib/main.dart

// ...

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();

  // ↓ Add this.
  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }
}

// ...

新的 getNext() 方法为 current 重新分配了新的随机 WordPair。它还调用 notifyListeners() (ChangeNotifier) 的一个方法),以确保向任何通过 watch 方法跟踪 MyAppState 的对象发出通知。

其余要做的就是通过按钮的回调来调用 getNext 方法。

lib/main.dart

// ...

    ElevatedButton(
      onPressed: () {
        appState.getNext();  // ← This instead of print().
      },
      child: Text('Next'),
    ),

// ...

现在,保存并尝试运行应用。当您每次按下 Next 按钮时,该应用都会生成一个新的随机单词对。

在下一节中,您将改善用户界面的外观。

5. 改善应用外观

下图展示了应用的当前外观。

3dd8a9d8653bdc56.png

不太好。应用的核心功能(随机生成单词对)应更显眼。毕竟,这是应用为用户提供的主要功能!其他问题还包括,应用的内容不在中心位置,整个应用只有单调的黑色和白色。

本节将通过调整应用设计来解决这些问题。本节的最终目标是实现类似下图的效果:

2bbee054d81a3127.png

提取 widget

现在,负责显示当前单词对的代码行大概是这样的:Text(appState.current.asLowerCase)。要改为更复杂的设计,一种行之有效的方式是将此代码行提取到单独的 widget 中。为 UI 的单独逻辑部分使用单独的 widget 是在 Flutter 中管理复杂性的一种重要方法。

Flutter 提供了一个用于提取 widget 的重构帮助程序,但在使用它之前,请确保所提取的代码行仅访问所需的内容。现在,该代码行将访问 appState,但实际上只需知道当前的单词对是什么。

综合考虑以下因素,重写 MyHomePage 的代码,如下所示:

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;                 // ← Add this.

    return Scaffold(
      body: Column(
        children: [
          Text('A random AWESOME idea:'),
          Text(pair.asLowerCase),                // ← Change to this.
          ElevatedButton(
            onPressed: () {
              appState.getNext();
            },
            child: Text('Next'),
          ),
        ],
      ),
    );
  }
}

// ...

很好!Text widget 不再引用整个 appState

现在,您需要调出 Refactor 菜单。在 VS Code 中,您可以通过以下两种方式之一执行此操作:

  1. 右键点击要重构的代码段(在本例中为 Text),然后从下拉菜单中选择 Refactor...

或者

  1. 将光标移到要重构的代码段上(在本例中为 Text),然后按下 Ctrl+. (Win/Linux) 或 Cmd+. (Mac)。

9e18590d82a6900.gif

Refactor 菜单中,选择 Extract Widget。指定一个名称,例如 BigCard,然后点击 Enter 键。

这会在当前文件的末尾自动创建一个新的 BigCard 类。该类应如下所示:

lib/main.dart

// ...

class BigCard extends StatelessWidget {
  const BigCard({
    super.key,
    required this.pair,
  });

  final WordPair pair;

  @override
  Widget build(BuildContext context) {
    return Text(pair.asLowerCase);
  }
}

// ...

请注意,即便在重构期间,应用也将保持正常运行。

添加卡片

接下来,我们要将这个新的 widget 转变为本节开始部分大胆设想的 UI。

在其中找到 BigCard 类和 build() 方法。采用与上文中一样的步骤,调出 Text widget 上的 Refactor 菜单。不过,这次并不会提取 widget。

而是选择 Wrap with Padding。这会围绕 Text widget 创建一个新的父 widget,其名称为 Padding。保存后,您会看到随机单词已经有了更宽敞的空间。

6b585b43e4037c65.gif

将 padding 从默认值 8.0 更改为更大的值。例如,使用 20 来实现更宽敞的内边距。

接下来,我们再进一步。将光标放在 Padding widget 上,调出 Refactor 菜单,然后选择 Wrap with widget...

这允许您指定父 widget。键入“Card”,然后按下 Enter 键。

523425642904374.gif

这会通过 Card widget 封装 Padding widget 以及 Text widget。

6031adbc0a11e16b.png

主题和样式

为了使卡片更加显眼,请用更丰富的颜色对其进行绘制。保持一致的配色方案始终是一个不想的想法。因此,使用应用的 Theme 来选择颜色。

BigCardbuild() 方法进行以下更改。

lib/main.dart

// ...

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);       // ← Add this.

    return Card(
      color: theme.colorScheme.primary,    // ← And also this.
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Text(pair.asLowerCase),
      ),
    );
  }

// ...

这两个新代码行完成了很多操作:

  • 首先,代码使用 Theme.of(context) 请求应用的当前主题。
  • 然后,代码将卡片的颜色定义为与主题的 colorScheme 属性相同。配色方案包含多种颜色,其中 primary 最为显眼,用于定义应用的颜色。

卡片现在会呈现为应用的 primary 颜色:

a136f7682c204ea1.png

您可以更改此颜色以及整个应用的配色方案,方法是向上滚动至 MyApp 并更改其中的 ColorScheme 种子颜色。

5bd5a50b5d08f5fb.gif

请注意,颜色的动画效果很流畅。这称为隐式动画。许多 Flutter widget 会在值之间平滑地插值,这样 UI 就不仅仅是在状态之间“跳转”。

卡片下方的凸起按钮也会改变颜色。这正是应用级 Theme 相对于硬编码值的强大优势。

文本主题

卡片还存在一个问题:文字太小,并且在该颜色下很难看清。如需解决此问题,请对 BigCardbuild() 方法进行以下更改。

lib/main.dart

// ...

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    // ↓ Add this.
    final style = theme.textTheme.displayMedium!.copyWith(
      color: theme.colorScheme.onPrimary,
    );

    return Card(
      color: theme.colorScheme.primary,
      child: Padding(
        padding: const EdgeInsets.all(20),
        // ↓ Change this line.
        child: Text(pair.asLowerCase, style: style),
      ),
    );
  }

// ...

下面详述此项更改:

  • 通过使用 theme.textTheme,,您可以访问应用的字体主题。此类包括以下成员:bodyMedium(针对中等大小的标准文本)、caption(针对图片的说明)或 headlineLarge(针对大标题)。
  • displayMedium 属性是专用于“展示文本”的大号样式。此处的“展示”一词用于反映版式效果,例如展示字体displayMedium 的文档指出“展示样式保留用于简短、重要的文本”— 这正是我们的应用场景。
  • 从理论上说,主题的 displayMedium 属性可以是 null。Dart(您编写此应用所使用的编程语言)采用 null 安全机制,因此不会允许您调用值可能为 null 的对象的方法。不过,在这种情况下,您可以使用 ! 运算符(“bang 运算符”)向 Dart 保证您知道自己在做什么。(在本例中,displayMedium 肯定不是 null。不过,判断这一点的方法超出了此 Codelab 的讨论范围。)
  • 调用 displayMedium 上的 copyWith() 会返回文本样式的副本,以及您定义的更改。在本例中,您只是更改文本的颜色。
  • 若要获取新颜色,您需要再次访问应用的主题。配色方案的 onPrimary 属性定义了一种非常适合在应用的 primary 颜色上使用的颜色。

现在,该应用应如下所示:

2405e9342d28c193.png

您可以根据需要进一步更改卡片。以下是一些建议:

  • 借助 copyWith(),您可以更改更多文本样式方面的属性,而不仅仅是颜色。若要获取可以更改的完整属性列表,请将光标放在 copyWith() 括号内的任意位置,然后按下 Ctrl+Shift+Space 键 (Win/Linux) 或 Cmd+Shift+Space 键 (Mac)。
  • 同样,您可以更改 Card widget 的更多其他属性。比如说,您可以通过增加 elevation 参数的值来扩大卡片的阴影。
  • 尝试更改颜色属性。除了 theme.colorScheme.primary 以外,还可以更改 .secondary.surface 以及各种其他属性。所有颜色都有对应的 onPrimary 属性。

优化无障碍功能

Flutter 会默认让应用支持无障碍功能。例如,每个 Flutter 应用都会正确地将应用中的所有文本和交互元素呈现给屏幕阅读器,例如 TalkBack 和 VoiceOver。

96e3f6d9d36615dd.png

但有时需要一些手动工作。就此应用而言,屏幕阅读器可能无法正确读出某些生成的单词对。尽管人类可以轻松识别 cheaphead 中的两个单词,但屏幕阅读器可能会将单词中间的 ph 发音为 f

一个简单的解决方案就是用 "${pair.first} ${pair.second}" 替代 pair.asLowerCase。前者将使用字符串插值,基于 pair 中包含的两个单词创建一个字符串(例如 "cheap head")。使用两个单独的单词而不是复合单词,这样可以确保屏幕阅读器正确识别它们,并为视障用户提供更出色的体验。

不过,您可能希望保持 pair.asLowerCase 的简洁视觉效果。借助 TextsemanticsLabel 属性,用更适合屏幕阅读器的语义内容来覆盖文本 widget 的视觉内容:

lib/main.dart

// ...

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final style = theme.textTheme.displayMedium!.copyWith(
      color: theme.colorScheme.onPrimary,
    );

    return Card(
      color: theme.colorScheme.primary,
      child: Padding(
        padding: const EdgeInsets.all(20),

        // ↓ Make the following change.
        child: Text(
          pair.asLowerCase,
          style: style,
          semanticsLabel: "${pair.first} ${pair.second}",
        ),
      ),
    );
  }

// ...

现在,屏幕阅读器可以正确读出每个生成的单词对,而 UI 仍然保持不变。在您的设备上使用屏幕阅读器尝试此功能。

在界面中居中显示

现在,随机单词对已经呈现出美观的视觉效果,下一步是将其置于应用窗口/屏幕的中间位置。

首先,请记住 BigCardColumn 的一部分。默认情况下,各个列会将其子项集中到顶部,但我们可以轻松覆盖此设置。找到 MyHomePagebuild() 方法,并进行以下更改:

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,  // ← Add this.
        children: [
          Text('A random AWESOME idea:'),
          BigCard(pair: pair),
          ElevatedButton(
            onPressed: () {
              appState.getNext();
            },
            child: Text('Next'),
          ),
        ],
      ),
    );
  }
}

// ...

这将在 Column 内部沿其主轴(垂直)轴居中子项。

b555d4c7f5000edf.png

子项已经沿列的横轴居中(换句话说,它们已水平居中)。但是,Column 本身并不在 Scaffold 的中心位置。我们可以使用 Widget Inspector 来验证这一点。

27c5efd832e40303.gif

Widget Inspector 超出了此 Codelab 的讨论范围。但您可以看到,当突出显示时,Column 不会占据应用的整个宽度,而是仅占据其子项所需的水平空间。

您可以仅对列进行居中。将光标放在 Column 上,调出 Refactor 菜单(使用 Ctrl+.Cmd+.),然后选择 Wrap with Center

56418a5f336ac229.gif

现在,该应用应如下所示:

455688d93c30d154.png

如果需要,您还可以再对其进行一些调整。

  • 您可以删除 BigCard 上方的 Text widget。一些人认为,界面中不再需要描述性文本 ("A random AWESOME idea:"),因为即使没有该文本,界面也可以发挥应有的作用。而且这样显得更加干净。
  • 您还可以在 BigCardElevatedButton 之间添加一个 SizedBox(height: 10) widget。这样一来,两个 widget 之间就会有更大的空间。SizedBox widget 只是会占用空间,而不会呈现任何内容。它通常用于创建视觉“间隙”。

进行一些可选更改后,MyHomePage 现在包含以下代码:

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10),
            ElevatedButton(
              onPressed: () {
                appState.getNext();
              },
              child: Text('Next'),
            ),
          ],
        ),
      ),
    );
  }
}

// ...

该应用如下所示:

3d53d2b071e2f372.png

在下一节中,您将添加收藏(或“喜欢”)生成的单词的功能。

6. 添加功能

应用现在运行良好,有时甚至会提供一些有趣的单词对。但是,当用户点击 Next 时,每个单词对都会永久消失。最好能通过一种方法来“记住”最佳建议,例如使用“Like”按钮。

e6b01a8c90df8ffa.png

添加业务逻辑

滚动至 MyAppState 并添加以下代码:

lib/main.dart

// ...

class MyAppState extends ChangeNotifier {
  var current = WordPair.random();

  void getNext() {
    current = WordPair.random();
    notifyListeners();
  }

  // ↓ Add the code below.
  var favorites = <WordPair>[];

  void toggleFavorite() {
    if (favorites.contains(current)) {
      favorites.remove(current);
    } else {
      favorites.add(current);
    }
    notifyListeners();
  }
}

// ...

下面分析各项更改:

  • 您在 MyAppState 中添加了一个名为 favorites 的新属性。此属性使用一个空的列表进行初始化,即 []
  • 您还使用 generics 指定该列表只能包含单词对:<WordPair>[]。这有助于增强应用的可靠性 — 如果您尝试向应用添加 WordPair 以外的任何内容,Dart 甚至会拒绝运行应用。相应的,您可以使用 favorites 列表,同时知道其中永远不会隐藏任何不需要的对象(如 null)。
  • 您还添加了一个新方法 toggleFavorite(),它可以从收藏夹列表中删除当前单词对(如果已经存在),或者添加单词对(如果不存在)。在任何一种情况下,代码都会在之后调用 notifyListeners();

添加按钮

完成“业务逻辑”后,接下来继续充实用户界面。如需将“Like”按钮放在“Next”按钮的左侧,我们需要使用 RowRow widget 是您之前看到的 Column 的水平等效项。

首先,将现有按钮封装在 Row 中。找到 MyHomePagebuild() 方法,将光标放在 ElevatedButton 上,使用 Ctrl+.Cmd+. 调出 Refactor 菜单,然后选择 Wrap with Row

7b9d0ea29e584308.gif

保存时,您会注意到 Row 在行为上类似于 Column — 默认情况下,它会将其子项集中在左侧。(Column 会将其子项集中到顶部。)要解决此问题,您可以使用与之前相同的方法,但这次要用到 mainAxisAlignment。不过,出于教学(学习)目的,请使用 mainAxisSize。这会告知 Row 不要占用所有可用的水平空间。

做出以下更改:

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10),
            Row(
              mainAxisSize: MainAxisSize.min,   // ← Add this.
              children: [
                ElevatedButton(
                  onPressed: () {
                    appState.getNext();
                  },
                  child: Text('Next'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ...

界面回到了之前的位置。

3d53d2b071e2f372.png

接下来,添加 Like 按钮并将其关联至 toggleFavorite()。为了考验大家的学习成果,请首先尝试自行完成此任务,而不要看下面的代码块。

e6b01a8c90df8ffa.png

如果您并未完全遵照以下方式操作,也没关系。事实上,除非您真的想要增加挑战难度,否则不必在意心形图标。

即使失败也没有关系 — 毕竟,您上手使用 Flutter 还只有一个小时。

252f7c4a212c94d2.png

接下来,在 MyHomePage 中添加第二个按钮。这次,使用 ElevatedButton.icon() 构造函数创建一个带有图标的按钮。在 build 方法顶部,根据当前单词对是否已在收藏夹中选择适当的图标。另外,请注意再次使用 SizedBox,以便让两个按钮稍微分开。

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    // ↓ Add this.
    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite;
    } else {
      icon = Icons.favorite_border;
    }

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            BigCard(pair: pair),
            SizedBox(height: 10),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [

                // ↓ And this.
                ElevatedButton.icon(
                  onPressed: () {
                    appState.toggleFavorite();
                  },
                  icon: Icon(icon),
                  label: Text('Like'),
                ),
                SizedBox(width: 10),

                ElevatedButton(
                  onPressed: () {
                    appState.getNext();
                  },
                  child: Text('Next'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ...

该应用应如下所示:

11981147e3497c77.gif

只不过,用户看不到收藏夹。因此,在下一节中,我们将在应用添加一个完整的独立屏幕!

7. 添加侧边导航栏

大多数应用都无法将所有内容放置在一个屏幕中。此特定应用或许可以这样做,但为了实现更好的学习效果,您将为用户的收藏夹创建一个单独的屏幕。为了在两个屏幕之间进行切换,您将实现您的第一个 StatefulWidget

9320e50cad339e7b.png

为了尽快了解这一步的内容,请将 MyHomePage 拆分为 2 个单独的 widget。

全选 MyHomePage 并删除,然后替换为以下代码:

lib/main.dart

// ...

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          SafeArea(
            child: NavigationRail(
              extended: false,
              destinations: [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorites'),
                ),
              ],
              selectedIndex: 0,
              onDestinationSelected: (value) {
                print('selected: $value');
              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

class GeneratorPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();
    var pair = appState.current;

    IconData icon;
    if (appState.favorites.contains(pair)) {
      icon = Icons.favorite;
    } else {
      icon = Icons.favorite_border;
    }

    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          BigCard(pair: pair),
          SizedBox(height: 10),
          Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              ElevatedButton.icon(
                onPressed: () {
                  appState.toggleFavorite();
                },
                icon: Icon(icon),
                label: Text('Like'),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: () {
                  appState.getNext();
                },
                child: Text('Next'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

// ...

保存后,您会看到界面的可视效果是正常的,但在功能上无法正常运行。点击侧边导航栏中的 ♥︎(心形符号)后,应用没有任何反应。

5a5a8e3a04789ce5.png

检查更改。

  • 首先,请注意 MyHomePage 的全部内容均被提取到新的 GeneratorPage widget。在旧版 MyHomePage widget 中,唯一未提取的部分是 Scaffold
  • 新的 MyHomePage 包含一个有两个子项的 Row。第一个是 SafeArea widget,第二个是 Expanded widget。
  • SafeArea 将确保其子项不会被硬件凹口或状态栏遮挡。在此应用中,widget 会将 NavigationRail 封装,以防止导航按钮被遮挡,例如被移动状态栏遮挡。
  • 您可以将 NavigationRail 中的 extended: false 行更改为 true。这将显示图标旁边的标签。在接下来的某个步骤中,你将学习如何在应用有足够的水平空间时自动完成此操作。
  • 侧边导航栏有两个目标页面(Home 和 Favorites),两者都有各自的图标和标签。侧边导航栏还定义了当前的 selectedIndex。若选定索引 (selectedIndex) 为零,则会选择第一个目标页面;若选定索引为一,则会选择第二个目标页面,依此类推。目前,它被硬编码为零。
  • 侧边导航栏还定义了当用户选择其中一个具有 onDestinationSelected 的目标页面时会发生什么。现在,应用仅通过 print() 输出所请求的索引值。
  • Row 的第二个子项是 Expanded widget。展开的 widget 在行和列中极具实用性 — 它们可用于呈现以下布局:一些子项仅占用其所需要的空间(在本例中为 NavigationRail),而其他 widget 则尽可能多地占用其余空间(在本例中为 Expanded)。可以将 Expanded widget 视为一种“贪婪的”元素。如果您想要更好地感受此 widget 的作用,请尝试用另一个 Expanded 封装 NavigationRail widget。生成的布局应如下所示:

d80b73b692fb04c5.png

  • 两个 Expanded widget 会分割两者之间所有可用的水平空间,即使侧边导航栏只需要左侧的一小部分。
  • Expanded widget 内部,有一个指定了颜色的 Container;而在该容器内部,有一个 GeneratorPage

无状态 widget 与有状态 widget

截至目前,MyAppState 涵盖了您的所有状态需求。正是因此,您目前为止编写的所有 widget 都是状态的。它们不包含任何自己的可变状态。所有 widget 都无法自行更改,而是必须经过 MyAppState

我们将改变这一状况。

您需要采用某种方法来保存侧边导航栏的 selectedIndex 的值。您还希望能够从 onDestinationSelected 回调中更改此值。

您可以添加 selectedIndex 作为 MyAppState 的另一个属性。它也会发挥作用。但不难想象,如果每个 widget 都将其值存储在其中,应用状态将快速增长到合理范围以外。

e52d9c0937cc0823.jpeg

某些状态仅与单个 widget 相关,因此应当与该 widget 保持一致。

输入 StatefulWidget,这是一种具有 State 的 widget。首先,将 MyHomePage 转换为有状态 widget。

将光标放在 MyHomePage 的第一行(以 class MyHomePage... 开头的行),然后使用 Ctrl+.Cmd+. 调出 Refactor 菜单。接下来,选择 Convert to StatefulWidget

238f98bceeb0de3a.gif

IDE 为您创建了一个新类 _MyHomePageState。此类扩展 State,因此可以管理其自己的值。(它可以自行改变。)另请注意,旧版无状态 widget 中的 build 方法已移至 _MyHomePageState(而不是保留在 widget 中)。build 方法会一字不差的完成移动,其内部不会发生任何改变。该方法现在只是换了个位置。

setState

新的有状态 widget 只需要跟踪一个变量,即 selectedIndex。对 _MyHomePageState 进行以下 3 处更改:

lib/main.dart

// ...

class _MyHomePageState extends State<MyHomePage> {

  var selectedIndex = 0;     // ← Add this property.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          SafeArea(
            child: NavigationRail(
              extended: false,
              destinations: [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorites'),
                ),
              ],
              selectedIndex: selectedIndex,    // ← Change to this.
              onDestinationSelected: (value) {

                // ↓ Replace print with this.
                setState(() {
                  selectedIndex = value;
                });

              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: GeneratorPage(),
            ),
          ),
        ],
      ),
    );
  }
}

// ...

下面分析各项更改:

  1. 您引入了一个新变量 selectedIndex,并将其初始化为 0
  2. 您在 NavigationRail 定义中使用此新变量,而不再是像之前那样将其硬编码为 0
  3. 当调用 onDestinationSelected 回调时,并不是仅仅将新值输出到控制台,而是将其分配到 setState() 调用内部的 selectedIndex。此调用类似于之前使用的 notifyListeners() 方法 — 它会确保界面始终更新为最新状态。

2b31dd91c5ba6766.gif

侧边导航栏现在会响应用户交互。但右侧的展开区域仍然保持不变。这是因为代码并未使用 selectedIndex 来确定显示哪一个屏幕。

使用 selectedIndex

将以下代码放在 _MyHomePageStatebuild 方法的顶部,即 return Scaffold 之前:

lib/main.dart

// ...

Widget page;
switch (selectedIndex) {
  case 0:
    page = GeneratorPage();
    break;
  case 1:
    page = Placeholder();
    break;
  default:
    throw UnimplementedError('no widget for $selectedIndex');
}

// ...

详细分析这段代码:

  1. 这段代码声明了一个类型为 Widget 的新变量 page
  2. 然后,根据 selectedIndex 中的当前值,switch 语句为 page 分配一个屏幕。
  3. 目前还没有 FavoritesPage,因此先使用 Placeholder;这是一个便捷易用的 widget,可以在其放置地方绘制一个交叉矩形,以便将界面的该部分标记为未完成。

5685cf886047f6ec.png

  1. 通过应用快速失败原则,switch 语句还将确保在 selectedIndex 既不是 0 也不是 1 的情况下抛出错误。这有助于防止后续 bug。如果您向侧边导航栏添加了一个新的目标页面而忘记更新此代码,则程序会在开发过程中崩溃(而不是让您猜测程序为何无法正常运行,或者让您将有缺陷的代码发布到生产环境中)。

page 现已包含您想要在右侧显示的 widget,您大概可以猜到还需要哪些其他更改。

完成最后一项更改的 _MyHomePageState 如下所示:

lib/main.dart

// ...

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = Placeholder();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }

    return Scaffold(
      body: Row(
        children: [
          SafeArea(
            child: NavigationRail(
              extended: false,
              destinations: [
                NavigationRailDestination(
                  icon: Icon(Icons.home),
                  label: Text('Home'),
                ),
                NavigationRailDestination(
                  icon: Icon(Icons.favorite),
                  label: Text('Favorites'),
                ),
              ],
              selectedIndex: selectedIndex,
              onDestinationSelected: (value) {
                setState(() {
                  selectedIndex = value;
                });
              },
            ),
          ),
          Expanded(
            child: Container(
              color: Theme.of(context).colorScheme.primaryContainer,
              child: page,  // ← Here.
            ),
          ),
        ],
      ),
    );
  }
}

// ...

现在,该应用将在 GeneratorPage 与即将成为 Favorites 页面的占位符之间切换。

4122ee1c4830e0eb.gif

自适用性

接下来,为侧边导航栏赋予自适用性。具体来说,让侧边导航栏在有足够空间的情况下自动显示标签(使用 extended: true)。

bef3378cb73f9a40.png

Flutter 提供了多个 widget,可帮助您为应用赋予自适用性。例如,Wrap 是一个类似于 RowColumn 的 widget,当没有足够的垂直或水平空间时,它会自动将子项封装到下一“行”(称为“运行”)中。FittedBox widget 可以自动根据您的规格将其子项放置到可用空间中。

不过,当有足够的空间时,NavigationRail 并不会自动显示标签,因为它无法判断在每个上下文中,什么才算是足够的空间。调用工作应当由您(开发者)来完成。

假设您决定仅当 MyHomePage 的宽度至少为 600 像素时才显示标签。

在本例中,我们将使用的 widget 是 LayoutBuilder。它允许根据可用空间大小来更改 widget 树。

再次在 VS Code 中使用 Flutter 的 Refactor 菜单进行所需的更改。不过,这一次有点复杂:

  1. _MyHomePageStatebuild 方法内部,将光标放在 Scaffold 上。
  2. 使用 Ctrl+. 键 (Windows/Linux) 或 Cmd+. 键 (Mac) 调出 Refactor 菜单。
  3. 选择 Wrap with Builder 并按下 Enter 键。
  4. 将新添加的 Builder 的名称修改为 LayoutBuilder
  5. 将回调参数列表从 (context) 修改为 (context, constraints)

52d18742c54f1022.gif

每当约束发生更改时,系统都会调用 LayoutBuilderbuilder 回调。比如说,以下场景就会触发这种情况:

  • 用户调整应用窗口的大小
  • 用户将手机从人像模式旋转到横屏模式,或从横屏模式旋转到人像模式
  • MyHomePage 旁边的一些 widget 变大,使 MyHomePage 的约束变小
  • 其他还有很多,不再一一列举

现在,您的代码可以通过查询当前的 constraints 来决定是否显示标签。对 _MyHomePageStatebuild 方法进行以下单行更改:

lib/main.dart

// ...

class _MyHomePageState extends State<MyHomePage> {
  var selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    Widget page;
    switch (selectedIndex) {
      case 0:
        page = GeneratorPage();
        break;
      case 1:
        page = Placeholder();
        break;
      default:
        throw UnimplementedError('no widget for $selectedIndex');
    }

    return LayoutBuilder(builder: (context, constraints) {
      return Scaffold(
        body: Row(
          children: [
            SafeArea(
              child: NavigationRail(
                extended: constraints.maxWidth >= 600,  // ← Here.
                destinations: [
                  NavigationRailDestination(
                    icon: Icon(Icons.home),
                    label: Text('Home'),
                  ),
                  NavigationRailDestination(
                    icon: Icon(Icons.favorite),
                    label: Text('Favorites'),
                  ),
                ],
                selectedIndex: selectedIndex,
                onDestinationSelected: (value) {
                  setState(() {
                    selectedIndex = value;
                  });
                },
              ),
            ),
            Expanded(
              child: Container(
                color: Theme.of(context).colorScheme.primaryContainer,
                child: page,
              ),
            ),
          ],
        ),
      );
    });
  }
}

// ...

现在,您的应用可以响应其环境,例如屏幕尺寸、方向和平台!换句话说,该应用现已具备自适用性!

6223bd3e2dc157eb.gif

接下来还有最后一项工作,那就是将 Placeholder 替换为真实的 Favorites 屏幕。下一节将介绍此项操作。

8. 添加新页面

还记得我们用来暂时替代 Favorites 页面的 Placeholder widget 吗?

4122ee1c4830e0eb.gif

是时候将其替换为真实页面了。

如果您敢于挑战,请尝试自行完成此步骤。您的目标是在新的 FavoritesPage 这一无状态 widget 中显示 favorites 列表,然后显示该 widget,而不是 Placeholder

下面提供了一些指引:

  • 如果想要一个可滚动的 Column 时,请使用 ListView widget。
  • 请记住,使用 context.watch<MyAppState>() 从任何 widget 访问 MyAppState 实例。
  • 如果您还想尝试新的 widget,可以使用 ListTiletitle(通常用于文本)、leading(用于图标或头像)和 onTap(用于交互)等属性。不过,您也可以使用已经掌握的 widget 来实现类似的效果。
  • Dart 允许在集合字面量内部使用 for 循环。例如,如果 messages 包含一个字符串列表,您可以使用如下代码:

f0444bba08f205aa.png

另一方面,如果您更熟悉函数式编程,Dart 还支持编写 messages.map((m) => Text(m)).toList() 这样的代码。当然,您始终可以创建一个 widget 列表,并将其强制添加到 build 中。

自行添加 Favorites 页面的好处是,您可以自己做决策,并从中学到更多知识。但其缺点是,您可能会遇到自己无法解决的问题。请记住:不要害怕失败,它是通往成功的必经之路。没有人要求您在一个小时内就掌握 Flutter 开发,这也不现实。

252f7c4a212c94d2.png

下面提供的只是实现 Favorites 页面的一种方法。其实现方法将(有希望)激发您完善代码、改进界面并为己所用。

新的 FavoritesPage 类如下所示:

lib/main.dart

// ...

class FavoritesPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var appState = context.watch<MyAppState>();

    if (appState.favorites.isEmpty) {
      return Center(
        child: Text('No favorites yet.'),
      );
    }

    return ListView(
      children: [
        Padding(
          padding: const EdgeInsets.all(20),
          child: Text('You have '
              '${appState.favorites.length} favorites:'),
        ),
        for (var pair in appState.favorites)
          ListTile(
            leading: Icon(Icons.favorite),
            title: Text(pair.asLowerCase),
          ),
      ],
    );
  }
}

此 widget 的作用如下:

  • 它获取应用的当前状态。
  • 如果收藏夹列表为空,则居中显示消息:No favorites yet*.*
  • 否则显示一个(可滚动的)列表。
  • 列表最开始显示一条概要消息(例如,You have 5 favorites*.*)。
  • 然后,代码遍历所有收藏夹,并为每个收藏夹构造一个 ListTile widget。

接下来,唯一要做的就是将 Placeholder widget 替换为 FavoritesPage。大功告成!

1d26af443561f39c.gif

您可以在 GitHub 上的 Codelab 代码库中获取此应用的最终代码。

9. 后续步骤

恭喜!

太棒了!您在没有功能的基架上,仅用了一个 Column 和两个 Text widget,就开发了一款精致有趣的自适用性应用。

d6e3d5f736411f13.png

所学内容

  • Flutter 的基本工作原理
  • 在 Flutter 中创建布局
  • 关联用户互动(如按下按钮)与应用行为
  • 让 Flutter 代码井然有序
  • 为应用赋予自适用性
  • 让应用具有一致的外观和风格

下一步做什么?

  • 基于您在此 Codelab 中编写的应用,开展进一步的尝试和探索。
  • 查看此高级版本的同一应用的代码,了解如何添加动画列表、渐变、淡出淡入效果等。

d4afd1f43ab976f7.gif