SFSafeSymbols / SFSafeSymbols

Safely access Apple's SF Symbols using static typing

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

StringProtocol type inference issue in SwiftUI's Label initializers

gongzhang opened this issue Β· comments

I just noticed a strange behavior. In SwiftUILabelExtension, the protocol StringProtocol is always matched to String instead of LocalizedStringKey if you write a string literal in your code. This is inconsistent with other SwiftUI native methods. For example:

// Native SwiftUI:
Text("some text")                      // inferred as LocalizedStringKey
Label("some text", systemImage: "...") // inferred as LocalizedStringKey
    .navigationBarTitle("some text")   // inferred as LocalizedStringKey

// SwiftUILabelExtension:
Label("some text", systemSymbol: .xxx) // hmm... inferred as String... πŸ€”

So I have to do the following to ensure the text is being localized correctly:

Label("some text" as LocalizedStringKey, systemSymbol: .xxx) // πŸ€”

I checked the implementation of SwiftUILabelExtension and it seems to be defined in the same way as in SwiftUI's native methods, but I don't know why the compiler doesn't give priority to matching LocalizedStringKey.

public extension Label where Title == Text, Icon == Image {
    
    /// Creates a label with a system symbol image and a title generated from a
    /// localized string.
    ///
    /// - Parameter systemSymbol: The `SFSymbol` describing this image.
    init(_ titleKey: LocalizedStringKey, systemSymbol: SFSymbol) {
        self.init(titleKey, systemImage: systemSymbol.rawValue)
    }
    
    /// Creates a label with a system symbol image and a title generated from a
    /// string.
    ///
    /// - Parameter systemSymbol: The `SFSymbol` describing this image.
    init<S>(_ title: S, systemSymbol: SFSymbol) where S : StringProtocol {
        // πŸ€” always goes here with S==String...
        self.init(title, systemImage: systemSymbol.rawValue)
    }
}

I suggest to check if we can find a better way to define the initializer signatures. Because this inconsistent behavior can easily lead to localization bugs, and it is less easy to be found if the app is not fully tested.

@gongzhang Thanks for reporting, this is a weird issue indeed.

I have pushed work/swiftui-localize and although it's rather unlikely that that change will fix the issue, it would help a lot if you could try that branch and report whether that makes a difference. Thanks in advance!

@fredpi Thanks for the reply! I tried it and it didn't work. Then I fiddled with the code snippet below in the Xcode Playground for a while, but string literal always fails to match LocalizedStringKey by default.

// Xcode Playground
import SwiftUI

public extension Label where Title == Text, Icon == Image {
    
    init(_ titleKey: LocalizedStringKey) {
        print("1")
        self.init(titleKey as LocalizedStringKey, systemImage: "1.circle")
    }
    
    init<S>(_ title: S) where S : StringProtocol {
        print("2")
        self.init(title, systemImage: "2.circle")
    }
    
}

// always match no.2 intializer, never goes to no.1.
// ... unless remove the no.2 completely 
Label("xxx")

I'm now a bit suspicious that the compiler might have taken some special care for SwiftUI. Or maybe I should take a look at the source code of SwiftUI.πŸ˜‚

@gongzhang Thanks! The playground helps understanding that the issue is about the the wrong initializer being selected. I guess what we're observing is quite non-deterministic behavior:

  • Every StringProtocol and LocalizedStringKey conform to ExpressibleByStringLiteral, so the type of an expression like "abc" can only be inferred based on where it gets used.
  • As there are both an initializer for StringProtocol and for LocalizedStringKey, that doesn't help with inferring the proper type.
  • Swift apparently just selects String as the type when using the SFSafeSymbols initializer and LocalizedStringKey when using the original initializer. There seem to be no deterministic reasons for that.

Interestingly, for SwiftUI's Text type, there is a differentiation, see Apple's documentation. There we have two distinct initializers (distinct because of the verbatim argument label):

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

I suggest the following:

  1. Adjust SFSafeSymbols' initializers argument labels from _ to titleKey or title respectively.
  2. File feedback with Apple about this (especially the inconsistency compared to the Text type)

Do you agree with my assessment? Would you mind doing the second task?

@fredpi I agree with you. 🀝

  1. Maybe we can keep init(_:systemSymbol:) and only update init<S>(_:systemSymbol:) to init<S>(verbatim:systemSymbol:)? The effect of this modification is as follows.
Label("Abc") // LocalizedStringKey, good! It is the expected.

let string: String = "some non-localization text"
Label(str) // compiler error: Missing argument label "verbatim:" in call - Fair enough.

Label(verbatim: str) // Acceptable, also more clarity.

What do you think?

  1. I will continue to research this topic then I'll be back to update :)

@gongzhang Did you find anything new about this issue?

If not, I'd go on and change the StringProtocol initializer to Label(verbatim:) as you suggested.

@fredpi Sorry for the lack of updates for a while, as I didn't find any useful information. 😬

@gongzhang @_disfavoredOverload is the solution, as @ddddxxx found out and implemented in #64.

Here's an article about it that even refers to the SwiftUI initializers SFSafeSymbols replicates: https://fivestars.blog/swift/disfavoredOverload.html

@fredpi That's brilliant! Thank you so much πŸ‘