Search code examples
swiftswiftuistring-interpolationlocalizedstringkey

Why does the behaviour of LocalizedStringKey depend on whether I pass a string interpolation to its initialiser?


While trying to answer this question, I found a strange behaviour.

Text(LocalizedStringKey("Hello \(Image(systemName: "globe"))"))

displays a globe, but

Text(LocalizedStringKey("Hello {world}".replacingOccurrences(of: "{world}", with: "\(Image(systemName: "globe"))")))
Text(LocalizedStringKey("Hello" + "\(Image(systemName: "globe"))"))

displays "Hello" followed by a jumble of SwiftUI's internal jargon mess.

An even more minimal example would be:

let x = "\(Image(systemName: "globe"))"
print(LocalizedStringKey.init(x))
print(LocalizedStringKey.init("\(Image(systemName: "globe"))"))

The values I'm passing to LocalizedStringKey.init should be the same, both "\(Image(systemName: "globe"))", but The first prints

LocalizedStringKey(key: "%@", hasFormatting: true, arguments: [...])

and the second prints

LocalizedStringKey(key: "Image(provider: SwiftUI.ImageProviderBox<SwiftUI.Image.(unknown context at $7ff91ccb3380).NamedImageProvider>)", hasFormatting: false, arguments: [])

It appears that LocalizedStringKey.init changes its behaviour depends on whether the arguments I pass is an (interpolated) string literal or not.

As far as I can see, the two calls to LocalizedStringKey.init are calling the same initialiser. There is only one parameter-label-less initialiser in LocalizedStringKey, which takes a String.

If there were also an initialiser that takes a LocalizedStringKey, the results would be much more understandable. LocalizedStringKey has custom string interpolation rules, and one specifically for Image, after all. But this is the only initialiser with no parameter labels as far as I know.

It would also be somewhat understandable if the initialiser's parameter is @autoclosure () -> String. If the expression I pass in is lazily evaluated, the method might be able to "peek inside" the closure by some means unknown to me. But the parameter isn't an auto closure.

What seems to be happening here is that the compiler is creating a LocalizedStringKey with key being that same pattern as the interpolation you passed in, even though the parameter is a String!

What is actually going on here? Did I miss a hidden initialiser somewhere?


Solution

  • TL;DR: the behaviour you're seeing comes from ExpressibleByStringInterpolation. But read on for more fun!

    LocalizedStringKey becomes easier to understand if you think of it purely as a convenience to allow SwiftUI interface elements to be localizable "for free" when using string literals. There's only one real time you'd use it directly.

    Consider Text. There are two relevant initializers:

    init(_ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil)
    

    which will attempt to localize the text passed in, and

    init<S>(_ content: S) where S : StringProtocol
    

    which will display the string without altering it.

    If you call Text("Hello"), which initializer is used?

    String literals conform to StringProtocol, but LocalizedStringKey is also ExpressibleByStringLiteral. The compiler would not know which one to choose.

    To get "free" localization, the StringProtocol initializer is marked with @_disfavoredOverload, which tells the compiler to assume that the string literal is a LocalizableStringKey rather than a String.

    Therefore, Text("Hello") and Text(LocalizedStringKey("Hello")) are equivalent.

    let string = "Hello"
    Text(string)
    

    In this case, there is no conflict - the compiler uses the StringProtocol initializer and the string is not localized.

    What does this have to do with your question? LocalizedStringKey is also ExpressibleByStringInterpolation, which is where your "hidden initializer" comes from. But like the examples above, this only comes into play if you are initializing it with a single, interpolated string.

    Text("Hello \(Image(systemName: "globe"))")
    

    You're passing an interpolated string, so the compiler can deal with it and add the image into the interpolation.

    Text("Hello {world}".replacingOccurrences(of: "{world}", with: "\(Image(systemName: "globe"))"))
    

    Here, replacingOccurrences(of: is evaluated first, meaning your argument is a String, which is not treated as a LocalizedStringKey expressed-via-string-interpolation. You're essentially seeing the description of the image.

    A similar thing happens with the example with + in it. That implicitly makes a String, so you lose the special image interpolation that LocalizedStringKey gives you.

    For your last code example:

    let x = "\(Image(systemName: "globe"))"
    print(LocalizedStringKey.init(x))
    print(LocalizedStringKey.init("\(Image(systemName: "globe"))"))
    

    x is a string containing a description of the image. Remember, only LocalizedStringKey has the magic power to actually understand and represent Image. Any other string interpolation will fall back to the description of the interpolated object.

    The first initializer is passing a string (which is treated as a key, that's the only time you'd really directly use LocalizedStringKey, if you were generating keys at run time and wanted to use them for lookup).

    The second initializer is using ExpressibleByStringInterpolation and is using LocalizedStringKey.StringInterpolation to insert images into its internal storage, which can then get rendered by Text.