前言
在前几篇文章中,我们搭好了 Birdle 猜词游戏的棋盘界面,也学会了用 DevTools 调试布局。但到目前为止,我们的应用只能"看",不能"用"——玩家没有办法输入猜测的单词。
今天这篇文章基于官方教程的「Handle User Input」章节,我们将学习如何接收用户输入。具体来说,你会学到四个核心知识点:用 TextField 创建文本输入框、用 TextEditingController 管理输入内容、用 FocusNode 控制输入焦点、用按钮和回调函数响应用户操作。
学完这一课,你的 Birdle 应用就能真正接收玩家的猜测了!
一、设计思路:GuessInput 组件
在动手写代码之前,先想清楚我们要做什么:
- 界面上需要一个文本输入框,让玩家输入 5 个字母的单词
- 输入框旁边需要一个提交按钮
- 玩家按回车键或点击按钮后,猜测的单词要传递给游戏逻辑处理
- 提交后,输入框要自动清空并保持焦点,方便玩家继续输入下一个单词
我们会把这些功能封装成一个独立的组件 GuessInput。
二、创建 GuessInput 组件骨架
2.1 基本结构与回调函数
在 main.dart 中添加以下代码:
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
// 回调函数:当玩家提交猜测时会被调用
// 类型是 void Function(String),表示接收一个字符串参数、不返回值
// 具体的处理逻辑由父组件(GamePage)提供
final void Function(String) onSubmitGuess;
@override
Widget build(BuildContext context) {
// 暂时返回空容器,后面会替换为实际 UI
return Container();
}
}
2.2 理解回调函数
这里的 onSubmitGuess 是一个回调函数(callback)。你可以把它理解为"预留的电话号码":
- GuessInput 说:"当玩家提交猜测时,我会打这个电话通知你。"
- GamePage 说:"好的,这是我的号码(具体的处理逻辑),有消息就打给我。"
为什么这样设计?因为 GuessInput 只负责收集输入,不关心输入之后要做什么。具体的游戏逻辑(判断对错、更新棋盘)交给父组件处理。这种设计让组件更独立、更可复用。
三、TextField:文本输入框
3.1 搭建输入框界面
把 build 方法中的 Container() 替换为:
@override
Widget build(BuildContext context) {
// Row 将输入框和按钮横向排列
return Row(
children: [
// Expanded 让 TextField 占满 Row 中除按钮之外的所有剩余空间
// 这解决了 TextField 在 Row 中不知道该多宽的问题
Expanded(
child: Padding(
// 四周留 8 像素的内边距
padding: const EdgeInsets.all(8.0),
child: TextField(
// 限制最多输入 5 个字符(因为游戏只允许猜 5 个字母的单词)
maxLength: 5,
// 输入框的装饰样式
decoration: InputDecoration(
// 圆角矩形边框,圆角半径 35
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
),
),
),
],
);
}
3.2 认识 Expanded 组件
这里的 Expanded 是一个非常重要的布局组件。还记得上一课提到的"无界约束"错误吗?当 TextField 被放在 Row 里时,Row 不知道该给 TextField 多少宽度。Expanded 解决了这个问题——它告诉 TextField:"除了其他兄弟组件占用的空间,剩下的都是你的。"
你可以把 Row 想象成一张桌子,Expanded 就是对 TextField 说:"其他东西摆完之后,剩下的桌面空间全归你。"
四、TextEditingController:管理输入内容
4.1 为什么需要 Controller?
TextField 只是一个输入框的外壳,要读取和控制里面的文字内容,需要一个"管理员"——TextEditingController。
它能做什么?
- 读取文字:
controller.text获取当前输入的内容 - 清空文字:
controller.clear()清空输入框 - 设置文字:
controller.text = '新内容'直接替换输入框内容
4.2 创建 Controller 并绑定
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
final void Function(String) onSubmitGuess;
// 创建一个 TextEditingController 实例来管理输入框的文字
final TextEditingController _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
maxLength: 5,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
// 将 controller 绑定到 TextField
// 绑定后,controller 就能读取和控制这个输入框的内容
controller: _textEditingController,
// onSubmitted:当用户按下回车键时触发
// 参数 _ 是用户输入的文字,但我们用 controller 来获取,所以用 _ 忽略它
onSubmitted: (_) {
// 通过 controller 读取输入内容
print(_textEditingController.text);
// 提交后清空输入框,方便输入下一个单词
_textEditingController.clear();
},
),
),
),
],
);
}
}
4.3 关于下划线 _ 的小知识
你可能注意到 onSubmitted: (_) 中用了一个下划线。在 Dart 中,_ 是一个通配符变量,表示"这个参数我不需要用,忽略它"。这是一种好的编码习惯——让阅读代码的人知道这个参数是故意不用的,不是遗漏了。
五、FocusNode:控制输入焦点
5.1 什么是焦点?
"焦点"就是当前活跃的输入位置。在我们的游戏中,输入框需要两种焦点行为:
- 应用启动时,输入框自动获得焦点(光标自动出现在输入框里,不需要用户点击)
- 每次提交后,焦点重新回到输入框(这样用户可以立刻开始输入下一个单词)
5.2 autofocus:自动获取焦点
第一个需求很简单,TextField 自带 autofocus 属性:
TextField(
// 应用启动时自动获取焦点,光标直接出现在输入框中
autofocus: true,
// ... 其他配置
)
5.3 FocusNode:提交后重新获取焦点
第二个需求需要用到 FocusNode。它就像一个"遥控器",能让你随时把焦点切换到指定的输入框上:
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
final void Function(String) onSubmitGuess;
final TextEditingController _textEditingController = TextEditingController();
// 创建一个 FocusNode 用来控制输入框的焦点
final FocusNode _focusNode = FocusNode();
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
maxLength: 5,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
controller: _textEditingController,
autofocus: true,
// 将 focusNode 绑定到 TextField
focusNode: _focusNode,
onSubmitted: (_) {
// 调用回调函数,将输入内容传递给父组件
// .trim() 去除首尾空格,防止玩家用空格凑字数
onSubmitGuess(_textEditingController.text.trim());
// 清空输入框
_textEditingController.clear();
// 重新请求焦点,让光标回到输入框中
// 这样用户不需要再次点击输入框就能继续输入
_focusNode.requestFocus();
},
),
),
),
],
);
}
}
现在的交互流程是:用户输入单词 → 按回车 → 单词被提交 → 输入框自动清空 → 光标仍在输入框中 → 用户直接输入下一个单词。非常流畅!
六、IconButton:添加提交按钮
6.1 为什么还需要按钮?
在电脑上,用户可以按回车提交。但在手机上,有一个可以点击的按钮会让体验更好。这也是常见的 UI 设计实践——提供多种交互方式。
6.2 添加 IconButton
Flutter 提供了多种按钮组件:TextButton(文字按钮)、ElevatedButton(凸起按钮)、IconButton(图标按钮)等。这里我们用 IconButton:
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
// ... TextField 部分(省略,和上面一样)
),
// 在输入框右侧添加一个图标按钮
IconButton(
// 去掉按钮默认的内边距,让按钮更紧凑
padding: EdgeInsets.zero,
// 使用一个向上的圆形箭头图标
// Icons 是 Flutter 内置的 Material Design 图标库
icon: Icon(Icons.arrow_circle_up),
// 点击按钮时触发的回调
// 逻辑和 TextField 的 onSubmitted 完全一样
onPressed: () {
onSubmitGuess(_textEditingController.text.trim());
_textEditingController.clear();
_focusNode.requestFocus();
},
),
],
);
}
6.3 优化:提取公共方法
你可能发现了——onSubmitted 和 onPressed 的逻辑完全一样,代码重复了。我们可以把它提取成一个方法:
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
final void Function(String) onSubmitGuess;
final TextEditingController _textEditingController = TextEditingController();
final FocusNode _focusNode = FocusNode();
// 提取公共的提交逻辑为独立方法
// 这样 TextField 和 IconButton 都调用同一个方法,避免代码重复
void _onSubmit() {
onSubmitGuess(_textEditingController.text.trim());
_textEditingController.clear();
_focusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
maxLength: 5,
focusNode: _focusNode,
autofocus: true,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
controller: _textEditingController,
// 按回车时调用 _onSubmit
// 注意:onSubmitted 需要一个接收 String 的函数
// 所以这里用匿名函数包一层,忽略参数后调用 _onSubmit
onSubmitted: (String value) {
_onSubmit();
},
),
),
),
IconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.arrow_circle_up),
// 点击按钮时调用 _onSubmit
// onPressed 不需要参数,所以可以直接传方法引用
onPressed: _onSubmit,
),
],
);
}
}
这就是代码重构的一个小例子——发现重复代码,提取为公共方法。代码更简洁,维护也更方便。
七、将 GuessInput 接入 GamePage
最后一步,把 GuessInput 添加到 GamePage 的界面中:
class GamePage extends StatelessWidget {
GamePage({super.key});
final Game _game = Game();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
spacing: 5.0,
children: [
// 游戏棋盘:5 行 × 5 列的 Tile 网格
for (var guess in _game.guesses)
Row(
spacing: 5.0,
children: [
for (var letter in guess)
Tile(letter.char, letter.type),
],
),
// 在棋盘下方添加输入组件
GuessInput(
// 传入回调函数:当玩家提交猜测时执行
// 目前只是打印,下一课(StatefulWidget)会实现真正的游戏逻辑
onSubmitGuess: (String guess) {
print(guess); // 临时代码,验证连接是否正确
},
),
],
),
);
}
}
热重载后,你会看到棋盘下方出现了一个带圆角边框的输入框和一个提交按钮。输入一个单词按回车(或点击按钮),终端会打印出你输入的内容。
目前猜测结果还不会显示在棋盘上——因为我们还没学 StatefulWidget(有状态组件),界面还不知道如何"更新"。这就是下一课的内容!
八、本节知识点小结
TextField: Flutter 的文本输入组件。通过 maxLength 限制输入长度,通过 decoration 自定义外观,通过 onSubmitted 响应回车键。
TextEditingController: TextField 的"管理员"。用 .text 读取内容,用 .clear() 清空内容。需要创建实例后通过 controller 属性绑定到 TextField。
FocusNode: 焦点"遥控器"。用 autofocus: true 实现自动聚焦,用 FocusNode.requestFocus() 在代码中主动请求焦点。
回调函数(Callback): 让组件保持独立和可复用的核心设计模式。子组件通过构造函数接收回调,在合适的时机调用它,把数据传递给父组件处理。
Expanded: 让子组件占满 Row 或 Column 中剩余的所有空间,常用于解决无界约束问题。
九、下一步学习
输入功能已经就绪,但猜测结果还不能更新到棋盘上。下一课我们将学习 StatefulWidget(有状态组件),这是让界面能够响应数据变化并自动更新的关键。到那时,Birdle 游戏就真正能玩起来了!
我们下篇文章见!