mac-cain13 / R.swift

Strong typed, autocompleted resources like images, fonts and segues in Swift projects
MIT License
9.49k stars 763 forks source link

Proposal: Support custom loading strategies for localized strings #843

Closed liamnichols closed 1 week ago

liamnichols commented 1 year ago

πŸ‘‹

Background

While best practices encourage apps to leave localisation down to the system, there are still many scenarios where this just doesn't cut it. The main use-case is where an app needs to override the system (or app specific) language selection and display content in a different languages, but also there are some limitations in how NSLocalizedString(_:tableName:bundle:value:comment:) handles missing translations in some languages that developers want to work around themselves.

While using non-system languages is a use case that is officially supported by R.swift via the preferredLanguages APIs, it is not currently possible to gain greater control over how NSLocalizedString is used or to bypass it entirely.

Goals

Proposal

Currently in R.swift, the source of the localization is determined by source property defined on StringResource. The source is resolved when accessing a generated StringResource property and it describes how the String.init(key:tableName:source:developmentValue:locale:arguments:) initializer should load the string:

https://github.com/mac-cain13/R.swift/blob/5721c1a948429232718fcbea3eb6132675192a72/Sources/RswiftResources/Integrations/StringResource%2BIntegrations.swift#L12-L27

This approach has been great for catering to the preferredLanguages API, however it is no longer sufficient for the second goal in this proposal. Instead, it is proposed that StringResource.Source is removed in a breaking change and replaced with a new approach for loading localized string values.

While such a change would be breaking, it would only be breaking to the RswiftResources API. Code generated by rswift will remain backwards compatible, although some of the existing methods will be deprecated and removed in a future version.

To replace source, a new type should be introduced:

public struct StringResource {
    public enum LoadingStrategy {
        /// Load strings directly using `NSLocalizedString` without changing the locale used for formatting
        public static let `default`: LoadingStrategy = .default(locale: nil)

        /// Load strings directly using `NSLocalizedString` using a custom `Locale` to format arguments
        case `default`(locale: Locale?)

        /// Load strings in the preferred languages only
        case preferredLanguages([String], locale: Locale? = nil)

        /// Load strings using a custom implementation
        case custom((_ key: StaticString, _ tableName: String, _ bundle: Bundle, _ developmentValue: String?, _ overrideLocale: Locale?, _ arguments: [CVarArg]) -> String)
    }

LoadingStrategy has a similar responsibility to Source however it allows for complete control over converting a StringResource to String via the custom case. The enum more directly describes the approach used to load a localized string and defers any actual work until the String needs to be loaded in the updated String.init(key:tableName:bundle:loadingStrategy:developmentValue:locale:arguments:) initializer.

Changes to the interface of generated _R types:

+ @available(*, deprecated, message: "Use string(loadingStrategy: .default(locale: myLocale)) instead")
  func string(locale: Foundation.Locale) -> string

+ @available(*, deprecated, message: "Use string(loadingStrategy: .preferredLanguages(myLanguages, locale: myLocale)) instead")
  func string(preferredLanguages: [String], locale: Locale? = nil)

+ func string(loadingStrategy: RswiftResources.StringResource.LoadingStrategy) -> string

And changes to the interface of nested types such as localizable:

+ @available(*, deprecated, message: "Use eight(loadingStrategy: .preferredLanguages(...)) instead")
  func localizable(preferredLanguages: [String]) -> localizable 

+ func localizable(loadingStrategy: RswiftResources.StringResource.LoadingStrategy) -> localizable

A consumer of R.swift can then leverage this new point of customisation similar to the example below:

import RswiftResources

extension StringResource.LoadingStrategy {
    static var granularFallback: Self {
        .custom { key, tableName, bundle, developmentValue, overrideLocale, arguments in
            // BetterLocalizedStringWithFallback looks through different .strings files in the event that the translation can't be found in the current lanuage.
            // e.g es_MX -> es_419 -> es -> developmentLanguage
            // NSLocalizedString would only go from es_MX to developmentLanguage 
            let format = BetterLocalizedStringWithFallback(key, tableName: tableName, bundle: bundle, value: developmentValue ?? "")
            return String(format: format, locale: overrideLocale ?? formatLocale ?? .current, arguments: arguments)
        }
    }
}

// ...

let strings = R.string(loadingStrategy: .granularFallback)
let string = strings.localizable.myString()

While this is a non-breaking change at the generated source level, there are breaking changes within the RswiftResources module:

Additional Questions

Alternatives Considered

Add RswiftLocalizedString extension point

Instead of refactoring/replacing Source with LoadingStrategy, we could keep it exactly how it is and instead provide a hook into replacing calls to NSLocalizedString as demonstrated in #841.

Add StringResource.Source.custom enum case

Instead of introducing an entirely new LoadingStrategy type, we could just add the .custom case to the existing Source enum however there are some reasons why I preferred to not take this approach...

Choosing the correct Source for the preferredLanguages array currently requires us finding the correct Bundle prior to initializing the StringResource. In an ideal world, this isn't the right place since we shouldn't need to do any 'work' until the point that the user actually wants a localized String. In reality, you are most likely to create the StringResource at the same point of invoking callAsFunction to produce the localized String so this has never really been a huge deal, but when experimenting with additive changes, it felt like the SourceΒ enum really wasn't the right type.

Next Steps

Firstly, thanks for taking the time to read though this proposal πŸ˜„ I understand that it's quite a complex change to support such a complex use case but it's currently a blocker for us to migrate to v7 and I would love for it to officially be supported in a way that keeps the code in this project at a high standard πŸ™

I've included an implementation of the main proposed implementation in #842 and also have #841 for the first alternative. What I haven't done yet is look more into the second alternative.

My suggestion for next steps would be to:

  1. Provide feedback on the proposed options, maybe investigate alternative 2 more if required
  2. Implement the chosen solution
  3. Ship it in the appropriate release (either v8 or v7.x)
tomlokhorst commented 1 year ago

Thank you for your detailed writeup!

This looks to be a big change for a specific use case. The first thing that comes to mind: Can this be written as an extension instead?

The StringResource types in Rswift are created specifically so that extensions can be written in third-party libraries. They contain should all the data necessary to write something like:

extension StringResource {
  func callAsFunction(loadingStrategy: LoadingStrategy) -> String {
     ...
  }
}
liamnichols commented 1 year ago

Thanks @tomlokhorst! Yeah I agree that it's quite a big change, and you're right that it likely is too much for such a specific use case.

Can this be written as an extension instead?

This is a great question. I was caught up thinking of how to integrate it directly so that we could continue using the API directly like we had done previously, but I didn't think about extending the functionality instead.

The first thing that comes to mind is that I don't want to have to update each of my call sites to inject loadingStrategy, but I will play around to see if there is a way to overload the existing callAsFunction methods and run with a custom implementation.

Thanks again for the review/suggestion πŸ‘

tomlokhorst commented 1 week ago

Closing this, because this feature can be implemented via an extension instead of built into the library.

Thanks again for the detailed proposal and PR!