無料翻訳APIを活用！iOS 18のシステムTranslationフレームワークでアプリ内翻訳実装（SwiftUI/UIKit対応）

もしあなたのアプリでコンテンツを提供していて、世界中のユーザーをターゲットとしているなら、翻訳機能を実装したいと考えるでしょう。ユーザー投稿テキストは様々な言語で書かれる可能性があり、翻訳機能を提供することでユーザーエクスペリエンスを向上させることができます。

たとえば、私が作っている Mastodon、Misskey、Bluesky、Nostr をまとめて閲覧できるクライアント「SoraSNS」では、このような記事で紹介しているテクニックを活用しています。

そこで登場するのが **Translation Framework** です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/7c067519-296f-bbab-730c-25e146264aab.png)

2020年に登場した iOS 14 以降、Appleは「翻訳」アプリを提供しています。これは単一テキストボックスのみという非常にシンプルかつ洗練されたアプリで、ローカル翻訳用の言語データをダウンロードしておけばオフラインでも翻訳可能です。

そして iOS 18 からは、この翻訳APIが開放され、あなたのアプリでも同様の翻訳機能を活用できるようになりました。

**注意事項**：これらのAPIは実機デバイスでテストする必要があります。翻訳機能はシミュレータ上では動作しません！（ただし奇妙なことに、組み込みUIだけはなぜかシミュレータで表示されることもあります。笑）

この記事：

- SwiftUIでプリビルトの翻訳UIを表示する
- iOSシステムでサポートされている言語を確認する
- テキストの言語判定 (Bonusトピック)
- 翻訳モデルのダウンロード
- プログラム的に翻訳結果を取得する
- 古いiOSバージョンへの対応 (<iOS 18)
- 複数の翻訳要求を同時に行う
- UIKitからの利用

## SwiftUIでプリビルトの翻訳UIを表示する

SwiftUIアプリでは、あなたのアプリ内にApple製翻訳UIポップアップを組み込み表示できます。

まずは、`Translation`フレームワークをインポートします。`#if canImport(Translation)` でシステムが対応しているかも判定できます。たとえば、iOS 18以上なら翻訳機能を提供し、iOS 17以下ならオンライン翻訳へフォールバックしたり、ボタン自体を非表示にしたりできます。

```swift
#if canImport(Translation)
import Translation
#endif
```

次に、翻訳ダイアログ表示を制御するための `@State` 変数を用意します。最初は`false`で、翻訳を行いたいときに`true`に切り替えます。

```swift
@State private var isTranslationShown: Bool = false
```

これを、`translationPresentation`修飾子でビューに付与し、`isPresented`には上記バインディング、`text`には翻訳対象テキストを渡します。

```swift
Form {
    // ... //
}
#if canImport(Translation)
.translationPresentation(isPresented: $isTranslationShown,
                         text: self.sourceText)
#endif
```

以下は完全なサンプルコードです。

```swift
import SwiftUI

#if canImport(Translation)
import Translation
#endif

struct PopupTranslation: View {
    
    @State private var sourceText = "Hello, World! This is a test."
    @State private var isTranslationShown: Bool = false
    
    var body: some View {
        
        NavigationStack {
            Form {
                
                Section {
                    Label("Source text", systemImage: "globe")
                    
                    TextField("What do you want to translate?",
                              text: $sourceText,
                              axis: .vertical)
                }
                
            }
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Translate") {
                        self.isTranslationShown = true
                    }
                }
            }
#if canImport(Translation)
            .translationPresentation(isPresented: $isTranslationShown,
                                     text: self.sourceText)
#endif
        }
        
    }
    
}

#Preview {
    PopupTranslation()
}
```

上記を実行すれば、「Translate」ボタンを押したときに、システム標準の翻訳ポップアップが表示されます。

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/ecd2af32-34d1-b692-96e8-55795e0fac15.gif)

また、ユーザーが翻訳結果をあなたのアプリ側に反映できるよう、プリビルトUI内にボタンを設置することもできます。

```swift
import SwiftUI

#if canImport(Translation)
import Translation
#endif

struct PopupTranslation: View {
    
    @State private var sourceText = "Hello, World!"
    @State private var targetText = ""
    @State private var isTranslationShown: Bool = false
    
    var body: some View {
        
        NavigationStack {
            Form {
                
                Section {
                    Label("Source text", systemImage: "globe")
                    TextField("What do you want to translate?",
                              text: $sourceText,
                              axis: .vertical)
                }
                
                Section {
                    Label("Translated text", systemImage: "globe")
                    Text(targetText)
                }
                
            }
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Translate") {
                        self.isTranslationShown = true
                    }
                }
            }
#if canImport(Translation)
            .translationPresentation(isPresented: $isTranslationShown,
                                     text: self.sourceText)
            { newString in
                self.targetText = newString
            }
#endif
        }
        
    }
    
}

#Preview {
    PopupTranslation()
}
```

