Small details that build taste in Flutter.

curated by Kamran BekirovKamran Bekirov

Tappable Terms links in any language

Sign up screens link to Terms of Use and Privacy Policy from inside a sentence. Each language puts those links in a different spot. English ends with them, Turkish starts with them:

🇬🇧 I agree to the Terms of Use and Privacy Policy
🇹🇷 Kullanım Koşullarını ve Gizlilik Politikasını kabul ediyorum

Don't build it from separate widgets glued together in code, they can't move like that. Keep it as one string and mark the links with tags:

LinkedText(
  text: 'I agree to the <terms>Terms of Use</terms> and <privacy>Privacy Policy</privacy>',
  style: const TextStyle(
    fontSize: 14, 
    color: Colors.black87,
  ),
  linkColor: Colors.blue,
  onTap: (tag) {
    if (tag == 'terms') openTerms();
    if (tag == 'privacy') openPrivacy();
  },
)

The string goes into your localization files as is. Translators move the tags to wherever their language puts the links, and the tag name tells you which link was tapped, same in every language.

Copy this widget and use it as is:

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
  
/// Text with tappable stretches marked inline as `<terms>Terms of Use</terms>`.
///
/// Keeping the tags in the string is what makes this translatable: a
/// sentence can put its links anywhere, in any order, without the layout
/// knowing. [onTap] is handed the tag name, so it stays the same in every
/// language no matter where the link moves.
class LinkedText extends StatefulWidget {
  final String text;
  final void Function(String tag) onTap;
  final TextStyle style;
  final Color linkColor;
  final TextAlign textAlign;
  
  const LinkedText({
    super.key,
    required this.text,
    required this.onTap,
    required this.linkColor,
    required this.style,
    this.textAlign = .center,
  });
  
  @override
  State<LinkedText> createState() => _LinkedTextState();
}
  
class _LinkedTextState extends State<LinkedText> {
  static final RegExp _pattern = RegExp(
    r'<(\w+)>(.*?)</\1>',
    dotAll: true,
  );
  
  List<RegExpMatch> _matches = <RegExpMatch>[];
  List<TapGestureRecognizer> _recognizers = <TapGestureRecognizer>[];
  
  @override
  void initState() {
    super.initState();
  
    _parse();
  }
  
  @override
  void didUpdateWidget(LinkedText oldWidget) {
    super.didUpdateWidget(oldWidget);
  
    if (oldWidget.text != widget.text) {
      _disposeRecognizers();
      _parse();
    }
  }
  
  @override
  void dispose() {
    _disposeRecognizers();
  
    super.dispose();
  }

  void _parse() {
    _matches = _pattern.allMatches(widget.text).toList();
  
    _recognizers = List<TapGestureRecognizer>.generate(
      _matches.length,
      (int index) {
        final String tag = _matches[index].group(1) ?? '';
  
        return TapGestureRecognizer()
          ..onTap = () {
            widget.onTap(tag);
          };
      },
    );
  }
  
  void _disposeRecognizers() {
    for (final TapGestureRecognizer recognizer in _recognizers) {
      recognizer.dispose();
    }
  
    _recognizers = <TapGestureRecognizer>[];
  }
  
  @override
  Widget build(BuildContext context) {
    final TextStyle style = widget.style;
    final TextStyle linkStyle = style.copyWith(
      color: widget.linkColor,
      fontWeight: .w600,
    );
  
    if (_matches.isEmpty) {
      return Text(
        widget.text,
        style: style,
        textAlign: widget.textAlign,
      );
    }
  
    final List<InlineSpan> spans = <InlineSpan>[];
    int cursor = 0;
  
    for (int index = 0; index < _matches.length; index++) {
      final RegExpMatch match = _matches[index];
      final String before = widget.text.substring(cursor, match.start);
  
      if (before.isNotEmpty) {
        spans.add(
          TextSpan(
            text: before,
          ),
        );
      }
  
      spans.add(
        TextSpan(
          text: match.group(2) ?? '',
          style: linkStyle,
          recognizer: _recognizers[index],
        ),
      );
  
      cursor = match.end;
    }
  
    final String tail = widget.text.substring(cursor);
  
    if (tail.isNotEmpty) {
      spans.add(
        TextSpan(
          text: tail,
        ),
      );
    }
  
    return Text.rich(
      TextSpan(
        children: spans,
        style: style,
      ),
      textAlign: widget.textAlign,
    );
  }
}
Kamran Bekirov
Kamran Bekirov

Want this level of care in your Flutter apps?

Work With Me