liamnichols/xcstrings-tool

Improve SwiftUI support on iOS 15

Brett-Best opened this issue Β· 16 comments

I was wondering what it would take to get iOS 15 SwiftUI support.

I think it would involve needing to use LocalizedStringKey but not sure how that would manifest with needing to specify a bundle in a Text init for example.

EDIT: Looks like https://github.com/iKenndac/localized-strings-symbols does something similar to my idea above.

Any thoughts around this?

Hey! So LocalizedStringKey has some annoying limitations, which I think that LocalizedStringResource was supposed to resolve, but in SwiftUI, many views still lack initialisers for the newer type, which is somewhat annoying 😞 (plus the iOS 16+ constraint).

Like you spotted, tableName and bundle exist on the Text initialiser directly and are not part of LocalizedStringKey, but also localized string arguments can only be passed in via string interpolation via LocalizedStringKey, which means that your key must contain the interpolation placeholders as well (i.e "MyKey \(arg)" == MyKey %@).

It's because of these annoying limitations with SwiftUI apis prior to iOS 16 that I didn't bother adding support initially. I couldn't find a worthwhile path forward that worked around all of this πŸ˜•

I guess that #50 is a step forward. You can add a convenience Text initialiser that resolves the string inside the initialiser, or write a small wrapper view that pull the Locale out of the environment and use that when resolving the string, but none of these will be quite as convenient as using Text + LocalizedStringResource unfortunately πŸ˜₯

I'd need to think about it some more, but another possibility might be to ship a small supplementary library that contains a LocalizedText type that could be used as a backwards compatible drop-in replacement to Text for those who need to support iOS 15 πŸ€”

I've not used SwiftUI too extensively myself, so I just want to confirm that the reason why you are looking for more advanced SwiftUI support on iOS 15 is because of the way that the language should be resolved from within the SwiftUI environment rather than using the String initialiser that would use Locale.current? Or is there something else? Or maybe I just need to push a release that contains #50 πŸ˜„

Hi hope you're doing well, is there any reason for not releasing the iOS 15 support yet ? Thank you

I had been on holiday and had only returned today. I’ll do it over the weekend πŸ™‚

Thank you so much, hope you had good holiday πŸ™‚

0.2.0 has now been released with initial support for iOS 15/macOS 12 πŸ™

Please try it out, and let me know how we might be able to improve usage in SwiftUI πŸ™‡

I tried, it's not working i think you forgot to change the mimimum requirement version platform in the package, i tried to change it locally, but i'm getting error on the parser

image

The minimum platform versions in Package.swift are correct. The CLI executable doesn't support macOS 12/iOS 15, only the generated code.

If you need to use the plugin in your iOS 15 project, you should use xcstrings-tool-plugin repository, which uses a precompiled binary artefact, not the xcstrings-tool repository as that requires compiling the CLI from source.

I'd need to think about it some more, but another possibility might be to ship a small supplementary library that contains a LocalizedText type that could be used as a backwards compatible drop-in replacement to Text for those who need to support iOS 15 πŸ€”

I've not used SwiftUI too extensively myself, so I just want to confirm that the reason why you are looking for more advanced SwiftUI support on iOS 15 is because of the way that the language should be resolved from within the SwiftUI environment rather than using the String initialiser that would use Locale.current? Or is there something else? Or maybe I just need to push a release that contains #50 πŸ˜„

I realised that this wont quite work, because the locale passed into the String.init(localized:defaultValue:table:bundle:locale:) is not used liek the locale on LocalizedStringResource.. Changing this locale only impacts the number formatting of int/float arguments, just like the old String.init(format:locale:arugments:) initializer, which kind of sucks 😞

I think that basically means that there is no clean way to be able to take the locale from the SwiftUI environment and then to resolve strings in that language prior to iOS 16, unless you poke into the bundle yourself, which isn't ideal.

Hopefully I missed something though.

Hi. I'm also trying to retrofit this into an iOS15 project. I tried integrating xcstrings-tool but when trying to pass in a string into a struct constructor, the error im getting is

Member 'regulationsLink1' in 'String' produces result of type 'String.Onboarding', but context expects 'String'

Am I missing something here?

@mylogon341 please share the code that you’re trying to use. For iOS 15, you’d need to resolve the String directly rather than passing the generated type into something like Text.

Something like this is correct usage:

String(onboarding: .regulationsLink1)

@mylogon341 please share the code that you’re trying to use. For iOS 15, you’d need to resolve the String directly rather than passing the generated type into something like Text.

Something like this is correct usage:

String(onboarding: .regulationsLink1)

Of course - yes that works as intended, thanks.

I tried thinking how to improve SwiftUI usage on iOS 15, but I am stuck. You are right that whatever we need to do will need to be done through LocalizedStringKey (with Text as well), but the main problem that I can't find a solution for is that with LocalizedStringKey, the format specifiers need to make up the key through interpolation.

If you dump a LocalizedStringKey instance, you can see that there are separate key and arguments properties, but I can't find a way to init a custom instance πŸ˜• ... Maybe there is some private API trickery that can be done, but i'm not sure.

