fluttervalidationinput

How to fix issue with backwards focus in OTP input in flutter


I have this code inside a TextFormField which handles going to the next box but it only goes back when the input box is complete.

onChanged: (value) {
     if (value.length == 1 && index < 5) {
           FocusScope.of(context).nextFocus();
      }
     if ((controllers[index].text.isEmpty || value.isEmpty) &&
                                  index > 0) {              
          FocusScope.of(context).previousFocus();
      }
    setState(() {});
 },

Let's say I want to go back in the middle of typing. How can I do that?


Solution

  • I'm assuming you are using multiple textfields to record each character of otp, the issue is that as each otp input box is empty, backspace doesn't delete anything therefore not triggering onChanged

    to detect a backspace you could instead initialize a character in each input, so that backspace will delete it and trigger onChanged, in my case i used a zero width character '\u200b' so it wont be visible to user

    Example:

    class OtpInputWidget extends StatefulWidget {
      final TextEditingController otpController;
    
      const OtpInputWidget({super.key, required this.otpController});
    
      @override
      State<OtpInputWidget> createState() => _OtpInputWidgetState();
    }
    
    class _OtpInputWidgetState extends State<OtpInputWidget> {
      final focusNode = FocusNode();
    
      @override
      void initState() {
        super.initState();
        // OnKeyEvent won't work because you can't detect a delete action in an empty text field
        // https://medium.com/super-declarative/why-you-cant-detect-a-delete-action-in-an-empty-flutter-text-field-3cf53e47b631
        focusNode.addListener(() {
          // On focusing on the text field, add a zero-width character at the front of text.
          // This enables the detection of delete actions in an "empty" text field.
          if (focusNode.hasFocus) {
            if (widget.otpController.text.isEmpty) {
              widget.otpController.text = '\u200b';
            } else {
              // This enables any changes to the text field value (whether the new value is
              // the same as or different from the old value) to trigger the OnChange event.
              final pin =
                  widget.otpController.text.replaceAll('\u200b', '');
              widget.otpController.text = '\u200b$pin';
            }
            _selectAllText();
          }
        });
      }
    
      @override
      void dispose() {
        focusNode.dispose();
        super.dispose();
      }
    

    in the above code, otpInputWidget is the individual input box, i initialize it with a focusNode listener to add a zero width character whenever its empty to add it back if its been deleted before.

    Additionally, since the input box now have a character, instead of adding character to the input we want to override the existing character with new one, by selecting the text so input will override it, hence why i have a _selectAllText() in the listener to select the input's text whenever its focused

    definition of _selectAllText():

    void _selectAllText() {
      widget.otpController.selection = TextSelection(
        baseOffset: 0, 
        extentOffset: widget.otpController.value.text.length
       );
          }
    

    and to switch focus in textfield:

    onChanged: (value) {
            if (value.isNotEmpty) {
              FocusScope.of(context).nextFocus();
            } else {
              FocusScope.of(context).previousFocus();
            }
          },
          onTap: () => _selectAllText(),
    

    *you should remember to sanitize the final full otp before sending it to validation by removing the extra zero width characters:

    otpInput.replaceAll('\u200b', ''),
    

    below is the full code:

    class OtpInputWidget extends StatefulWidget {
      final TextEditingController otpController;
    
      const OtpInputWidget({super.key, required this.otpController});
    
      @override
      State<OtpInputWidget> createState() => _OtpInputWidgetState();
    }
    
    class _OtpInputWidgetState extends State<OtpInputWidget> {
      final focusNode = FocusNode();
    
      @override
      void initState() {
        super.initState();
        // OnKeyEvent won't work because you can't detect a delete action in an empty text field
        // https://medium.com/super-declarative/why-you-cant-detect-a-delete-action-in-an-empty-flutter-text-field-3cf53e47b631
        focusNode.addListener(() {
          // On focusing on the text field, add a zero-width character at the front of text.
          // This enables the detection of delete actions in an "empty" text field.
          if (focusNode.hasFocus) {
            if (widget.otpController.text.isEmpty) {
              widget.otpController.text = '\u200b';
            } else {
              // This enables any changes to the text field value (whether the new value is
              // the same as or different from the old value) to trigger the OnChange event.
              final pin =
                  widget.otpController.text.replaceAll('\u200b', '');
              widget.otpController.text = '\u200b$pin';
            }
            _selectAllText();
          }
        });
      }
    
      @override
      void dispose() {
        focusNode.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        double screenWidth = MediaQuery.sizeOf(context).width;
    
        final otpTextField = TextField(
          textAlign: TextAlign.center,
          keyboardType: TextInputType.number,
          controller: widget.otpController,
          focusNode: focusNode,
          maxLength: 1,
          decoration: InputDecoration(
              isDense: true,
              counterText: '',
              filled: true,
              fillColor: customColors.emailInputContainer,
              border: outlineInputBorderless),
          onChanged: (value) {
            if (value.isNotEmpty) {
              FocusScope.of(context).nextFocus();
            } else {
              FocusScope.of(context).previousFocus();
            }
          },
          onTap: () => _selectAllText(),
        );
    
        return SizedBox(width: screenWidth * 0.095, child: otpTextField);
      }
    
      void _selectAllText() {
        widget.otpController.selection = TextSelection(
            baseOffset: 0, extentOffset: widget.otpController.value.text.length);
      }
    }