You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter自定义OTP组件:索引越界错误与退格键处理问题

Flutter自定义OTP组件问题修复方案

问题1:索引越界错误修复

错误原因

_updateParentController方法处理父控制器文本时,未考虑父文本长度小于目标索引的情况,直接调用substring触发越界;同时_fieldControllers的监听器与_updateOtp形成循环调用,加剧了越界风险。

修复步骤

  1. 移除子控制器监听器:删除initState中给_fieldControllers添加监听器的代码,避免父、子控制器循环触发。
  2. 安全处理字符串截取:修改_updateParentController,确保所有截取操作不超出文本实际长度:
    void _updateParentController(int index) {
      final text = _fieldControllers[index].text;
      final currentText = widget.controller.text;
    
      if (index >= 0 && index < widget.length) {
        String newText;
        // 安全获取前缀,限制截取范围不超过文本长度
        final prefix = index > 0 ? currentText.substring(0, index.clamp(0, currentText.length)) : '';
        // 后缀截取起始索引超出长度时返回空串
        final suffix = (index + 1 < currentText.length) ? currentText.substring(index + 1) : '';
    
        if (text.isNotEmpty) {
          newText = prefix + text[0] + suffix;
        } else {
          newText = prefix + suffix;
        }
    
        // 确保新文本长度不超过OTP设定长度
        if (newText.length > widget.length) {
          newText = newText.substring(0, widget.length);
        }
    
        widget.controller.text = newText;
        widget.controller.selection = TextSelection.fromPosition(
          TextPosition(offset: newText.length),
        );
      }
    }
    

问题2:退格键行为异常修复

错误原因

原onChanged逻辑仅处理焦点切换,未针对退格操作做字段清空逻辑,导致空字段按退格时无法正确清除前一个字段内容,焦点行为不符合预期。

修复步骤

修改_buildOtpField中的onChanged回调,优化退格逻辑:

onChanged: (value) {
  if (value.length <= 1) {
    if (value.isNotEmpty) {
      // 输入字符后自动跳转到下一个字段
      if (index < widget.length - 1) {
        _focusNodes[index + 1].requestFocus();
      }
    } else {
      // 当前字段为空时的退格处理
      if (index > 0) {
        final prevController = _fieldControllers[index - 1];
        // 前一个字段有内容则先清空
        if (prevController.text.isNotEmpty) {
          prevController.clear();
          _updateParentController(index - 1);
        }
        // 移动焦点到前一个字段
        _focusNodes[index - 1].requestFocus();
      }
    }
    _updateParentController(index);
  }
},

优化后的退格行为

  • 当前字段有内容时,按下退格:清空当前字段,焦点留在当前。
  • 当前字段为空时,按下退格:若不是第一个字段,清空前一个字段并移焦点过去;若为第一个字段,无操作。

完整修改后的代码

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

class CustomOtpField extends StatefulWidget {
  final TextEditingController controller;
  final int length;
  final double size;
  final double borderRadius;
  final double borderWidth;
  final Color focusedBorderColor;
  final Color unfocusedBorderColor;
  final Color backgroundColor;
  final TextStyle textStyle;
  final double fieldSpacing;

  const CustomOtpField({
    required this.controller,
    this.length = 6,
    this.size = 50.0,
    this.borderRadius = 4.0,
    this.borderWidth = 1.0,
    this.focusedBorderColor = Colors.teal,
    this.unfocusedBorderColor = Colors.grey,
    this.backgroundColor = Colors.white,
    this.textStyle = const TextStyle(fontSize: 20.0, color: Colors.black),
    this.fieldSpacing = 12.0,
    super.key,
  });

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

class CustomOtpFieldState extends State<CustomOtpField> {
  late List<FocusNode> _focusNodes;
  late List<TextEditingController> _fieldControllers;

