flutter:验证码输入pinput

是什么

pinput 全称是 PIN input。它提供了一个漂亮且高度可定制的 分格输入框,常用于输入固定长度的验证码或 PIN 码。相比 TextField 自己拆格子,pinput 开箱即用。

主要特性

  • 固定长度输入(默认 4 位,可自定义 4/6 位甚至更多)。
  • 自定义样式:边框、颜色、形状、大小、间距等。
  • 自动聚焦:打开页面时光标自动进入输入状态。
  • 粘贴功能:支持从剪贴板粘贴验证码。
  • 动画效果:输入时格子样式可以动态变化(例如高亮)。
  • 验证器:类似 TextFormField,可以设置 validator
  • 事件回调onChangedonCompletedonSubmitted 等。

入门案例

安装

flutter pub add pinput

代码

import 'package:flutter/material.dart';
import 'package:pinput/pinput.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text("Pinput 示例")),
        body: const Center(child: PinInputDemo()),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Pinput(
      length: 6, // 验证码长度
      autofocus: true,
      showCursor: true,
      onCompleted: (pin) {
        print("输入完成: $pin");
      },
      onChanged: (value) {
        print("当前输入: $value");
      },
    );
  }
}
  • length:PIN/验证码长度(默认 4)
  • autofocus:是否自动聚焦输入
  • showCursor:是否显示光标
  • onCompleted:输入完成时回调
  • onChanged:输入变化时回调

日志如下:

I/flutter (15560): 当前输入: 1
I/flutter (15560): 当前输入: 12
I/flutter (15560): 当前输入: 123
I/flutter (15560): 当前输入: 1234
I/flutter (15560): 当前输入: 12345
I/flutter (15560): 当前输入: 123456
I/flutter (15560): 输入完成: 123456

Pinput 常用属性

基础配置

属性 类型 作用
length int PIN / 验证码的位数,默认 4。
controller TextEditingController? 管理输入内容,支持读写、清空等。
focusNode FocusNode? 控制焦点,适合和表单一起用。
autofocus bool 页面加载后是否自动聚焦输入框。
keyboardType TextInputType 键盘类型,常用 TextInputType.number
obscureText String? 是否隐藏输入(比如显示 * 代替密码)。

样式相关

属性 类型 作用
defaultPinTheme PinTheme 默认格子的样式。
focusedPinTheme PinTheme 聚焦时的样式。
submittedPinTheme PinTheme 输入完成时的样式。
errorPinTheme PinTheme 校验失败时的样式。
separatorBuilder WidgetBuilder? 自定义每个格子之间的分隔符(默认间距)。

PinTheme 里可设置:宽高、字体、边框、圆角、背景色等。

功能行为

属性 类型 作用
showCursor bool 是否显示光标。
enableSuggestions bool 是否启用输入建议(默认关闭)。
enableIMEPersonalizedLearning bool 是否允许输入法学习(安全场景可关闭)。
inputFormatters List<TextInputFormatter> 限制输入内容(比如只允许数字)。
readOnly bool 是否只读。
enabled bool 是否可用。

表单 & 校验

属性 类型 作用
validator FormFieldValidator<String>? 表单验证函数,返回错误信息或 null。
onChanged ValueChanged<String>? 输入变化时触发。
onCompleted ValueChanged<String>? 输入完成(达到 length 位数)时触发。
onSubmitted ValueChanged<String>? 按键盘确认键时触发。
  • 当用户**输入框已经填满(即长度达到 length 属性设置的数字)**时,
  • 并且按下了键盘上的“确认/完成/回车”键(即提交输入)时,
  • 就会触发 onSubmitted 回调。

