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 π
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 toText
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 useLocale.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