flutter_interactive_text

A performant, theme-aware Flutter widget for detecting, styling, and handling interactive text. The same InteractiveText widget can render ordinary, limited, selectable, or expandable content without requiring an editing controller or application-specific model.

Features

  • One widget for static and expandable interactive text.
  • Built-in URLs, deep links, email addresses, phone numbers, mentions, hashtags, cashtags, emoji, commands, references, dates, times, currency, percentages, UUIDs, IPv4 addresses, variables, and lightweight Markdown.
  • Stable 22 font size for complete emoji graphemes in both mixed and emoji-only text.
  • Theme-aware defaults and a TextStyle override for every built-in type.
  • Typed tap callbacks and automatic URL, email, and phone launching.
  • Optional domain allowlist for user-generated links.
  • Custom regex, trigger, emoji, or function-based detectors.
  • Grapheme-safe expand and collapse behavior.
  • Optional selectable text.
  • Cached parsing, spans, recognizers, and line measurements for list usage.

Installation

Add the package to pubspec.yaml:

dependencies:
  flutter_interactive_text: ^0.1.1

Basic usage

The default configuration recognizes common social and messaging content:

import 'package:flutter_interactive_text/flutter_interactive_text.dart';

InteractiveText(
  'Write to hello@example.com or visit example.org. Hi @maria #Flutter 👍🏽',
)

URLs, email addresses, and phone numbers launch automatically. Supply onTap when the application should own navigation or another action:

InteractiveText(
  'Explore #Flutter with @maria',
  onTap: (match) {
    if (match.type == InteractiveTextType.hashtag) {
      openSearch(match.text);
    } else if (match.type == InteractiveTextType.mention) {
      openProfile(match.text.substring(1));
    }
  },
)

Expandable text

Expansion is opt-in and remains part of the same widget:

InteractiveText(
  longDescription,
  expansion: InteractiveTextExpansion(
    collapsedLines: 3,
    expandText: 'More',
    collapseText: 'Less',
    onChanged: (expanded) => analytics.track(expanded),
  ),
)

The collapsed value is measured with the effective theme, locale, text scaler, strut, alignment, and available width. Truncation only occurs at complete Unicode grapheme boundaries.

Choose detected types

InteractiveTextGroups.common is enabled by default. More focused and advanced presets are available:

InteractiveText(
  message,
  types: InteractiveTextGroups.chat,
)

InteractiveText(
  assistantResponse,
  types: InteractiveTextGroups.assistantChat,
)

InteractiveText(
  technicalDetails,
  types: InteractiveTextGroups.combine([
    InteractiveTextGroups.links,
    InteractiveTextGroups.identifiers,
    InteractiveTextGroups.markdown,
  ]),
)

Available groups include links, contact, social, chat, common, commerce, temporal, identifiers, markdown, assistantChat, and all.

Styling

Every built-in type has an independent TextStyle. Defaults use the current ColorScheme, so they react to light, dark, and custom application themes.

InteractiveText(
  content,
  style: Theme.of(context).textTheme.bodyLarge,
  styles: const InteractiveTextStyles(
    mention: TextStyle(fontWeight: FontWeight.w700),
    hashtag: TextStyle(color: Colors.teal),
    emoji: TextStyle(fontSize: 24),
    emojiOnly: TextStyle(fontSize: 24),
    expansion: TextStyle(fontWeight: FontWeight.w700),
  ),
)

Use styleResolver when the style depends on match metadata or source value:

InteractiveText(
  content,
  styleResolver: (details) {
    if (details.match.type == InteractiveTextType.mention &&
        details.match.text == '@admin') {
      return details.suggestedStyle.copyWith(color: Colors.red);
    }
    return details.suggestedStyle;
  },
)

The package does not define a domain allowlist. A null or empty allowedDomains collection permits every web host. To make only selected domains interactive, provide their host names from the host application:

InteractiveText(
  userGeneratedContent,
  allowedDomains: const ['example.com', 'example.org'],
)

Subdomains are included. Disallowed URLs remain visible and styled but do not receive a recognizer.

Custom detection

InteractiveTextType is a value object, so applications are not limited to an enum. Define a type and detector, then provide an optional custom style:

const orderType = InteractiveTextType('order');

final orderDetector = RegexInteractiveTextDetector(
  type: orderType,
  id: 'order',
  pattern: RegExp(r'ORDER-\d+'),
  priority: 100,
);

InteractiveText(
  'Track ORDER-2048',
  detectors: [orderDetector],
  onTap: (match) => openOrder(match.text),
  styles: InteractiveTextStyles(
    custom: {
      orderType: const TextStyle(
        color: Colors.indigo,
        fontWeight: FontWeight.w700,
      ),
    },
  ),
)

For non-regex requirements, use FunctionInteractiveTextDetector. Trigger syntax can use TriggerInteractiveTextDetector, and its character and boundary policies are fully configurable.

Interaction rules

  • Without onTap, supported links and contact values launch automatically.
  • With onTap, the callback owns the action for non-formatting matches.
  • interactiveTypes can explicitly include or exclude actionable types.
  • Emoji and formatting-only ranges are not tappable by default.
  • Set autoLaunch: false to render styles without automatic actions.

Performance

Each mounted widget parses only when its text or parser changes. It reuses tap recognizers and caches built spans and collapsed measurements across ordinary rebuilds. Built-in regular expressions are also shared. For repeated custom rules, create one InteractiveTextParser and pass it to every list item.

See example/lib/main.dart for a runnable example.

Libraries

flutter_interactive_text
Theme-aware interactive text with optional expansion for Flutter.