注意:

  • 只是每输入一个数字不会触发 onSubmitted,那是 onChanged 的工作。
  • 只有完成输入后,按下键盘上的 done/enter/submit 才会触发 onSubmitted
  • 如果输入已经填满所有位数(达到 length但没有点击键盘上的「完成 / 回车 / 提交」键,那么 onSubmitted 是不会触发的

动画 & 粘贴

属性 类型 作用
androidSmsAutofillMethod 枚举 在 Android 自动填充验证码。
listenForMultipleSmsOnAndroid bool 是否监听多条短信验证码。
hapticFeedbackType 枚举 输入时的震动反馈。
toolbarEnabled bool 是否允许复制/粘贴菜单。
pinputAutovalidateMode AutovalidateMode 验证模式
enum PinputAutovalidateMode {
  /// 不会自动触发校验
  disabled,

  /// 仅在 onCompleted / onSubmitted 被调用后触发校验
  onSubmit,
}
  • disabled(默认)

    • 不会自动调用 validator
    • 只有你手动调用 formKey.currentState!.validate() 时,才会校验
  • onSubmit

    • 用户输入满位数(触发 onCompleted) 或者 调用 onSubmitted 时,自动执行 validator
    • 适合验证码场景:
      • 用户输入完 6 位 → 自动检查是否符合规则
      • 用户点提交按钮 → 校验

案例

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pinput/pinput.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text("登录验证码 Demo")),
        body: const LoginPage(),
      ),
    );
  }
}

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

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _phoneController = TextEditingController();
  final _codeController = TextEditingController();

  int _seconds = 0;
  Timer? _timer;

  /// 启动倒计时
  void _startTimer() {
    setState(() => _seconds = 5);
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (_seconds <= 1) {
        timer.cancel();
      }
      setState(() => _seconds--);
    });
  }

  @override
  void dispose() {
    _phoneController.dispose();
    _codeController.dispose();
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final defaultPinTheme = PinTheme(
      width: 50,
      height: 50,
      textStyle: const TextStyle(fontSize: 20, color: Colors.black),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
        borderRadius: BorderRadius.circular(8),
      ),
    );

    return Padding(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 手机号输入
          TextField(
            controller: _phoneController,
            keyboardType: TextInputType.phone,
            decoration: const InputDecoration(
              labelText: "手机号",
              border: OutlineInputBorder(),
            ),
          ),
          const SizedBox(height: 16),

          // 发送验证码按钮
          Row(
            children: [
              Expanded(
                child: ElevatedButton(
                  onPressed: _seconds == 0
                      ? () {
                          if (_phoneController.text.isEmpty) {
                            ScaffoldMessenger.of(context).showSnackBar(
                              const SnackBar(content: Text("请输入手机号")),
                            );
                            return;
                          }
                          // TODO: 在这里调用接口发送验证码
                          _startTimer();
                        }
                      : null,
                  child: Text(_seconds == 0 ? "发送验证码" : "重新发送 ($_seconds)"),
                ),
              ),
            ],
          ),
          const SizedBox(height: 24),

          // 验证码输入框
          Pinput(
            length: 6, // 6位验证码
            controller: _codeController, // 绑定控制器
            defaultPinTheme: defaultPinTheme, // 默认样式
            showCursor: true, // 显示光标
            onCompleted: (pin) { // 输入完成回调
              print("验证码输入完成: $pin");
            },
          ),
          const SizedBox(height: 24),

          // 登录按钮
          SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              onPressed: () {
                final phone = _phoneController.text.trim();
                // _codeController.text" 获取验证码
                final code = _codeController.text.trim();

                if (phone.isEmpty) {
                  ScaffoldMessenger.of(
                    context,
                  ).showSnackBar(const SnackBar(content: Text("请输入手机号")));
                  return;
                }
                if (code.length != 6) {
                  ScaffoldMessenger.of(
                    context,
                  ).showSnackBar(const SnackBar(content: Text("请输入 6 位验证码")));
                  return;
                }

                // TODO: 在这里调用登录接口
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text("手机号: $phone, 验证码: $code")),
                );
              },
              child: const Text("登录"),
            ),
          ),
        ],
      ),
    );
  }
}

自定义样式

