Soarias ← All SwiftUI guides

How to implement RTL support in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: environment/layoutDirection Updated: May 11, 2026
TL;DR

SwiftUI flips .leading/.trailing alignments automatically for RTL locales. Read @Environment(\.layoutDirection) only when you need to branch logic — for example, to mirror a custom arrow icon or decide which side to anchor a swipe action.

struct BackButton: View {
    @Environment(\.layoutDirection) var layoutDirection

    var body: some View {
        HStack(spacing: 4) {
            Image(systemName: layoutDirection == .rightToLeft
                ? "chevron.right"
                : "chevron.left")
            Text("Back")
        }
    }
}

#Preview("RTL") {
    BackButton()
        .environment(\.layoutDirection, .rightToLeft)
}

Full implementation

The example below demonstrates the three most common RTL scenarios in a single screen: an HStack-based list row that auto-mirrors via semantic alignment, a custom chevron icon that reads layoutDirection explicitly, and a progress bar drawn with GeometryReader that manually accounts for direction. Two #Preview macros let you validate both directions without changing your simulator language.

import SwiftUI

// MARK: - Main screen

struct InboxView: View {
    @Environment(\.layoutDirection) var layoutDirection

    private let messages = Message.samples

    var body: some View {
        NavigationStack {
            List(messages) { message in
                MessageRow(message: message)
            }
            .navigationTitle("Inbox")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    DirectionalChevronButton(label: "Back") {}
                }
            }
        }
    }
}

// MARK: - Message row (uses semantic .leading — auto-mirrors in RTL)

struct MessageRow: View {
    let message: Message

    var body: some View {
        HStack(alignment: .top, spacing: 12) {
            Circle()
                .fill(message.color)
                .frame(width: 44, height: 44)
                .overlay {
                    Text(message.initials)
                        .font(.headline)
                        .foregroundStyle(.white)
                }

            VStack(alignment: .leading, spacing: 4) {   // .leading mirrors automatically
                HStack {
                    Text(message.sender)
                        .font(.headline)
                    Spacer()
                    Text(message.timestamp)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Text(message.preview)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)

                ProgressBar(value: message.readProgress)
                    .frame(height: 4)
                    .padding(.top, 4)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(message.sender), \(message.preview)")
    }
}

// MARK: - Chevron button (reads layoutDirection explicitly)

struct DirectionalChevronButton: View {
    @Environment(\.layoutDirection) var layoutDirection
    let label: String
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            HStack(spacing: 4) {
                Image(systemName: layoutDirection == .rightToLeft
                    ? "chevron.right"
                    : "chevron.left")
                    .imageScale(.small)
                Text(label)
            }
        }
        .accessibilityLabel(label)
    }
}

// MARK: - Progress bar (GeometryReader — must respect direction manually)

struct ProgressBar: View {
    @Environment(\.layoutDirection) var layoutDirection
    let value: Double   // 0.0 – 1.0

    var body: some View {
        GeometryReader { geo in
            let filled = geo.size.width * value
            ZStack(alignment: layoutDirection == .rightToLeft ? .trailing : .leading) {
                Capsule().fill(Color.secondary.opacity(0.2))
                Capsule()
                    .fill(Color.accentColor)
                    .frame(width: filled)
            }
        }
    }
}

// MARK: - Model

struct Message: Identifiable {
    let id = UUID()
    let sender: String
    let initials: String
    let preview: String
    let timestamp: String
    let readProgress: Double
    let color: Color

    static let samples = [
        Message(sender: "Layla Hassan", initials: "LH",
                preview: "يرجى مراجعة المستند المرفق",
                timestamp: "10:42", readProgress: 0.6, color: .purple),
        Message(sender: "Amir Khoury",  initials: "AK",
                preview: "شكراً على ردك السريع",
                timestamp: "09:15", readProgress: 1.0, color: .teal),
        Message(sender: "Sara Nouri",   initials: "SN",
                preview: "هل يمكنك الحضور غداً؟",
                timestamp: "Yesterday", readProgress: 0.2, color: .orange),
    ]
}

// MARK: - Previews

#Preview("LTR — English") {
    InboxView()
        .environment(\.layoutDirection, .leftToRight)
}

#Preview("RTL — Arabic") {
    InboxView()
        .environment(\.layoutDirection, .rightToLeft)
}

