Localizing App Shortcuts with App Intents
When implementing App Shortcuts with App Intents, it can be a bit daunting to localize everything.
Below is a small inventory of each String, and how to translate them. But first…
The big picture
After wandering the net without finding anything that worked, I reached out to Andrea Gottardo who kindly replied, and I quote:
I followed their advice and it worked.
When I first read this, I thought that the key element was "for each", because when I wanted to test the localization, I only translated the first sentence to save time. It was a little weird because I thought I had also tested this at the very beginning, but I didn't pay more attention than that.
The thing is, translating all the phrases was not the only thing I did: I also created a file called "AppShortcuts.strings" and moved the translations there. I'm so used to breaking my Localizable.strings into multiple files that I didn't pay attention to this, because it's part of my workflow. But it may be a key element.
Indeed, this is the only file that throws this type of error: "Invalid Utterance. Every App Shortcut utterance should have '${applicationName}' in it".
So it seems there are two files involved:
- Localizable.strings: the default file for your app, for everything but the phrases
- AppShortcuts.strings: for all the AppShortcut/AppShortcutsProvider phrases, and it has to be called exactly like this
Localizable.strings is not mandatory
While the AppShorcuts.strings must be used for phrases, you don't have to use Localizable.strings for the other strings.
Most strings are relying on LocalizedStringResource for translation, and as you can see from the image below, you can use a custom file and provide its name as the "table" argument.
Translating App Shortcuts
Here's the inventory with code samples.
AppShortcutsProvider & AppShortcut
The AppShortcutsProvider prepares a set of preconfigured Shortcuts that will appear in the Shortcuts.app. You can help your users find it using the new SwiftUI ShortcutsLink button.
Here's what my AppShortcutsProvider currently looks like
struct NoMeatTodayAppShortcuts: AppShortcutsProvider {
static var shortcutTileColor: ShortcutTileColor = .lime
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: AddMealIntent(quantity: 1),
phrases: [
"Add a meal \(\.$content) to \(.applicationName)",
],
systemImageName: "leaf.fill"
)
AppShortcut(
intent: AddMealIntent(1, content: .clear),
phrases: [
"Add a vegetarian meal in \(.applicationName)",
"Add a vegetarian meal to \(.applicationName)",
"Add a vegan meal in \(.applicationName)",
"Add a vegan meal to \(.applicationName)",
"Add a meatless meal in \(.applicationName)",
"Add a meatless meal to \(.applicationName)",
"Add a plant-based meal in \(.applicationName)",
"Add a plant-based meal to \(.applicationName)",
],
systemImageName: "leaf.fill"
)
AppShortcut(
intent: OpenViewIntent(),
phrases: [
"Open \(\.$view) in \(.applicationName)",
],
systemImageName: "leaf.fill"
)
}
}
As you may notice, there's some SwiftUI building magic involved since we're turning a list of AppShortcut into an array, all thanks to the AppShortcutsBuilder.
If you want to have an initializer like in the above (`AddMealIntent(quantity: 1)`), do like I did, listen to Emmanuel Crousivier.
I just made one change because I prefer to add initializers without overwriting the default one. This can save you from some troubles if the init does a bit more than you'd expect. To do that, create an extension instead of declaring the initializer in the struct itself.
// Risky
@available(iOS 16, *)
struct WhichCardIntent: AppIntent {
init() {
}
init(whichCardType: WhichCardType?= nil) {
self.whichCardType = whichCardType
)
// perform(), etc…
}
// Safer
@available(iOS 16, *)
struct WhichCardIntent: AppIntent {
// perform(), etc…
}
@available(iOS 16, *)
extension WhichCardIntent {
init(whichCardType: WhichCardType?= nil) {
self.whichCardType = whichCardType
)
}
Anyway, phrases should go into your AppShortcuts.strings file, and the variables or the application name should be surrounded by ${}.
"Open ${view} in ${applicationName}" = "Ouvre la vue ${view} dans ${applicationName}";
"Add a meal ${content} to ${applicationName}" = "Ajoute un repas ${content} à ${applicationName}";
"Add a vegetarian meal in ${applicationName}" = "Ajoute un repas végétarien dans ${applicationName}";
"Add a vegetarian meal to ${applicationName}" = "Ajoute un repas végétarien à ${applicationName}";
"Add a vegan meal in ${applicationName}" = "Ajoute un repas végan dans ${applicationName}";
"Add a vegan meal to ${applicationName}" = "Ajoute un repas végan à ${applicationName}";
"Add a meatless meal in ${applicationName}" = "Ajoute un repas sans viande dans ${applicationName}";
"Add a meatless meal to ${applicationName}" = "Ajoute un repas sans viande à ${applicationName}";
"Add a plant-based meal in ${applicationName}" = "Ajoute un repas à base de plantes dans ${applicationName}";
"Add a plant-based meal to ${applicationName}" = "Ajoute un repas à base de plantes à ${applicationName}";
The strange part is that when ${applicationName}
is the only variable, only one AppShortcut will be available in the Library (Siri is supposed to understand them all though), whereas adding two phrases with an AppEnum having three values will result in six Shortcuts.
For instance, with Open ${view} in ${applicationName}
where view can have 3 values, this creates in 3 preconfigured Shortcuts. But if I were to add Show ${view} in ${applicationName}
to the list, I'd get 6.
That being said, if you want to create one preconfigured Shorctut for each of your phrases having only ${applicationName}
in it, add multiple AppShortcuts with one phrase instead of adding one AppShortcut with multiple phrases.
AppIntent
When it comes to AppIntent, everything goes into your Localizable.strings (or the file of your choice if you use tables, see above).
struct OpenViewIntent: AppIntent {
static var openAppWhenRun: Bool = true
static var title: LocalizedStringResource = "Open View"
static var description: IntentDescription = .init("Opens the selected view", categoryName: "Open in App", searchKeywords: ["open", "view"])
@Parameter(title: "View")
var view: ShortcutableView
@MainActor
func perform() async throws -> some IntentResult {}
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$view)")
}
}
Where do they appear?
title and description appear when you click on the (i) next to an action.
The @Parameter(title:) "Vue" and ParameterSummary "Ouvrir …" appear in the details of your action, while the values "Aujourd'hui/Population/Historique" are provided by the AppEnum (see below)
title and @Parameter(title:)
Just give them a string that you'll translate in Localizable.strings, or use on of LocalizedStringResource's init if you want to use a custom table.
description
As you can see, I didn't use a simple string for the description. This is because I want to be able to group Shortcuts by type of intent in the action picker. I only have 2 Shortcuts for now so it's not very useful, but I intend (sorry…) to add more.
ParameterSummary
This one is tricky. You'd think that because it has a variable it should be with the translations of the phrases in AppShortcuts.strings, but no, it belongs in Localizable.strings.
But, you still need to use the same syntax with the ${}
"Open ${view}" = "Ouvrir ${view}";
AppEnum
Here's the enum I used in the example above, with Today/Population/History being translated in French ("Aujourd'hui/Population/Historique").
public enum ShortcutableView: String, AppEnum {
case today
case population
case history
static var typeDisplayName: LocalizedStringResource = "View"
public static var typeDisplayRepresentation: TypeDisplayRepresentation = "View"
public static var caseDisplayRepresentations: [ShortcutableView: DisplayRepresentation] = [
.today: "Today",
.population: "Population",
.history: "History",
]
}
typeDisplayName
, typeDisplayRepresentation
(which I'm not sure ever appears anywhere anyway) and caseDisplayRepresentations
all go in your Localizable.strings file, or the file of your choice with the proper initializer.
For instance, for caseDisplayRepresentations
you will have to use something like.init(title: LocalizedStringResource("Today", table: "CustomFileName"))
instead of the simple "Today" I used.
Other strings
I think it's safe to assume that anything that's not covered here goes into Localizable.strings, but we'll see :)
Parting notes
- You need two files: Localizable.strings and AppShortcuts.strings
- For phrases without variables / having only \(.applicationName) in them, only the first one will appear in the Shortcuts library
- Translate all phrases (although, I'm not sure that's a real requirement)
- When testing your actions (creating a new shortcut using the shortcuts your provide), make sure you create a new shortcut each time. There is some caching involved and the translations might not show up otherwise.
- I haven't covered Application Name synonyms and SiriTipView yet, so come back sometime later or follow me on Twitter for updates.
And if you're looking for a tool to manage the localization, I'd suggest you use my tool of choice Localazy. I wrote some articles to get you set up and have a referral link that should earn you some free stuff.
Before you leave 😇
Consider doing one (or more) of these:
- Follow me on Twitter @sowenjub
- Download No Meat Today, a companion app for people who want to eat less meat, whatever you put behind "less" or "meat". Most of us are eating too much meat for our own sake these days (health, environment, climate, money…), this app will help you get on top of your consumption.