上記コードでは、`translationPresentation`修飾子に結果を受け取るクロージャを追加し、`targetText`変数に翻訳結果を格納しています。これによりユーザーはボタンを押して翻訳結果を取得できます。

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/0d25c5fe-ee48-e34d-ec88-79a6fa25b924.gif)

この手法を使うと、システムが用意したUIをそのまま利用できるので、ソース言語・ターゲット言語を指定しなくても自動判別に任せられます。  
しかし、プログラム的に翻訳結果を取得して独自UIを実現したい場合は、以下を続けて読んでください。

## iOSシステムでサポートされている言語を確認する

iOSシステムは主要言語に対して翻訳モデルを提供しています。利用可能な言語ペアを確認するには、以下のようなコードを書きます。

```swift
func checkSpecificLanguagePairs() async {
    let availability = LanguageAvailability()
    
    // English to Japanese
    let english = Locale.Language(identifier: "en")
    let japanese = Locale.Language(identifier: "ja")
    let statusEnJa = await availability.status(from: english, to: japanese)
    print("English to Japanese: \(statusDescription(statusEnJa))")
    
    // English to Simplified Chinese
    let chinese = Locale.Language(identifier: "zh-Hans")
    let statusEnCh = await availability.status(from: english, to: chinese)
    print("English to Simplified Chinese: \(statusDescription(statusEnCh))")
    
    
    // English to German
    let german = Locale.Language(identifier: "de")
    let statusEnDe = await availability.status(from: english, to: german)
    print("English to German: \(statusDescription(statusEnDe))")
}

// ヘルパー関数
func statusDescription(_ status: LanguageAvailability.Status) -> String {
    switch status {
        case .installed:
            return "Translation installed and ready to use."
        case .supported:
            return "Translation supported but requires download of translation model."
        case .unsupported:
            return "Translation unsupported between the given language pair."
        @unknown default:
            return "Unknown status"
    }
}
```

`installed`なら即時利用可能、`supported`だが`installed`でない場合は、翻訳モデルのダウンロードが必要です。`unsupported`は翻訳非対応となります。

これらのモデルは一度ダウンロードすれば、そのデバイス上では繰り返し使用できます。

### テキストの言語判定 (Bonusトピック)

`NaturalLanguage`フレームワークを使えば、与えたテキストがどの言語なのか判定できます。

```swift
import NaturalLanguage

static func detectLanguage(for string: String) -> String? {
    let recognizer = NLLanguageRecognizer()
    recognizer.processString(string)
    guard let languageCode = recognizer.dominantLanguage?.rawValue else {
        return nil
    }
    return languageCode
}
```

## 翻訳モデルのダウンロード

ローカル翻訳を行うには翻訳モデルをデバイス上にダウンロードする必要があることがあります。

以下はモデルダウンロードをトリガーする例です。

```swift
struct TranslationModelDownloader: View {
    
    var configuration: TranslationSession.Configuration {
        TranslationSession.Configuration(
            source: Locale.Language(identifier: "en"),
            target: Locale.Language(identifier: "ja")
        )
    }
    
    var body: some View {
        NavigationView {
            Text("Download translation files between \(configuration.source?.minimalIdentifier ?? "?") and \(configuration.target?.minimalIdentifier ?? "?")")
            .translationTask(configuration) { session in
                do {
                    try await session.prepareTranslation()
                } catch {
                    // Handle any errors.
                    print("Error downloading translation: \(error)")
                }
            }
        }
    }
}
```

`translationTask`修飾子で、`session.prepareTranslation()`を呼ぶと、必要な言語モデルがなければダイアログが表示され、ダウンロードが行われます。

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/7ef30e98-6d78-7323-cd1a-a05091d2c037.gif)

以下は言語サポート状況をチェックし、必要であればダウンロード画面へ進む包括的なデモ例です。