How it works

  1. @Environment(\.layoutDirection) — passive detection. The property wrapper gives you a LayoutDirection value (.leftToRight or .rightToLeft). The system sets it automatically based on the device locale, but you can override it per-subtree with .environment(\.layoutDirection, .rightToLeft) — useful for targeted previews and testing.
  2. Semantic alignment in MessageRow — free mirroring. Using VStack(alignment: .leading) and Spacer() inside HStack costs nothing extra — SwiftUI interprets .leading as "start of the reading direction," so the avatar appears on the right and text aligns right in RTL without any code change.
  3. Explicit branch in DirectionalChevronButton — when semantics aren't enough. The back-navigation chevron is directionally meaningful; "back" in RTL points right, not left. The layoutDirection == .rightToLeft ternary swaps the SF Symbol name accordingly. Many SF Symbols have built-in RTL variants — check the SF Symbols app under "Localization" before writing manual branches.
  4. Manual anchor in ProgressBarGeometryReader is direction-agnostic. Absolute-width fills drawn with GeometryReader always start from a fixed origin. The ZStack(alignment:) ternary anchors the filled capsule to .trailing in RTL so the bar fills from right to left as expected.
  5. Dual #Preview macros — catch regressions instantly. Both LTR and RTL previews render simultaneously in Xcode's canvas. Because the override is injected via .environment, no scheme or simulator language change is needed — iterate fast without leaving Xcode.

Variants

Force RTL on a single view for debugging

Wrap any subtree with the environment modifier to isolate RTL testing without touching the rest of the app. This is especially handy when building a new component in isolation.

struct MyFeatureView: View {
    var body: some View {
        VStack {
            // Normal LTR section
            Text("English section")

            Divider()

            // RTL section — e.g. an Arabic chat bubble
            ArabicChatBubble(text: "مرحباً!")
                .environment(\.layoutDirection, .rightToLeft)
        }
    }
}

#Preview {
    MyFeatureView()
}

struct ArabicChatBubble: View {
    let text: String
    var body: some View {
        HStack {
            Spacer()
            Text(text)
                .padding(12)
                .background(.blue, in: RoundedRectangle(cornerRadius: 16))
                .foregroundStyle(.white)
        }
        .padding(.horizontal)
    }
}

Using SF Symbol RTL variants automatically

Many SF Symbols ship with RTL-aware variants. Instead of a manual ternary, append .rtl to the symbol name where available, or use the Image(systemName:) + .imageFlippedForRightToLeftLayoutDirection(true) modifier. SwiftUI will automatically show the flipped variant when the environment direction is .rightToLeft. Open the SF Symbols 6 app, select a symbol, and check the "Localization" tab to see if a directional variant exists before writing branching code.

// Preferred: let SwiftUI flip automatically
Image(systemName: "arrow.right")
    .imageFlippedForRightToLeftLayoutDirection(true)

// In RTL environments this renders as "arrow.left" without any extra code.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement RTL support in SwiftUI for iOS 17+.
Use environment/layoutDirection.
Make it accessible (VoiceOver labels).
Add a #Preview with realistic sample data.
Cover three scenarios: a list row using semantic .leading alignment,
a directional icon that branches on layoutDirection,
and a GeometryReader-based component that anchors correctly in RTL.
Include both LTR and RTL #Preview macros.

In Soarias, paste this into the Build phase prompt for your i18n story — Claude Code will scaffold the component, wire up the environment, and generate dual previews so you can validate Arabic and Hebrew layouts before wiring up real locale data.

Related

FAQ

Does this work on iOS 16?

Yes — LayoutDirection and environment(\.layoutDirection) have been available since SwiftUI's first release. The #Preview macro requires iOS 17+ / Xcode 15+, so replace it with PreviewProvider if you still support iOS 16. All semantic alignment behaviour described here is identical on iOS 16.

Do I need to do anything special for bidirectional text (e.g. mixing Arabic and English in one label)?

SwiftUI's Text view uses NSAttributedString / CoreText under the hood, which handles Unicode bidirectional text automatically. Mixed-direction strings (e.g. an Arabic sentence containing an English brand name) render correctly without extra work. If you need to force a specific base direction for a paragraph, use multilineTextAlignment(.trailing) combined with the layoutDirection environment — never use multilineTextAlignment(.right).

What is the UIKit equivalent?

In UIKit, you read UIView.userInterfaceLayoutDirection or UIApplication.shared.userInterfaceLayoutDirection. Semantic content attributes (semanticContentAttribute = .forceLeftToRight) control per-view mirroring. SwiftUI's environment(\.layoutDirection) maps directly to this concept but is scoped to the view tree rather than being a global override, making it cleaner and easier to test in previews.

Last reviewed: 2026-05-11 by the Soarias team.