本文適合:剛入門 Flutter 想搞懂 TextField 的同學,以及已經在項目中使用,但總覺得“只會 60%”的同學。
文章會從基礎用法一直講到 Controller、Focus、表單驗證、輸入限制、常見坑,配完整代碼。

一、為什麼要系統學 TextField?

在實際業務裏,輸入框幾乎無處不在:

  • 登錄 / 註冊:手機號、驗證碼、密碼
  • 搜索:搜索框 + 清空按鈕 + 聯想
  • 表單:收貨地址、個人信息、反饋意見
  • 評論 / 聊天:多行輸入 + 發送按鈕

TextField 是 Flutter 中最基礎的輸入組件,但它涉及:

  • 狀態管理(TextEditingController
  • 焦點管理(FocusNode
  • 裝飾樣式(InputDecoration + Theme
  • 驗證與表單(TextFormField + Form
  • 輸入限制(inputFormatters
  • 鍵盤行為與收起

如果這些你只是“零碎知道一點”,那這篇可以幫你把腦子裏的碎片拼成完整的知識圖。
 

二、TextField 是什麼?最小可用示例

1. TextField 是誰?

  • TextField:最基礎的輸入組件
  • TextFormField:在 Form 中使用的輸入組件,天生支持表單驗證(本質也是包了一層 TextField

最簡單的 TextField

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

  @override
  Widget build(BuildContext context) {
    return const Padding(
      padding: EdgeInsets.all(16),
      child: TextField(
        decoration: InputDecoration(
          border: OutlineInputBorder(),
          labelText: '用户名',
          hintText: '請輸入用户名',
        ),
      ),
    );
  }
}

關鍵點:

  • 不指定 controller,內部也會維護一個
  • decoration 負責“長什麼樣”
  • 這是開發中最常見的寫法之一

三、TextField 核心屬性總覽(按類別理解)

把屬性按“功能”記,比一個個死記要舒服得多。

1. 文本內容相關

TextField(
  controller: _controller,         // 文本控制器(推薦)
  onChanged: (value) {},           // 每次內容變化回調
  onSubmitted: (value) {},         // 點擊鍵盤“完成/回車”時
)

controller:讀寫文本的入口

  • 讀:_controller.text
  • 寫:_controller.text = 'hello'

onChanged:每次字符變化都會觸發(帶中文輸入法時,會比較頻繁)

onSubmitted:用户點擊鍵盤上的 done / send / search 時觸發

注意:
TextField 沒有 initialValue 屬性(那是 TextFormField 的),想設置默認文本就寫在 controller.text 裏。

2. 光標和焦點相關

TextField(
  focusNode: _focusNode,     // 控制焦點
  autofocus: true,           // 自動獲取焦點
  enabled: true,             // 是否可編輯
  readOnly: false,           // 可獲取焦點但不能改內容
  showCursor: true,          // 是否顯示光標
  cursorColor: Colors.blue,  // 光標顏色
)
  • enabled = false:灰掉 + 不可編輯 + 不可獲取焦點
  • readOnly = true:可以獲取焦點、彈鍵盤(可控制),但內容不能改。很適合“點擊輸入框跳到新頁面填寫”的場景(比如“選擇地址”)

3. 鍵盤、輸入行為相關

TextField(
  keyboardType: TextInputType.number,   // 鍵盤類型:數字、email、多行…
  textInputAction: TextInputAction.done,// 鍵盤右下角按鈕類型
  maxLength: 11,                        // 最大長度
  maxLines: 1,                          // 最大行數
  minLines: 1,                          // 最小行數
  inputFormatters: [                    // 輸入限制
    FilteringTextInputFormatter.digitsOnly,
  ],
)

常見鍵盤類型:

  • TextInputType.text:普通文本
  • TextInputType.number:數字
  • TextInputType.phone:手機號
  • TextInputType.emailAddress:郵箱
  • TextInputType.multiline:支持換行

常見 textInputAction

  • TextInputAction.done:完成
  • TextInputAction.next:下一項(比如切換到下一個輸入框)
  • TextInputAction.search:搜索
  • TextInputAction.send:發送

4. 文本樣式相關

TextField(
  style: const TextStyle(
    fontSize: 16,
    color: Colors.black87,
  ),
  textAlign: TextAlign.left,      // 對齊方式
  obscureText: true,              // 是否密文(密碼)
  obscuringCharacter: '•',        // 密文替換字符
  maxLines: 1,                    // 單行
)

密碼框的最簡單寫法:

TextField(
  obscureText: true,
  decoration: const InputDecoration(
    labelText: '密碼',
  ),
)

5. 裝飾樣式 InputDecoration(重點中的重點)

decoration 決定了 TextField 外觀的 80%

TextField(
  decoration: InputDecoration(
    labelText: '用户名',            // 上飄的標籤
    hintText: '請輸入用户名',      // 灰色提示
    helperText: '用户名長度 4~16',  // 底部輔助文案
    errorText: null,               // 錯誤提示
    prefixIcon: const Icon(Icons.person),   // 左側圖標
    suffixIcon: IconButton(                // 右側圖標
      icon: const Icon(Icons.clear),
      onPressed: () {},
    ),
    border: const OutlineInputBorder(),    // 默認邊框
    focusedBorder: OutlineInputBorder(     // 聚焦邊框
      borderSide: BorderSide(color: Colors.blue),
    ),
  ),
)

常見用法:

  • 登錄 / 搜索:prefixIcon
  • 清空按鈕 / 顯示密碼:suffixIcon
  • 錯誤提示:errorText: '手機號格式錯誤'

四、TextEditingController:輸入框的“數據大腦”

1. 基本用法

class ControllerDemo extends StatefulWidget {
  const ControllerDemo({super.key});

  @override
  State<ControllerDemo> createState() => _ControllerDemoState();
}

class _ControllerDemoState extends State<ControllerDemo> {
  final TextEditingController _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    _controller.text = '初始文本'; // 設置默認內容

    _controller.addListener(() {
      debugPrint('當前內容:${_controller.text}');
    });
  }

  @override
  void dispose() {
    _controller.dispose(); // 一定要釋放
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _controller,
        ),
        ElevatedButton(
          onPressed: () {
            debugPrint('提交內容:${_controller.text}');
          },
          child: const Text('打印內容'),
        ),
      ],
    );
  }
}

要點:

  • State 裏創建 final TextEditingController
  • initState 中初始化 / 監聽
  • dispose 裏記得 dispose(),避免內存泄漏

2. 控制光標位置 & 選中內容(進階)

有時候你需要在中間插入文本、或者選中一段文本:

_controller.value = _controller.value.copyWith(
  text: '新的內容',
  selection: TextSelection.collapsed(offset: '新的內容'.length),
);

常見場景:

  • 處理輸入格式(比如自動插入空格)
  • 恢復光標位置

五、FocusNode:掌控焦點和鍵盤

1. FocusNode 的基本用法

class FocusDemo extends StatefulWidget {
  const FocusDemo({super.key});

  @override
  State<FocusDemo> createState() => _FocusDemoState();
}

class _FocusDemoState extends State<FocusDemo> {
  final FocusNode _focusNode1 = FocusNode();
  final FocusNode _focusNode2 = FocusNode();

  @override
  void dispose() {
    _focusNode1.dispose();
    _focusNode2.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          focusNode: _focusNode1,
          decoration: const InputDecoration(labelText: '輸入框1'),
        ),
        TextField(
          focusNode: _focusNode2,
          decoration: const InputDecoration(labelText: '輸入框2'),
        ),
        ElevatedButton(
          onPressed: () {
            FocusScope.of(context).requestFocus(_focusNode2); // 切換到輸入框2
          },
          child: const Text('切換到輸入框2'),
        ),
      ],
    );
  }
}

