Search code examples
iosswiftstringlocalizationnslocalizedstring

How to localise strings that pre-dominantly consist of variables – efficiently as dev and intuitively as translator


I have hundreds of dynamic strings, i.e. one or more variables exist within these string that are dynamically generated in code. For example, this is a string with anything in <> representing a variable whose value is unknown until the code is executed:

<Mostly cloudy> with to <25°> by <2pm>

As these strings will be localised, ideally I’d depend on a .strings file to store them. Here’s the above example if I was to define it there:

/*
    One condition throughout an hourly range
    e.g. <Mostly cloudy> with <temperatures> <rising> to <25°> by <2pm>.
    
    Parameters:
    1- weather
    2- measurement point (default=temperature)
    3- measurement trajectory (upwards or downwards)
    4- measurement value
    5- time above value is reached
 */
"hourSeries_const" = "%@ with %@ %@ to %@ by %@.";

Thanks to https://stackoverflow.com/a/56445894/698971, I created a String extension that returns the localised string with its arguments passed in:

/// Fetches a localised String Arguments
///
/// - Parameter arguments: parameters to be added in a string
/// - Returns: localized string
public func localized(with arguments: [CVarArg]) -> String {
    return String(format: self.localized, locale: nil, arguments: arguments)
}

And getting the final string for the UI is as simple as calling:

let a = "hourSeries_const".localized(with: ["Mostly cloudy","temperatures","rising","24°","2pm"])

But this isn’t perfect for a couple of reasons. The string in the .strings file won’t be reader-friendly. Comments are essential to understand what each variable represents. And then imagine a situation in which the variable order needs to be rearranged so that the string reads naturally in a language. This needs to be tracked somehow, and then I have to intervene in code too to ensure the order I pass in the arguments is changed accordingly.

The alternative I thought of that may partially address this (but has its own issues – more on that later) is to move the strings into code. For example, have a func:

func hourSeries_const(weather:String, dataPoint:String, valueDirection:String, valueHighlight:String, highlightedValueTime:String) -> String {
  return "\(weather) with \(dataPoint) \(valueDirection) to \(valueHighlight) by \(highlightedValueTime)."
}

But with several languages to support, I’ll need to have a switch to choose between the different languages. This isn’t ideal as I was planning to send each translator their language file(s) to work with, i.e. it should only include their language’s strings. I could work around by adding a selector function that calls the selected language’s function:

func hourSeries_const(...) -> String {
 switch language {
  case "en": return hourSeries_const_en(...)
  case "de": return hourSeries_const_de(...)
 }
}

This is not the end of the world, but it does mean every time I add a new language, I’ll need to add a new case for every one of these functions pointing to the language’s corresponding function for the string.

Is there an option that has the convenience of depending on .strings files but that offers the readability of descriptive variable names in strings for translators to work with?


Solution

  • Thanks to Sam Deane’s Localization package provided exactly what I was after (and I believe does essentially what Larme suggested in the comment above).

    public class Localization {
        static var bundlesToSearch: [Bundle] = [Bundle.main]
        
        public class func registerLocalizationBundle(_ bundle: Bundle) {
            bundlesToSearch.append(bundle)
        }
    }
    
    extension String {
        /**
         Look up a localized version of the string.
         If a bundle is specified, we only search there.
         If no bundle is specified, we search in a set of registered bundles.
         This always includes the main bundle, but can have other bundles added to it, allowing you
         to automatically pick up translations from framework bundles (without having to search through
         every loaded bundle).
        */
        
        public func localized(with args: [String:Any], tableName: String? = nil, bundle: Bundle? = nil, value: String = "", comment: String = "") -> String {
            var string = self
            let bundlesToSearch = bundle == nil ? Localization.bundlesToSearch : [bundle!]
            
            for bundle in bundlesToSearch {
                string = NSLocalizedString(self, tableName: tableName, bundle: bundle, value: value, comment: comment)
                if string != self {
                    break
                }
            }
            
            for (key, value) in args {
                string = string.replacingOccurrences(of: "{\(key)}", with: String(describing: value))
            }
            return string
        }
    }
    

    Thanks to the above I get exactly what I was after. Strings are defined as such in Localizable.strings:

    "hourSeries_const" = "{weather} with {point} {direction} to {value} by {time}.";
    

    And passed to the UI in code like this:

    "hourSeries_const".localized(with: ["weather":"Mostly cloudy","point":"temperatures","direction":"rising","value":"24°","time":"2pm"])