In the iOS version of my music app, Lily, several native UIKit dialogs are invoked from Unity via an iOS plugin, such as the delete confirmation action sheet shown above. Due to the fact that multiple languages are supported, it was required that any user facing copy be localized. iOS provides powerful and easy-to-use localization tools to native app developers, which I wanted to make use of for accomplishing this task within Unity. This post documents how Unity developers can use the native iOS localization tools - namely NSLocalizedString and corresponding '.strings' files - to localize iOS plugins in Unity.
1. CREATE A BUNDLE OF TRANSLATIONS
Firstly, we can create a bundle to store the localizations. By using a bundle and placing it within a folder named 'iOS', Unity will automatically copy our localizations into the generated Xcode project for the default Unity-iPhone target. A bundle will also work well with the NSLocalizedString API we will use later.
The bundle should consist of directories for each localization that each contain a single .strings file, where the translations are stored. The directories should be named by their language designator followed by '.lproj'. We can create this quickly in the terminal and then populate the .strings files with our translations, like so:
cd <your project directory> mkdir Lily.bundle cd Lily.bundle mkdir en.lproj mkdir ja.lproj mkdir zh-Hans.lproj for dir in */; do touch "$dir"/Localizable.strings; done
Ensure that your bundle is stored within your Unity project inside a folder named 'iOS'. As previously mentioned, this will cause Unity to automatically copy it into the generated Xcode project for the default Unity-iPhone target.
2. Use the bundle from native code
Next, we can use the bundle's translations from our native iOS plugin code by using the NSLocalizedString variant, NSLocalizedStringFromTableInBundle. If you aren’t familiar with NSLocalizedString, it will return a localised version of a string using the current device’s preferred language, region and the corresponding .strings file. This particular variant will allow us to specify the bundle we created earlier for the location of our translations.
Firstly, from our native iOS plugin code, we can retrieve our bundle using NSBundle's class method 'URLForResource:withExtension:'.
NSURL *bundleURL = [[NSBundle mainBundle] URLForResource:@"Lily" withExtension:@"bundle"]; NSBundle localizationBundle = [NSBundle bundleWithURL:bundleURL];
Depending on how your project is structured you may wish to have a centralised access point. For example, in Lily we have a single localisation bundle for all the iOS plugins, which we create only once, like so:
@implementation LocalizationBundle : NSObject + (NSBundle *)bundle { static dispatch_once_t onceToken; static NSBundle *localizationBundle = nil; dispatch_once(&onceToken, ^{ NSURL *bundleURL = [[NSBundle mainBundle] URLForResource:@"Lily" withExtension:@"bundle"]; localizationBundle = [NSBundle bundleWithURL:bundleURL]; }); return localizationBundle; } @end
Now that we can access our bundle, all we need to do is ensure that any time we supply a user facing string, we use NSLocalizedStringFromTableInBundle to query for the current localization, passing in our bundle and the text key.
NSString *alertTitle = NSLocalizedStringFromTableInBundle(@"Delete", nil, [LocalizationBundle bundle], nil);
Our native iOS plugin will now use our supplied translations on devices using those locales. Additionally, by leaning on NSLocalizedString to handle resolving the runtime locale we can, for example, specify additional region designators to our translations, such as using en-GB.lproj translations over en.lproj for users in the United Kingdom.
3. Automate Xcode project configuration
Finally, we need to configure the Xcode project for localisation. This is simply a case of adding the CFBundleLocalizations entry to the project’s Info.plist and adding the identifiers for each of our localisations.
However, this configuration will be lost when Unity regenerates the Xcode project. This will happen if we bulid-and-replace or if we build to a new directory. It's not optimal to have to manually configure the Info.plist with all the localisations every time the iOS project is built. This is both duplicate work as well as a potential for error. Missing localisations won't show an error at compile time either; they'll simply not localise at runtime.
Instead, we can write a post build process to make the modifications to the Info.plist file for us. That way, whenever an iOS build is executed we know that the resultant Xcode project will be configured for localisation and our native iOS plugins will be localised.
The post process script below will do exactly that, adding all localisations defined in kProjectLocalizations (represented by their language designators) to the Info.plist file, automating the configuration of the Xcode project for localisation.
#if UNITY_EDITOR using UnityEngine; using UnityEditor; using UnityEditor.Callbacks; using System.IO; using System.Xml; public class LocalizationPostBuildProcess { static string[] kProjectLocalizations = {"en", "ja", "zh_CN"}; [PostProcessBuild] public static void OnPostprocessBuild(BuildTarget buildTarget, string path) { if (buildTarget == BuildTarget.iOS) { string infoPList = System.IO.Path.Combine(path, "Info.plist"); if (File.Exists(infoPList) == false) { Debug.LogError("Could not add localizations to Info.plist file."); return; } XmlDocument xmlDocument = new XmlDocument(); xmlDocument.Load(infoPList); XmlNode pListDictionary = xmlDocument.SelectSingleNode("plist/dict"); if (pListDictionary == null) { Debug.LogError("Could not add localizations to Info.plist file."); return; } XmlElement localizationsKey = xmlDocument.CreateElement("key"); localizationsKey.InnerText = "CFBundleLocalizations"; pListDictionary.AppendChild(localizationsKey); XmlElement localizationsArray = xmlDocument.CreateElement("array"); foreach (string localization in kProjectLocalizations) { XmlElement localizationElement = xmlDocument.CreateElement("string"); localizationElement.InnerText = localization; localizationsArray.AppendChild(localizationElement); } pListDictionary.AppendChild(localizationsArray); xmlDocument.Save(infoPList); } } } #endif