```swift
import SwiftUI
import Translation

fileprivate class ViewModel: ObservableObject {
    @Published var sourceLanguage: Locale.Language = Locale.current.language
    @Published var targetLanguage: Locale.Language = Locale.current.language
    
    @Published var languageStatus: LanguageAvailability.Status = .unsupported
    
    @Published var sourceFilter: String = "English"
    @Published var targetFilter: String = "German"
    
    let languages: [Locale.Language]
    
    init() {
        // Initialize the list of available languages
        let languageCodes = Locale.LanguageCode.isoLanguageCodes
        self.languages = languageCodes.compactMap { Locale.Language(languageCode: $0) }
    }
    
    func displayName(for language: Locale.Language) -> String {
        guard let languageCode = language.languageCode?.identifier else {
            return language.maximalIdentifier
        }
        return Locale.current.localizedString(forLanguageCode: languageCode) ?? languageCode
    }
    
    var filteredSourceLanguages: [Locale.Language] {
        if sourceFilter.isEmpty {
            return languages
        } else {
            return languages.filter {
                displayName(for: $0).localizedCaseInsensitiveContains(sourceFilter)
            }
        }
    }
    
    var filteredTargetLanguages: [Locale.Language] {
        if targetFilter.isEmpty {
            return languages
        } else {
            return languages.filter {
                displayName(for: $0).localizedCaseInsensitiveContains(targetFilter)
            }
        }
    }
    
    func checkLanguageSupport() async {
        let availability = LanguageAvailability()
        let status = await availability.status(from: sourceLanguage, to: targetLanguage)
        
        DispatchQueue.main.async {
            self.languageStatus = status
        }
    }
}


struct LanguageAvailabilityChecker: View {
    @StateObject fileprivate var viewModel = ViewModel()
    
    var body: some View {
        Form {
            // Source Language Section
            Section("Source Language") {
                TextField("Filter languages", text: $viewModel.sourceFilter)
                    .padding(.vertical, 4)
                
                Picker("Select Source Language", selection: $viewModel.sourceLanguage) {
                    ForEach(viewModel.filteredSourceLanguages, id: \.maximalIdentifier) { language in
                        Button {} label: {
                            Text(viewModel.displayName(for: language))
                            Text(language.minimalIdentifier)
                        }
                        .tag(language)
                    }
                }
                .disabled(viewModel.filteredSourceLanguages.isEmpty)
                .onChange(of: viewModel.sourceLanguage) { _, _ in
                    Task {
                        await viewModel.checkLanguageSupport()
                    }
                }
            }
            
            // Target Language Section
            Section("Target Language") {
                TextField("Filter languages", text: $viewModel.targetFilter)
                
                Picker("Select Target Language", selection: $viewModel.targetLanguage) {
                    ForEach(viewModel.filteredTargetLanguages, id: \.maximalIdentifier) { language in
                        Button {} label: {
                            Text(viewModel.displayName(for: language))
                            Text(language.minimalIdentifier)
                        }
                        .tag(language)
                    }
                }
                .disabled(viewModel.filteredTargetLanguages.isEmpty)
                .onChange(of: viewModel.targetLanguage) { _, _ in
                    Task {
                        await viewModel.checkLanguageSupport()
                    }
                }
            }
            
            // Status Section
            Section {
                if viewModel.languageStatus == .installed {
                    Text("✅ Translation Installed")
                        .foregroundColor(.green)
                } else if viewModel.languageStatus == .supported {
                    Text("⬇️ Translation Available to Download")
                        .foregroundColor(.orange)
                } else {
                    Text("❌ Translation Not Supported")
                        .foregroundColor(.red)
                }
            }
            
            // Download Button Section
            if viewModel.languageStatus == .supported {
                NavigationLink("Download") {
                    TranslationModelDownloader(sourceLanguage: viewModel.sourceLanguage,
                                               targetLanguage: viewModel.targetLanguage)
                }
            }
        }
        .navigationTitle("Language Selector")
        .onAppear {
            Task {
                await viewModel.checkLanguageSupport()
            }
        }
    }
}

#Preview {
    LanguageAvailabilityChecker()
}

struct TranslationModelDownloader: View {
    
    var configuration: TranslationSession.Configuration
    
    init(sourceLanguage: Locale.Language, targetLanguage: Locale.Language) {
        self.configuration = TranslationSession.Configuration(source: sourceLanguage, target: targetLanguage)
    }
    
    var body: some View {
        NavigationView {
            Text("Download translation files between \(configuration.source?.minimalIdentifier ?? "?") and \(configuration.target?.minimalIdentifier ?? "?")")
            .translationTask(configuration) { session in
                do {
                    try await session.prepareTranslation()
                } catch {
                    // Handle any errors.
                    print("Error downloading translation: \(error)")
                }
            }
        }
    }
}
```

### プログラム的に翻訳結果を取得する

もし独自のUI上で翻訳結果を表示したい場合は、`translationTask`を利用してコードから直接翻訳結果を得ることができます。

