← Back to Blog

Fix Flutter Arabic + English Text Rendering in Wrong Order

flutterrtlbidiarbi18narabic

Fix Flutter Arabic + English Text Rendering in Wrong Order

You ship an Arabic build. The translation is perfect in the ARB file. Then a user named "Ali Hassan" signs in and your greeting renders like this:

؟مرحبًا Ali Hassan، هل تريد المتابعة

The question mark teleported to the far left. Or the English handle you interpolated lands in the middle of the sentence instead of where the translator put it. Or the trailing colon in رسالة من John: shows up as :رسالة من John.

Nothing is wrong with your Directionality widget, your TextAlign, or your font. This is the Unicode Bidirectional Algorithm doing exactly what it's specified to do — and the fix is three characters wide.

Why the punctuation jumps

Every character in Unicode has a bidi class. Letters are strong: Arabic and Hebrew letters are strong RTL, Latin letters are strong LTR. Digits are weak. And ?, :, ., !, @, -, (, and spaces are neutral.

Neutral characters have no direction of their own. The algorithm resolves them from their neighbours, and when a neutral run sits between two runs of different direction — or between a run and the end of the paragraph — it falls back to the paragraph direction.

So in an Arabic (RTL) paragraph:

  • مرحبًا → strong RTL run
  • Ali Hassan → strong LTR run, correctly reordered as an island
  • ، هل تريد المتابعة → strong RTL run
  • ؟ → neutral, at the paragraph edge, so it takes RTL and gets placed at the visual left end of the line

The ؟ is still the last character in logical (storage) order. Its visual position is just wrong relative to the sentence you meant. Same mechanism in reverse when an Arabic name is interpolated into an English sentence: the trailing : gets pulled to the left of the Arabic run.

This is the entire class of bugs behind "flutter arabic english mixed text wrong order" and "flutter arabic punctuation wrong side". The interpolated value's direction is leaking into the surrounding sentence.

What not to do

Three fixes get repeated constantly and all three are wrong:

  1. Wrapping the Text in Directionality(textDirection: TextDirection.ltr). This flips the whole sentence's base direction, so now your Arabic renders left-aligned and starts on the wrong side. You traded one bug for a worse one.
  2. Reversing the string with split('').reversed.join(). This destroys grapheme clusters, breaks Arabic contextual shaping, and produces text that copy-pastes as garbage.
  3. Splitting into TextSpans and giving each a direction. Bidi resolution in Flutter runs over the whole paragraph, not per span. TextSpan has no textDirection, and adjacent spans do not isolate each other.

The correct fix: isolate the placeholder

Unicode defines isolate control characters for precisely this. Wrap a run in FSI (U+2068, First Strong Isolate) and close it with PDI (U+2069, Pop Directional Isolate), and the run becomes a single neutral object from the outside: its internal direction is auto-detected from its first strong character, and it cannot influence how the surrounding text is ordered.

The Unicode Consortium recommends isolates as the default for all inline bidi embedding, and the W3C recommends the same wherever markup isn't available. Flutter exposes the constants in package:flutter/foundation.dart:

import 'package:flutter/foundation.dart' show Unicode;

/// Wrap an interpolated value so it can't reorder the sentence around it.
/// FSI auto-detects the value's own direction; PDI restores the outer one.
String isolate(Object? value) => '${Unicode.FSI}$value${Unicode.PDI}';

/// Use these when you *know* the value's direction (safer than auto-detect).
String isolateLtr(Object? value) => '${Unicode.LRI}$value${Unicode.PDI}';
String isolateRtl(Object? value) => '${Unicode.RLI}$value${Unicode.PDI}';

Unicode also carries LRE, RLE, PDF, LRM, RLM, and ALM. The LRE/RLE/PDF trio is the legacy embedding mechanism — it stops the outer text from reordering the inner run, but not the reverse, which is why isolates superseded it.

If you already depend on package:intl, note what Bidi actually gives you. Its two enforce helpers are embedding-based, not isolate-based:

// From the intl source — these use LRE/RLE + PDF, the legacy mechanism:
static String enforceRtlInText(String text) => '$RLE$text$PDF';
static String enforceLtrInText(String text) => '$LRE$text$PDF';

They work for the common case, but for new code prefer FSI/PDI. Bidi has no isolate helper — which is why the four-line isolate() above is worth keeping in your codebase.

Applying it to ARB placeholders

Your ARB file doesn't change at all. Isolation is a call-site concern, because only the call site knows the value is user-controlled.

{
  "welcomeUser": "مرحبًا {name}، هل تريد المتابعة؟",
  "@welcomeUser": {
    "description": "Home screen greeting. {name} is user-supplied and may be LTR.",
    "placeholders": {
      "name": { "type": "String" }
    }
  },
  "sharedLink": "شارك {handle} الرابط {url}",
  "@sharedLink": {
    "description": "Feed item. Both values are LTR even in RTL locales.",
    "placeholders": {
      "handle": { "type": "String" },
      "url": { "type": "String" }
    }
  }
}

Then at the call site:

final l10n = AppLocalizations.of(context)!;

Text(l10n.welcomeUser(isolate(user.displayName)));

Text(l10n.sharedLink(
  isolate('@${user.handle}'),
  isolate(post.url),
));

Now the ؟ stays at the visual left-to-right logical end of the Arabic sentence where the translator put it, the @ stays glued to the handle instead of drifting mid-sentence, and the URL renders as one unbroken LTR island.

Don't isolate one placeholder at a time forever

Threading isolate() through hundreds of call sites invites the one omission that ships the bug. Wrap the generated class instead:

/// Thin wrapper over the gen-l10n class that isolates every user-supplied
/// value. Inject this instead of AppLocalizations in your widgets.
class SafeL10n {
  const SafeL10n(this._l);
  final AppLocalizations _l;

  String welcomeUser(String name) => _l.welcomeUser(isolate(name));

  String sharedLink(String handle, String url) =>
      _l.sharedLink(isolate(handle), isolate(url));

  // Messages with no placeholders just forward.
  String get settingsTitle => _l.settingsTitle;
}

One place to audit, one place to fix, and a code review rule that's easy to state: any placeholder carrying data you didn't write gets isolated.

Numbers, dates, and prices

Digits are weak, so they inherit direction from context — which means 12-15 inside an Arabic sentence can render as 15-12. Format with intl first, then isolate the result:

import 'package:intl/intl.dart';

final price = NumberFormat.currency(locale: 'ar', symbol: 'د.إ').format(1250.5);
Text(l10n.totalDue(isolate(price)));

Same for version strings (2.14.1), phone numbers, ranges, and file paths — anything where neutral separators sit between digit runs.

Isolating a whole user-generated blob

Placeholder isolation solves interpolation. A different problem is a chat bubble or comment whose entire content is user-generated and could be in either script. There, you want the paragraph's base direction to follow the content:

import 'package:intl/intl.dart';

Widget bubble(String message) {
  final dir = Bidi.detectRtlDirectionality(message)
      ? TextDirection.rtl
      : TextDirection.ltr;
  return Directionality(
    textDirection: dir,
    child: Text(message, textAlign: TextAlign.start),
  );
}

Bidi.detectRtlDirectionality is a heuristic — it estimates from the ratio of RTL to LTR "words" against a 40% threshold, so short mixed strings can guess wrong. It's the right tool for a free-text blob and the wrong tool for a placeholder.

Two caveats worth knowing

FSI can guess wrong. It picks direction from the first strong character. An Arabic value that begins with an English quotation or a ( still resolves correctly (those are neutral), but an Arabic value beginning with a Latin brand name resolves as LTR. When you know the direction of the data — a URL is always LTR, a machine-translated Arabic string is always RTL — use isolateLtr / isolateRtl instead of isolate.

The control characters are real characters. They count toward String.length, they end up in the clipboard on copy, and they'll break naive equality checks and analytics. Strip them at the boundary:

final _bidiControls = RegExp(
  '[' // bidi control characters
  '\u202A-\u202E'   // LRE, RLE, PDF, LRO, RLO
  '\u2066-\u2069'   // LRI, RLI, FSI, PDI
  '\u200E\u200F\u061C' // LRM, RLM, ALM
  ']',
);

String stripBidi(String s) => s.replaceAll(_bidiControls, '');

Never persist isolated strings to your database or send them to an API — isolate at render time only.

A test that actually catches regressions

Golden tests on Arabic screens are the real safety net, but a cheap unit test pins the contract:

test('greeting isolates the user name', () {
  final l10n = AppLocalizationsAr();
  final out = SafeL10n(l10n).welcomeUser('Ali Hassan');

  expect(out, contains('${Unicode.FSI}Ali Hassan${Unicode.PDI}'));
  // The sentence-final question mark is still logically last.
  expect(stripBidi(out).trim(), endsWith('؟'));
});

Get the ARB layer right too

Isolation fixes rendering. It doesn't fix a message whose Arabic plural forms are incomplete — and Arabic needs zero, one, two, few, many, and other, which is exactly where hand-edited ARB files quietly fall over. FlutterLocalisation's ARB editor lets you edit app_ar.arb in a UI instead of raw JSON, and its ICU plural-syntax validation flags any locale missing a plural category the language actually requires, so a dropped few for Arabic gets caught before release.

If you're setting RTL support up from scratch, start with our complete guide to RTL language support in Flutter and the Flutter localization complete guide. See what's included on features and pricing.

Try FlutterLocalisation free — manage your Arabic, Hebrew, and Persian ARB files in a real editor, with plural validation that catches what your eyes won't.