Translation Framework: On-Device Language Translation for iOS Apps


Your app displays user-generated content from around the world — film reviews in Japanese, character descriptions in Spanish, fan theories in Portuguese. You could ship every string to a cloud translation API, pay per character, and handle latency and privacy concerns. Or you could use Apple’s Translation framework, which runs entirely on-device with zero cloud cost, zero network dependency, and no data leaving the user’s phone.

This post covers the Translation framework end to end: .translationPresentation for one-line SwiftUI integration, TranslationSession for programmatic batch translation, language availability management, and iOS 26’s Call Translation API for real-time VoIP translation. We won’t cover localization of your own UI strings (String(localized:) and .strings catalogs) — that’s a separate topic. This guide assumes familiarity with SwiftUI state management and async/await.

Note: The Translation framework requires iOS 17.4+ / macOS 14.4+. The Call Translation API requires iOS 26. All code in this post uses Swift 6.2 strict concurrency.

Contents

The Problem

Consider a Pixar fan community app where users post reviews in their native language. A user in Tokyo writes a review of Inside Out 2 in Japanese. A user in Mexico City wants to read it in Spanish. Without translation, you’re stuck with one of these approaches:

// Option A: Cloud translation API — cost, latency, privacy concerns
func translateReview(_ review: FilmReview) async throws -> String {
    let request = URLRequest(url: cloudTranslationEndpoint)
    // $20 per million characters, 200–500ms latency per request
    // User's review content leaves the device and hits your server
    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(TranslationResponse.self, from: data).text
}

// Option B: Hope users speak the same language
// (Not a real solution for a global app)

Cloud translation works, but it introduces cost that scales with usage, latency that degrades the reading experience, and privacy implications — you’re transmitting user-generated content to a third-party server. For a film review, that might seem harmless. For a health journal entry or private message, it’s a different story entirely.

The Translation framework eliminates all three concerns. Translation happens on the Apple Neural Engine using downloaded language models. No network call. No cost per character. No data leaves the device.

translationPresentation: One-Line Translation UI

Apple Docs: translationPresentation(isPresented:text:attachmentAnchor:arrowEdge:replacementAction:) — Translation

The fastest way to add translation to your app is the .translationPresentation view modifier. It presents Apple’s system translation sheet — the same UI you see in Safari and Messages — with a single line of SwiftUI:

import SwiftUI
import Translation

struct FilmReviewView: View {
    let review: FilmReview

    @State private var showTranslation = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(review.filmTitle)
                .font(.headline)
            Text(review.author)
                .font(.subheadline)
                .foregroundStyle(.secondary)
            Text(review.body)
                .font(.body)

            Button("Translate Review") {
                showTranslation = true
            }
        }
        .padding()
        .translationPresentation(
            isPresented: $showTranslation,
            text: review.body
        )
    }
}

When the user taps “Translate Review,” the system sheet slides up with the original text and its translation. The framework auto-detects the source language and translates to the user’s preferred language. No configuration needed.

Replacing Text In-Place

The system sheet includes a “Replace with Translation” button by default. You can handle this replacement explicitly with the replacementAction closure:

struct EditableReviewView: View {
    let review: FilmReview

    @State private var displayedText: String
    @State private var showTranslation = false

    init(review: FilmReview) {
        self.review = review
        self._displayedText = State(initialValue: review.body)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(review.filmTitle)
                .font(.headline)

            Text(displayedText)
                .font(.body)

            Button("Translate") {
                showTranslation = true
            }
        }
        .padding()
        .translationPresentation(
            isPresented: $showTranslation,
            text: review.body
        ) { translatedText in
            // Called when user taps "Replace with Translation"
            displayedText = translatedText
        }
    }
}

The replacementAction closure receives the translated string. You decide what to do with it — replace text in a TextEditor, update a view model, or store it alongside the original for bilingual display.

Specifying Source and Target Languages

By default, the framework auto-detects the source language and translates to the user’s device language. You can override both:

import Translation

struct DirectedTranslationView: View {
    let review: FilmReview

    @State private var showTranslation = false

    var body: some View {
        Text(review.body)
            .translationPresentation(
                isPresented: $showTranslation,
                text: review.body,
                attachmentAnchor: .point(.bottom),
                arrowEdge: .top
            )
    }
}

For explicit language control, use the TranslationSession.Configuration API with the programmatic approach covered in the next section.

TranslationSession: Programmatic Translation

Apple Docs: TranslationSession — Translation

When you need translation without the system UI — batch processing, background work, or custom display — use TranslationSession directly. This is the workhorse API for programmatic translation.

Single String Translation

import Translation

