依照上一篇中我们新建的flutter应用
让我们更改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
文件指定与您的应用相关的基本信息,例如其当前版本、依赖项以及其随附的资源。
注意:如果您为应用指定的名称不是 namer_app
,则需要对第一行进行相应的更改
接下来,在项目中打开另一个配置文件 analysis_options.yaml
。
将其内容替换为以下内容:
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
文件。
将此文件的内容替换为以下内容。
注意:
重新下载dependeces
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 行代码是应用的全部。
添加按钮
第一次热重载
在 lib/main.dart
的底部,向第一个 Text
对象中的字符串添加一些内容,然后保存文件(使用 Ctrl+S
或 Cmd+S
)。例如:
lib/main.dart
// ...
return Scaffold(
body: Column(
children: [
Text('A random AWESOME idea:'), // ← Example change.
Text(appState.current.asLowerCase),
],
),
);
// ...
请注意应用会立即发生更改,但随机单词保持不变。这正是 Flutter 广为人知的有状态热重载功能在发挥作用。当您将更改保存到源文件时,系统会触发热重载。
添加按钮
接下来,在 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'),
),
],
),
);
// ...
当您保存更改时,应用会再次更新:其中会显示一个按钮,当您点击该按钮时,IDE中的调试控制台会显示 button pressed! 消息。
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。
注意:我们稍后将详细解释 StatelessWidget
(相对于 StatefulWidget
)。
MyApp
中的代码设置了整个应用,包括创建应用级状态(稍后会详细介绍)、命名应用、定义视觉主题以及设置“主页” widget,即应用的起点。
lib/main.dart
// ...
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
}
// ...
接下来,MyAppState
类定义应用的状态。这是您第一次使用 Flutter。因此,在此 Codelab 中,我们让该类保持简单和专注。在 Flutter 中,可以采用许多有效的方法来管理应用状态。其中最容易理解的一种方法就是 ChangeNotifier
,也是此应用所采用的方法。
MyAppState
定义应用运行所需的数据。现在,其中仅包含一个变量,即通过随机函数生成当前的随机单词对。您稍后将在其中添加代码。- 状态类扩展
ChangeNotifier
,这意味着它可以向其他人通知自己的更改。例如,如果当前单词对发生变化,应用中的一些 widget 需要知晓此变化。 - 使用
ChangeNotifierProvider
创建状态并将其提供给整个应用(参见上面MyApp
中的代码)。这样一来,应用中的任何 widget 都可以获取状态。
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。下面每个带编号的行均映射到上面代码中相应行编号的注释:
- 每个 widget 均定义了一个
build()
方法,每当 widget 的环境发生变化时,系统都会自动调用该方法,以便 widget 始终保持最新状态。 MyHomePage
使用watch
方法跟踪对应用当前状态的更改。- 每个
build
方法都必须返回一个 widget 或(更常见的)嵌套 widget 树。在本例中,顶层 widget 是Scaffold
。您不会在此 Codelab 中使用Scaffold
,但它是一个有用的 widget。在绝大多数真实的 Flutter 应用中都可以找到该 widget。 Column
是 Flutter 中最基础的布局 widget 之一。它接受任意数量的子项并将这些子项从上到下放在一列中。默认情况下,该列会以可视化形式将其子项置于顶部。您很快就会对其进行更改,使该列居中。- 您在第一步中更改了此
Text
widget。 - 第二个
Text
widget 接受appState
,并访问该类的唯一成员current
(这是一个WordPair
)。WordPair
提供了一些有用的 getter,例如asPascalCase
或asSnakeCase
。此处,我们使用了asLowerCase
。但如果您希望选择其他选项,您现在可以对其进行更改。 - 请注意,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. 改善应用外观
下图展示了应用的当前外观。
不太好。应用的核心功能(随机生成单词对)应更显眼。毕竟,这是应用为用户提供的主要功能!其他问题还包括,应用的内容不在中心位置,整个应用只有单调的黑色和白色。
本节将通过调整应用设计来解决这些问题。本节的最终目标是实现类似下图的效果:
提取 widget
现在,负责显示当前单词对的代码行大概是这样的:Text(appState.current.asLowerCase)
。要改为更复杂的设计,一种行之有效的方式是将此代码行提取到单独的 widget 中。为 UI 的单独逻辑部分使用单独的 widget 是在 Flutter 中管理复杂性的一种重要方法。
Flutter 提供了一个用于提取 widget 的重构帮助程序,但在使用它之前,请确保所提取的代码行仅访问所需的内容。现在,该代码行将访问 appState
,但实际上只需知道当前的单词对是什么。
综合考虑以下因素,重写 MyHomePage
的代码,如下所示:
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 菜单。在 AS 中,您可以通过以下两种方式之一执行此操作:
- 右键点击要重构的代码段(在本例中为
Text
),然后从下拉菜单中选择 Refactor...,
在 Refactor 菜单中,选择 Extract Widget。指定一个名称,例如 1,然后点击 Enter
键。
这会在当前文件的末尾自动创建一个新的 BigCard
类。该类应如下所示:
请注意,即便在重构期间,应用也将保持正常运行。
添加卡片
接下来,我们要将这个新的 widget 转变为本节开始部分大胆设想的 UI。
在其中找到 BigCard
类和 build()
方法。
在AS中,光标移动至TEXT然后输入alt+enter
而是选择 Wrap with Padding。这会围绕 Text
widget 创建一个新的父 widget,其名称为 Padding
。保存后,您会看到随机单词已经有了更宽敞的空间。
下来,我们再进一步。将光标放在 Padding
widget 上,调出 Refactor 菜单,然后选择 Wrap with widget...。
这允许您指定父 widget。键入“Card”,然后按下 Enter 键。
主题和样式
为了使卡片更加显眼,请用更丰富的颜色对其进行绘制。保持一致的配色方案始终是一个不想的想法。因此,使用应用的 Theme
来选择颜色。
对 BigCard
的 build()
方法进行以下更改。
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 颜色:
您可以更改此颜色以及整个应用的配色方案,方法是向上滚动至 MyApp
并更改其中的 ColorScheme
种子颜色。
提示:Flutter 的 Colors
类可让您方便地访问精选颜色的调色板,例如 Colors.deepOrange
或 Colors.red
。不过,您当然可以选择任何颜色。例如,要定义完全不透明的纯绿色,请使用 Color.fromRGBO(0, 255, 0, 1.0)
。如果您喜欢使用十六进制数,也可以使用 Color(0xFF00FF00)
。
请注意,颜色的动画效果很流畅。这称为隐式动画。许多 Flutter widget 会在值之间平滑地插值,这样 UI 就不仅仅是在状态之间“跳转”。
卡片下方的凸起按钮也会改变颜色。这正是应用级 Theme
相对于硬编码值的强大优势。
文本主题
卡片还存在一个问题:文字太小,并且在该颜色下很难看清。如需解决此问题,请对 BigCard
的 build()
方法进行以下更改。
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 颜色上使用的颜色。
现在,该应用应如下所示:
在界面中居中显示
现在,随机单词对已经呈现出美观的视觉效果,下一步是将其置于应用窗口/屏幕的中间位置。
首先,请记住 BigCard
是 Column
的一部分。默认情况下,各个列会将其子项集中到顶部,但我们可以轻松覆盖此设置。找到 MyHomePage
的 build()
方法,并进行以下更改:
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
本身并不在 Scaffold
的中心位置。我们可以使用 Widget Inspector 来验证这一点。
Widget Inspector 超出了此 Codelab 的讨论范围。但您可以看到,当突出显示时,Column
不会占据应用的整个宽度,而是仅占据其子项所需的水平空间。
您可以仅对列进行居中。将光标放在 Column
上,并输入alt+enter 随后选中Wrap with Center。
如果需要,您还可以再对其进行一些调整。
- 您可以删除
BigCard
上方的Text
widget。一些人认为,界面中不再需要描述性文本 ("A random AWESOME idea:"),因为即使没有该文本,界面也可以发挥应有的作用。而且这样显得更加干净。 - 您还可以在
BigCard
和ElevatedButton
之间添加一个SizedBox(height: 10)
widget。这样一来,两个 widget 之间就会有更大的空间。SizedBox
widget 只是会占用空间,而不会呈现任何内容。它通常用于创建视觉“间隙”。
进行一些可选更改后,MyHomePage
现在包含以下代码:
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'),
),
],
),
),
);
}
}
该应用会象是
在下一节中,您将添加收藏(或“喜欢”)生成的单词的功能。
6. 添加功能
应用现在运行良好,有时甚至会提供一些有趣的单词对。但是,当用户点击 Next 时,每个单词对都会永久消失。最好能通过一种方法来“记住”最佳建议,例如使用“Like”按钮。
添加业务逻辑
滚动至 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
)。
注意:除了 List
(用 []
表示)以外,Dart 还提供了其他一些集合类型。您可能认为 Set
(用 {}
表示)可以更有效地表示收藏夹集合。为了让此 Codelab 保持尽可能简单易懂,我们仍然坚持使用了列表。但如果需要,您可以改为使用 Set
。代码不会有太大变化。
- 您还添加了一个新方法
toggleFavorite()
,它可以从收藏夹列表中删除当前单词对(如果已经存在),或者添加单词对(如果不存在)。在任何一种情况下,代码都会在之后调用notifyListeners();
。
添加按钮
完成“业务逻辑”后,接下来继续充实用户界面。如需将“Like”按钮放在“Next”按钮的左侧,我们需要使用 Row
。Row
widget 是您之前看到的 Column
的水平等效项。
首先,将现有按钮封装在 Row
中。找到 MyHomePage
的 build()
方法,将光标放在 ElevatedButton
上,使用 Ctrl+.
或 Cmd+.
调出 Refactor 菜单,然后选择 Wrap with Row。
保存时,您会注意到 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'),
),
],
),
],
),
),
);
}
界面回到了之前的位置。
接下来,添加 Like 按钮并将其关联至 toggleFavorite()
。为了考验大家的学习成果,请首先尝试自行完成此任务,而不要看下面的代码块。
接下来,在 MyHomePage
中添加第二个按钮。这次,使用 ElevatedButton.icon()
构造函数创建一个带有图标的按钮。在 build
方法顶部,根据当前单词对是否已在收藏夹中选择适当的图标。另外,请注意再次使用 SizedBox
,以便让两个按钮稍微分开。
只不过,用户看不到收藏夹。因此,在下一节中,我们将在应用添加一个完整的独立屏幕!
7. 添加侧边导航栏
大多数应用都无法将所有内容放置在一个屏幕中。此特定应用或许可以这样做,但为了实现更好的学习效果,您将为用户的收藏夹创建一个单独的屏幕。为了在两个屏幕之间进行切换,您将实现您的第一个 StatefulWidget
。
为了尽快了解这一步的内容,请将 MyHomePage
拆分为 2 个单独的 widget。
全选 MyHomePage
并删除,然后替换为以下代码:
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'),
),
],
),
],
),
);
}
}
保存后,您会看到界面的可视效果是正常的,但在功能上无法正常运行。点击侧边导航栏中的 ♥︎(心形符号)后,应用没有任何反应。
检查更改。
- 首先,请注意
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。
- 两个
Expanded
widget 会分割两者之间所有可用的水平空间,即使侧边导航栏只需要左侧的一小部分。 - 在
Expanded
widget 内部,有一个指定了颜色的Container
;而在该容器内部,有一个GeneratorPage
。
无状态 widget 与有状态 widget
截至目前,MyAppState
涵盖了您的所有状态需求。正是因此,您目前为止编写的所有 widget 都是无状态的。它们不包含任何自己的可变状态。所有 widget 都无法自行更改,而是必须经过 MyAppState
。
我们将改变这一状况。
您需要采用某种方法来保存侧边导航栏的 selectedIndex
的值。您还希望能够从 onDestinationSelected
回调中更改此值。
您可以添加 selectedIndex
作为 MyAppState
的另一个属性。它也会发挥作用。但不难想象,如果每个 widget 都将其值存储在其中,应用状态将快速增长到合理范围以外。
某些状态仅与单个 widget 相关,因此应当与该 widget 保持一致。
输入 StatefulWidget
,这是一种具有 State
的 widget。首先,将 MyHomePage
转换为有状态 widget。
将光标放在 MyHomePage
的第一行(以 class MyHomePage...
开头的行),然后使alt+enter。接下来,选择 Convert to StatefulWidget。
IDE 为您创建了一个新类 _MyHomePageState
。此类扩展 State
,因此可以管理其自己的值。(它可以自行改变。)另请注意,旧版无状态 widget 中的 build
方法已移至 _MyHomePageState
(而不是保留在 widget 中)。build
方法会一字不差的完成移动,其内部不会发生任何改变。该方法现在只是换了个位置。
_MyHomePageState
开始部分的下划线 (_
) 将该类设置为私有类,并由编译器强制执行。如果想要详细了解 Dart 中私有属性以及其他主题,请参阅语言导览。
setState
新的有状态 widget 只需要跟踪一个变量,即 selectedIndex
。对 _MyHomePageState
进行以下 3 处更改:
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(),
),
),
],
),
);
}
}
下面分析各项更改:
- 您引入了一个新变量
selectedIndex
,并将其初始化为0
。 - 您在
NavigationRail
定义中使用此新变量,而不再是像之前那样将其硬编码为0
。 - 当调用
onDestinationSelected
回调时,并不是仅仅将新值输出到控制台,而是将其分配到setState()
调用内部的selectedIndex
。此调用类似于之前使用的notifyListeners()
方法 — 它会确保界面始终更新为最新状态。 - 侧边导航栏现在会响应用户交互。但右侧的展开区域仍然保持不变。这是因为代码并未使用
selectedIndex
来确定显示哪一个屏幕。
使用 selectedIndex
将以下代码放在 _MyHomePageState
的 build
方法的顶部,即 return Scaffold
之前:
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
详细分析这段代码:
- 这段代码声明了一个类型为
Widget
的新变量page
。 - 然后,根据
selectedIndex
中的当前值,switch 语句为page
分配一个屏幕。 - 目前还没有
FavoritesPage
,因此先使用Placeholder
;这是一个便捷易用的 widget,可以在其放置地方绘制一个交叉矩形,以便将界面的该部分标记为未完成。
- 通过应用快速失败原则,switch 语句还将确保在
selectedIndex
既不是 0 也不是 1 的情况下抛出错误。这有助于防止后续 bug。如果您向侧边导航栏添加了一个新的目标页面而忘记更新此代码,则程序会在开发过程中崩溃(而不是让您猜测程序为何无法正常运行,或者让您将有缺陷的代码发布到生产环境中)。
page
现已包含您想要在右侧显示的 widget,您大概可以猜到还需要哪些其他更改。
完成最后一项更改的 _MyHomePageState
如下所示:
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 页面的占位符之间切换。
自适用性
接下来,为侧边导航栏赋予自适用性。具体来说,让侧边导航栏在有足够空间的情况下自动显示标签(使用 extended: true
)。
Flutter 提供了多个 widget,可帮助您为应用赋予自适用性。例如,Wrap
是一个类似于 Row
或 Column
的 widget,当没有足够的垂直或水平空间时,它会自动将子项封装到下一“行”(称为“运行”)中。FittedBox
widget 可以自动根据您的规格将其子项放置到可用空间中。
不过,当有足够的空间时,NavigationRail
并不会自动显示标签,因为它无法判断在每个上下文中,什么才算是足够的空间。调用工作应当由您(开发者)来完成。
假设您决定仅当 MyHomePage
的宽度至少为 600 像素时才显示标签。
注意:Flutter 使用逻辑像素作为长度单位。逻辑像素有时也称为与设备无关的像素。无论应用是在分辨率较低的旧款手机上运行,还是在新款“视网膜”设备上运行,8 像素的内边距在视觉上都是一样的。物理显示器每厘米大约有 38 个逻辑像素,相当于每英寸大约有 96 个逻辑像
在本例中,我们将使用的 widget 是 LayoutBuilder
。它允许根据可用空间大小来更改 widget 树。
再次在 VS Code 中使用 Flutter 的 Refactor 菜单进行所需的更改。不过,这一次有点复杂:
- 在
_MyHomePageState
的build
方法内部,将光标放在Scaffold
上。 - 使用
Ctrl+.
键 (Windows/Linux) 或Cmd+.
键 (Mac) 调出 Refactor 菜单。 - 选择 Wrap with Builder 并按下 Enter 键。
- 将新添加的
Builder
的名称修改为LayoutBuilder
。 - 将回调参数列表从
(context)
修改为(context, constraints)
。
每当约束发生更改时,系统都会调用 LayoutBuilder
的 builder
回调。比如说,以下场景就会触发这种情况:
- 用户调整应用窗口的大小
- 用户将手机从人像模式旋转到横屏模式,或从横屏模式旋转到人像模式
MyHomePage
旁边的一些 widget 变大,使MyHomePage
的约束变小- 其他还有很多,不再一一列举
现在,您的代码可以通过查询当前的 constraints
来决定是否显示标签。对 _MyHomePageState
的 build
方法进行以下单行更改:
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,
),
),
],
),
);
});
}
}
现在,您的应用可以响应其环境,例如屏幕尺寸、方向和平台!换句话说,该应用现已具备自适用性!
接下来还有最后一项工作,那就是将 Placeholder
替换为真实的 Favorites 屏幕。下一节将介绍此项操作
8. 添加新页面
还记得我们用来暂时替代 Favorites 页面的 Placeholder
widget 吗?
是时候将其替换为真实页面了。
如果您敢于挑战,请尝试自行完成此步骤。您的目标是在新的 FavoritesPage
这一无状态 widget 中显示 favorites
列表,然后显示该 widget,而不是 Placeholder
。
下面提供了一些指引:
- 如果想要一个可滚动的
Column
时,请使用ListView
widget。 - 请记住,使用
context.watch<MyAppState>()
从任何 widget 访问MyAppState
实例。 - 如果您还想尝试新的 widget,可以使用
ListTile
的title
(通常用于文本)、leading
(用于图标或头像)和onTap
(用于交互)等属性。不过,您也可以使用已经掌握的 widget 来实现类似的效果。 - Dart 允许在集合字面量内部使用
for
循环。例如,如果messages
包含一个字符串列表,您可以使用如下代码: -
另一方面,如果您更熟悉函数式编程,Dart 还支持编写
messages.map((m) => Text(m)).toList()
这样的代码。当然,您始终可以创建一个 widget 列表,并将其强制添加到build
中。自行添加 Favorites 页面的好处是,您可以自己做决策,并从中学到更多知识。但其缺点是,您可能会遇到自己无法解决的问题。请记住:不要害怕失败,它是通往成功的必经之路。没有人要求您在一个小时内就掌握 Flutter 开发,这也不现实。
-
下面提供的只是实现 Favorites 页面的一种方法。其实现方法将(有希望)激发您完善代码、改进界面并为己所用。
新的
FavoritesPage
类如下所示: -
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), ), ], ); } }