Flutter 用户输入让你的应用学会"听话"(五)

14 阅读8分钟

前言

在前几篇文章中,我们搭好了 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 什么是焦点?

"焦点"就是当前活跃的输入位置。在我们的游戏中,输入框需要两种焦点行为:

  1. 应用启动时,输入框自动获得焦点(光标自动出现在输入框里,不需要用户点击)
  2. 每次提交后,焦点重新回到输入框(这样用户可以立刻开始输入下一个单词)

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 优化:提取公共方法

你可能发现了——onSubmittedonPressed 的逻辑完全一样,代码重复了。我们可以把它提取成一个方法:

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 游戏就真正能玩起来了!

我们下篇文章见!

参考资料:Flutter 官方教程 - Handle User Input