2. 收起鍵盤(全局通用寫法)

void hideKeyboard(BuildContext context) {
  FocusScope.of(context).unfocus();
}

常見用法:

  • 頁面點擊空白處收起鍵盤
  • 提交後收起鍵盤

例子(外層包 GestureDetector):

GestureDetector(
  onTap: () => FocusScope.of(context).unfocus(),
  behavior: HitTestBehavior.translucent,
  child: Scaffold(
    // ...
  ),
)

六、TextField vs TextFormField:什麼時候用誰?

1. TextFormField 是誰?

  • 使用場景:需要表單驗證
  • 搭配 Form + GlobalKey<FormState> 使用
  • 多個 TextFormField 可以統一 validate() 和 save()

2. 登錄表單示例(手機號 + 密碼)

class LoginFormDemo extends StatefulWidget {
  const LoginFormDemo({super.key});

  @override
  State<LoginFormDemo> createState() => _LoginFormDemoState();
}

class _LoginFormDemoState extends State<LoginFormDemo> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  String _phone = '';
  String _password = '';

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Form(
        key: _formKey,
        child: Column(
          children: [
            TextFormField(
              decoration: const InputDecoration(
                labelText: '手機號',
                border: OutlineInputBorder(),
              ),
              keyboardType: TextInputType.phone,
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return '請輸入手機號';
                }
                if (value.length != 11) {
                  return '手機號長度必須為 11 位';
                }
                return null;
              },
              onSaved: (value) => _phone = value ?? '',
            ),
            const SizedBox(height: 16),
            TextFormField(
              decoration: const InputDecoration(
                labelText: '密碼',
                border: OutlineInputBorder(),
              ),
              obscureText: true,
              validator: (value) {
                if (value == null || value.isEmpty) {
                  return '請輸入密碼';
                }
                if (value.length < 6) {
                  return '密碼至少 6 位';
                }
                return null;
              },
              onSaved: (value) => _password = value ?? '',
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                if (_formKey.currentState?.validate() == true) {
                  _formKey.currentState?.save();
                  debugPrint('手機號=$_phone, 密碼=$_password');
                }
              },
              child: const Text('登錄'),
            ),
          ],
        ),
      ),
    );
  }
}

