Skip to content

Bidirectional Text and RTL

Use when handling bidirectional text, RTL languages, writing direction controls, or cursor behavior in Arabic/Hebrew.

Reference Skills

Use when handling bidirectional text, RTL languages, writing direction controls, or cursor behavior in Arabic/Hebrew.

Family: Text Model And Foundation Utilities

Use this skill when the main question involves right-to-left text, mixed-direction content, or the APIs that control writing direction.

  • Supporting Arabic, Hebrew, or other RTL languages in text editors
  • Mixed LTR/RTL content (phone numbers in Arabic text, etc.)
  • Cursor movement or selection in bidirectional text
  • Writing direction APIs at any level (paragraph, attribute, view, SwiftUI)
  • iOS 26 Natural Selection / selectedRanges migration
Using standard UITextView / UITextField?
→ Bidi mostly "just works" with .natural writing direction.
→ Read the Mixed Content section for edge cases.
Building a custom UITextInput view?
→ You must handle bidi yourself. Read everything here.
Targeting iOS 26+?
→ Adopt Natural Selection (selectedRanges) for correct bidi selection.
Need inline direction overrides?
→ Use .writingDirection attributed string key.

Direction is controlled at multiple levels, from highest to lowest:

// Read current layout direction
@Environment(\.layoutDirection) var layoutDirection
// Force direction on a view hierarchy
VStack { ... }
.environment(\.layoutDirection, .rightToLeft)
// Mirror images for RTL
Image("arrow")
.flipsForRightToLeftLayoutDirection(true)

Note: SwiftUI Text ignores the .writingDirection attributed string key. Use the environment for direction control in SwiftUI.

The most common way to set direction for a block of text:

let style = NSMutableParagraphStyle()
style.baseWritingDirection = .natural // Auto-detect from content (default)
// or .leftToRight
// or .rightToLeft
let attrs: [NSAttributedString.Key: Any] = [.paragraphStyle: style]

.natural uses the Unicode Bidi Algorithm (rules P2/P3) to detect direction from the first strong directional character.

3. .writingDirection Attribute (Inline Overrides)

Section titled “3. .writingDirection Attribute (Inline Overrides)”

For overriding direction within a paragraph — equivalent to Unicode bidi control characters:

// Force a range to LTR embedding (like Unicode LRE + PDF)
let ltrEmbed: [NSNumber] = [
NSNumber(value: NSWritingDirection.leftToRight.rawValue | NSWritingDirectionFormatType.embedding.rawValue)
]
attrString.addAttribute(.writingDirection, value: ltrEmbed, range: range)
// Force a range to RTL override (like Unicode RLO + PDF)
let rtlOverride: [NSNumber] = [
NSNumber(value: NSWritingDirection.rightToLeft.rawValue | NSWritingDirectionFormatType.override.rawValue)
]

Embedding respects the content’s own directionality within the override. Override forces all characters to display in the specified direction regardless of their inherent directionality.

// Set direction for a text range at runtime
textInput.setBaseWritingDirection(.rightToLeft, for: textRange)
// Read current direction
let direction = textInput.baseWritingDirection(for: position, in: .forward)

5. iOS 26: AttributedString.WritingDirection

Section titled “5. iOS 26: AttributedString.WritingDirection”
var text = AttributedString("Hello عربي")
text.writingDirection = .rightToLeft
// Values: .leftToRight, .rightToLeft

Writing direction affects line fragment advancement:

// The writingDirection parameter determines which side lines start from
textContainer.lineFragmentRect(
forProposedRect: rect,
at: index,
writingDirection: .rightToLeft,
remaining: &remaining
)

This is the core concept for bidi text.

Logical order: How characters are stored in memory (the string). Always follows reading order of each language — Arabic characters are stored right-to-left in logical order.

Visual order: How characters appear on screen after the Unicode Bidi Algorithm reorders them.

Logical: "Hello مرحبا World"
H-e-l-l-o- -ا-ب-ح-ر-م- -W-o-r-l-d
Visual: "Hello ابحرم World"
Characters 6-10 are visually reordered (RTL run)

A single cursor position can map to two visual positions at direction boundaries. This is why cursor movement in bidi text is inherently ambiguous.

Previously, selectedRange was a single contiguous NSRange. In bidirectional text, this caused visually disjoint selections — the selection included storage-contiguous characters that were visually separated.