`@State`変数で翻訳対象テキストや結果を管理し、`.translationTask`修飾子でセッション開始時に`session.translate(...)`を呼び出します。

```swift
import SwiftUI
import Translation

struct CustomTranslation: View {
    
    @State private var textToTranslate: String?
    @State private var translationConfiguration: TranslationSession.Configuration?
    @State private var translationResult: String?
    
    var body: some View {
        Form {
            
            Section("Original text") {
                if let textToTranslate {
                    Text(textToTranslate)
                }
            }
            
            Section("Translated text") {
                if let translationResult {
                    Text(translationResult)
                }
            }
            
        }
        .translationTask(translationConfiguration) { session in
            do {
                guard let textToTranslate else { return }
                let response = try await session.translate(textToTranslate)
                self.translationResult = response.targetText
            } catch {
                print("Error: \(error)")
            }
        }
        .task {
            // 例: GitHubの特定ページを取得し、それを日本語へ翻訳
            do {
                let (data, _) = try await URLSession.shared.data(from: URL(string: "https://raw.githubusercontent.com/swiftlang/swift/refs/heads/main/.github/ISSUE_TEMPLATE/task.yml")!)
                guard let webPageContent = String(data: data, encoding: .utf8) else { return }
                self.textToTranslate = webPageContent
                self.translationConfiguration = .init(target: .init(identifier: "ja"))
            } catch {
                print("Error: \(error)")
            }
        }
    }
    
}

#Preview {
    CustomTranslation()
}
```

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/f39f008f-f816-a078-9910-17ad83b75c35.gif)

## 古いiOSバージョンへの対応

`translationTask`ビュー修飾子は、iOS 18以降でのみ利用可能です。もしアプリがiOS 17にも対応する必要がある場合、以下のようなカスタムSwiftUIビューモディファイアを作成することで、iOS 18以上なら翻訳タスクを実行し、未対応バージョンなら何もしないようにできます。

```swift
@ViewBuilder
public func translationTaskCompatible(
    shouldRun: Bool,
    textToTranslate: String,
    targetLanguage: Locale.Language = Locale.current.language,
    action: @escaping (_ detectedSourceLanguage: String, _ translationResult: String) -> Void
) -> some View {
    if shouldRun, #available(iOS 18.0, *) {
        self
            .translationTask(.init(target: targetLanguage), action: { session in
                do {
                    let response = try await session.translate(textToTranslate)
                    action(response.sourceLanguage.minimalIdentifier, response.targetText)
                } catch {
                    print("Translation failed: \(error.localizedDescription)")
                }
            })
    } else {
        self // 対応していないiOSバージョンでは何もしない
    }
}
```

このモディファイアは、アプリのiOSコード内で以下のように利用できます。

```swift
.translationTaskCompatible(shouldRun: self.runAppleTranslation,
                           textToTranslate: self.displayedPostContent,
                           targetLanguage: Locale.current.language,
                           action: { detectedSourceLanguageCode, translationResult in
    self.displayedPostContent = translationResult
})
```

## 複数の翻訳要求を同時に行う

複数のテキストを同時に翻訳するには、`session.translate(batch:)`を用いて一括リクエストを行い、返ってきた結果をIDで対応付けることが可能です。

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/c0e64c29-de92-b3f3-5190-841372f7e655.gif)