@MainActor
final class ReviewTranslator {
    func translate(_ text: String, from source: Locale.Language, to target: Locale.Language) async throws -> String {
        let configuration = TranslationSession.Configuration(
            source: source,
            target: target
        )

        let response = try await TranslationSession(configuration: configuration).translate(text)
        return response.targetText
    }
}

Batch Translation

For translating multiple strings — say, all reviews on a film detail screen — use the batch API. It’s significantly more efficient than translating strings one at a time because the framework can optimize neural engine scheduling across the batch:

@MainActor
final class BatchReviewTranslator {
    func translateReviews(
        _ reviews: [FilmReview],
        to targetLanguage: Locale.Language
    ) async throws -> [String: String] {
        let configuration = TranslationSession.Configuration(
            source: nil, // Auto-detect source language per string
            target: targetLanguage
        )

        let session = TranslationSession(configuration: configuration)

        let requests = reviews.map { review in
            TranslationSession.Request(
                sourceText: review.body,
                clientIdentifier: review.id.uuidString // Track which result maps to which review
            )
        }

        var results: [String: String] = [:]

        for try await response in session.translate(batch: requests) {
            if let id = response.clientIdentifier {
                results[id] = response.targetText
            }
        }

        return results
    }
}

The translate(batch:) method returns an AsyncSequence of responses. Each response includes the clientIdentifier you provided, so you can map results back to their source objects. The sequence yields results as they complete — earlier results may arrive while later ones are still processing.

Using TranslationSession in SwiftUI with .translationTask

SwiftUI provides a dedicated view modifier for TranslationSession lifecycle management:

struct FilmReviewListView: View {
    let reviews: [FilmReview]

    @State private var translatedBodies: [UUID: String] = [:]
    @State private var translationConfig: TranslationSession.Configuration?

    var body: some View {
        List(reviews) { review in
            VStack(alignment: .leading) {
                Text(review.filmTitle)
                    .font(.headline)
                Text(translatedBodies[review.id] ?? review.body)
                    .font(.body)
            }
        }
        .toolbar {
            Button("Translate All") {
                translationConfig = .init(target: Locale.Language(identifier: "es"))
            }
        }
        .translationTask(translationConfig) { session in
            let requests = reviews.map { review in
                TranslationSession.Request(
                    sourceText: review.body,
                    clientIdentifier: review.id.uuidString
                )
            }

            do {
                for try await response in session.translate(batch: requests) {
                    if let id = response.clientIdentifier,
                       let uuid = UUID(uuidString: id) {
                        translatedBodies[uuid] = response.targetText
                    }
                }
            } catch {
                // Handle error — language model may not be downloaded
            }
        }
    }
}

The .translationTask modifier creates a TranslationSession scoped to the view’s lifetime. When the configuration changes (from nil to a value), the closure runs. When the view disappears, the session is automatically invalidated.

Managing Language Availability

Translation models are downloaded on demand. Not every language pair is available immediately — the user may need to download a model first.

Apple Docs: LanguageAvailability — Translation

import Translation

@MainActor
final class LanguageManager {
    func checkAvailability(
        from source: Locale.Language,
        to target: Locale.Language
    ) async -> LanguageAvailability.Status {
        let availability = LanguageAvailability()
        return await availability.status(from: source, to: target)
    }

    func supportedLanguages() async -> [Locale.Language] {
        let availability = LanguageAvailability()
        return await availability.supportedLanguages
    }
}

The status(from:to:) method returns one of three values:

StatusMeaningAction
.installedModel is downloaded and readyTranslate immediately
.supportedLanguage pair is supported but model not downloadedPrompt download or use .translationPresentation (which handles download UI)
.unsupportedLanguage pair is not availableFall back to cloud API or disable translation

When you use .translationPresentation, the system handles the download prompt automatically — the sheet shows a “Download” button if the model isn’t installed. With TranslationSession, you need to handle this yourself. Attempting to translate with an uninstalled model throws an error.

func translateWithDownloadCheck(
    _ text: String,
    from source: Locale.Language,
    to target: Locale.Language
) async throws -> String {
    let availability = LanguageAvailability()
    let status = await availability.status(from: source, to: target)

    switch status {
    case .installed:
        let config = TranslationSession.Configuration(source: source, target: target)
        let session = TranslationSession(configuration: config)
        let response = try await session.translate(text)
        return response.targetText

    case .supported:
        // Model needs to be downloaded first
        // Use .translationPresentation for automatic download UI,
        // or direct users to Settings > General > Language & Region > Translation Languages
        throw TranslationError.modelNotInstalled(source: source, target: target)

    case .unsupported:
        throw TranslationError.unsupportedLanguagePair(source: source, target: target)

    @unknown default:
        throw TranslationError.unknownAvailability
    }
}

iOS 26: Call Translation API

iOS 26 introduces the Call Translation API, which enables real-time translation during VoIP calls. If your app uses CallKit for voice calls, you can integrate live translation directly into the call experience.