// New: multiple ranges following visual cursor movement
textView.selectedRanges // [NSRange] — replaces selectedRange
// New delegate method for multi-range edits
func textView(_ textView: UITextView,
shouldChangeTextInRanges ranges: [NSRange],
replacementStrings: [String]?) -> Bool {
// Handle multi-range replacement
return true
}
  • Requires TextKit 2 (accessing textView.layoutManager reverts to TextKit 1 and disables Natural Selection)
  • selectedRange (singular) still works but will be deprecated in a future release
  • iOS 26+ only

Phone numbers are LTR even in RTL context. Without explicit direction, they may reorder incorrectly:

// Problem: "اتصل بـ 555-1234" may render with digits reordered
// Solution: Wrap with Unicode LTR mark
let phone = "\u{200E}555-1234\u{200E}"
let text = "اتصل بـ \(phone)"
CharacterPurpose
\u{200E} (LRM)Left-to-right mark — invisible, asserts LTR direction
\u{200F} (RLM)Right-to-left mark — invisible, asserts RTL direction
\u{202A} (LRE)Left-to-right embedding start
\u{202B} (RLE)Right-to-left embedding start
\u{202C} (PDF)Pop directional formatting (ends LRE/RLE)

User-generated content (usernames, titles) may be any direction:

// Wrap unknown-direction content with first-strong isolate
let username = "\u{2068}\(user.name)\u{2069}"
// U+2068 = First Strong Isolate
// U+2069 = Pop Directional Isolate
// Use .natural (not .left/.right) for automatic RTL support
style.alignment = .natural
// .natural = left-aligned in LTR, right-aligned in RTL
// If you need explicit alignment regardless of direction:
style.alignment = .left // Always left, even in RTL context
style.alignment = .right // Always right, even in LTR context

Gotcha: In an RTL text view, .left alignment still means left — it does NOT flip. But .natural and leading/trailing constraints DO flip.

Previously, writing direction was determined by the first strong character (Unicode Bidi Algorithm P2/P3). iOS 26 introduces content-aware dynamic direction detection:

  • Direction is determined by the content of the text, not just the first character
  • New Language Introspector API for custom text engines to query direction
  1. Using .left/.right instead of .natural/leading/trailing — Hardcoded left/right alignment and constraints don’t flip for RTL. Always use .natural alignment and leading/trailing constraints.
  2. Assuming cursor movement is simple in bidi — At direction boundaries, a single logical position maps to two visual positions. The cursor can appear to “jump” or move in unexpected directions.
  3. Phone numbers reordering in RTL — Wrap with LRM (\u{200E}) or use first-strong isolate (\u{2068}/\u{2069}) to prevent digit reordering.
  4. SwiftUI ignoring .writingDirection — The attributed string key is silently ignored in SwiftUI Text. Use .environment(\.layoutDirection, .rightToLeft) instead.
  5. In-app language switching not updating text direction — Changing locale programmatically doesn’t reliably update text view direction. The system responds to system-level language changes, not in-app ones.
  6. selectedRange in bidi text (pre-iOS 26) — A single contiguous NSRange creates visually disjoint selections in bidirectional text. Adopt selectedRanges on iOS 26+.
  7. TextKit 1 glyph range bugs with RTL — NSLayoutManager methods can return incorrect CGRect values for RTL character ranges. Consider TextKit 2 or Core Text for precise RTL geometry.
  8. NSTextView not accepting direction change — On macOS, makeTextWritingDirectionRightToLeft(_:) requires the view to be first responder and editable.

This page documents the apple-text-bidi reference skill. Use it when the subsystem is already known and you need mechanics, behavior, or API detail.

  • apple-text-formatting-ref: Use when looking up NSAttributedString.Key values, underline styles, shadows, lists, tables, or view-compatibility rules.
  • apple-text-input-ref: Use when implementing or debugging UITextInput, UIKeyInput, or NSTextInputClient — marked text, selection UI, custom input.
  • apple-text-interaction: Use when customizing selection, edit menus, link taps, gestures, cursor appearance, or long-press actions in text editors.
