swiftxcodeswiftui

SwiftUI Localization of interpolated strings in a Framework with LocalizedStringResource


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.


Solution

  • 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))
            }
        }
    }