← 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 Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.