GetTranslated.AI
Mobile App Development

6 Localization Testing Failures That Only Show Up in RTL Languages (And How to Prevent Each One)

6 Localization Testing Failures That Only Show Up in RTL Languages (And How to Prevent Each One)

RTL support is one of those things that teams assume they've handled until they check their app reviews in Saudi Arabia. The failures are rarely subtle — broken layouts, reversed price strings, navigation arrows pointing the wrong direction — and they cluster in predictable patterns that most test suites never exercise. This post picks up where general localization testing leaves off, focusing specifically on the RTL layer: six failure modes, how to reproduce them reliably, and how to build tests that catch them before your users do.


Failure #1: Mirroring Gaps — Components That Ignore layoutDirection

The most pervasive RTL failure isn't in your core views — it's in the components you didn't write. Custom views, third-party libraries, and anything rendered on a Canvas often ignore the layoutDirection property entirely. The system flips everything it controls; your canvas-drawn progress ring stays exactly where it is.

How to detect it: Android ships with a pseudo-locale designed exactly for this: ar-XB. Enable it in Developer Options (or inject it in your test runner), and any view that ignores layout direction will immediately stand out. On iOS, enable "Right to Left Pseudolanguage" under Scheme → Run → App Language. Run your screenshot tests against both, then diff against LTR. The gaps are obvious.

The fix on Android:

<!-- res/layout/my_custom_view.xml -->
<com.example.MyCustomView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layoutDirection="locale" />

For custom views drawn in code:

// Inside your custom View subclass
override fun onDraw(canvas: Canvas) {
    val isRtl = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL
    val startX = if (isRtl) width - paddingEnd else paddingStart.toFloat()
    // draw relative to startX
}

On iOS:

// Force RTL for testing or apply semanticContentAttribute
myView.semanticContentAttribute = .forceRightToLeft

// In production, respect the locale:
myView.semanticContentAttribute = UIView.appearance().semanticContentAttribute

In React Native:

import { I18nManager } from 'react-native';

// In your root component or app entry:
if (shouldBeRtl && !I18nManager.isRTL) {
  I18nManager.forceRTL(true);
  // Requires app reload to take effect
}

The key rule: never assume a third-party component handles RTL. Test it explicitly with pseudo-locale before shipping. If it doesn't mirror, file the bug and add a wrapper view with explicit layoutDirection.


Failure #2: Hardcoded LTR Punctuation and String Concatenation

This one is sneaky. The string looks right in your English build. It looks right in Hebrew. It breaks specifically in Arabic, in specific numeric contexts, on specific OS versions. The cause is almost always string concatenation that embeds directionality assumptions.

The classic example is price formatting:

// Android — this is wrong
val price = "$" + amount + " USD"
textView.text = price

// Also wrong
val price = String.format("$%s USD", amount)

In Arabic, the $ character has inherent LTR directionality. When you concatenate it with an Arabic string, the bidi algorithm makes a guess about rendering order — and it guesses wrong about half the time depending on what surrounds it.

The fix is locale-aware formatters, not string manipulation:

// Android — correct
val formatter = NumberFormat.getCurrencyInstance(Locale.getDefault())
val price = formatter.format(amount) // Handles currency symbol position and numeral script
textView.text = price
// iOS — correct
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale.current
let price = formatter.string(from: NSNumber(value: amount)) ?? ""
// React Native — correct
const price = new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: 'USD',
}).format(amount);

When you absolutely must compose strings from parts, wrap directional content in Unicode bidi isolate characters:

// Wrap LTR content to prevent bidi leakage
val isolatedAmount = "\u2068${amount}\u2069" // FSI...PDI

Use \u2068 (First Strong Isolate) and \u2069 (Pop Directional Isolate) around mixed-direction fragments. It's not glamorous, but it's correct. For a deeper dive into how AI translation tooling can silently corrupt these string patterns, see our post on why AI translation breaks mobile resource files.


Failure #3: Icon and Image Mirroring — What Should Flip and What Shouldn't

The decision of what to mirror is a semantic question, not a technical one — and getting it wrong in either direction causes real problems. Mirror everything and your logo renders backwards. Mirror nothing and your back arrow points forward.

The decision table:

Asset Type Should Mirror? Reason
Back/forward arrows Yes Navigation affordance follows reading direction
Progress indicators (horizontal) Yes Progress flows in reading direction
Directional chevrons Yes Reading direction affordance
Logos and wordmarks No Brand identity is not directional
Play/pause buttons No Media controls are culturally fixed LTR
Checkmarks No Semantic meaning, not directional
Alert/warning icons No Universal symbols
Chat bubble tails Yes Sender position follows reading direction

Android — use autoMirrored for drawables that should flip:

<!-- res/drawable/ic_back_arrow.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:autoMirrored="true"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path android:pathData="M20,11H7.83l5.59-5.59L12,4l-8,8 8,8 1.41-1.41L7.83,13H20v-2z"/>
</vector>

iOS:

