flutterdarttextfieldtexteditingcontroller

How to implement numbering list feature in textfield like whatsApp with dart code in flutter?


I'm trying to implement numbering list feature to my app like whatsApp does. It almost got implemented but I found 3 bugs in my code.

Note : I'm not using any outside packages for it, created custom TextEditingController to achieve this.

  1. BUG1

  2. BUG2

  3. BUG3

Code:

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

  @override
  State<TestingPage> createState() => _TestingPageState();
}

class _TestingPageState extends State<TestingPage> {
  CustomTextEditingController controller = CustomTextEditingController();
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Testing"),
        centerTitle: true,
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 30),
          child: TextField(
            controller: controller,
            maxLines: 8,
            decoration: const InputDecoration(
                hintText: "Enter text here...", border: OutlineInputBorder()),
          ),
        ),
      ),
    );
  }
}


Custom TextEditingController class :

import 'package:flutter/cupertino.dart';

class CustomTextEditingController extends TextEditingController {
  bool isBackPressed = false;
  String _previousText = '';

  CustomTextEditingController({String text = ''}) : super(text: text) {
    addListener(_onTextChanged);
  }

// Custom function to handle text changes
  void _onTextChanged() {
    String text = this.text;
    int cursorPosition = selection.baseOffset;

    if (_previousText.length > text.length) {
      isBackPressed = true;
    } else {
      isBackPressed = false;
    }
    _previousText = text;

    if (cursorPosition > 0) {
      // Detect Enter key press
      if (text[cursorPosition - 1] == '\n' && !isBackPressed) {
        debugPrint("inside12");
        String previousLine = _getPreviousLine(text, cursorPosition);
        RegExp numberRegex = RegExp(r'^(\d+)\.\s');

        Match? match = numberRegex.firstMatch(previousLine);
        if (match != null) {
          int currentNumber = int.parse(match.group(1)!);
          String newText = text.replaceRange(
            cursorPosition,
            cursorPosition,
            '${currentNumber + 1}. ',
          );

          value = TextEditingValue(
            text: newText,
            selection: TextSelection.collapsed(
              offset: cursorPosition + currentNumber.toString().length + 2,
            ),
          );
        }
      }
      // Handle backspace to clear line numbering
      if (text[cursorPosition - 1] == '\n' &&
          cursorPosition > 1 &&
          text.substring(cursorPosition - 4, cursorPosition - 1) == '. ') {
        debugPrint("inside11");
        String newText = text.substring(0, cursorPosition - 4) +
            text.substring(cursorPosition);
        value = TextEditingValue(
          text: newText,
          selection: TextSelection.collapsed(offset: cursorPosition - 4),
        );
      }
    }
  }

  @override
  void dispose() {
    // Clean up the listener when the controller is disposed
    removeListener(_onTextChanged);
    super.dispose();
  }

  String _getPreviousLine(String text, int cursorPosition) {
    int lastNewline = text.lastIndexOf('\n', cursorPosition - 2);
    if (lastNewline == -1) {
      return text.substring(0, cursorPosition).trim();
    } else {
      return text.substring(lastNewline + 1, cursorPosition - 1).trim();
    }
  }
}