  @override
  void initState() {
    super.initState();
    _focusNodes = List.generate(widget.length, (index) => FocusNode());
    _fieldControllers =
        List.generate(widget.length, (index) => TextEditingController());
    widget.controller.addListener(_updateOtp);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_focusNodes.isNotEmpty) {
        _focusNodes[0].requestFocus();
      }
    });
  }

  @override
  void dispose() {
    for (var node in _focusNodes) {
      node.dispose();
    }
    for (var controller in _fieldControllers) {
      controller.dispose();
    }
    widget.controller.removeListener(_updateOtp);
    super.dispose();
  }

  void _updateParentController(int index) {
    final text = _fieldControllers[index].text;
    final currentText = widget.controller.text;

    if (index >= 0 && index < widget.length) {
      String newText;
      final prefix = index > 0 ? currentText.substring(0, index.clamp(0, currentText.length)) : '';
      final suffix = (index + 1 < currentText.length) ? currentText.substring(index + 1) : '';

      if (text.isNotEmpty) {
        newText = prefix + text[0] + suffix;
      } else {
        newText = prefix + suffix;
      }

      if (newText.length > widget.length) {
        newText = newText.substring(0, widget.length);
      }

      widget.controller.text = newText;
      widget.controller.selection = TextSelection.fromPosition(
        TextPosition(offset: newText.length),
      );
    }
  }

  void _updateOtp() {
    final text = widget.controller.text;
    if (text.length > widget.length) {
      widget.controller.text = text.substring(0, widget.length);
      return;
    }
    if (text.length == widget.length) {
      _focusNodes.last.unfocus();
    } else if (text.length < widget.length && text.length < _focusNodes.length) {
      _focusNodes[text.length].requestFocus();
    }
    for (int i = 0; i < widget.length; i++) {
      if (i < text.length) {
        if (_fieldControllers[i].text != text[i]) {
          _fieldControllers[i].text = text[i];
        }
      } else {
        if (_fieldControllers[i].text.isNotEmpty) {
          _fieldControllers[i].clear();
        }
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: List.generate(widget.length, (index) {
        return Row(
          children: [
            _buildOtpField(index),
            if (index < widget.length - 1) SizedBox(width: widget.fieldSpacing),
          ],
        );
      }),
    );
  }

  Widget _buildOtpField(int index) {
    return SizedBox(
      height: widget.size,
      width: widget.size,
      child: TextField(
        focusNode: _focusNodes[index],
        controller: _fieldControllers[index],
        onChanged: (value) {
          if (value.length <= 1) {
            if (value.isNotEmpty) {
              if (index < widget.length - 1) {
                _focusNodes[index + 1].requestFocus();
              }
            } else {
              if (index > 0) {
                final prevController = _fieldControllers[index - 1];
                if (prevController.text.isNotEmpty) {
                  prevController.clear();
                  _updateParentController(index - 1);
                }
                _focusNodes[index - 1].requestFocus();
              }
            }
            _updateParentController(index);
          }
        },
        textAlign: TextAlign.center,
        maxLength: 1,
        keyboardType: TextInputType.number,
        inputFormatters: [FilteringTextInputFormatter.digitsOnly],
        decoration: InputDecoration(
          counterText: '',
          contentPadding: EdgeInsets.zero,
          filled: true,
          fillColor: widget.backgroundColor,
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(widget.borderRadius),
            borderSide: BorderSide(
              color: _focusNodes[index].hasFocus
                  ? widget.focusedBorderColor
                  : widget.unfocusedBorderColor,
              width: widget.borderWidth,
            ),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(widget.borderRadius),
            borderSide: BorderSide(
              color: widget.focusedBorderColor,
              width: widget.borderWidth,
            ),
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(widget.borderRadius),
            borderSide: BorderSide(
              color: widget.unfocusedBorderColor,
              width: widget.borderWidth,
            ),
          ),
        ),
        style: widget.textStyle,
      ),
    );
  }
}

内容的提问来源于stack exchange,提问作者Mirza

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 21:58:09