// Assets marked with "Direction: Left to Right" in the asset catalog
// will automatically flip. For programmatic use:
let arrowImage = UIImage(named: "ic_back_arrow")?
    .imageFlippedForRightToLeftLayoutDirection()
imageView.image = arrowImage

React Native — manual flip for directional assets:

import { I18nManager, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  directionalIcon: {
    transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }],
  },
  // Do NOT apply this to logos or media controls
});

The common mistake is applying a global transform to all icons. Maintain an explicit list — or annotation in your asset catalog — of which assets are directional.


Failure #4: Text Alignment Fallbacks That Hardcode left or right

This one is frequently introduced by developers who aren't thinking about RTL at all — a quick fix, a custom style, a designer handoff. textAlignment = .left in iOS or android:gravity="left" in Android XML overrides the system's automatic RTL behavior for text. Every instance is a regression waiting to happen.

How to find them at scale:

# Find hardcoded text alignment in Android XML
grep -rn 'gravity="left"\|gravity="right"\|textAlignment="left"\|textAlignment="right"' app/src/main/res/

# Find them in iOS Swift/ObjC
grep -rn 'textAlignment = .left\|textAlignment = .right\|NSTextAlignmentLeft\|NSTextAlignmentRight' .

Android Lint also has the RtlHardcoded rule built in. Run it in CI:

./gradlew lint
# Look for: RtlHardcoded — Using left/right instead of start/end attributes

The fix is straightforward — use semantic direction attributes everywhere:

<!-- Android: replace gravity="left" with gravity="start" -->
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:gravity="start"
    android:text="@string/label" />
// iOS: use .natural instead of .left
label.textAlignment = .natural
// React Native: avoid hardcoding 'left' or 'right'
// Use 'auto' or rely on the default (which respects I18nManager.isRTL)
const styles = StyleSheet.create({
  label: {
    textAlign: 'auto', // Respects RTL automatically
  },
});

Gate this in CI. Add the grep patterns above as a lint step in your GitHub Actions or Bitrise workflow. A failed grep — any match for hardcoded left/right in layout files — blocks the merge. This is a ten-line CI addition that eliminates an entire regression category permanently.

# .github/workflows/rtl-lint.yml
- name: Check for hardcoded RTL violations
  run: |
    matches=$(grep -rn 'gravity="left"\|gravity="right"' app/src/main/res/ || true)
    if [ -n "$matches" ]; then
      echo "RTL violation: hardcoded gravity found:"
      echo "$matches"
      exit 1
    fi

Failure #5: ScrollView, ViewPager, and Horizontal List Direction

Carousels and onboarding flows are a special category of RTL failure because the issue isn't visual — it's behavioral. In an RTL layout, a user swiping to navigate "forward" expects to swipe right-to-left. If your ViewPager or FlatList moves the opposite direction, the experience feels broken even if everything looks fine in a screenshot test.

Platform specifics matter here:

ViewPager2 on Android handles RTL automatically as of API 17 — if you're still on ViewPager (original), you're on your own and should migrate. RecyclerView with LinearLayoutManager respects RTL when setLayoutDirection is called or the locale is RTL. The problem is manual scroll offset math:

// Wrong: manual offset calculation ignores RTL
scrollView.scrollTo(targetPosition * itemWidth, 0)

// Right: let the LayoutManager handle it
recyclerView.smoothScrollToPosition(targetPosition)
// Or for RTL-aware offset math:
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val isRtl = ViewCompat.getLayoutDirection(recyclerView) == ViewCompat.LAYOUT_DIRECTION_RTL
val offset = if (isRtl) totalWidth - targetPosition * itemWidth else targetPosition * itemWidth

React Native FlatList requires explicit handling:

import { FlatList, I18nManager } from 'react-native';

// For RTL, the list needs to start from the right
<FlatList
  data={items}
  horizontal
  inverted={I18nManager.isRTL}  // Reverses item order for RTL
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
/>

Note: inverted reverses the entire list, which is what you want for RTL navigation flows. For carousels where order matters independently of direction, you may need to reverse the data array instead.

Testing swipe direction with Espresso:

@Test
fun onboardingFlow_swipesCorrectlyInRtl() {
    // Set locale to Arabic before test
    activityRule.scenario.onActivity { activity ->
        val config = Configuration(activity.resources.configuration)
        config.setLocale(Locale("ar"))
        activity.createConfigurationContext(config)
    }

    onView(withId(R.id.onboarding_pager))
        .perform(swipeLeft()) // In RTL, swipeLeft moves forward

    onView(withId(R.id.page_2_content))
        .check(matches(isDisplayed()))
}

Failure #6: Date, Number, and Bidirectional Text in the Same String

This is the most technically complex failure and the hardest to reproduce reliably outside of real Arabic and Hebrew device locales. It's also the one most likely to be introduced by translation memory.