After some digging with @Brett-Best today, I think that we are going to try with the following:

#if canImport(SwiftUI)
extension Text {
    init(localizable: String.Localizable) {
        if #available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) {
            self.init(LocalizedStringResource(localizable: localizable))
            return
        }

        var stringInterpolation = LocalizedStringKey.StringInterpolation(
            literalCapacity: 0,
            interpolationCount: localizable.arguments.count
        )

        for argument in localizable.arguments {
            switch argument {
            case .int(let value):
                stringInterpolation.appendInterpolation(value)
            case .uint(let value):
                stringInterpolation.appendInterpolation(value)
            case .double(let value):
                stringInterpolation.appendInterpolation(value)
            case .float(let value):
                stringInterpolation.appendInterpolation(value)
            case .object(let value):
                stringInterpolation.appendInterpolation(value)
            }
        }

        let makeKey = LocalizedStringKey.init(stringInterpolation:)
        var key = makeKey(stringInterpolation)
        key.overrideKeyForLookup(using: localizable.key)

        self.init(key, tableName: localizable.table, bundle: .from(description: localizable.bundle))
    }
}

extension LocalizedStringKey {
    mutating func overrideKeyForLookup(using key: StaticString) {
        withUnsafeMutablePointer(to: &self) { pointer in
            let raw = UnsafeMutableRawPointer(pointer)
            let bound = raw.assumingMemoryBound(to: String.self)
            bound.pointee = String(describing: key)
        }
    }
}
#endif

It's somewhat creative, but it will bring a Text initialiser that will work on iOS 13+ πŸ™Œ

A draft is up on #82 on branch ln/swift-ui-backport!

It works as expected from my testing, but it would be great if it could be tested some more πŸ™‡

Here is an example of the generated source:

import Foundation

extension String {
    /// Constant values for the Localizable Strings Catalog
    ///
    /// ```swift
    /// // Accessing the localized value directly
    /// let value = String(localizable: .key)
    /// value // "Default Value"
    /// ```
    internal struct Localizable {
        enum BundleDescription {
            case main
            case atURL(URL)
            case forClass(AnyClass)

            #if !SWIFT_PACKAGE
            private class BundleLocator {
            }
            #endif

            static var current: BundleDescription {
                #if SWIFT_PACKAGE
                .atURL(Bundle.module.bundleURL)
                #else
                .forClass(BundleLocator.self)
                #endif
            }
        }

        enum Argument {
            case int(Int)
            case uint(UInt)
            case float(Float)
            case double(Double)
            case object(String)

            var value: CVarArg {
                switch self {
                case .int(let value):
                    value
                case .uint(let value):
                    value
                case .float(let value):
                    value
                case .double(let value):
                    value
                case .object(let value):
                    value
                }
            }
        }

        let key: StaticString
        let arguments: [Argument]
        let table: String?
        let bundle: BundleDescription

        fileprivate init(
            key: StaticString,
            arguments: [Argument],
            table: String?,
            bundle: BundleDescription
        ) {
            self.key = key
            self.arguments = arguments
            self.table = table
            self.bundle = bundle
        }

        /// This is a comment
        ///
        /// ### Source Localization
        ///
        /// ```
        /// Default Value
        /// ```
        internal static var key: Localizable {
            Localizable(
                key: "Key",
                arguments: [],
                table: "Localizable",
                bundle: .current
            )
        }

        /// ### Source Localization
        ///
        /// ```
        /// Multiplatform Original
        /// ```
        internal static var myDeviceVariant: Localizable {
            Localizable(
                key: "myDeviceVariant",
                arguments: [],
                table: "Localizable",
                bundle: .current
            )
        }

        /// ### Source Localization
        ///
        /// ```
        /// I have %lld plurals
        /// ```
        internal static func myPlural(_ arg1: Int) -> Localizable {
            Localizable(
                key: "myPlural",
                arguments: [
                    .int(arg1)
                ],
                table: "Localizable",
                bundle: .current
            )
        }

        /// ### Source Localization
        ///
        /// ```
        /// %lld: People liked %lld posts
        /// ```
        internal static func mySubstitute(_ arg1: Int, count arg2: Int) -> Localizable {
            Localizable(
                key: "mySubstitute",
                arguments: [
                    .int(arg1),
                    .int(arg2)
                ],
                table: "Localizable",
                bundle: .current
            )
        }

        @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
        fileprivate var defaultValue: String.LocalizationValue {
            var stringInterpolation = String.LocalizationValue.StringInterpolation(literalCapacity: 0, interpolationCount: arguments.count)
            for argument in arguments {
                switch argument {
                case .int(let value):
                    stringInterpolation.appendInterpolation(value)
                case .uint(let value):
                    stringInterpolation.appendInterpolation(value)
                case .float(let value):
                    stringInterpolation.appendInterpolation(value)
                case .double(let value):
                    stringInterpolation.appendInterpolation(value)
                case .object(let value):
                    stringInterpolation.appendInterpolation(value)
                }
            }
            let makeDefaultValue = String.LocalizationValue.init(stringInterpolation:)
            return makeDefaultValue(stringInterpolation)
        }
    }