import Translation
import CallKit

@available(iOS 26, *)
@MainActor
final class TranslatedCallManager {
    private var translationSession: TranslationSession?

    func startTranslation(
        for call: CXCall,
        sourceLanguage: Locale.Language,
        targetLanguage: Locale.Language
    ) async throws {
        let config = TranslationSession.Configuration(
            source: sourceLanguage,
            target: targetLanguage
        )

        translationSession = TranslationSession(configuration: config)
    }

    /// Called for each incoming audio segment from the remote participant
    func translateIncomingSpeech(_ transcribedText: String) async throws -> String {
        guard let session = translationSession else {
            throw TranslationError.sessionNotConfigured
        }

        let response = try await session.translate(transcribedText)
        return response.targetText
    }

    func endTranslation() {
        translationSession = nil
    }
}

The Call Translation API builds on TranslationSession — the core translation engine is the same. The difference is the real-time context: you feed transcribed speech segments into the session and receive translations with low enough latency for conversational use. Apple’s on-device speech recognition (via the Speech framework) handles the transcription step.

Warning: Call Translation requires both the Translation framework language models and the Speech framework language models to be downloaded. Verify availability for both before offering the feature. A user with Spanish translation but no Spanish speech recognition model will get a confusing failure.

Bidirectional Translation for Calls

Real conversations are bidirectional. You need two translation sessions — one for each direction:

@available(iOS 26, *)
@MainActor
final class BidirectionalCallTranslator {
    private var outgoingSession: TranslationSession?
    private var incomingSession: TranslationSession?

    func configure(
        localLanguage: Locale.Language,
        remoteLanguage: Locale.Language
    ) async throws {
        // Translate local user's speech → remote participant's language
        outgoingSession = TranslationSession(
            configuration: .init(source: localLanguage, target: remoteLanguage)
        )

        // Translate remote participant's speech → local user's language
        incomingSession = TranslationSession(
            configuration: .init(source: remoteLanguage, target: localLanguage)
        )
    }

    func translateOutgoing(_ text: String) async throws -> String {
        guard let session = outgoingSession else { throw TranslationError.sessionNotConfigured }
        return try await session.translate(text).targetText
    }

    func translateIncoming(_ text: String) async throws -> String {
        guard let session = incomingSession else { throw TranslationError.sessionNotConfigured }
        return try await session.translate(text).targetText
    }
}

Advanced Usage

Language Detection

When you don’t know the source language upfront, pass nil as the source in TranslationSession.Configuration. The framework auto-detects it. For explicit detection before translation, use the Natural Language framework:

import NaturalLanguage

func detectLanguage(_ text: String) -> Locale.Language? {
    let recognizer = NLLanguageRecognizer()
    recognizer.processString(text)

    guard let dominantLanguage = recognizer.dominantLanguage else { return nil }
    return Locale.Language(identifier: dominantLanguage.rawValue)
}

Combining detection with translation lets you build smart UIs — show a “Translate from Japanese” button only when you detect non-English content:

struct SmartTranslationReviewView: View {
    let review: FilmReview

    @State private var detectedLanguage: Locale.Language?
    @State private var showTranslation = false
    @State private var translatedText: String?

    private var needsTranslation: Bool {
        guard let detected = detectedLanguage else { return false }
        let deviceLanguage = Locale.current.language
        return detected.languageCode != deviceLanguage.languageCode
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(review.filmTitle).font(.headline)
            Text(translatedText ?? review.body).font(.body)

            if needsTranslation {
                Button("Translate from \(detectedLanguage?.languageCode?.identifier ?? "unknown")") {
                    showTranslation = true
                }
                .buttonStyle(.bordered)
            }
        }
        .onAppear {
            detectedLanguage = detectLanguage(review.body)
        }
        .translationPresentation(
            isPresented: $showTranslation,
            text: review.body
        ) { translated in
            translatedText = translated
        }
    }
}

Caching Translations

The Translation framework does not cache results across sessions. If you translate the same review twice, it runs the neural engine both times. For content that doesn’t change (published reviews, film descriptions), cache translations locally:

@MainActor
final class TranslationCache {
    private var cache: [String: String] = [:]

    private func cacheKey(text: String, target: Locale.Language) -> String {
        "\(text.hashValue)_\(target.languageCode?.identifier ?? "unknown")"
    }

    func translate(
        _ text: String,
        to target: Locale.Language,
        using session: TranslationSession
    ) async throws -> String {
        let key = cacheKey(text: text, target: target)

        if let cached = cache[key] {
            return cached
        }

        let response = try await session.translate(text)
        cache[key] = response.targetText
        return response.targetText
    }
}

