SFSafeSymbols / SFSafeSymbols

Safely access Apple's SF Symbols using static typing

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Supporting explicit symbol localization

StevenSorial opened this issue · comments

Apple mentioned in this WWDC Video (4:10) that explicit localization is supported, and we know what suffixes are supported based on name_availability.plist extracted from the app.

This issue is for discussion to support this feature or not and to choose the implementation approach. Implementation details (e.g: Naming) will be discussed separately once the PR is made.

These are the implementations that I have in mind, ordered based on type-safety:

Implementation 1

  • Providing localized properties on SFSymbol for all the possible localizations, returns a new type LocalizedSFSymbol.
  • Initializing UIImage with LocalizedSFSymbol returns UIImage?.

Example:

extension SFSymbol {
	var ar: LocalizedSFSymbol { LocalizedSFSymbol(rawValue: "\(rawValue).ar") }
	// all other possible localizations, even ".rtl" which is not available for "character"
}

let arabicCharacterImage = UIImage(systemSymbol: .character.ar) // UIImage?

Implementation 2

  • Having an internal Dictionary<SFSymbol, Set<Localization>>.
  • Providing localized properties on SFSymbol for all the possible localizations, returns a new type LocalizedSFSymbol? based on the Dictionary.
  • Initializing UIImage with LocalizedSFSymbol returns UIImage.

Example:

extension SFSymbol {
	var ar: LocalizedSFSymbol? {
		guard localizationDictionary[self]?.contains(.ar) == true else { return nil }
		return LocalizedSFSymbol(rawValue: "\(rawValue).ar") 
	}
	// all other possible localizations, even ".rtl" which is not available for "character"
}

if let arabicCharacterSymbol = SFSymbol.character.ar {
	let arabicCharacterImage = UIImage(systemSymbol: arabicCharacterSymbol) // UIImage
}

Implementation 3

  • Providing a new namespace for each symbol with localization.
  • For each symbol, The new namespace will have only the localization(s) available for this symbol
  • Keeping the default symbols as they are currently for source compatibility.

Example:

extension SFSymbol {
	enum Character {
		static var ar: SFSymbol { SFSymbol(rawValue: "character.ar") }
		// all other localizations available for "character" only
	}
}

let defaultCharacterImage = UIImage(systemSymbol: .character) // UIImage
let arabicCharacterImage = UIImage(systemSymbol: .Character.ar) // UIImage
// or
let arabicCharacterImage = UIImage(systemSymbol: .Localizable.Character.ar) // UIImage

Implementation 4

  • Same idea as Implementation 3.
  • Instead of keeping the default symbols static in SFSymbol, it would be moved to its dedicated namespace. Avoiding the confusion of .Character and .character.

Example:

extension SFSymbol {
	enum Character {
		static var default: SFSymbol { SFSymbol(rawValue: "character") }
		static var ar: SFSymbol { SFSymbol(rawValue: "character.ar") }
		// all other localizations available for "character" only
	}
}

let defaultCharacterImage = UIImage(systemSymbol: .Character.default) // UIImage
let arabicCharacterImage = UIImage(systemSymbol: .Character.ar) // UIImage

@fredpi @knothed what do you think?

commented

Hmmm, I would love to just write .character.ar and have a localized symbol. However the optionality is a dealbreaker for me. Therefore I like option 3 best.

In theory it would be best to have a protocol SFSymbol, so character and other symbols could be of a different type exposing their own localizations.

Hmmm, I would love to just write .character.ar and have a localized symbol. However the optionality is a dealbreaker for me. Therefore I like option 3 best.

@knothed Agreed on the optionality. Regarding option 3, don't you think the user would be confused because .character and .Character means different things?

In theory it would be best to have a protocol SFSymbol, so character and other symbols could be of a different type exposing their own localizations.

Could you explain the implementation more? do you mean providing a separate type for each symbol?

commented

Yes, we‘d have a protocol SFSymbolProtocol which requires a rawValue and can be used for (UIImage.init). Then normal symbols could be of type SFStandardSymbol: SFSymbolProtocol, and symbols with localizations would each have their own type (being an SFSymbolProtocol) exposing additional properties which would all be SFStandardSymbols.

I'm a big fan of this protocol approach. It makes for nice ergonomics at the call-site.

@knothed @rygood I'm sorry but I still don't understand how the implementation (and the call-site) will look like? 🤔

if I understood correctly, SFStandardSymbol would be for symbols with no localizations, does that mean that a new type would be made for each combination of localization(s)? SFSymbolWithArabicOnly, SFSymbolWithArabicAndHindi, SFSymbolWithHindiOnly, ... ? I don't see that as being maintainable in the long run

