Localising a modularised application

Sponsored
RevenueCat logo
Build High-Performance Chat Experiences Without the Hassle šŸ› ļø

Now with integrated support for your favorite LLM APIs! Start building for free today. šŸš€

It had been a while since I had to start a new side project from scratch which required support for multiple languages from the get-go. Certainly not from scratch in a codebase where the code was divided into different modules by making use of Swift Packages.

I had to remind myself of a lot of the processes which make localisation possible, so I thought it would be good to write an article I can refer to when I next start a project like this.

Letā€™s get started!

Letā€™s take a look at a very simplified version of the codebase. It consists of an Xcode project, with a single app target (which will run the app) and a Swift Package called ā€˜Featuresā€™. The latter will contain the code for all screens in the app, each of which will be defined as its own product:

Package.swift
// swift-tools-version: 5.6
import PackageDescription

let package = Package(
    name: "Features",
    products: [
        .library(
            name: "Home",
            targets: ["Home"]),
        .library(
            name: "Detail",
            targets: ["Detail"]
        )
    ],
    dependencies: [
    ],
    targets: [
        .target(
            name: "Home"
        ),
        .target(
            name: "Detail"
        )
    ]
)

The app target will act as the compositional layer of the application, and its sole purpose will be to import each of the features, instantiate them and coordinate navigation. The UI, presentation and business logic for all features will leave in its respective ā€˜moduleā€™ (a target in the Features Swift Package). This will allow each feature to be independently developed and completely isolated from other features.

For the sake of simplicity there are only two features in this example: Home and Detail, which represent the only two screens in the app.

The home screen has a button which allows the user to navigate to the detail screen and a label which shows the current language code for the userā€™s locale. The detail screen shows a single label, containing the same information as the home screenā€™s label:

Images of both screens with english selected as the language

Letā€™s add some strings!

That looks great, but currently the screens use hardcoded strings written in English. The app requires its content to be translated into two other languages: Catalan and Spanish.

While this could be achieved in a number of ways, my personal preference is that each feature (or screen) contains only the localised strings it needs, which increases the featureā€™s portability and reusability.

This can be done in Swift Packages by placing all the necessary .lproj files with all content to be localised (in this case only Localizable.strings files) in the targetā€™s folder - my preference is under a parent Resources/ folder and defining these as resources for the specific target in the Package.swift.

Building the feature now after adding those files in will cause the compiler to throw an error:

Error thrown by Xcode when no default localisation is set

This is because a defaultLocalization must be provided to the Package.swift. All features are targets to the same package, so only one defaultLocalization must be provided. This is what the resulting Package.swift looks like after adding the localised content:

Package.swift
// swift-tools-version: 5.6
import PackageDescription

let package = Package(
    name: "Features",
    defaultLocalization: "en",
    platforms: [.iOS(.v15)],
    products: [
        .library(
            name: "Home",
            targets: ["Home"]),
        .library(
            name: "Detail",
            targets: ["Detail"]
        )
    ],
    dependencies: [
    ],
    targets: [
        .target(
            name: "Home",
            dependencies: [],
            resources: [.process("Resources/")]
        ),
        .target(
            name: "Detail",
            resources: [.process("Resources/")]
        )
    ]
)

āš ļø Note that the compiler will show a warning if no localised content is provided for the default localisation code. This is very helpful to ensure that you donā€™t ship a version of your package with the base localised content in. Xcode warning shown when default localisation is missing.

Supporting localisations

Contrary to what you might be thinking now, running the app with the deviceā€™s language set to Catalan or Spanish still shows the content in English. The reason for this is that Swift Packages need extra information to determine which localised content to use and, as it stands, they will just use the targetā€™s base localisation if the package has content for it or, alternatively, the packageā€™s default localisation.

There are two different ways we can make localisation work in our features: making the new localisation available in the app target or enabling mixed localisations.

Adding a new localisation to the app target

One way of enable new localisations in the ā€˜Featuresā€™ Swift Package would be to add them to the Xcode project importing the features. This can be achieved by heading over to the Xcode project and, in the Info tab of the project settings, adding the new localisation/s to support:

Note that for this to work, the localisation will need to have at least 1 localised file (e.g. an empty Localizable.strings file). In this case, because the app target is built using UIKit and the launch screen storyboard was chosen to be localised when adding the new localisation (see video above), there is already one localised file.

This will now allow packages to get the supported localisations from the main bundle and choose which resources to use accordingly.

It is worth noting that, if the device has a locale thatā€™s supported by the app but not by the package, the latter will fall back to the defaultLocalization provided in its Package.swift.

Similarly, if the locale is not supported by the app, it will also fall back to that same value. This is why it is so important to set the defaultLocalization to the same as the base language in the main app target to ensure consistence across all screens. This is one of the reasons why I prefer to group all features under a single Swift Package, so that there is a single source of truth for the defaultLocalization value across all screens.

Allowing mixed localisations

While using the app targetā€™s localisation tends to be the preferred approach as it ensures consistency across all screens and allows only a few supported locales to be used, there is another way of allowing package content to be localised without having to add any localisation support on the main app target.

This can be done by setting the CFBundleAllowMixedLocalizations value in the appā€™s Info.plist file to YES.

This setting will then tell the app target that it is fine to have different localisations in different targets or features, which will make our app localisation work straight away when new localised resources are added.

Enabling mixed localisations in the app target

There are a couple of things to bear in mind with this approach though:

  1. There is no longer any need to add localisations to the app target, adding the lproj with the localised content to the packageā€™s resources is enough. When a user changes locale, the package will either show content for that locale if it exists as a resource or for the default provided in its Package.swift.
  2. There is as many supported regions as there are localisations resources. What this means is that there isnā€™t a single source of truth to determine which localisations are supported across the app. This can lead to issues where, for example, a feature has localised content which has not yet been sourced for other features. In that case, there is no way to hide it other than removing the resource itself:

Point number two is portrayed in the video above, where the user has its device language set to French. Mixed sources causes an inconsistency as there is no fr.lproj on the home screen - hence it falling back to the default localisation, which is english. On the other hand, there is localised content available in the detail screen, which is why the strings are properly trasnlated. For this reason, I like to keep the app target as the source of truth for all supported localisations.

Bonus tip - Automation

Something I would always encourage is to automate the process of retrieving the localised strings for a given bundle as much as you can. If your app has a lot of screens, you will want to make the process of adding localised strings as easy and streamlined as possible.

A tool I have been using for a while is SwiftGen, which can generate Swift interfaces for all sorts of assets and resources, such as Localizable.strings files.

Creating a build tool plugin which makes use of this executable could make the process of supporting new localisations a lot easier and consistent across features.