    internal init(localizable: Localizable, locale: Locale? = nil) {
        let bundle: Bundle = .from(description: localizable.bundle) ?? .main
        let key = String(describing: localizable.key)
        self.init(
            format: bundle.localizedString(forKey: key, value: nil, table: localizable.table),
            locale: locale,
            arguments: localizable.arguments.map(\.value)
        )
    }
}

extension Bundle {
    static func from(description: String.Localizable.BundleDescription) -> Bundle? {
        switch description {
        case .main:
            Bundle.main
        case .atURL(let url):
            Bundle(url: url)
        case .forClass(let anyClass):
            Bundle(for: anyClass)
        }
    }
}

@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
private extension LocalizedStringResource.BundleDescription {
    static func from(description: String.Localizable.BundleDescription) -> Self {
        switch description {
        case .main:
            .main
        case .atURL(let url):
            .atURL(url)
        case .forClass(let anyClass):
            .forClass(anyClass)
        }
    }
}

@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
extension LocalizedStringResource {
    /// Constant values for the Localizable Strings Catalog
    ///
    /// ```swift
    /// // Accessing the localized value directly
    /// let value = String(localized: .localizable.key)
    /// value // "Default Value"
    ///
    /// // Working with SwiftUI
    /// Text(.localizable.key)
    /// ```
    ///
    /// - Note: Using ``LocalizedStringResource.Localizable`` requires iOS 16/macOS 13 or later. See ``String.Localizable`` for a backwards compatible API.
    internal struct Localizable {
        /// This is a comment
        ///
        /// ### Source Localization
        ///
        /// ```
        /// Default Value
        /// ```
        internal var key: LocalizedStringResource {
            LocalizedStringResource(localizable: .key)
        }

        /// ### Source Localization
        ///
        /// ```
        /// Multiplatform Original
        /// ```
        internal var myDeviceVariant: LocalizedStringResource {
            LocalizedStringResource(localizable: .myDeviceVariant)
        }

        /// ### Source Localization
        ///
        /// ```
        /// I have %lld plurals
        /// ```
        internal func myPlural(_ arg1: Int) -> LocalizedStringResource {
            LocalizedStringResource(localizable: .myPlural(arg1))
        }

        /// ### Source Localization
        ///
        /// ```
        /// %lld: People liked %lld posts
        /// ```
        internal func mySubstitute(_ arg1: Int, count arg2: Int) -> LocalizedStringResource {
            LocalizedStringResource(localizable: .mySubstitute(arg1, count: arg2))
        }
    }

    internal static let localizable = Localizable()

    init(localizable: String.Localizable) {
        self.init(
            localizable.key,
            defaultValue: localizable.defaultValue,
            table: localizable.table,
            bundle: .from(description: localizable.bundle)
        )
    }
}

#if canImport (SwiftUI)
import SwiftUI

@available(macOS 10.5, iOS 13, tvOS 13, watchOS 6, *)
extension Text {
    init(localizable: String.Localizable) {
        if #available (macOS 13, iOS 16, tvOS 16, watchOS 9, *) {
            self.init(LocalizedStringResource(localizable: localizable))
            return
        }

        var stringInterpolation = LocalizedStringKey.StringInterpolation(literalCapacity: 0, interpolationCount: localizable.arguments.count)
        for argument in localizable.arguments {
            switch argument {
            case .int(let value):
                stringInterpolation.appendInterpolation(value)
            case .uint(let value):
                stringInterpolation.appendInterpolation(value)
            case .float(let value):
                stringInterpolation.appendInterpolation(value)
            case .double(let value):
                stringInterpolation.appendInterpolation(value)
            case .object(let value):
                stringInterpolation.appendInterpolation(value)
            }
        }
        let makeKey = LocalizedStringKey.init(stringInterpolation:)

        var key = makeKey(stringInterpolation)
        key.overrideKeyForLookup(using: localizable.key)

        self.init(key, tableName: localizable.table, bundle: .from(description: localizable.bundle))
    }
}

@available(macOS 10.5, iOS 13, tvOS 13, watchOS 6, *)
extension LocalizedStringKey {
    init(localizable: String.Localizable) {
        let text = Text(localizable: localizable)

        var stringInterpolation = LocalizedStringKey.StringInterpolation(literalCapacity: 0, interpolationCount: 1)
        stringInterpolation.appendInterpolation(text)

        let makeKey = LocalizedStringKey.init(stringInterpolation:)
        self = makeKey(stringInterpolation)
    }

    fileprivate mutating func overrideKeyForLookup(using key: StaticString) {
        withUnsafeMutablePointer(to: &self) { pointer in
            let raw = UnsafeMutableRawPointer(pointer)
            let bound = raw.assumingMemoryBound(to: String.self)
            bound.pointee = String(describing: key)
        }
    }
}
#endif