總結:

  • 不需要複雜驗證 → 用 TextField 就夠了
  • 多個輸入框 + 統一驗證 / 提交 → 推薦 TextFormField + Form

七、輸入限制與格式化:inputFormatters 實戰

inputFormatters 是一個 List<TextInputFormatter>,可以對每次輸入做截斷 / 替換。

1. 只允許數字輸入

TextField(
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
  ],
)

2. 小數(最多兩位小數)

class DecimalTextInputFormatter extends TextInputFormatter {
  final int decimalRange;

  DecimalTextInputFormatter({required this.decimalRange})
      : assert(decimalRange >= 0);

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    String text = newValue.text;

    if (text.isEmpty) return newValue;

    // 非法字符
    if (!RegExp(r'^\d*\.?\d*$').hasMatch(text)) {
      return oldValue;
    }

    // 限制小數位
    if (text.contains('.') &&
        text.split('.').length == 2 &&
        text.split('.').last.length > decimalRange) {
      return oldValue;
    }

    return newValue;
  }
}

使用:

TextField(
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  inputFormatters: [
    DecimalTextInputFormatter(decimalRange: 2),
  ],
)

3. 手機號中間自動插空格(進階)

例如:138 0013 8000

思路:在 inputFormatter 中插入空格,並且恢復光標位置(略複雜,這裏就不展開光標修正邏輯)。

八、三個常用“業務輸入框”完整示例

1. 密碼框 + 顯示/隱藏眼睛按鈕

class PasswordField extends StatefulWidget {
  final TextEditingController controller;

  const PasswordField({super.key, required this.controller});

  @override
  State<PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  bool _obscure = true;

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: widget.controller,
      obscureText: _obscure,
      decoration: InputDecoration(
        labelText: '密碼',
        border: const OutlineInputBorder(),
        suffixIcon: IconButton(
          icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
          onPressed: () {
            setState(() {
              _obscure = !_obscure;
            });
          },
        ),
      ),
    );
  }
}

2. 搜索框:帶搜索圖標 + 清空按鈕 + 鍵盤搜索

class SearchBar extends StatefulWidget {
  const SearchBar({super.key});

  @override
  State<SearchBar> createState() => _SearchBarState();
}