Also with UIImage.init talking SFSymbolProtocol the leading dot notation would not work. The user would have to type UIImage(systemSymbol: SFStandardSymbol.circle) instead of UIImage(systemSymbol: .circle)

@knothed Apologies if didn't get what you meant.

Here’s one possible way to avoid having an explosion in the number of named types

public protocol _arSymbol {
  var ar: SFLocalizedSymbol { get }
}
public protocol _hiSymbol {
  var hi: SFLocalizedSymbol { get }
}
// ...

internal struct LocalizableSymbol: SFSymbol, _arSymbol, _hiSymbol, [...] {
  var ar: SFLocalizedSymbol { .init(rawValue: "\(rawValue).ar") }
  var hi: SFLocalizedSymbol { .init(rawValue: "\(rawValue).hi") }
  // ...
}

extension SFSymbol {
  public let anArabicOnlySymbol: SFSymbol & _arSymbol = LocalizableSymbol(rawValue: "some.name")
}

Since anArabicOnlySymbol is not declared as conforming to _hiSymbol, .hi will not be available on it.

@j-f1 Wow, nice!. Although I think we would hit the infamous "has Self or associated type requirements" because the SFSymbol protocol should be refining RawRepresentable, Equatable, and Hashable.

@j-f1 Solved it by using opaqe types

extension SFSymbol {
    static public var anArabicOnlySymbol: some SFSymbol & _arSymbol { LocalizableSymbol(rawValue: "some.name") }
}

however, there are other issues:

extension UIImage {
    convenience init<S: SFSymbol>(systemSymbol: S) {
        self.init(systemName: systemSymbol.rawValue)!
    }
}

// Member 'anArabicOnlySymbol' cannot be used on value of protocol type 'SFSymbol.Protocol'; 
// use a generic constraint instead 
let image = UIImage(systemSymbol: .anArabicOnlySymbol) 

also for allSymbols, its type was Set<SFSymbol>, so until SE-0328 is released, we cannot do Set<some SFSymbol>

Thank you to everyone involved in this discussion! We have some really good ideas here and it took me some time to review them.

I really like the approach suggested by @j-f1. However, for it to work, SFSymbol must be a protocol or a class: internal struct LocalizableSymbol: SFSymbol, _arSymbol, _hiSymbol only works if SFSymbol is a protocol or a class. Unfortunately, SFSymbol can't be a protocol because

extension SFSymbol {
  public let anArabicOnlySymbol: SFSymbol & _arSymbol = LocalizableSymbol(rawValue: "some.name")
}

is not supported if SFSymbol is a protocol.

Therefore, this leaves us with SFSymbol being a class.

I thought about whether it was possible to have a SFSymbol and a SFSymbolProtocol instead, but concluded that would not work: The extension with the static lets would need to be on the SFSymbol type (because it's not possible to have a protocol extension with static lets). This would mean that the type of the static lets would need to be SFSymbol (or at least they would need to conform to SFSymbol if it were a protocol (but it can't be a protocol because then the extension would not be possible)). But if the type of the static lets is SFSymbol and SFSymbol is not a protocol, we would lose the ability to conform to the localization protocols.

With SFSymbol being a class, the code would look as follows:

// File: SFSymbol.swift
public class SFSymbol: RawRepresentable, Equatable, Hashable {
    public let rawValue: String

    public required init(rawValue: String) {
        self.rawValue = rawValue
    }
}

// File: LocalizableSymbol.swift
public protocol arLocalizable {
    var ar: SFSymbol { get }
}

public protocol hiLocalizable {
    var hi: SFSymbol { get }
}

public protocol chLocalizable {
    var ch: SFSymbol { get }
}

internal class LocalizableSymbol: SFSymbol, arLocalizable, hiLocalizable, chLocalizable {
    var ar: SFSymbol { .init(rawValue: "\(rawValue).ar") }
    var hi: SFSymbol { .init(rawValue: "\(rawValue).hi") }
    var ch: SFSymbol { .init(rawValue: "\(rawValue).ch") }
}

// Use in symbol extension files
extension SFSymbol {
    public static let symbolAvailableInArAndHi: SFSymbol & arLocalizable & hiLocalizable = LocalizableSymbol(rawValue: "some.name")
}

// Functionality test
func someTest(symbol: SFSymbol) {
    print(symbol.rawValue)
}

