Build for Everyone · Part 5 of 5
Previously: Parts 1 to 4 made screens readable, operable, testable, and alive for assistive technology. This final part is about a different kind of inclusion: the hundreds of millions of people who read Arabic, Hebrew, Persian, or Urdu, right to left.
TL;DR Compose mirrors most layouts for free, as long as you say start and end*, never* left and right*.* The rest of RTL support is judgement. Directional icons must flip; play buttons, checkmarks, and clocks must not. Phone numbers and English product names inside Arabic sentences need isolating. Raw gesture math, slide animations, and custom
Canvasdrawing don't mirror on their own. Test all of it in minutes with a forced-RTL preview and thear-XBpseudolocale, then lock the result in with a layout assertion and reviewed LTR/RTL screenshot baselines.
The back arrow that points the wrong way
An Arabic launch goes out. The layouts mirror nicely: titles sit on the right, lists align to the right, and the navigation icon moves to the top-right corner. Then a reviewer posts a screenshot. The back arrow, now on the right edge, still points left, deeper into the screen, which in an RTL layout means forward.
The cause is one line from years ago:
// ❌ A fixed, left-pointing arrow. It moves with the layout but never turns around. Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_navigate_up)) It’s a small bug that tells every RTL user the app was never designed with them in mind. It also confuses screen reader users: TalkBack says “Navigate up” about an arrow that visually points forward. The fix is one word:
// ✅ Flips automatically in RTL layouts Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.cd_navigate_up)) The AutoMirrored icon set has existed since material-icons 1.6, and the old directional icons are deprecated in favour of it. If your build shows deprecation warnings for Icons.Filled.ArrowBack, that's your RTL backlog announcing itself.
How Compose handles layout direction
Layout direction is part of the composition, exposed as LocalLayoutDirection. Compose derives it from the device configuration: an Arabic or Hebrew locale means LayoutDirection.Rtl. Every direction-aware API reads it, so start resolves to the left edge in English and to the right edge in Arabic, with no code on your part.
There’s one switch upstream of all of this. Your manifest must opt in:
<application android:supportsRtl="true" ... > Without it, the configuration reports left-to-right for every locale, and nothing in this post applies. New projects set it by default. Older apps, and some white-label templates, quietly don’t.
The rule that follows is simple: use the start/end API, and treat every left/right API as a decision you need to justify.
@Composable fun IngredientRow(ingredient: Ingredient, onRemove: () -> Unit, modifier: Modifier = Modifier) { Row( modifier = modifier .fillMaxWidth() .padding(start = 16.dp, end = 8.dp), // ✅ not absolutePadding(left = 16.dp) horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.Start), verticalAlignment = Alignment.CenterVertically, ) { Text( text = ingredient.quantityLabel, textAlign = TextAlign.End, // ✅ not TextAlign.Right modifier = Modifier.width(56.dp), ) Text(ingredient.name, modifier = Modifier.weight(1f)) IconButton(onClick = onRemove) { Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.cd_remove_ingredient, ingredient.name)) } } } Two details catch even experienced teams:
-
Modifier.offset(x = …)mirrors. Its documentation states that a positivexmoves content right in LTR and left in RTL, toward the end. That's usually what you want, for example nudging a badge toward the edge of an icon. When an offset is genuinely physical, such as aligning with a fixed piece of artwork, useabsoluteOffsetand leave a comment explaining why. - Reading order follows direction too. Part 1 explained that TalkBack orders siblings top to bottom, then start to end. Use start/end APIs and TalkBack reads Arabic screens right to left automatically. Hard-code absolute positions and you break visual order and reading order in one go.
Icons: flip the ones that point
An icon should mirror when it shows direction through the reading flow: back, forward, next, previous, send, list indentation, or a chevron that opens something “after” the current item. An icon should not mirror when its direction comes from the physical world or a universal convention.
- Mirror: back and forward arrows, chevrons for navigation, send, undo and redo, “next step” indicators, text-indent icons.
- Don’t mirror:
- Media playback controls. Play, fast-forward, and rewind describe the direction of the media’s timeline, not of reading.
- Checkmarks. A checkmark is a universal symbol, not a direction.
- Clocks and timers. Clock hands turn the same way in every country.
- Logos, and any icon containing Latin letters or numbers.
For your own vector drawables, the flag lives in the XML:
<!-- res/drawable/ic_reply.xml --> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> <!-- paths --> </vector> The common failure is the reverse of the opening bug. A well-meaning bulk change adds autoMirrored="true" to every drawable, and suddenly the play button in your Arabic build points backwards. Review mirroring per icon, not per folder.
Edge cases: where direction hides
Mixed-direction text
Arabic sentences regularly contain English: product names, usernames, brand names. Unicode’s bidirectional algorithm usually places them correctly. It goes wrong at the edges, when an inserted LTR fragment starts or ends with numbers or punctuation. Then the surrounding RTL text can grab those characters and reorder them. The fix is to isolate the inserted value so it’s laid out as one unit:
import androidx.compose.ui.platform.LocalConfiguration import androidx.core.os.ConfigurationCompat import androidx.core.text.BidiFormatter import java.util.Locale @Composable fun rememberBidiFormatter(): BidiFormatter { val locale = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.getDefault() return remember(locale) { BidiFormatter.getInstance(locale) } } @Composable fun OrderConfirmation(productName: String) { val bidi = rememberBidiFormatter() // Without unicodeWrap, a name like "Pixel 9 Pro" can have its trailing // number pulled into the Arabic text around it. Text(stringResource(R.string.order_confirmed, bidi.unicodeWrap(productName))) } Wrap every runtime value you insert into a translated string: names, titles, search queries. The cost is negligible, and it removes an entire class of bug reports that are almost impossible to reproduce if you don’t read the language.
Numbers, phone numbers, and charts
Digits always read left to right, even inside RTL text. What breaks is sequences with separators. A phone number like +91 98765 43210, placed in an RTL paragraph, can be displayed with its groups in reverse order. To a reader it's a different number entirely. The same applies to ranges ("10–20"), IBANs, card numbers, and version strings.
Force these values to left-to-right explicitly:
// Display: isolate as LTR inside any surrounding text Text(bidi.unicodeWrap(phoneNumber, TextDirectionHeuristicsCompat.LTR)) // Input: keep the field's text LTR while the field itself stays in the RTL layout OutlinedTextField( value = phone, onValueChange = onPhoneChange, label = { Text(stringResource(R.string.label_phone)) }, textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Ltr), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), ) Two more number traps:
- Locale digits. Formatting a number with the user’s locale may produce Eastern Arabic digits (٠١٢٣…), depending on the locale. That’s correct for display. Never parse displayed text back into numbers, and never build IDs or API parameters from locale-formatted strings.
- Charts. Should a time axis run right to left? There is no universal answer. Many bidirectional design guidelines flip timelines so “later” sits toward the reading end, while many finance apps keep charts LTR because that’s what their users see everywhere else. Decide deliberately, apply it consistently, and ask people who read the language every day.
Gestures, animations, and custom drawing
This is where layouts that mirror perfectly still feel wrong. Anything built on direction-aware APIs is handled for you. Anything built on raw coordinates is not.
Handled for you: HorizontalPager, LazyRow, horizontalScroll, SwipeToDismissBox (its values are StartToEnd and EndToStart), and AnimatedContent transitions built with SlideDirection.Start and SlideDirection.End.
You must flip yourself:
// 1. Slide animations with raw pixel offsets AnimatedContent( targetState = step, transitionSpec = { // ❌ slideInHorizontally { it } always enters from the physical right. // ✅ Start/End resolve against the current layout direction. val direction = if (targetState > initialState) { AnimatedContentTransitionScope.SlideDirection.Start } else { AnimatedContentTransitionScope.SlideDirection.End } slideIntoContainer(direction) togetherWith slideOutOfContainer(direction) }, label = "wizard-step", ) { current -> WizardStep(current) } // 2. Raw drag gestures: dragAmount is in screen coordinates, not reading direction @Composable fun Modifier.swipeBetweenPhotos(onNext: () -> Unit, onPrevious: () -> Unit): Modifier { val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val currentOnNext by rememberUpdatedState(onNext) val currentOnPrevious by rememberUpdatedState(onPrevious) return pointerInput(isRtl) { val threshold = 56.dp.toPx() var total = 0f detectHorizontalDragGestures( onDragStart = { total = 0f }, onDragEnd = { // Normalise so that positive always means "toward the end". val towardEnd = if (isRtl) -total else total when { towardEnd < -threshold -> currentOnNext() // swiped toward start: next towardEnd > threshold -> currentOnPrevious() } }, ) { _, dragAmount -> total += dragAmount } } } The rememberUpdatedState calls are there for a reason too. pointerInput is keyed only on direction, so without them the gesture would keep calling whichever lambdas existed on first composition. And per Part 2: any swipe-only interaction also needs a custom accessibility action, such as "Next photo," because swipes aren't available to everyone.
// 3. Custom Canvas drawing: coordinates are physical @Composable fun ReadingProgressBar(progress: Float, modifier: Modifier = Modifier) { val track = MaterialTheme.colorScheme.surfaceVariant val fill = MaterialTheme.colorScheme.primary Canvas(modifier.fillMaxWidth().height(6.dp)) { val mirror = layoutDirection == LayoutDirection.Rtl // DrawScope knows the direction scale(scaleX = if (mirror) -1f else 1f, scaleY = 1f) { // mirror the shapes, not the text drawRect(track) drawRect(fill, size = Size(size.width * progress.coerceIn(0f, 1f), size.height)) } } } Mirror shapes this way, never text. A drawText call inside a mirrored scale block renders backwards, letters and all. Draw labels outside the mirrored block, at positions you calculate from layoutDirection.
Testing RTL in minutes, not sprints
You don’t need to read Arabic to catch 90% of RTL bugs. You need five habits, from fastest to most thorough:
1. Previews in both directions. One extra annotation per component:
@Preview(name = "LTR · English", locale = "en") @Preview(name = "RTL · Arabic", locale = "ar") @Composable private fun IngredientRowPreview() { AppTheme { IngredientRow(ingredient = sampleIngredient, onRemove = {}) } } For a reusable wrapper that works in tests and screenshot tooling too:
@Composable fun ForceRtl(content: @Composable () -> Unit) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl, content = content) } 2. Pseudolocales. Enable them for debug builds:
android { buildTypes.getByName("debug") { isPseudoLocalesEnabled = true } } Then make AR (XB) the active language in your device’s language settings. It renders your English strings right to left, with the characters reversed, so you can review RTL layouts without any translations. Its sibling, en-XA, adds accents and expands text length to expose clipping, and it flags hard-coded strings that never went through resources.
3. Force RTL on a device. In Developer options, enable Force RTL layout direction. It mirrors every app without changing the language, which makes it handy for checking third-party SDK screens your previews never see.
4. A layout assertion in CI. Part 3’s tooling extends naturally:
@Test fun topBar_backButtonSitsAtStartInRtl() { rule.setContent { ForceRtl { AppTheme { RecipeTopBar(title = "Carbonara", onBack = {}) } } } val root = rule.onRoot().getBoundsInRoot() val back = rule.onNodeWithTag(TestTags.BACK).getBoundsInRoot() assertTrue(back.left > root.width / 2, "Back button should sit on the right edge in RTL") } This test proves placement, not orientation. The opening bug would pass it: the icon reaches the right edge while the arrow keeps pointing left. Bounds can’t see the pixels inside an icon, so pair this with the next habit.
5. *Reviewed LTR/RTL screenshot baselines.* Keep one small fixture holding an icon that must mirror and one that must not, and record it in both directions.
@Composable fun DirectionalIconFixture() { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { // Must mirror Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) // Must NOT mirror Icon(Icons.Filled.PlayArrow, contentDescription = null) } } @Preview(name = "LTR", locale = "en") @Preview(name = "RTL", locale = "ar") @Composable private fun DirectionalIconFixturePreview() { AppTheme { DirectionalIconFixture() } } Note: Paparazzi and Roborazzi don’t read the
@Previewannotations, so with those tools set the locale or layout direction in the test configuration. Compose Preview Screenshot Testing does use them.Expected result: the arrow points left in LTR and right in RTL, while the play triangle points right in both. A missing flip shows up as a wrong arrow, and an overbroad mirroring change (the bulk
autoMirrored="true"mistake) shows up as a backwards play button. Because both icons sit in one fixture, anyone updating a shared component can see the intended difference at a glance. Record with Paparazzi, Roborazzi, or Compose Preview Screenshot Testing, whichever you already use.Baselines only help if diffs are reviewed. Check the first recording by eye, ideally with someone who reads an RTL language, and treat “update snapshots” commits with suspicion.
A screen-by-screen audit in RTL takes about two minutes per screen. Check each of these:
- Alignment and padding mirror.
- Directional icons flip; media icons, checkmarks, and logos don’t.
- The screenshot fixture still shows the arrow flipped and the play button unchanged.
- Inserted names and numbers read correctly.
- Swipes and slide animations move with the reading direction.
- Custom drawing such as progress bars and charts fills from the start.
- TalkBack reads right to left.
Common mistakes
- Hard-coded left/right in custom drawing.
Canvascode,drawBehind, and customLayoutplacement all use physical coordinates. ReadlayoutDirectionexplicitly or mirror withscale, and keep text out of the mirrored block. - Mirroring icons that shouldn’t mirror. A bulk
autoMirrored="true"across the drawable folder flips play buttons, checkmarks, and logos. Decide per icon. - Forgetting
supportsRtl. A single missing manifest attribute silently disables every direction-aware API in the app. - Concatenating strings around user content. Build sentences from resources with placeholders, and wrap the inserted values. Concatenation can’t be reordered by translators and can’t be isolated.
- Trusting bounds tests for icon direction. A layout assertion confirms where an icon sits, not which way it points. Add a reviewed LTR/RTL screenshot baseline for directional and non-directional icons.
Series wrap-up: an app that works for far more people
Five parts, one idea: the pixels are only one of the interfaces you ship. The semantics tree, the reading order, the announcements, and the layout direction are interfaces too, and they’re used by real people every day.
- Part 1, *Raising the Floor*, set the baseline: meaningful labels, 48dp touch targets, readable contrast, meaning that survives without color, and intentional reading order.
- Part 2, *From Five Stars to One Sentence*, turned a custom rating widget into one operable node with a state, a range, and custom actions.
- Part 3, *Green Builds, Blind Spots*, made all of it testable in five layers, from semantics assertions to a ten-minute TalkBack routine.
- Part 4, *When the Screen Changes Silently*, covered live regions, headings, and focus for screens that change after they load.
- Part 5, this post, made every screen direction-aware.
Here is everything condensed into one release checklist. Print it, or paste it into your pull request template.
What to do next week
- Add one layer of tests. Pick your most valuable flow and add Part 3’s targeted and bulk assertions to CI. It’s an afternoon’s work that protects everything else.
- Run both manual passes on that flow: the ten-minute TalkBack routine, then two minutes per screen in
ar-XB. Write down everything you find. That list is your roadmap. - Add the icon fixture from Part 5 to your screenshot suite while you’re there. It takes minutes and guards your shared components.
- Fix it in the design system, not the screen. Required
contentDescriptionparameters, aSectionHeaderwithheading(), auto-mirrored icons by default. Every fix in a shared component pays out on every screen. - Talk to users. Five sessions with people who use TalkBack, Switch Access, magnification, or an RTL language every day will reprioritise your backlog better than any checklist, this one included.
Further reading
- Accessibility in Jetpack Compose: the official guide to semantics, traversal, merging, and testing.
- Make apps more accessible: platform-level Android guidance.
- Test your app with pseudolocales: setup for
en-XAandar-XB. - Web Content Accessibility Guidelines (WCAG) 2.2: the standard behind contrast ratios and target sizes.
- Accessibility Test Framework for Android: the engine behind ATF checks and Accessibility Scanner.
- Material Design: Bidirectionality: visual guidance on mirroring layouts and icons.
Thanks for reading the series. If one screen in your app works for one more person next month than it did last month, it did its job.
📱 Level Up Your Android System Design Game
If you’re prepping for a senior Android role and system design rounds are the part that makes you sweat, I highly recommend “Mastering Android System Design” — a 37-chapter playbook that treats Android like the constrained runtime it actually is, not a small server. It covers Binder IPC, ART’s GC, process death, and background execution limits, then walks through the CRDDS interview framework and 13 full worked case studies (chat, ride-hailing, video streaming, payments, and more) carried end-to-end through API design, offline sync, and the Android-specific edge cases that trip candidates up mid-interview.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.