The problem: Arabic uses Arabic-Indic numerals (٠١٢٣٤٥٦٧٨٩) by default in many regional locales, while Hebrew uses Western Arabic numerals but with RTL text flow. When a translated string contains a date, a number, a currency symbol, and a translated phrase — all in one TextView — the bidi algorithm has to make decisions about rendering order. It doesn't always make the right ones.

Why translation memory makes this worse: TM matches pull in previously approved translations of strings like "Updated %s ago". The old translation was built for a context where the placeholder held an LTR time string. Now you're passing "منذ ٣ دقائق" into the same template. The bidi rendering breaks in a way it never did before, on a string that has a 100% TM match and was never flagged for review.

The structural fix is to separate numerals and text into discrete views when possible:

<!-- Android: separate text and number to avoid bidi conflicts -->
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layoutDirection="locale">

    <TextView
        android:id="@+id/label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/updated_label" />

    <TextView
        android:id="@+id/timestamp"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textDirection="ltr" /><!-- Timestamps are LTR -->
</LinearLayout>

For formatters, always pass the correct locale:

// iOS — explicitly pass locale to DateFormatter
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "ar_SA") // Not Locale.current if you need to test
formatter.dateStyle = .medium
formatter.timeStyle = .short
let formatted = formatter.string(from: date)
// Arabic locale returns Arabic-Indic numerals by default
// Android — use locale-aware formatter
val formatter = DateTimeFormatter
    .ofLocalizedDateTime(FormatStyle.MEDIUM)
    .withLocale(Locale("ar", "SA"))
val formatted = localDateTime.format(formatter)

Testing note: pseudo-RTL locale (ar-XB) does not reproduce Arabic-Indic numeral rendering. You must test with real Arabic locale (ar, ar-SA, ar-EG) to catch this class of failure. Run at least one configuration of your screenshot tests against a real Arabic locale, not just pseudo-RTL.

This is also one area where translation memory tooling needs to be configured carefully — segments with embedded numeric placeholders should be flagged for human review rather than auto-applied from TM matches, because the rendering context may have changed. See our notes on translation memory best practices for how to configure those rules.


Building RTL Into Your Localization Test Suite

Six failure patterns is manageable, but only if you build systematic coverage rather than testing ad hoc. Here's the minimum viable RTL test matrix:

Device and locale combinations to cover:

Priority Locale Device What it covers
P0 ar-XB (pseudo) Any Layout mirroring, text alignment, icon direction
P0 ar-SA or ar Real device or emulator Arabic-Indic numerals, bidi text rendering
P1 he-IL Emulator Hebrew-specific bidi, Western numerals in RTL context
P1 fa-IR Emulator Farsi (another major RTL market, extends Arabic coverage)

You don't need every locale on every device. Two configurations — pseudo-RTL for structural tests and real Arabic for bidi rendering tests — cover the overwhelming majority of production failures.

Automating RTL screenshot tests in GitHub Actions:

# .github/workflows/rtl-screenshot-tests.yml
name: RTL Screenshot Tests

on: [pull_request]

jobs:
  rtl-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run RTL screenshot tests (pseudo-locale)
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          locale: ar
          language: ar
          country: XB
          script: ./gradlew screenshotTests -PtestLocale=ar-XB

  rtl-ios:
    runs-on: macos-13
    steps:
      - uses: actions/checkout@v3
      - name: Run RTL screenshot tests
        run: |
          xcodebuild test \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -testLanguage ar \
            -testRegion SA \
            | xcpretty

For Bitrise, the same locale injection works via the xcodebuild and Gradle steps — pass -testLanguage ar and -testRegion XB (for pseudo) or SA (for real Arabic) as environment variables.

For more context on scaling Arabic and Hebrew support as your team grows, the post on how growing teams scale Arabic and Hebrew support covers the organizational side of this problem in detail.


Conclusion

RTL failures are not exotic edge cases — they're predictable, reproducible, and almost entirely preventable once you know the six patterns. Mirroring gaps in custom views, bidi-broken string concatenation, incorrect icon mirroring decisions, hardcoded text alignment, scroll direction inversion, and mixed bidi in formatted strings account for the vast majority of RTL bugs that make it to production.

The investments required are small: a grep in your CI pipeline catches hardcoded alignment regressions forever. Two screenshot test configurations — pseudo-RTL and real Arabic — surface layout and rendering failures automatically. Locale-aware formatters replace string concatenation once and stop the bidi punctuation bugs entirely.

Arabic and Hebrew together represent a substantial addressable market, and RTL failures have a disproportionate impact on app ratings in MENA and Israeli markets because they signal that users in those markets weren't tested for. Getting this right is a statement about who your app is built for.

If you want to turn the six patterns above into a repeatable pre-release checklist, the next post in this series — RTL Layout Testing: The Complete Checklist for Arabic and Hebrew Localization — does exactly that. For context on how these failures manifest platform-by-platform, 5 platform-specific ways RTL breaks your mobile app is worth reading alongside this one.

Ready to localize your app?

Get started free — no credit card required.

Start Translating →