Solution

  • As I looked at the bugs you attached to you question I thought that there could be other situations where your current implementation will not work perfectly (for example the user cuts out lines from the middle of a list). So I spent some time and came up with an alternative approach because I have found the problem interesting. It is not very optimal and I am not even sure it works 100% but I share what I came up with, hope it helps you.

    Basically you have two handle situations:

    For the modification part my approach (and that's why it is not optimal) simply takes the entire textfield and renumbers the lines. While being not very optimal, it provides some additional features:

    The code can be copy-pasted to DartPad, please try it. I added some comments, but one thing I would like to explain: why I decided to add a KeyBoardListener? Without this I could not differentiate between adding a new line somewhere and deleting the last line. Without watching whether the Enter key was pressed, if there was a number in the last line, and the user tried to delete it, it was re-added immediately. Maybe you find a better solution to that.

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(
          debugShowCheckedModeBanner: false,
          home: TestingPage(),
        );
      }
    }
    
    class TestingPage extends StatefulWidget {
      const TestingPage({super.key});
    
      @override
      State<TestingPage> createState() => _TestingPageState();
    }
    
    class _TestingPageState extends State<TestingPage> {
      // separator between number and text in numbered list
      final _numberRegExp1 = RegExp(r'^(\d+)\.\s');
      final _numberRegExp2 = RegExp(r'^(\d+)\.');
      late final TextEditingController _controller;
      String _previousText = '';
    
      @override
      void initState() {
        super.initState();
        _controller = TextEditingController();
      }
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text("Testing"),
            centerTitle: true,
          ),
          body: Center(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 32),
              child: TextField(
                onChanged: _onTextChanged,
                controller: _controller,
                maxLines: 32,
                decoration: const InputDecoration(
                    hintText: "Enter text here...", border: OutlineInputBorder()),
              ),
            ),
          ),
        );
      }
    
      // called on every change in the text field
      void _onTextChanged(String text) {
        // if field is empty or offset is 0, nothing needs to be done
        if (text.isEmpty || _controller.selection.baseOffset < 1) {
          return;
        }
    
        // if the character before the current cursor is new line and
        // Enter was pressed, add a new number if needed
        if (text[_controller.selection.baseOffset - 1] == '\n' &&
            _previousText.length <= text.length) {
          // add the next number
          final newText = _addNextNumber(text);
          // also renumber lists if anything was added
          if (newText != null) {
            _renumberLists(newText);
          }
        } else {
          // otherwise just renumber lists
          _renumberLists(text);
        }
        _previousText = text;
      }
    
      // add next number if a previous list is continued
      String? _addNextNumber(String text) {
        final offset = _controller.selection.baseOffset;
        String? previousLine;
        int currentPosition = 0;
        final lines = text.split('\n');
    
        // find the line before the current cursor, return if not found
        for (var line in lines) {
          currentPosition += (line.length + 1);
          if (currentPosition == offset) {
            previousLine = line;
            break;
          }
        }
    
        if (previousLine == null) {
          return null;
        }
    
        // check if the line before has a leading number, if not, return
        Match? match = _numberRegExp1.firstMatch(previousLine);
    
        if (match == null) {
          return null;
        }
    
        // get number from previous row as text and integer
        final matched = match.group(0)!;
        final previousNumberAsText = matched.substring(0, matched.length - 2);
        final previousNumber = int.tryParse(previousNumberAsText);
    
        if (previousNumber == null) {
          return null;
        }
    
        // this will be the new number and the entire new value of the field
        // after the new number is inserted to the beginning of the new line
        final newNumberAsText = '${previousNumber + 1}. ';
        final newText = text.replaceRange(offset, offset, newNumberAsText);
    
        // set new value and manage offset
        _controller.value = TextEditingValue(
          text: newText,
          selection: TextSelection.collapsed(
            offset: _controller.selection.baseOffset + newNumberAsText.length,
          ),
        );
    
        // return the new text because _renumberLists needs it
        return newText;
      }
    
      // renumber every list found in text field
      void _renumberLists(String text) {
        final lines = text.split('\n');
        final newLines = <String>[];
        int nextNumber = 1;
        int charactersDifference = 0;
        int separatorLength = 0;
    
        for (var line in lines) {
          Match? match = _numberRegExp1.firstMatch(line);
          if (match != null) {
            separatorLength = 2;
          } else {
            match = _numberRegExp2.firstMatch(line);
            if (match != null) {
              separatorLength = 1;
            }
          }
          if (match == null) {
            // this restarts the numbering for the next numbered block
            nextNumber = 1;
            newLines.add(line);
          } else {
            final currentNumber = match.group(0)!;
            final nextNumberAsText = nextNumber.toString();
            nextNumber++;
            final currentLineText = line.substring(currentNumber.length);
            charactersDifference -= currentNumber.length;
            charactersDifference += nextNumberAsText.length + separatorLength;
            newLines.add('$nextNumberAsText. $currentLineText');
          }
        }
    
        // set new value and manage offset
        _controller.value = TextEditingValue(
          text: newLines.join('\n'),
          selection: TextSelection.collapsed(
            offset: _controller.selection.baseOffset + charactersDifference,
          ),
        );
      }
    }