【flutter大杂烩】短信验证码输入组件

2,270 阅读3分钟

如果我的文章对您有所帮助,欢迎 👍 , 我的Github.


最近在写 仿网易云音乐app ,需要弄一个短信验证码输入控件。

大概长这样子:

需要实现的功能

  1. 可以任意选中某个TextField
  2. 选中后可以输入,输入完自动跳转到下一个未输入的TextField(循环)
  3. 全部输入完之后触发事件,消除TextField焦点
  4. 输入删除键可以删除当前值,如果当前值为空则自动跳转上一个TextField(循环)

初步思路

  1. 4个TextField
  2. FocusScope.of(context) 改变TextField焦点
  3. 监听删除按键

由于flutter的"限制"

  1. TextField的onChanged在空值时按下删除键并不会进行回调
  2. flutter提供的RawKeyboardListener监听事件,只能对硬件按键事件有效果,而大多数移动端手机软键盘并没有模拟硬件事件。

google一下, 找到了一些解决办法:

pub.flutter-io.cn/packages/pi… 它的思路是放一个隐藏的TextField用于输入,几个Text用于显示,这样就绕开了无法监听键盘删除键的问题。

能不能不用隐藏TextField实现这个组件呢?

思考TextField的onChanged在输入值未空时不能触发,我们可不可用一个空白符来代替这个空,这样就能响应删除按键了。

思路实现

  1. 定义一个空白符,4个TextField默认值都为空白符

  2. 第一次按下删除键时替换值为空白符

  3. 第二次按下删除键时把空白符删除(执行动跳转上一个TextField)

  4. TextField在焦点变化时,如果输入值不是空白符也不是正常值,替换成空白符(用于下次直接按下删除键)

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

class PinInput extends StatefulWidget {
  final ValueChanged<List<String>> pinInputDone;

  PinInput(this.pinInputDone);

  @override
  _PinInputState createState() => _PinInputState();
}

/// 因为flutter没有提供一个完整的按键监听
/// 没有办法知晓在TextField中连续输入删除键
/// 临时方案:
/// 第一次按下删除键时替换值为空白符
/// 第二次按下删除键时把空白符删除
/// TextField在焦点变化时,如果输入值不是空白符也不是正常值,替换成空白符(用于下次直接按下删除键)
class _PinInputState extends State<PinInput> {
  static const _pinSize = 4;
  static const _whiteSpace = ' ';

  /// pin结果
  List<String> _pin;

  /// 控制focus,并且在focus变化时保证_pin值为空白符或者正常值
  List<FocusNode> _focusNodes;

  void _pinChange(index, String str) {
    if (str != _whiteSpace) {
      _pin[index] = str;
      if (isStrEmpty(str)) {
        _moveItem(index, true);
      } else {
        _moveEmptyItemOrDone(index);
      }
    }
  }

  void _moveItem(index, bool pre) {
    FocusScope.of(context)
        .requestFocus(_focusNodes[(index + (pre ? -1 : 1)) % _pinSize]);
  }

  void _moveEmptyItemOrDone(index) {
    var findFocusPin = _pin.sublist(index) + _pin.sublist(0, index);

    var emptyIndex = findFocusPin.indexWhere((str) => isStrEmpty(str));

    if (emptyIndex != -1) {
      FocusScope.of(context)
          .requestFocus(_focusNodes[(emptyIndex + index) % _pinSize]);
    } else {
      FocusScope.of(context).unfocus();
      widget.pinInputDone(_pin);
    }
  }

  bool isStrEmpty(String str) {
    return str.isEmpty || str == _whiteSpace;
  }

  @override
  void initState() {
    super.initState();
    _pin = List.filled(_pinSize, _whiteSpace);
    _focusNodes = List.generate(_pinSize, (index) {
      return FocusNode()
        ..addListener(() {
          if (_pin[index].isEmpty) {
            setState(() {
              _pin[index] = _whiteSpace;
            });
          }
        });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: buildPinInput(),
    );
  }

  List<Widget> buildPinInput() {
    return List.generate(_pinSize, (index) {
      return SizedBox(
          width: 48,
          child: TextField(
            cursorWidth: 0,
            textAlign: TextAlign.center,
            enableInteractiveSelection: false,
            keyboardType: TextInputType.number,
            inputFormatters: [
              _PinInputFormatter(_whiteSpace),
              WhitelistingTextInputFormatter(RegExp('[\\d$_whiteSpace]*'))
            ],
            decoration: InputDecoration(
              isDense: true,
              enabledBorder: UnderlineInputBorder(
                  borderSide: BorderSide(color: Colors.grey)),
            ),
            textInputAction: TextInputAction.next,
            controller: TextEditingController.fromValue(TextEditingValue(
                text: _pin[index],
                selection: TextSelection.collapsed(
                    offset: _pin[index].length,
                    affinity: TextAffinity.upstream))),
            onSubmitted: (str) {
              _moveItem(index, false);
            },
            autofocus: index == 0,
            focusNode: _focusNodes[index],
            onChanged: (str) {
              _pinChange(index, str);
            },
          ));
    });
  }
}

/// 用来当输入为空时替换为空白符,如果连续出现俩次空白符则替换为空值
class _PinInputFormatter extends TextInputFormatter {
  String _whiteSpace;

  _PinInputFormatter(this._whiteSpace);

  @override
  TextEditingValue formatEditUpdate(
      TextEditingValue oldValue, TextEditingValue newValue) {
    var result = '';
    if (newValue.text == '') {
      if (oldValue.text != _whiteSpace) {
        result = _whiteSpace;
      } else {
        return newValue;
      }
    } else {
      result = newValue.text.substring(newValue.text.length - 1);
    }
    return TextEditingValue(
        text: result, selection: TextSelection.collapsed(offset: 1));
  }
}

效果

demo源码

github.com/hcanyz/z-ga… ~

这只是一个简单的demo,不具备完整功能。