1

I'm working on a SwiftUI iOS app that uses a custom framework I have created to be able to share code with an app extension.

In that framework I've created a CustomError enum with a message parameter that needs to be translated so that users see the error in their own language. Often, I have to pass interpolated strings to the CustomError to clarify where the error is.

The translation of the message works well but for interpolated strings. With those the test is translated but the but the placeholders of the interpolated string are not replaced by its value.

To showcase the problem, I've created some demo code which I've separated in two parts: the part of the app and the part of the framework.

Below, is th part of the app:

struct ContentView: View {
    @State private var message: String = "No messages"
    @State private var otherMessage: String = "No messages"
    
    var body: some View {
        VStack {
            Text("Super app")
            Text(message)
            Text(otherMessage)
        }
        .padding()
        .onAppear {
            let fwCode = FrameworkCode()
            do {
                try fwCode.doStuff()
            }
            catch {
                self.message = error.localizedDescription
            }
            do {
                try fwCode.doOtherStuff()
            }
            catch {
                self.otherMessage = error.localizedDescription
            }

        }
    }
}

///Localizable.xcstrings for the app:
{
  "sourceLanguage" : "en",
  "strings" : {
    "Super app" : {
      "localizations" : {
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Super aplicación"
          }
        }
      }
    }
  },
  "version" : "1.0"
}

and next the part of the framework (the framework is created from Xcode goint to File -> New -> Target -> Framework):

public enum CustomError :  LocalizedError {
    
    case myError(message: LocalizedStringResource)
    
    public var errorDescription: String? {
        switch self {
            case let .myError(message):
                return String(localizedFromFramework:message)
        }
    }
}

public class FrameworkCode {
    
    public init() {}
    
    public func doStuff() throws {
        let param = "XBox"
        throw CustomError.myError(message: "The best console is \(param)")
    }
    
    public func doOtherStuff() throws {
        throw CustomError.myError(message: "The best console is PlayStation")
    }
}

public extension String {
    
    init(localizedFromFramework name: LocalizedStringResource, comment: StaticString? = nil) {
        self.init(localized: String.LocalizationValue(name.key), table: "Localizable", bundle: Bundle(identifier:"eversoft.TestFramework"), comment: comment)
    }

}

///Below is the code for the Localizable.xcstrings for the farmework:
{
  "sourceLanguage" : "en",
  "strings" : {
    "The best console is %@" : {
      "localizations" : {
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "La mejor consola es %@"
          }
        }
      }
    },
    "The best console is PlayStation" : {
      "localizations" : {
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "La mejor consola es la PlayStation"
          }
        }
      }
    }
  },
  "version" : "1.0"
}

When executing the app, the error thrown from the method doOtherStuff() is translated correctly, but the error thrown by the method doStuff() gets translated but the placeholders are not replaced by the value.

What is causing the placeholders not to be replaced?

I'm using Xcode 16.2 and I've tested this with iOS 17.4, 18.1 and 18.2 and all of them behave equally.

1
  • Just mention that if all the code is moved to the app, dicarding the framework, then the translations work as expected. Commented Mar 14 at 20:31

1 Answer 1

2

In your String.init, you only extracted the key from the string resource.

self.init(localized: String.LocalizationValue(name.key), table: "Localizable", bundle: Bundle(identifier:"eversoft.TestFramework"), comment: comment)
                                              ^^^^^^^^

You basically lose the values to substituted here.


Your String.init is totally unnecessary. If you have a LocalizableStringResource, you can just localise it directly. LocalizedStringResource contains information about which bundle the resource is in. It's the main bundle by default, but you can specify your framework bundle.

public var errorDescription: String? {
    switch self {
        case let .myError(message):
            return String(localized: message)
    }
}
public func doStuff() throws {
    let param = "XBox"
    throw CustomError.myError(message: LocalizedStringResource("The best console is \(param)", bundle: .forClass(FrameworkCode.self)))
}

public func doOtherStuff() throws {
    throw CustomError.myError(message: LocalizedStringResource("The best console is PlayStation", bundle: .forClass(FrameworkCode.self)))
}

To still be able to pass a string interpolation to myError(message:), have the error message be a String.LocalizationValue and you can forget about LocalizedStringResource:

public enum CustomError : LocalizedError {
    
    case myError(message: String.LocalizationValue)
    
    public var errorDescription: String? {
        switch self {
            case let .myError(message):
            return String(localized: message, bundle: Bundle(for: FrameworkCode.self))
        }
    }
}
Sign up to request clarification or add additional context in comments.

3 Comments

Thanks Sweeper, now I understand why i'm losing the values to subtitute. I was aware of the possibility to specify the bundle manually but having to always specify the framework bundle for strings that reside in the framework seems like a bug to me and I thought I was doing somethning wrong. Regarding the switch to String.LocalizationValue instead of using LocalizedStringResource, I thought that the LocalizedStringResource was the newest approach to localization and the way to go for future proof code. Is that true?
Well there is no way to express "the current framework's bundle" as a default value of a parameter so .main is the default value you get. And I don't think it's a bad idea to leave localisation as a job for the main bundle to do in the first place. It could be argued that being able to customise localisation of strings in a framework makes that framework more flexible.
@Enric LocalizedStringResource is just a LocalizationValue plus the table name, bundle, and locale. If you don't need to pass those other 3 things around, a LocalizationValue is enough. It's simple to create a LocalizedStringResource from LocalizationValue anyway. Just do LocalizedStringResource(message, bundle: .forClass(FrameworkCode.self)).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.