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.
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();
}
}
}
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:
Enter
to continue the previous list.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,
),
);
}
}