Building Live-Formatting Text Fields in Flutter: A Guide to Rich Text Input
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
Enhancing User Experience with Live-Formatting Text Fields in Flutter
Imagine your users typing in a note-taking app, a comment box, or a markdown editor. They want to emphasize a word with bold, add emphasis with italics, or denote a snippet of code. Traditionally, they’d need to remember special syntax (like Markdown’s ** or *), and the visual feedback would only appear after parsing, often on a different preview pane. This disconnect breaks the flow of writing.
The ideal experience is live formatting: the styling appears instantly, inline, as the user types. This is not just a cosmetic improvement; it provides immediate, intuitive feedback, making the app feel more responsive and professional. While Flutter’s built-in TextField is fantastic for plain text, achieving this rich, live-formatted input requires a bit of custom engineering under the hood.
The Core Challenge: One Text, Two Representations
At the heart of the problem is a duality. You need to:
- Store the raw text that the user is actually typing, including any formatting markers (e.g.,
*hello*). - Display a styled version of that text (e.g., hello) in the same input field.
Flutter’s standard TextEditingController is designed for the first job. It manages the raw text string and the cursor position. The TextField widget then displays this string using a single, uniform TextStyle. To break this uniformity, we need to intervene in the process that translates the raw text into something visually rich.
The Solution: A Custom TextEditingController with a Rich Text Span Builder
The most elegant and powerful approach is to create a custom controller that extends TextEditingController. Its superpower is the buildTextSpan method, which allows us to replace the default plain TextSpan with a RichText-compatible tree of styled TextSpans.
Let’s build a basic LiveFormattingController that handles **bold** and *italic*.
import 'package:flutter/material.dart';
class LiveFormattingController extends TextEditingController {
// Define our formatting delimiters and corresponding styles.
final Map<String, TextStyle> _formatters = {
'**': const TextStyle(fontWeight: FontWeight.bold),
'*': const TextStyle(fontStyle: FontStyle.italic),
'`': const TextStyle(
fontFamily: 'RobotoMono',
backgroundColor: Colors.grey,
),
};
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
// Start with the plain text from the controller.
final String plainText = text;
// We'll build a list of TextSpan children.
final List<TextSpan> children = [];
// A simple parser to find our markers.
int currentIndex = 0;
while (currentIndex < plainText.length) {
// Look for the next formatting marker in our map.
int? nextMarkerPos;
String? foundMarker;
for (final marker in _formatters.keys) {
final pos = plainText.indexOf(marker, currentIndex);
if (pos != -1 && (nextMarkerPos == null || pos < nextMarkerPos)) {
nextMarkerPos = pos;
foundMarker = marker;
}
}
// If no more markers are found, add the rest as plain text and exit.
if (foundMarker == null) {
children.add(
TextSpan(text: plainText.substring(currentIndex), style: style),
);
break;
}
// 1. Add the plain text before the marker.
if (nextMarkerPos! > currentIndex) {
children.add(
TextSpan(
text: plainText.substring(currentIndex, nextMarkerPos),
style: style,
),
);
}
// Move index past the opening marker.
final contentStart = nextMarkerPos + foundMarker.length;
// Find the closing marker.
final closingMarkerPos = plainText.indexOf(foundMarker, contentStart);
// If a closing marker is found, apply the style.
if (closingMarkerPos != -1) {
final styledText = plainText.substring(contentStart, closingMarkerPos);
children.add(
TextSpan(text: styledText, style: _formatters[foundMarker]),
);
// Move index past the styled content and the closing marker.
currentIndex = closingMarkerPos + foundMarker.length;
} else {
// If no closing marker, treat the opening marker as plain text.
children.add(
TextSpan(
text: plainText.substring(nextMarkerPos, contentStart),
style: style,
),
);
currentIndex = contentStart;
}
}
// Return the new rich text span.
return TextSpan(style: style, children: children);
}
}
Now, using it is as simple as replacing your standard controller:
class FormattedTextFieldDemo extends StatefulWidget {
const FormattedTextFieldDemo({super.key});
@override
State<FormattedTextFieldDemo> createState() => _FormattedTextFieldDemoState();
}
class _FormattedTextFieldDemoState extends State<FormattedTextFieldDemo> {
// Use our custom controller instead of TextEditingController
final LiveFormattingController _controller = LiveFormattingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Live Formatting Demo')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _controller,
maxLines: 5,
decoration: const InputDecoration(
hintText: 'Type **bold**, *italic*, or `code`...',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
// Display the raw text for comparison
Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text('Raw Text: ${_controller.text}'),
),
),
],
),
),
);
}
}
Common Pitfalls and Advanced Considerations
- Cursor Position & Selection: This is the trickiest part. Our simple parser doesn’t adjust the cursor. The cursor position is still tied to the raw text index. If you type
**hello**, the cursor moves through 9 raw positions but only 5 visual ones. For a production solution, you’d need to overrideTextEditingController.selectionandvaluegetters/setters to map visual cursor positions back to raw text indices—a non-trivial task. - Complex Nesting: Our parser is linear and doesn’t handle nested formats like
**bold *and italic***well. A more robust solution would use a stack-based parser or a proper state machine. - Performance: For very long documents, rebuilding the entire
TextSpantree on every keystroke can be expensive. Consider optimizations like caching or incremental updates. - Paste & Cut: Ensure your logic handles pasted text correctly, potentially stripping or interpreting formatting from the clipboard.
Getting Started
Start with the LiveFormattingController above. It provides a clear, working foundation. Focus first on getting the visual formatting correct. Once that’s solid, you can tackle the more advanced challenge of cursor and selection management if your app requires flawless text editing behavior.
By implementing live formatting, you move beyond simple text input and create a truly engaging, WYSIWYG editing experience that will make your Flutter application stand out.
This blog is produced with the assistance of AI by a human editor. Learn more
Related Posts
Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?
Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.
Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug
Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.
Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps
As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.