class _SearchBarState extends State<SearchBar> {
  final TextEditingController _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _onSearch(String keyword) {
    debugPrint('搜索:$keyword');
    // TODO: 調用搜索接口
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      textInputAction: TextInputAction.search,
      onSubmitted: _onSearch,
      decoration: InputDecoration(
        hintText: '搜索內容',
        prefixIcon: const Icon(Icons.search),
        suffixIcon: _controller.text.isEmpty
            ? null
            : IconButton(
                icon: const Icon(Icons.clear),
                onPressed: () {
                  _controller.clear();
                  setState(() {});
                },
              ),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(24),
          borderSide: BorderSide.none,
        ),
        filled: true,
      ),
      onChanged: (value) {
        setState(() {}); // 刷新 suffixIcon 顯隱
      },
    );
  }
}

3. 多行評論輸入框 + 發送按鈕

class CommentInput extends StatefulWidget {
  const CommentInput({super.key});

  @override
  State<CommentInput> createState() => _CommentInputState();
}

class _CommentInputState extends State<CommentInput> {
  final TextEditingController _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _send() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    debugPrint('發送評論:$text');
    _controller.clear();
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: TextField(
            controller: _controller,
            minLines: 1,
            maxLines: 4,
            decoration: const InputDecoration(
              hintText: '寫下你的評論...',
              border: OutlineInputBorder(),
            ),
            onChanged: (_) => setState(() {}),
          ),
        ),
        const SizedBox(width: 8),
        IconButton(
          icon: const Icon(Icons.send),
          onPressed: _controller.text.trim().isEmpty ? null : _send,
        ),
      ],
    );
  }
}

九、和鍵盤的交互:完成、下一項、收起

1. textInputAction + onEditingComplete

TextField(
  textInputAction: TextInputAction.next,
  onEditingComplete: () {
    // 比如切到下一個 TextField
    FocusScope.of(context).nextFocus();
  },
)

常見組合:

  • 表單第一項:TextInputAction.next + nextFocus()
  • 最後一項:TextInputAction.done + unfocus() + 提交表單

2. 手動收起鍵盤的幾種方式

1)最推薦:unfocus

FocusScope.of(context).unfocus();

2)極端情況:調用系統通道隱藏

SystemChannels.textInput.invokeMethod('TextInput.hide');

一般用不到,有 unfocus() 足夠了。

十、國際化 / 中文輸入法的一點注意

  • 中文輸入法有“拼寫中狀態”(組合輸入,尚未上屏)
  • onChanged 在拼寫過程中會觸發多次
  • 一些比較激進的 inputFormatter 會在組合狀態下干擾輸入

如果你對輸入有複雜控制(比如在 onChanged 中強行改 controller.text),要注意不要破壞 IME 的組合狀態,否則會出現“中文打不出來”“光標亂跳”等問題。
實在複雜的場景,建議單獨開一個 demo 專門調試各種輸入法(包括 iOS / Android)。

十一、TextField 常見坑總結

1. 在 build() 裏 new TextEditingController

❌ 錯誤寫法:

@override
Widget build(BuildContext context) {
  final controller = TextEditingController(); // 每次 build 都 new
  return TextField(controller: controller);
}

這樣會導致:

  • 每次重建都重新 new controller,內容丟失
  • 還可能有內存泄漏

✅ 正確寫法:

  • 在 State 中聲明為成員變量,並在 dispose() 裏釋放

2. 忘記 dispose() controller / focusNode

  • TextField 內部會訂閲 controller 的變化
  • 如果不 dispose,頁面關掉後仍然有監聽 → 內存泄漏

3. TextField 外層沒有高度約束

例如直接寫在 Column 裏且沒有 Expanded / 父佈局約束,可能會報錯:

RenderFlex children have non-zero flex but incoming height constraints are unbounded

解決方式:

  • 給外層加 SizedBox / Expanded / Container(height: ...)
  • 或者正確使用 Column + mainAxisSize

4. 鍵盤遮擋輸入框

常見場景:頁面底部的輸入框被鍵盤擋住了。

解決方向:

  • Scaffold 上:resizeToAvoidBottomInset: true(默認一般就是 true)
  • 外層使用可滾動佈局,比如 SingleChildScrollView
  • 或者使用更高級的庫:flutter_keyboard_visibilitykeyboard_actions 等

5. 濫用 onChanged 做重型操作

  • onChanged 觸發頻率非常高(每個字符輸入 / 刪除)
  • 不要在裏面做複雜同步操作(比如每改一次就請求網絡)
  • 必須做的話,建議加防抖(debounce)