For production apps, persist this cache to disk (SwiftData, SQLite, or a simple JSON file) keyed by a hash of the source text and target language. Translations of static content like Pixar film synopses won’t change between model updates.

Handling Long Text

The Translation framework handles long text well — it segments internally. However, for very long documents (entire screenplays, book chapters), you may want to segment yourself to provide progressive UI updates:

func translateLongDocument(
    _ paragraphs: [String],
    to target: Locale.Language
) -> AsyncStream<(index: Int, translation: String)> {
    AsyncStream { continuation in
        Task {
            let config = TranslationSession.Configuration(source: nil, target: target)
            let session = TranslationSession(configuration: config)

            let requests = paragraphs.enumerated().map { index, text in
                TranslationSession.Request(
                    sourceText: text,
                    clientIdentifier: "\(index)"
                )
            }

            do {
                for try await response in session.translate(batch: requests) {
                    if let idString = response.clientIdentifier,
                       let index = Int(idString) {
                        continuation.yield((index: index, translation: response.targetText))
                    }
                }
                continuation.finish()
            } catch {
                continuation.finish()
            }
        }
    }
}

This lets your UI update paragraph by paragraph as translations complete, rather than blocking until the entire document is translated.

Performance Considerations

Neural Engine utilization. Translation runs on the Apple Neural Engine (ANE) when available, falling back to GPU and then CPU. On devices with the A17 Pro or M-series chips, translation of a typical paragraph (100–200 words) takes 200–500ms. On older A-series chips without a dedicated ANE, expect 1–2 seconds per paragraph.

Model download size. Each language pair model is approximately 100–200MB. Users with limited storage may not download many models. Always check availability before offering translation and handle the “not installed” case gracefully.

Batch vs. sequential. The batch API (translate(batch:)) is 30–40% faster than sequential single-string calls for 10+ strings because the framework can pipeline ANE operations. Always prefer batch translation when you have multiple strings.

Memory footprint. A loaded translation model consumes approximately 200–400MB of RAM. The system manages model loading and unloading — you don’t control it directly. Creating multiple TranslationSession instances for different language pairs can spike memory temporarily. Reuse sessions when possible.

Concurrency. TranslationSession is not Sendable. Create and use sessions on the same actor. For @MainActor views, this happens naturally. For background translation work, use a dedicated actor:

actor TranslationWorker {
    func translateBatch(
        _ texts: [String],
        to target: Locale.Language
    ) async throws -> [String] {
        let config = TranslationSession.Configuration(source: nil, target: target)
        let session = TranslationSession(configuration: config)

        let requests = texts.enumerated().map { index, text in
            TranslationSession.Request(sourceText: text, clientIdentifier: "\(index)")
        }

        var results = [String](repeating: "", count: texts.count)
        for try await response in session.translate(batch: requests) {
            if let id = response.clientIdentifier, let index = Int(id) {
                results[index] = response.targetText
            }
        }
        return results
    }
}

Apple Docs: TranslationSession.Request — Translation

When to Use (and When Not To)

ScenarioRecommendation
User-generated content (reviews, comments, messages)Primary use case. On-device, no cost, no privacy concerns.
Static app content localizationUse String(localized:) and .strings catalogs. Translation framework is for dynamic content, not UI strings.
Real-time chat translationTranslationSession with batch API. Cache results for repeated messages.
VoIP call translation (iOS 26+)Call Translation API with bidirectional sessions. Verify both Translation and Speech models are available.
Document translation (1,000+ words)Segment into paragraphs, use batch API for progressive updates.
Offline-first appExcellent fit — prompt users to download language models during onboarding. All translation is local after that.
50+ language pairs simultaneouslyCheck supportedLanguages. Apple supports ~20 languages as of iOS 18. Cloud API is better for exotic pairs.
Machine-critical accuracy (legal, medical)Translation quality is good for comprehension but not certified. Add a disclaimer for sensitive domains.

Summary

  • .translationPresentation provides a one-line SwiftUI integration that presents Apple’s system translation sheet with automatic language detection, download management, and replacement actions.
  • TranslationSession is the programmatic API for custom UIs and batch translation. Use translate(batch:) for multiple strings — it’s 30–40% faster than sequential calls.
  • Language models are downloaded on demand. Always check LanguageAvailability.status(from:to:) before attempting programmatic translation. .translationPresentation handles download prompts automatically.
  • iOS 26 adds the Call Translation API for real-time VoIP translation. Use bidirectional TranslationSession instances — one for each direction of conversation.
  • All translation runs on the Apple Neural Engine. No data leaves the device. No per-character cost. No network dependency after model download.

The Translation framework pairs well with language analysis. Explore Natural Language Framework for sentiment analysis, named entity recognition, and language detection on user content, or see Speech Recognition for converting spoken audio to text that you can then translate.