Flutter RTL Layout Broken in Arabic? Fix the Half-Mirror
You switch the locale to ar or he. The text moves to the right side, the AppBar title flips, and then the rest of the app gives up. Padding still hugs the left edge, a badge sits in the wrong corner, a custom back arrow points the wrong way, and your progress painter fills from the left.
This is the most common report from teams shipping Flutter RTL Arabic support. Flutter mirrors layout automatically, but only for code written in terms of start and end. Anything that says left or right stays put. This post is an audit you can run file by file, with copy-paste fixes and a widget test that keeps it from coming back.
Step 0: confirm the app actually knows it is RTL
If nothing flips at all, the problem is setup, not layout. Directionality comes from GlobalWidgetsLocalizations, so that delegate has to be registered. With gen-l10n, AppLocalizations.localizationsDelegates already includes it:
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('ar'),
home: const HomePage(),
);
If you list delegates by hand, make sure GlobalWidgetsLocalizations.delegate is there next to GlobalMaterialLocalizations.delegate and GlobalCupertinoLocalizations.delegate.
Then check for a stray override. Search the codebase for textDirection: TextDirection.ltr. A single Directionality(textDirection: TextDirection.ltr, ...) wrapped around a screen, or a Row(textDirection: TextDirection.ltr), pins everything under it to LTR. Keep those only where LTR is intended, such as phone numbers or code snippets.
If text flips but layout does not, carry on. That is the half-mirrored app, and the rest of this audit is for you.
Step 1: EdgeInsets to EdgeInsetsDirectional
EdgeInsets is visual: left means the left edge in every language. EdgeInsetsDirectional uses start and end, which resolve to right and left in RTL.
// Before: stays on the left in Arabic
Padding(
padding: const EdgeInsets.only(left: 16, right: 8),
child: child,
);
// After: flips with the locale
Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: child,
);
EdgeInsets.all, EdgeInsets.symmetric and any padding where left equals right are safe and need no change. Grep for EdgeInsets.only(, EdgeInsets.fromLTRB( and margin: and fix only the asymmetric ones.
The same split exists for corners. Replace BorderRadius.only(topLeft: ...) with BorderRadiusDirectional.only(topStart: ...) so a chat bubble's tail lands on the correct side.
Step 2: Alignment to AlignmentDirectional
Alignment.centerLeft is always the left. Use AlignmentDirectional.centerStart instead:
Container(
alignment: AlignmentDirectional.centerStart, // was Alignment.centerLeft
child: Text(l10n.title),
);
Do the same for text: prefer TextAlign.start and TextAlign.end over TextAlign.left and TextAlign.right. The start and end values are resolved against the ambient direction.
Step 3: Positioned to PositionedDirectional
Inside a Stack, Positioned(left: 8) pins a badge to the left in every locale. Swap it for PositionedDirectional:
Stack(
children: [
avatar,
const PositionedDirectional(
top: 0,
end: 0, // top-right in English, top-left in Arabic
child: UnreadBadge(),
),
],
);
If you need to compute the direction yourself, Positioned.directional(textDirection: ..., start: ..., ...) does the same job.
Row and Wrap need no changes. They lay out children from the start edge already, so MainAxisAlignment.start puts the first child on the right in Hebrew. If a Row looks unflipped, look for an explicit textDirection argument or a parent Directionality override from step 0.
Step 4: icons that should (and should not) mirror
This is the core of most "Flutter icon mirror RTL" reports. The Material Icons set already marks directional glyphs with matchTextDirection: true. Icons.arrow_back, Icons.arrow_forward and Icons.arrow_back_ios_new are examples, and the Icon widget flips them automatically when Directionality is RTL. The default BackButton in an AppBar handles this for you as well.
The icons that stay wrong are almost always custom ones:
IconDatafrom your own icon font, declared withoutmatchTextDirection: true- SVGs or PNGs used as arrows
- third-party icon packs whose glyphs are not flagged
For your own icon font, set the flag at declaration:
class AppIcons {
static const IconData back = IconData(
0xe800,
fontFamily: 'AppIcons',
matchTextDirection: true,
);
}
For images, Image has a matchTextDirection parameter. For anything else, flip it yourself:
Transform.flip(
flipX: Directionality.of(context) == TextDirection.rtl,
child: SvgPicture.asset('assets/arrow_back.svg'),
);
Do not mirror everything. Checkmarks, play buttons, clocks, media controls and brand logos keep the same orientation in RTL. Mirror only icons that point in the reading direction: back, forward, send, undo and redo, list bullets.
Step 5: CustomPainter has no context, so pass the direction in
A CustomPainter draws in raw canvas coordinates. It never sees Directionality, which is why progress bars and charts keep filling from the left. Read the direction in build and hand it to the painter:
class ProgressBar extends StatelessWidget {
const ProgressBar({super.key, required this.value});
final double value;
@override
Widget build(BuildContext context) {
return CustomPaint(
size: const Size(double.infinity, 8),
painter: _ProgressPainter(
value: value,
textDirection: Directionality.of(context),
),
);
}
}
class _ProgressPainter extends CustomPainter {
_ProgressPainter({required this.value, required this.textDirection});
final double value;
final TextDirection textDirection;
@override
void paint(Canvas canvas, Size size) {
final width = size.width * value;
final left = textDirection == TextDirection.rtl ? size.width - width : 0.0;
canvas.drawRect(
Rect.fromLTWH(left, 0, width, size.height),
Paint()..color = const Color(0xFF2E7D32),
);
}
@override
bool shouldRepaint(_ProgressPainter old) =>
old.value != value || old.textDirection != textDirection;
}
Include textDirection in shouldRepaint. Otherwise a runtime locale switch will not repaint. If the painter also draws text, give the TextPainter the same textDirection so Arabic labels shape and align correctly. Do not mirror the whole canvas with canvas.scale(-1, 1) when text is involved, because the glyphs will come out backwards.
Step 6: a widget test that fails on hard-coded left/right
Audits drift. The next PR will bring back an EdgeInsets.only(left: 12). This test pumps the app in Arabic, confirms the direction is RTL, and fails on asymmetric visual padding or horizontal non-directional alignment:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/app.dart';
void main() {
testWidgets('home screen has no hard-coded left/right in ar', (tester) async {
await tester.pumpWidget(const MyApp(locale: Locale('ar')));
await tester.pumpAndSettle();
final scaffold = tester.element(find.byType(Scaffold).first);
expect(Directionality.of(scaffold), TextDirection.rtl);
final offenders = <String>[];
for (final padding in tester.widgetList<Padding>(find.byType(Padding))) {
final p = padding.padding;
if (p is EdgeInsets && p.left != p.right) {
offenders.add('Padding $p on ${padding.child.runtimeType}');
}
}
for (final align in tester.widgetList<Align>(find.byType(Align))) {
final a = align.alignment;
if (a is Alignment && a.x != 0) {
offenders.add('Align $a on ${align.child.runtimeType}');
}
}
expect(offenders, isEmpty, reason: offenders.join('\n'));
});
}
This works because Container(padding:) and Container(alignment:) build Padding and Align widgets under the hood, so most offenders surface here. Two notes from running it on real apps:
- A few framework or package widgets may use visual insets internally. If one shows up, filter it out by
child.runtimeTyperather than deleting the check. PositionedDirectionalresolves to a plainPositionedat build time, so this test cannot tell the two apart. Cover stacks with a golden test or a quick visual pass inhe.
Run one test per main screen, and add Locale('he') or Locale('fa') as a second variant if you ship those. Flutter Hebrew RTL support goes through exactly the same code paths, so one pass fixes both.
The quick grep checklist
Before you ship, search for these and justify every hit:
EdgeInsets.only(andEdgeInsets.fromLTRB(Alignment.centerLeft,Alignment.centerRight,topLeft,topRightTextAlign.left,TextAlign.rightPositioned(withleft:orright:BorderRadius.only(TextDirection.ltrCustomPaintersubclasses with notextDirectionfield
RTL is half of an Arabic launch
A perfectly mirrored layout still looks broken if a string reads "1 days". Arabic uses six plural categories (zero, one, two, few, many, other), and a dropped few or many silently falls back to other. We covered which forms gen-l10n honors, with a test for all six, in Fix Flutter gen-l10n Arabic plurals (zero/two/few/many).
The FlutterLocalisation ARB editor lets you edit app_ar.arb and app_he.arb in a UI instead of raw JSON, and its ICU plural validation flags locales that are missing a category the language needs, such as Arabic's few and many. It also manages translations across all your locales in one place. Plans are on the pricing page.
Try FlutterLocalisation free and check your Arabic plurals while you fix your RTL layout.