案例

import 'package:flutter/material.dart';
import 'package:pinput/pinput.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: CustomPinputPage());
  }
}

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

  @override
  Widget build(BuildContext context) {
    // 默认样式
    final defaultPinTheme = PinTheme(
      width: 56,
      height: 56,
      textStyle: const TextStyle(
        fontSize: 20,
        color: Colors.black,
        fontWeight: FontWeight.w600,
      ),
      decoration: BoxDecoration(
        color: Colors.grey.shade200,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: Colors.grey.shade400),
      ),
    );

    // 聚焦状态样式
    final focusedPinTheme = defaultPinTheme.copyWith(
      decoration: defaultPinTheme.decoration!.copyWith(
        border: Border.all(color: Colors.blue, width: 2),
        color: Colors.white,
      ),
    );

    // 已输入的格子样式
    final submittedPinTheme = defaultPinTheme.copyWith(
      decoration: defaultPinTheme.decoration!.copyWith(
        color: Colors.green.shade100,
      ),
    );

    return Scaffold(
      appBar: AppBar(title: const Text("Pinput 自定义样式 Demo")),
      body: Center(
        child: Pinput(
          length: 6,
          defaultPinTheme: defaultPinTheme,
          focusedPinTheme: focusedPinTheme,
          submittedPinTheme: submittedPinTheme,
          showCursor: true,
          onCompleted: (pin) {
            ScaffoldMessenger.of(
              context,
            ).showSnackBar(SnackBar(content: Text("验证码输入完成: $pin")));
          },
        ),
      ),
    );
  }
}

三个属性

Pinput 的一个强大之处就是 可以完全自定义输入框的样式。核心是三个属性:

  • defaultPinTheme → 普通状态下的样式
  • focusedPinTheme → 输入框被选中、正在输入时的样式
  • submittedPinTheme → 输入完成(已有内容)的样式

PinTheme

PinTheme 是一个样式配置类,控制单个 PIN 输入格子的宽高、边框、背景、文字样式等。主要属性:

  • width / height → 每个格子的宽高
  • textStyle → 输入内容的文字样式
  • decoration → 背景和边框装饰(BoxDecoration

copyWith 的作用

PinTheme 是个不可变对象(immutable),如果你想改里面的某个属性(比如边框颜色),不能直接改原来的,要用 copyWith 复制出一个新对象。比如:

final focusedPinTheme = defaultPinTheme.copyWith(
  width: 60,
  height: 60,
  decoration: defaultPinTheme.decoration!.copyWith(
    border: Border.all(color: Colors.blue, width: 2),
    color: Colors.white,
  ),
);
  • 先复制一份 defaultPinTheme
  • 然后把 widthheight 改成新的值
  • 再把 decoration 的边框颜色换成蓝色,背景改成白色
  • 这样写的好处是可以避免许多重复的代码。

注意:基于原有对象,生成一个新的对象(不可变),并且只修改你指定的属性,其他属性保持不变。

常见支持 copyWith 的类

在 Flutter 里,大部分和样式/主题相关的类都支持 copyWith 方法,这是 Flutter 的一种设计习惯。

说明 示例
TextStyle 字体样式(大小、颜色、加粗等) style.copyWith(color: Colors.red)
ThemeData 全局主题(颜色、字体、按钮样式等) Theme.of(context).copyWith(primaryColor: Colors.blue)
TextTheme 一组文本样式 theme.textTheme.copyWith(bodyLarge: newStyle)
BoxDecoration 背景装饰(颜色、圆角、边框) decoration.copyWith(color: Colors.green)
InputDecoration 输入框样式(hintText、边框等) decoration.copyWith(hintText: "新提示")
ButtonStyle 按钮样式 ElevatedButton.styleFrom(...).copyWith(...)
PinTheme(第三方) Pinput 的格子样式 defaultPinTheme.copyWith(width: 60)

×

喜欢就点赞,疼爱就打赏