もしあなたのアプリでコンテンツを提供していて、世界中のユーザーをターゲットとしているなら、翻訳機能を実装したいと考えるでしょう。ユーザー投稿テキストは様々な言語で書かれる可能性があり、翻訳機能を提供することでユーザーエクスペリエンスを向上させることができます。
たとえば、私が作っている Mastodon、Misskey、Bluesky、Nostr をまとめて閲覧できるクライアント「SoraSNS」では、このような記事で紹介しているテクニックを活用しています。
そこで登場するのが Translation Framework です。
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以下ならオンライン翻訳へフォールバックしたり、ボタン自体を非表示にしたりできます。
#if canImport(Translation)
import Translation
#endif
次に、翻訳ダイアログ表示を制御するための @State
変数を用意します。最初はfalse
で、翻訳を行いたいときにtrue
に切り替えます。
@State private var isTranslationShown: Bool = false
これを、translationPresentation
修飾子でビューに付与し、isPresented
には上記バインディング、text
には翻訳対象テキストを渡します。
Form {
// ... //
}
#if canImport(Translation)
.translationPresentation(isPresented: $isTranslationShown,
text: self.sourceText)
#endif
以下は完全なサンプルコードです。
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」ボタンを押したときに、システム標準の翻訳ポップアップが表示されます。
また、ユーザーが翻訳結果をあなたのアプリ側に反映できるよう、プリビルトUI内にボタンを設置することもできます。
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
変数に翻訳結果を格納しています。これによりユーザーはボタンを押して翻訳結果を取得できます。
この手法を使うと、システムが用意したUIをそのまま利用できるので、ソース言語・ターゲット言語を指定しなくても自動判別に任せられます。
しかし、プログラム的に翻訳結果を取得して独自UIを実現したい場合は、以下を続けて読んでください。
iOSシステムでサポートされている言語を確認する
iOSシステムは主要言語に対して翻訳モデルを提供しています。利用可能な言語ペアを確認するには、以下のようなコードを書きます。
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
フレームワークを使えば、与えたテキストがどの言語なのか判定できます。
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
}
翻訳モデルのダウンロード
ローカル翻訳を行うには翻訳モデルをデバイス上にダウンロードする必要があることがあります。
以下はモデルダウンロードをトリガーする例です。
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()
を呼ぶと、必要な言語モデルがなければダイアログが表示され、ダウンロードが行われます。
以下は言語サポート状況をチェックし、必要であればダウンロード画面へ進む包括的なデモ例です。
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(...)
を呼び出します。
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()
}
古いiOSバージョンへの対応
translationTask
ビュー修飾子は、iOS 18以降でのみ利用可能です。もしアプリがiOS 17にも対応する必要がある場合、以下のようなカスタムSwiftUIビューモディファイアを作成することで、iOS 18以上なら翻訳タスクを実行し、未対応バージョンなら何もしないようにできます。
@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コード内で以下のように利用できます。
.translationTaskCompatible(shouldRun: self.runAppleTranslation,
textToTranslate: self.displayedPostContent,
targetLanguage: Locale.current.language,
action: { detectedSourceLanguageCode, translationResult in
self.displayedPostContent = translationResult
})
複数の翻訳要求を同時に行う
複数のテキストを同時に翻訳するには、session.translate(batch:)
を用いて一括リクエストを行い、返ってきた結果をIDで対応付けることが可能です。
//
// 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)
//
// 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)を用いて、アプリ内で翻訳機能を実装・拡張する方法を紹介しました。
このコード全体は以下で公開しています:
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
- YouTube: https://www.youtube.com/@MszPro6
- Mastodon, Misskey: @me@mszpro.com
- Bluesky: @mszpro.com
- Webサイト: https://mszpro.com