← Back to posts Cover image for Building Live-Formatting Text Fields in Flutter: A Guide to Rich Text Input

Building Live-Formatting Text Fields in Flutter: A Guide to Rich Text Input

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

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:

  1. Store the raw text that the user is actually typing, including any formatting markers (e.g., *hello*).
  2. 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

  1. 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 override TextEditingController.selection and value getters/setters to map visual cursor positions back to raw text indices—a non-trivial task.
  2. 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.
  3. Performance: For very long documents, rebuilding the entire TextSpan tree on every keystroke can be expensive. Consider optimizations like caching or incremental updates.
  4. 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

Cover image for Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.

Cover image for Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.