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
Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations
Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.
Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity
Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.
Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps
Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.