someTest(symbol: .symbolAvailableInArAndHi)
someTest(symbol: .symbolAvailableInArAndHi.ar)

I'm only worried whether this will introduce a significant decrease in performance (compared to SFSymbol being a struct). What do y'all think? @Stevenmagdy @knothed @j-f1 @rygood

@fredpi Great improvement. Didn't know that classes can be used with the & syntax.

A couple of questions:

  • What about symbols with localization available on higher versions than the base symbol itself? For example, "a.book.closed" is available since 14.0, but the Traditional Chinese localization is >= 14.2. My suggestion is that we ignore those localizations and write in the README that some localizations are unavailable.

  • When apple releases new localized symbols but delays the release of the new version of the app like they did with version 2.2. The user would be able to define the new symbol like:

extension SFSymbol {
    public static let newSymbolAvailableInAr: SFSymbol = SFSymbol(rawValue: "new.symbol")
}

but would not be able to use newSymbolAvailableInAr.ar because LocalizableSymbol is internal. I suggest making open SFSymbol so they can at least write:

class NewArSymbol: SFSymbol, arLocalizable {
  init(){ super.init(rawValue: "new.symbol") }
  var ar: SFSymbol { .init(rawValue: "\(rawValue).ar") } // This line can be omitted if we made a default implementation for `arLocalizable`
  required init(rawValue: String) { fatalError() }
}
commented

Looks very nice @fredpi.
I don’t think there will be a noticeable performance decrease.

I like making SFSymbol open as @Stevenmagdy suggested and providing a default implementation for the protocols.
Why is the SFSymbol initializer required however?

commented

Regarding the performance: there is no notable performance difference between struct SFSymbol and class SFSymbol. I performed some tests (like creating large arrays of SFSymbols and performing operations on the elements, sorting etc.) and there was no single test where I found any significant performance difference between the two options.

Therefore I propose to actually implement @fredpi's suggestion. I would write an implementation soon.

commented

What about symbols with localization available on higher versions than the base symbol itself? For example, "a.book.closed" is available since 14.0, but the Traditional Chinese localization is >= 14.2. My suggestion is that we ignore those localizations and write in the README that some localizations are unavailable.

There are two options here.
First one is, as you said, ignoring those localizations which are lesser-available than the base symbol.

Second option is to do some compile-time magic. I would love to write something like

public extension SFSymbol {
#if available(iOS 14.0, *)
    static let arabicSymbol: SFSymbol & arLocalizable = LocalizableSymbol(rawValue: "test")
#else
    static let arabicSymbol: SFSymbol = LocalizableSymbol(rawValue: "test")
#endif
}

or

public extension SFSymbol {
    @available(iOS 13.0, < iOS 14.0)
    static let arabicSymbol: SFSymbol = LocalizableSymbol(rawValue: "test")
    @available(iOS 14.0, *)
    static let arabicSymbol: SFSymbol & arLocalizable = LocalizableSymbol(rawValue: "test")
}

but sadly, both of these options do not work - #available is runtime only, and @avaiable is just not powerful enough.

Also, macros provided by Objective-C like TARGET_OS_IOS and __IPHONE_OS_VERSION_MIN_REQUIRED sadly do just not work in Swift.

This leaves us just with option one, or option three, which would be to expose these symbols optionally at run-time as follows:

public extension SFSymbol {
    static let arabicSymbol: SFSymbol & possiblyArLocalizable = ArabicSymbol(rawValue: "test")
}

// Some other file
public protocol possiblyArLocalizable {
    var ar: SFSymbol? { get }
}

internal class ArabicSymbol: SFSymbol, possiblyArLocalizable {
    required init(rawValue: String) {
        super.init(rawValue: rawValue)
    }

    var ar: SFSymbol? {
        if #available(iOS 14.0, *) {
            return .init(rawValue: "\(rawValue).ar")
        } else {
            return nil
        }
    }
}

But as this requires an extra class for each of these symbols, besides being really ugly, I would say we stay with option 1.

What do you say? @Stevenmagdy @fredpi @rygood @j-f1

commented

There is a fourth option, which is to expose two different symbols, one being unsafe and requiring the user‘s caution:

public extension SFSymbol {
    static let arabicSymbol: SFSymbol = LocalizableSymbol(rawValue: "test")
    static let arabicSymbolUnsafeLocalizable: SFSymbol & arLocalizable = LocalizableSymbol(rawValue: "test")
}
commented

Okay, option 5: declare these symbols in an Objective-C class. This would give full access to above mentioned availability macros.

As I think this is too much effort to put in here (and could still be done in the future if necessary), I would just stick to option 1 and drop a notice in the README.