```swift
//
//  MultipleTranslate.swift
//  iOSTranslationVideo
//
//  Created by msz on 2024/12/05.
//

import SwiftUI
import Translation

struct MultipleTranslate: View {
    
    // translation struct with the original text and optional translated text String
    struct TranslationEntry: Identifiable {
        let id: String
        let originalText: String
        var translatedText: String?
        
        init(id: String = UUID().uuidString, originalText: String, translatedText: String? = nil) {
            self.id = id
            self.originalText = originalText
            self.translatedText = translatedText
        }
    }
    
    @State private var textsToTranslate: [TranslationEntry] = [
        .init(originalText: "Hello world! This is just a test."),
        .init(originalText: "The quick brown fox jumps over the lazy dog."),
        .init(originalText: "How are you doing today?"),
        .init(originalText: "It is darkest just before the dawn."),
        .init(originalText: "The early bird catches the worm."),
    ]
    @State private var userEnteredNewText: String = ""
    
    @State private var configuration: TranslationSession.Configuration?
    
    var body: some View {
        
        Form {
            
            // list all text
            Section("Texts to translate") {
                List {
                    ForEach(textsToTranslate) { text in
                        VStack(alignment: .leading) {
                            // original text
                            Text(text.originalText)
                                .font(.headline)
                            // translated text, if available
                            if let translatedText = text.translatedText {
                                Text(translatedText)
                                    .font(.subheadline)
                            }
                        }
                    }
                }
            }
            
            // allow user to add a new text, using a TextField and a Button
            Section("Add new text") {
                HStack {
                    TextField("Enter text to translate",
                              text: $userEnteredNewText)
                    Button("Add") {
                        textsToTranslate.append(.init(originalText: userEnteredNewText))
                        userEnteredNewText = ""
                    }
                }
            }
            
            Button("Translate all to Japanese") {
                self.configuration = .init(target: .init(identifier: "ja"))
            }
            
        }
        .translationTask(configuration) { session in
            let allRequests = textsToTranslate.map {
                return TranslationSession.Request(
                    sourceText: $0.originalText,
                    clientIdentifier: $0.id)
            }
            do {
                for try await response in session.translate(batch: allRequests) {
                    print(response.targetText, response.clientIdentifier ?? "")
                    if let i = self.textsToTranslate.firstIndex(where: { $0.id == response.clientIdentifier }) {
                        var entry = self.textsToTranslate[i]
                        entry.translatedText = response.targetText
                        self.textsToTranslate.remove(at: i)
                        self.textsToTranslate.insert(entry, at: i)
                    }
                }
            } catch {
                print(error.localizedDescription)
            }
        }
        
    }
    
}

#Preview {
    MultipleTranslate()
}
```

これにより、任意のテキスト群をまとめて翻訳し、各レスポンスが戻ってくる度に個別に結果を処理できます。

## UIKitからの利用

これらの翻訳APIはSwiftUIトリガーが必要ですが、UIKitベースのアプリでも`UIHostingController`を用いることで、必要な箇所に小さなSwiftUIビューを埋め込み、その中で`.translationPresentation`や`.translationTask`を実行できます。

Appleエンジニア：  
「Translation APIはSwiftUIからトリガーする必要がありますが、UIKit（またはAppKit）アプリでも簡単な回避策があります。`UIHostingController`を使ってごく小さなSwiftUIビューを配置し、そのビュー上で`.translationPresentation`や`.translationTask`を実行すれば良いのです。」  
（参考：https://developer.apple.com/forums/thread/756837?answerId=791116022#791116022）

```swift
//
//  TranslationUIKit.swift
//  iOSTranslationVideo
//
//  Created by msz on 2024/12/05.
//

import Foundation
import UIKit
import SwiftUI

#if canImport(Translation)
import Translation
#endif

struct EmbeddedTranslationView: View {
    var sourceText: String
    @State private var isTranslationShown: Bool = false
    
    var body: some View {
        VStack {
#if canImport(Translation)
            Button("Translate") {
                self.isTranslationShown = true
            }
            .translationPresentation(isPresented: $isTranslationShown,
                                     text: self.sourceText)
#else
            Text("Translation feature not available.")
#endif
        }
    }
}

// UIKit ViewController
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        
        // Create the SwiftUI view
        let embeddedSwiftUIView = EmbeddedTranslationView(sourceText: "Hello world! This is a test.")
        
        // Embed the SwiftUI view in a UIHostingController
        let hostingController = UIHostingController(rootView: embeddedSwiftUIView)
        
        // Add the UIHostingController as a child view controller
        addChild(hostingController)
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)
        
        // Layout the SwiftUI view
        NSLayoutConstraint.activate([
            hostingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            hostingController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            hostingController.view.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8),
            hostingController.view.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5)
        ])
    }
}
```

このようにUIKitアプリでもSwiftUIを組み合わせれば、翻訳APIを利用できます。

## まとめ

以上、iOS 18で開放された翻訳API（Translation Framework）を用いて、アプリ内で翻訳機能を実装・拡張する方法を紹介しました。  
このコード全体は以下で公開しています：  

https://github.com/mszpro/iOS-System-Translation-Demo

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/c000a32a-bf78-434c-cadf-34f081211a1e.png)

Please follow me!

English version: https://medium.com/@MszPro/free-translation-api-using-the-ios-18s-system-translation-framework-in-your-app-5cf2aa205f7a

- Twitter: [https://twitter.com/mszpro](https://twitter.com/mszpro)  
- YouTube: [https://www.youtube.com/@MszPro6](https://www.youtube.com/@MszPro6)  
- Mastodon, Misskey: [@me@mszpro.com](mailto:me@mszpro.com)  
- Bluesky: [@mszpro.com](https://mszpro.com)  
- Webサイト: [https://mszpro.com](https://mszpro.com)