Full SKILL.md source
SKILL.md
---
name: apple-text-bidi
description: Use when handling bidirectional text, RTL languages, writing direction controls, or cursor behavior in Arabic/Hebrew
license: MIT
---
# Bidirectional Text and RTL
Use this skill when the main question involves right-to-left text, mixed-direction content, or the APIs that control writing direction.
## When to Use
- Supporting Arabic, Hebrew, or other RTL languages in text editors
- Mixed LTR/RTL content (phone numbers in Arabic text, etc.)
- Cursor movement or selection in bidirectional text
- Writing direction APIs at any level (paragraph, attribute, view, SwiftUI)
- iOS 26 Natural Selection / `selectedRanges` migration
## Quick Decision
```
Using standard UITextView / UITextField?
→ Bidi mostly "just works" with .natural writing direction.
→ Read the Mixed Content section for edge cases.
Building a custom UITextInput view?
→ You must handle bidi yourself. Read everything here.
Targeting iOS 26+?
→ Adopt Natural Selection (selectedRanges) for correct bidi selection.
Need inline direction overrides?
→ Use .writingDirection attributed string key.
```
## Core Guidance
## Writing Direction API Layers
Direction is controlled at multiple levels, from highest to lowest:
### 1. SwiftUI Environment
```swift
// Read current layout direction
@Environment(\.layoutDirection) var layoutDirection
// Force direction on a view hierarchy
VStack { ... }
.environment(\.layoutDirection, .rightToLeft)
// Mirror images for RTL
Image("arrow")
.flipsForRightToLeftLayoutDirection(true)
```
**Note:** SwiftUI `Text` ignores the `.writingDirection` attributed string key. Use the environment for direction control in SwiftUI.
### 2. NSParagraphStyle (Paragraph Level)
The most common way to set direction for a block of text:
```swift
let style = NSMutableParagraphStyle()
style.baseWritingDirection = .natural // Auto-detect from content (default)
// or .leftToRight
// or .rightToLeft
let attrs: [NSAttributedString.Key: Any] = [.paragraphStyle: style]
```
`.natural` uses the Unicode Bidi Algorithm (rules P2/P3) to detect direction from the first strong directional character.
### 3. .writingDirection Attribute (Inline Overrides)
For overriding direction within a paragraph — equivalent to Unicode bidi control characters:
```swift
// Force a range to LTR embedding (like Unicode LRE + PDF)
let ltrEmbed: [NSNumber] = [
NSNumber(value: NSWritingDirection.leftToRight.rawValue | NSWritingDirectionFormatType.embedding.rawValue)
]
attrString.addAttribute(.writingDirection, value: ltrEmbed, range: range)
// Force a range to RTL override (like Unicode RLO + PDF)
let rtlOverride: [NSNumber] = [
NSNumber(value: NSWritingDirection.rightToLeft.rawValue | NSWritingDirectionFormatType.override.rawValue)
]
```
**Embedding** respects the content's own directionality within the override. **Override** forces all characters to display in the specified direction regardless of their inherent directionality.
### 4. UITextInput Protocol
```swift
// Set direction for a text range at runtime
textInput.setBaseWritingDirection(.rightToLeft, for: textRange)
// Read current direction
let direction = textInput.baseWritingDirection(for: position, in: .forward)
```
### 5. iOS 26: AttributedString.WritingDirection
```swift
var text = AttributedString("Hello عربي")
text.writingDirection = .rightToLeft
// Values: .leftToRight, .rightToLeft
```
### 6. NSTextContainer
Writing direction affects line fragment advancement:
```swift
// The writingDirection parameter determines which side lines start from
textContainer.lineFragmentRect(
forProposedRect: rect,
at: index,
writingDirection: .rightToLeft,
remaining: &remaining
)
```
## Visual vs Logical Order
This is the core concept for bidi text.
**Logical order:** How characters are stored in memory (the string). Always follows reading order of each language — Arabic characters are stored right-to-left in logical order.
**Visual order:** How characters appear on screen after the Unicode Bidi Algorithm reorders them.
```
Logical: "Hello مرحبا World"
H-e-l-l-o- -ا-ب-ح-ر-م- -W-o-r-l-d
Visual: "Hello ابحرم World"
Characters 6-10 are visually reordered (RTL run)
```
**A single cursor position can map to two visual positions** at direction boundaries. This is why cursor movement in bidi text is inherently ambiguous.
## iOS 26: Natural Selection
Previously, `selectedRange` was a single contiguous NSRange. In bidirectional text, this caused visually disjoint selections — the selection included storage-contiguous characters that were visually separated.
### New APIs
```swift
// New: multiple ranges following visual cursor movement
textView.selectedRanges // [NSRange] — replaces selectedRange
// New delegate method for multi-range edits
func textView(_ textView: UITextView,
shouldChangeTextInRanges ranges: [NSRange],
replacementStrings: [String]?) -> Bool {
// Handle multi-range replacement
return true
}
```
### Requirements
- Requires TextKit 2 (accessing `textView.layoutManager` reverts to TextKit 1 and disables Natural Selection)
- `selectedRange` (singular) still works but will be deprecated in a future release
- iOS 26+ only
## Mixed Content Patterns
### Phone Numbers in RTL Text
Phone numbers are LTR even in RTL context. Without explicit direction, they may reorder incorrectly:
```swift
// Problem: "اتصل بـ 555-1234" may render with digits reordered
// Solution: Wrap with Unicode LTR mark
let phone = "\u{200E}555-1234\u{200E}"
let text = "اتصل بـ \(phone)"
```
| Character | Purpose |
|-----------|---------|
| `\u{200E}` (LRM) | Left-to-right mark — invisible, asserts LTR direction |
| `\u{200F}` (RLM) | Right-to-left mark — invisible, asserts RTL direction |
| `\u{202A}` (LRE) | Left-to-right embedding start |
| `\u{202B}` (RLE) | Right-to-left embedding start |
| `\u{202C}` (PDF) | Pop directional formatting (ends LRE/RLE) |
### Unknown-Directionality Variables
User-generated content (usernames, titles) may be any direction:
```swift
// Wrap unknown-direction content with first-strong isolate
let username = "\u{2068}\(user.name)\u{2069}"
// U+2068 = First Strong Isolate
// U+2069 = Pop Directional Isolate
```
### Text Alignment in RTL
```swift
// Use .natural (not .left/.right) for automatic RTL support
style.alignment = .natural
// .natural = left-aligned in LTR, right-aligned in RTL
// If you need explicit alignment regardless of direction:
style.alignment = .left // Always left, even in RTL context
style.alignment = .right // Always right, even in LTR context
```
**Gotcha:** In an RTL text view, `.left` alignment still means left — it does NOT flip. But `.natural` and leading/trailing constraints DO flip.
## iOS 26: Dynamic Writing Direction
Previously, writing direction was determined by the first strong character (Unicode Bidi Algorithm P2/P3). iOS 26 introduces content-aware dynamic direction detection:
- Direction is determined by the **content** of the text, not just the first character
- New Language Introspector API for custom text engines to query direction
## Common Pitfalls
1. **Using `.left`/`.right` instead of `.natural`/leading/trailing** — Hardcoded left/right alignment and constraints don't flip for RTL. Always use `.natural` alignment and leading/trailing constraints.
2. **Assuming cursor movement is simple in bidi** — At direction boundaries, a single logical position maps to two visual positions. The cursor can appear to "jump" or move in unexpected directions.
3. **Phone numbers reordering in RTL** — Wrap with LRM (`\u{200E}`) or use first-strong isolate (`\u{2068}`/`\u{2069}`) to prevent digit reordering.
4. **SwiftUI ignoring `.writingDirection`** — The attributed string key is silently ignored in SwiftUI Text. Use `.environment(\.layoutDirection, .rightToLeft)` instead.
5. **In-app language switching not updating text direction** — Changing locale programmatically doesn't reliably update text view direction. The system responds to system-level language changes, not in-app ones.
6. **`selectedRange` in bidi text (pre-iOS 26)** — A single contiguous NSRange creates visually disjoint selections in bidirectional text. Adopt `selectedRanges` on iOS 26+.
7. **TextKit 1 glyph range bugs with RTL** — NSLayoutManager methods can return incorrect CGRect values for RTL character ranges. Consider TextKit 2 or Core Text for precise RTL geometry.
8. **NSTextView not accepting direction change** — On macOS, `makeTextWritingDirectionRightToLeft(_:)` requires the view to be first responder and editable.
## Related Skills
- Use `/skill apple-text-formatting-ref` for the `.writingDirection` attribute and `baseWritingDirection` on NSParagraphStyle.
- Use `/skill apple-text-input-ref` for `setBaseWritingDirection(_:for:)` in custom UITextInput views.
- Use `/skill apple-text-interaction` for cursor and selection behavior.
- Use `/skill apple-text-appkit-vs-uikit` for platform differences in RTL support.