@knothed we can use namespaces (similar to option 3 in the OP), just for the missing localizations:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension SFSymbol {
    enum MissingLocalizations {
        @available(iOS 14.2, macOS 11.0, tvOS 14.2, watchOS 7.1, *)
        static public var aBookClosed_traditionalChinese: SFSymbol { .init(rawValue: "a.book.closed.zh.traditional") }
    }
    // OR
    enum MissingLocalizations {
        enum ABookClosed {
            @available(iOS 14.2, macOS 11.0, tvOS 14.2, watchOS 7.1, *)
            static public var traditionalChinese: SFSymbol { .init(rawValue: "a.book.closed.zh.traditional") }
        }
    }
}

I think we should count how many symbols have localizations with lesser availability to make a decision

I think we should count how many symbols have localizations with lesser availability to make a decision

Yeah, that is an important metric that we should know before we decide.

commented

There are 40 such symbols.

Wow, that is more than I expected. Just to make sure we are counting correctly, Is a.book.closed for example one of these symbols? because it should not. The Traditional Chinese for a.book.closed is only available for its newer name, not itself. In other words, we can't write a.book.closed.zh.traditional.

commented

No, a.book.closed is not one of these symbols.

commented

We could, per @fredpi‘s suggestion, create a custom class for each of those symbols:

public class SomeSymbol: SFSymbol {
    var rawValue: String = ”some.symbol“

    @available(iOS 14.0, *)
    var ar: SFSymbol = SFSymbol(rawValue +.ar“)
}

and then

public extension SFSymbol {
    static let someSymbol: SomeSymbol = SomeSymbol()
}

This would solve our problems, but would introduce one class for each of these 40 symbols.

I wonder if we just went with option 3/4 from the original post, what are the downsides? because I think it will solve these issues while being consistent.
My only concern would be code size. would case-less(empty) enums affect code size?

Another solution that just came to mind:

public class SFSymbol: RawRepresentable, Equatable, Hashable {
    public let rawValue: String

    required public init(rawValue: String) {
        self.rawValue = rawValue
    }
}

public protocol SymbolLocalizations {
    init()
}

@dynamicMemberLookup
public class LocalizableSFSymbol<T: SymbolLocalizations>: SFSymbol {
    subscript(dynamicMember keyPath: KeyPath<T, SFSymbol>) -> SFSymbol {
        T()[keyPath: keyPath]
    }
}

struct CharacterLocalizations: SymbolLocalizations {
    var ar: SFSymbol { .init(rawValue: "character.ar") }
}

extension SFSymbol {
    static let character = LocalizableSFSymbol<CharacterLocalizations>(rawValue: "character")
}

let exampleImage = UIImage(systemSymbol: .character.ar)

It can be improved but it improves on options 3 and 4 from my original post
@fredpi @knothed @rygood @j-f1 what do you think?

commented
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension SFSymbol {
   enum MissingLocalizations {
       enum ABookClosed {
           @available(iOS 14.2, macOS 11.0, tvOS 14.2, watchOS 7.1, *)
           static public var traditionalChinese: SFSymbol { .init(rawValue: "a.book.closed.zh.traditional") }
       }
   }
}

I like this option most.

How many different classes would be needed if there was one for each unique combo of languages + availability?

proposal based on @Stevenmagdy’s most recent proposal:

public protocol SymbolExtension {
    init(rawValue: String)
}

@dynamicMemberLookup
public class ExtendedSFSymbol<T: SymbolExtension>: SFSymbol {
    private let extended: SymbolExtension
    init(rawValue: String) {
        extended = SymbolExtension(rawValue: rawValue)
        super.init(rawValue: rawValue)
    }

    public subscript(dynamicMember keyPath: KeyPath<T, SFSymbol>) -> SFSymbol {
      extended[keyPath: keyPath]
    }
}

// one per localization+version combo
public protocol ArabicLocalizable: SymbolExtension {
    var ar: SFSymbol { get }
}
extension ArabicLocalizable {
    var ar: SFSymbol { .init(rawValue: "\(rawValue).ar") }
}

// one per icon (although icons with the same set of localizations can use the same struct)
public struct OnlyArabicLocalizable: ArabicLocalizable {
    let rawValue: String
    init(rawValue: String) { self.rawValue = rawValue }
}

extension SFSymbol {
    static let character = LocalizableSFSymbol<OnlyArabicLocalizable>(rawValue: "character")
}

let exampleImage = UIImage(systemSymbol: .character.ar)

