Translate Text In SwiftUI With The iOS 18 Translation API
Localization is still the right answer for your app’s own interface. The Translation framework is for text you do not control ahead of time: user reviews, comments, messages, imported notes, travel descriptions, support snippets, or any other content that can arrive in a language the reader does not understand.
iOS 18 and macOS 15 add a flexible TranslationSession API that works nicely from SwiftUI. You attach a .translationTask modifier to a view, SwiftUI gives you a session, and you use that session to translate one string or a batch of strings.
The examples below target iOS 18, iPadOS 18, and macOS 15. The Translation framework APIs used here are unavailable on watchOS, tvOS, and visionOS. For Mac Catalyst, the current SDK marks these APIs available starting in 26.0. Also test on a real iPhone, iPad, or Mac. Apple calls out that these translation APIs do not function in the simulator.
Check language availability
Before showing a custom translation action, check whether the language pair is supported. LanguageAvailability can tell you whether a pair is already installed, supported but not installed, or unsupported:
import SwiftUI
import Translation
struct TranslationAvailabilityView: View {
private let sourceLanguage = Locale.Language(identifier: "es")
private let targetLanguage = Locale.Language(identifier: "en")
private let languageAvailability = LanguageAvailability()
@State private var status: LanguageAvailability.Status?
var body: some View {
ContentUnavailableView(
availabilityTitle,
systemImage: "translate",
description: Text(availabilityMessage)
)
.task {
status = await languageAvailability.status(
from: sourceLanguage,
to: targetLanguage
)
}
}
private var availabilityTitle: String {
switch status {
case .installed:
return "Ready to Translate"
case .supported:
return "Download May Be Needed"
case .unsupported:
return "Unsupported Language Pair"
case nil:
return "Checking Translation"
}
}
private var availabilityMessage: String {
switch status {
case .installed:
return "The required languages are already installed."
case .supported:
return "The framework can translate this pair after language assets are available."
case .unsupported:
return "Choose a different source or target language."
case nil:
return "One moment."
}
}
}
supported is not a failure. It means the framework supports that translation pair, but the language assets may need to be downloaded. When your app translates, the framework handles asking the user for permission if a download is required.
When you do not know the source language, prefer passing nil as the source language to the translation session and let the framework identify it. If the language is unclear, the framework can prompt the user to choose it. If you need to check arbitrary text before translating, LanguageAvailability also has status(for:to:).
Translate one string inline
For custom inline UI, store an optional TranslationSession.Configuration. Keeping it nil prevents translation from starting until the user asks for it:
import SwiftUI
import Translation
struct InlineTranslationView: View {
let sourceText: String
private let sourceLanguage = Locale.Language(identifier: "es")
private let targetLanguage = Locale.Language(identifier: "en")
@State private var configuration: TranslationSession.Configuration?
@State private var translatedText: String?
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(sourceText)
if let translatedText {
Text(translatedText)
.foregroundStyle(.secondary)
}
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
Button {
translate()
} label: {
Label("Translate", systemImage: "translate")
}
}
.translationTask(configuration) { @MainActor session in
do {
let response = try await session.translate(sourceText)
translatedText = response.targetText
errorMessage = nil
} catch {
translatedText = nil
errorMessage = "Translation is not available for this text."
}
}
}
private func translate() {
if configuration == nil {
configuration = TranslationSession.Configuration(
source: sourceLanguage,
target: targetLanguage
)
} else {
configuration?.invalidate()
}
}
}
The Configuration controls the languages and the lifecycle of the task. The closure runs when the view appears with a non-nil configuration and again when the configuration changes. If the user taps the button again with the same languages, call invalidate() so SwiftUI knows there is new work to perform.
You can also use .translationTask(source:target:action:) directly when the translation should run as soon as the view appears. I prefer the configuration form for user-triggered translation because it makes the idle state explicit.
Using nil has useful behavior:
source: nilasks the framework to identify the source language, and may prompt the user if it is unclear.target: nillets the framework choose an appropriate target using the source language and the user’s preferred languages.
That is often better than guessing from Locale.current, especially for user-generated text.
Prepare downloads without translating
The first translation for a supported-but-not-installed language pair may need language assets. Usually the framework can ask at the moment the user translates. If the user is about to go offline, or you know they are entering a screen that needs a particular pair, you can ask for download approval ahead of time:
.translationTask(configuration) { session in
do {
try await session.prepareTranslation()
} catch {
// Show a non-blocking message or keep the translate button available.
}
}
Use this sparingly. A translation download prompt makes sense when the user has intent, not just because a screen happened to load.
For this preflight path, give the configuration a concrete source language. prepareTranslation() has no sample text to inspect, so it can throw TranslationError.unableToIdentifyLanguage if the source is nil.
Translate a batch
For multiple strings in the same source language, use one of the batch APIs instead of looping over translate(_:). translations(from:) waits for all results and returns them together. translate(batch:) streams responses as they become available, which is usually nicer for a list.
Here is a review list that updates each row as its translation arrives:
import SwiftUI
import Translation
struct Review: Identifiable {
let id: UUID
var body: String
var translatedBody: String?
}
struct ReviewTranslationList: View {
@State private var reviews: [Review]
@State private var configuration: TranslationSession.Configuration?
init(reviews: [Review]) {
_reviews = State(initialValue: reviews)
}
var body: some View {
List(reviews) { review in
VStack(alignment: .leading, spacing: 6) {
Text(review.body)
if let translatedBody = review.translatedBody {
Text(translatedBody)
.foregroundStyle(.secondary)
}
}
}
.toolbar {
Button {
translateReviews()
} label: {
Label("Translate", systemImage: "translate")
}
}
.translationTask(configuration) { @MainActor session in
await translateReviews(using: session)
}
}
private func translateReviews() {
if configuration == nil {
configuration = TranslationSession.Configuration(
source: nil,
target: Locale.Language(identifier: "en")
)
} else {
configuration?.invalidate()
}
}
@MainActor
private func translateReviews(using session: TranslationSession) async {
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) {
guard
let identifier = response.clientIdentifier,
let id = UUID(uuidString: identifier),
let index = reviews.firstIndex(where: { $0.id == id })
else {
continue
}
reviews[index].translatedBody = response.targetText
}
} catch {
// Keep the original text visible and let the user retry.
}
}
}
The clientIdentifier matters with streaming results. Responses can arrive in a different order than the requests, so give each request a stable identifier and use it to update the right row.
There is one important constraint: a batch should contain text from the same source language. If you know you have Spanish and German comments, make separate batch calls. If you are relying on language identification with source: nil, still group content so each batch is one language as much as your app can reasonably know.
Keep the session tied to the view
Do not store a TranslationSession in a long-lived model object. The session is handed to you by SwiftUI because it may need to show system UI for downloads or permissions, and that UI is anchored to the view with the .translationTask modifier.
It is fine to move translation helper methods into the view or into short-lived collaborators called by the view. Just avoid treating the session as a reusable service that can outlive the screen.
With that boundary in place, the API is pleasantly small: check support with LanguageAvailability, trigger work by changing TranslationSession.Configuration, translate inside .translationTask, and use batch requests when you have more than one string.
Further reading:
- Meet the Translation API: https://developer.apple.com/videos/play/wwdc2024/10117/
- Translating text within your app: https://developer.apple.com/documentation/translation/translating-text-within-your-app
- TranslationSession: https://developer.apple.com/documentation/translation/translationsession
- TranslationSession.Configuration: https://developer.apple.com/documentation/translation/translationsession/configuration
- LanguageAvailability: https://developer.apple.com/documentation/translation/languageavailability
- translationTask(source:target:action:): https://developer.apple.com/documentation/swiftui/view/translationtask(source:target:action:)