Going back to @fredpi’s earlier proposal, could we simply have separate protocols for each version where the relevant property is marked as @available in that version?

commented

I don’t quite understand the two most recent proposals – (how) do they solve the mixed localization availability problem? We want strong compile-time knowledge of all available symbols, how does dynamicMemberLookup help here? @Stevenmagdy @j-f1

How many different classes would be needed if there was one for each unique combo of languages + availability?

There are 40 symbols with problematic localization availabilities. We could just create 40 (possibly less) classes or enums, one for each such symbol, i.e. either this or this proposal.

For all other symbols (which are probably 99%), I would love to use this implementation, as it is simply the most elegant one.

Thanks everyone for the suggestions! I like the dynamic member solution very much (because it maintains the lower case notation), but I think there's an even better option:

Going back to @fredpi’s earlier proposal, could we simply have separate protocols for each version where the relevant property is marked as @available in that version?

This will result in code like this (tested in a Xcode playground):

// File: SFSymbol.swift
open class SFSymbol: RawRepresentable, Equatable, Hashable {
    public let rawValue: String

    open required init(rawValue: String) {
        self.rawValue = rawValue
    }
}

// File: LocalizableSymbol.swift
internal class LocalizableSymbol: SFSymbol, Localizable_A, Localizable_B {
    var ar: SFSymbol { .init(rawValue: "\(rawValue).ar") }
    var hi: SFSymbol { .init(rawValue: "\(rawValue).hi") }
   // ... all localizations that exist somewhere
}

// All combinations of localizations and their availabilities that exist somewhere
public protocol Localizable_A {
    @available(iOS 15.0, macOS 12.0, *)
    var ar: SFSymbol { get }

    @available(iOS 16.0, macOS 13.0, *)
    var hi: SFSymbol { get }
}

public protocol Localizable_B {
    @available(iOS 14.1, macOS 11.0, *)
    var ar: SFSymbol { get }

    @available(iOS 16.0, macOS 13.0, *)
    var hi: SFSymbol { get }
}

// ... Localizable_C ...

// Use in symbol extension files
extension SFSymbol {
    public static let symbolAvailableInArAndHi: SFSymbol & Localizable_A = LocalizableSymbol(rawValue: "some.name")
}

// Functionality test
func someTest(symbol: SFSymbol) {
    print(symbol.rawValue)
}

someTest(symbol: .symbolAvailableInArAndHi)
if #available(iOS 16.0, *) {
    someTest(symbol: .symbolAvailableInArAndHi.hi)
} else {
    // Fallback on earlier versions
}

With this, the availability problem is solved without any compromises and there's only need for a separate protocol for each localization availability combination (not for every symbol).

What do you think? @Stevenmagdy @knothed @j-f1 @rygood

commented

Seems good, would just be good to know how many localization availability combinations there are.

Here is another proposal, instead of making a new protocol for every localization combination, we make (generated if needed) a new protocol for every special localization availability.

open class SFSymbol: RawRepresentable, Equatable, Hashable {
    public let rawValue: String
    public required init(rawValue: String) {
        self.rawValue = rawValue
    }
}

internal class LocalizableSymbol: SFSymbol, ARLocalizable, ARLocalizable3_0 {
    var ar: SFSymbol { .init(rawValue: "\(rawValue).ar") }
   // ... all localizations
}

// Base (default) AR localization. Used when the localization has the same availability as the base symbol.
public protocol ARLocalizable {
    var ar: SFSymbol { get }
}

// Version 3.0 AR localization. Used when the localization is higher than the base symbol and was introduced in 3.0.
// Generated if needed.
public protocol ARLocalizable3_0 {
    @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
    var ar: SFSymbol { get }
}

extension SFSymbol {
    // Introduced in 13.0. Its Arabic localization is available since 13.0
    public static let firstSymbol: SFSymbol & ARLocalizable = LocalizableSymbol(rawValue: "some.name")
    // Introduced in 13.0. Its Arabic localization is available since 15.0
    public static let secondSymbol: SFSymbol & ARLocalizable3_0 = LocalizableSymbol(rawValue: "another.name")
}

func someTest() {
    _ = SFSymbol.firstSymbol.ar
    if #available(iOS 15.0, *) {
        _ = SFSymbol.secondSymbol.ar
    }
}

I think this is better than a new protocol for every combination since it will decrease the number of new protocols.
what do you think? @fredpi @knothed @j-f1 @rygood

commented

I like this suggestion more. It limits the worst maximal number of total protocols by O(n*m) instead of O(n^m).