diff --git a/Sources/Orbit/Components/Heading.swift b/Sources/Orbit/Components/Heading.swift index 0b8faa0b351..b8cc35e65c7 100644 --- a/Sources/Orbit/Components/Heading.swift +++ b/Sources/Orbit/Components/Heading.swift @@ -64,7 +64,7 @@ import SwiftUI /// When the provided content is empty, the component results in `EmptyView` so that it does not take up any space in the layout. /// /// - Note: [Orbit.kiwi documentation](https://orbit.kiwi/components/heading/) -public struct Heading: View, FormattedTextBuildable { +public struct Heading: View, FormattedTextBuildable, PotentiallyEmptyView { // Builder properties var accentColor: Color? @@ -80,7 +80,7 @@ public struct Heading: View, FormattedTextBuildable { var size: CGFloat? var strikethrough: Bool? - private let content: String + private let content: Text private let style: Style public var body: some View { @@ -89,7 +89,7 @@ public struct Heading: View, FormattedTextBuildable { } @ViewBuilder private var textContent: Text { - Text(content) + content .textColor(color) .textSize(custom: style.size) .fontWeight(fontWeight) @@ -103,22 +103,37 @@ public struct Heading: View, FormattedTextBuildable { .textAccentColor(accentColor) .textLineHeight(style.lineHeight) } + + public var isEmpty: Bool { + content.isEmpty + } } // MARK: - Inits public extension Heading { /// Creates Orbit ``Heading`` component. - /// - /// - Parameters: - /// - content: String to display. Supports html formatting tags ``, ``, ``, `` and ``. - /// - style: A predefined title style. init( - _ content: String, + _ content: some StringProtocol = String(""), + style: Style + ) { + self.content = Text(content) + self.style = style + + // Set default weight + self.fontWeight = style.weight + } + + /// Creates Orbit ``Heading`` component using localizable key. + @_semantics("swiftui.init_with_localization") + init( + _ keyAndValue: LocalizedStringKey, style: Style, - isSelectable: Bool = false + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil ) { - self.content = content + self.content = Text(keyAndValue, tableName: tableName, bundle: bundle) self.style = style // Set default weight diff --git a/Sources/Orbit/Components/Icon.swift b/Sources/Orbit/Components/Icon.swift index 6448f6a8e04..7bea7e500cc 100644 --- a/Sources/Orbit/Components/Icon.swift +++ b/Sources/Orbit/Components/Icon.swift @@ -64,6 +64,7 @@ public struct Icon: View, TextBuildable, PotentiallyEmptyView { @Environment(\.iconColor) private var iconColor @Environment(\.iconSize) private var iconSize + @Environment(\.locale) private var locale @Environment(\.textColor) private var textColor @Environment(\.textFontWeight) private var textFontWeight @Environment(\.textSize) private var textSize @@ -110,6 +111,7 @@ public struct Icon: View, TextBuildable, PotentiallyEmptyView { .init( iconColor: iconColor, iconSize: iconSize, + locale: locale, textAccentColor: nil, textColor: textColor, textFontWeight: textFontWeight, diff --git a/Sources/Orbit/Components/Text.swift b/Sources/Orbit/Components/Text.swift index 21eaf75c414..1c89e1f8c7f 100644 --- a/Sources/Orbit/Components/Text.swift +++ b/Sources/Orbit/Components/Text.swift @@ -1,9 +1,10 @@ import SwiftUI /// Orbit component that displays one or more lines of read-only text. -/// A counterpart of the native `SwiftUI.Text` with a support for Orbit markup formatting and ``TextLink``s. +/// A counterpart of the native `SwiftUI.Text` with a support for Orbit markup formatting, ``TextLink``s and custom vertical padding. /// -/// A ``Text`` is created using the `String` content that can include html tags ``, ``, ``, `
` to customize formatting. +/// A ``Text`` is created using the `String` verbatim or localizable content that can include +/// html tags ``, ``, ``, `
` to customize formatting. /// Interactive tags `
` and `` can be used to insert ``TextLink``s in the text. /// /// ```swift @@ -18,6 +19,17 @@ import SwiftUI /// 2) ``TextLink`` overlay (when the text contains ``TextLink``s). The native text modifier support is limited for this layer. /// 3) A long tap gesture overlay (when the `textIsCopyable` is `true`) /// +/// ### Localization +/// +/// The localization follows the same pattern as the native ``SwiftUI.Text``. +/// The default `main` bundle can be modified by using ``SwiftUI/View/localizationBundle(_:)`` modifier. +/// +/// ### Concatenation +/// +/// The Orbit Text supports concatenation, but does not fully support concatenating text that includes interactive ``TextLink``s. +/// It also does not fully support some global SwiftUI native text formatting view modifiers. +/// For proper rendering of `TextLinks`, any required text formatting modifiers must be also called on the `Text` directly. +/// /// ### Customizing appearance /// /// Textual properties can be modified in two ways: @@ -77,6 +89,8 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { @Environment(\.multilineTextAlignment) private var multilineTextAlignment @Environment(\.lineSpacing) private var lineSpacing + @Environment(\.locale) private var locale + @Environment(\.localizationBundle) private var localizationBundle @Environment(\.textAccentColor) private var textAccentColor @Environment(\.textColor) private var textColor @Environment(\.textFontWeight) private var textFontWeight @@ -85,8 +99,11 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { @Environment(\.textSize) private var textSize @Environment(\.sizeCategory) private var sizeCategory - private let content: String - + private let verbatimContent: String + + // Localization + private let localization: TextLocalization? + // Builder properties var size: CGFloat? var baselineOffset: CGFloat? @@ -107,7 +124,7 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { .lineSpacing(lineSpacingAdjusted) .overlay(copyableText) // If the text contains links, the TextLink overlay takes accessibility priority - .accessibility(hidden: content.containsTextLinks) + .accessibility(hidden: content(textRepresentableEnvironment.locale).containsTextLinks) .overlay(textLinks) .padding(.vertical, textRepresentableEnvironment.lineHeightPadding(lineHeight: lineHeight, size: size)) .fixedSize(horizontal: false, vertical: true) @@ -115,7 +132,7 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { } @ViewBuilder private var textLinks: some View { - if content.containsTextLinks { + if content(locale).containsTextLinks { TextLink(textLinkAttributedString(textRepresentableEnvironment: textRepresentableEnvironment)) } } @@ -129,11 +146,11 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { } var isEmpty: Bool { - content.isEmpty + verbatimContent.isEmpty } func text(textRepresentableEnvironment: TextRepresentableEnvironment, isConcatenated: Bool = false) -> SwiftUI.Text { - if content.containsHtmlFormatting { + if content(textRepresentableEnvironment.locale).containsHtmlFormatting { return modifierWrapper( SwiftUI.Text( attributedString( @@ -146,7 +163,7 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { return modifierWrapper( fontWeightWrapper( boldWrapper( - SwiftUI.Text(verbatim: content) + SwiftUI.Text(verbatim: content(textRepresentableEnvironment.locale)) .foregroundColor(textRepresentableEnvironment.resolvedColor(color)) ) ) @@ -154,11 +171,35 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { .font(textRepresentableEnvironment.font(size: size, weight: fontWeight, isBold: isBold)) } } + + private func content(_ locale: Locale) -> String { + switch localization { + case .key(let localizedStringKey, let bundle, let tableName, let explicitKey): + localizedStringKey.localized(locale: locale, bundle: bundle ?? localizationBundle, tableName: tableName, explicitKey: explicitKey) ?? verbatimContent + case .resource(let resource): + resolvedLocalizedStringResource(resource, locale: locale) ?? verbatimContent + case nil: + verbatimContent + } + } + + private func resolvedLocalizedStringResource(_ resource: Any, locale: Locale) -> String? { + if #available(iOS 16, *) { + if let resource = resource as? LocalizedStringResource { + var res = resource + res.locale = locale + return String(localized: res) + } + } + + return nil + } private var textRepresentableEnvironment: TextRepresentableEnvironment { .init( iconColor: nil, iconSize: nil, + locale: locale, textAccentColor: textAccentColor, textColor: textColor, textFontWeight: textFontWeight, @@ -270,7 +311,7 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { textRepresentableEnvironment: TextRepresentableEnvironment ) -> NSAttributedString { TagAttributedStringBuilder.all.attributedString( - content, + content(textRepresentableEnvironment.locale), alignment: multilineTextAlignment, fontSize: textRepresentableEnvironment.scaledSize(size), fontWeight: textRepresentableEnvironment.resolvedFontWeight(fontWeight, isBold: isBold), @@ -287,7 +328,7 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { isConcatenated: Bool = false ) -> NSAttributedString { TagAttributedStringBuilder.all.attributedString( - content, + content(textRepresentableEnvironment.locale), alignment: multilineTextAlignment, fontSize: textRepresentableEnvironment.scaledSize(size), fontWeight: textRepresentableEnvironment.resolvedFontWeight(fontWeight, isBold: isBold), @@ -304,18 +345,83 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView { } } -// MARK: - Inits +// MARK: - Inits (Localized) + public extension Text { - /// Creates Orbit ``Text`` component that displays a string literal. + /// Creates Orbit ``Text`` component that displays a localized content identified by a separate key and default value. /// /// - Parameters: - /// - content: String to display. Supports html formatting tags ``, ``, ``, `` and ``. + /// - key: The unique key for a string in the table identified by `tableName`. + /// - value: The default value for a string in the table identified by `tableName`. + /// - tableName: The name of the string table to search. + /// - bundle: The bundle containing the strings file. If `nil`, use the + /// main bundle. + /// - comment: Contextual information about this key-value pair. + @_semantics("swiftui.init_with_localization") + init( + _ key: StaticString, + value: LocalizedStringKey, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) { + self.localization = .key(value, bundle: bundle, tableName: tableName, explicitKey: key.description) + self.verbatimContent = String(describing: key) + } + + /// Creates Orbit ``Text`` component that displays a localized content identified by a key. + /// + /// - Parameters: + /// - keyAndValue: The key for a string in the table identified by `tableName`. + /// - tableName: The name of the string table to search. + /// - bundle: The bundle containing the strings file. If `nil`, use the + /// main bundle. + /// - comment: Contextual information about this key-value pair. + @_semantics("swiftui.init_with_localization") + init( + _ keyAndValue: LocalizedStringKey, + tableName: String? = nil, + bundle: Bundle? = nil, + comment: StaticString? = nil + ) { + self.localization = .key(keyAndValue, bundle: bundle, tableName: tableName) + self.verbatimContent = keyAndValue.key ?? "" + } +} + +// MARK: iOS16+ +@available(iOS 16, *) +public extension Text { + + /// Creates Orbit ``Text`` component that displays a localized string resource. + /// + /// - Parameter resource: Localized resource to display. + @_disfavoredOverload + @_semantics("swiftui.init_with_localization") + init(_ resource: LocalizedStringResource) { + self.localization = .resource(resource) + self.verbatimContent = resource.key + } +} + +// MARK: - Inits (Verbatim) +public extension Text { + + /// Creates Orbit ``Text`` component that displays a stored string without localization. /// - /// - Important: The text concatenation does not support interactive ``TextLink``s. It also does not fully support some global SwiftUI native text formatting view modifiers. - /// For proper rendering of `TextLinks`, any required text formatting modifiers must be also called on the `Text` directly. - init(_ content: String) { - self.content = content + /// - Parameter content: The string value to display without localization. + @_disfavoredOverload + init(_ content: S) { + self.init(verbatim: String(content)) + } + + /// Creates Orbit ``Text`` component that displays a string literal without localization. + /// + /// - Parameter resource: A string to display without localization. + init(verbatim content: String) { + self.localization = nil + self.verbatimContent = content } } @@ -365,6 +471,11 @@ public extension Text { } } +enum TextLocalization { + case key(LocalizedStringKey, bundle: Bundle?, tableName: String? = nil, explicitKey: String? = nil) + case resource(Any) +} + // MARK: - TextRepresentable extension Text: TextRepresentable { @@ -375,6 +486,19 @@ extension Text: TextRepresentable { } } +@available(iOS 16, *) +extension LocalizedStringResource.BundleDescription { + + fileprivate var bundle: Bundle? { + switch self { + case .forClass(let anyClass): Bundle(for: anyClass) + case .atURL(let uRL): Bundle(url: uRL) + case .main: .main + @unknown default: .main + } + } +} + // MARK: - Previews struct TextPreviews: PreviewProvider { @@ -667,7 +791,7 @@ Multiline text underlined, strong, var body: some View { HStack(spacing: .xxSmall) { - Text("\(sizeText) \(formatted ? "" : "")\(size)\(formatted ? "" : "")") + Text("\(sizeText) \(formatted ? "" : "")\(String(describing: size))\(formatted ? "" : "")") .textSize(size) .textAccentColor(Status.info.darkColor) .fixedSize() diff --git a/Sources/Orbit/Support/Environment Keys/LocalizationBundle.swift b/Sources/Orbit/Support/Environment Keys/LocalizationBundle.swift new file mode 100644 index 00000000000..47315d2aee8 --- /dev/null +++ b/Sources/Orbit/Support/Environment Keys/LocalizationBundle.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct LocalizationBundleKey: EnvironmentKey { + static let defaultValue: Bundle = .main +} + +public extension EnvironmentValues { + + /// A bundle used for Orbit localization using `LocalizedStringKey` stored in a view’s environment. + /// The `Bundle.main` is used by default. + var localizationBundle: Bundle { + get { self[LocalizationBundleKey.self] } + set { self[LocalizationBundleKey.self] = newValue } + } +} + +public extension View { + + /// Set the default bundle used for Orbit localization of `LocalizedStringKey` based texts in this view. + /// + /// Only applies to texts where the bundle is not explicitly specified. + /// + /// - Parameters: + /// - bundle: A bundle that will be used to localize Orbit texts within the view hierarchy. + /// - Important: Does not affect texts specified using `LocalizedStringResource` where the bundle is embedded. + func localizationBundle(_ bundle: Bundle) -> some View { + environment(\.localizationBundle, bundle) + } +} diff --git a/Sources/Orbit/Support/Text/ConcatenatedText.swift b/Sources/Orbit/Support/Text/ConcatenatedText.swift index cc8eebc243f..10d5fcf157d 100644 --- a/Sources/Orbit/Support/Text/ConcatenatedText.swift +++ b/Sources/Orbit/Support/Text/ConcatenatedText.swift @@ -5,6 +5,7 @@ struct ConcatenatedText: View { @Environment(\.iconColor) var iconColor @Environment(\.iconSize) var iconSize @Environment(\.lineSpacing) var lineSpacing + @Environment(\.locale) var locale @Environment(\.sizeCategory) var sizeCategory @Environment(\.textAccentColor) var textAccentColor @Environment(\.textColor) var textColor @@ -32,6 +33,7 @@ struct ConcatenatedText: View { .init( iconColor: iconColor, iconSize: iconSize, + locale: locale, textAccentColor: textAccentColor, textColor: textColor, textFontWeight: textFontWeight, diff --git a/Sources/Orbit/Support/Text/LocalizedStringKey.swift b/Sources/Orbit/Support/Text/LocalizedStringKey.swift new file mode 100644 index 00000000000..f31effe37fc --- /dev/null +++ b/Sources/Orbit/Support/Text/LocalizedStringKey.swift @@ -0,0 +1,84 @@ +import SwiftUI + +public extension Bundle { + + func localized(locale: Locale) -> Bundle { + let bundlePath = path(forResource: locale.identifier, ofType: "lproj") + ?? path( + forResource: locale.identifier.split(separator: "_").map(String.init).first ?? locale.identifier, + ofType: "lproj" + ) + return (bundlePath.map(Bundle.init(path:)) ?? self) ?? self + } +} + +public extension LocalizedStringKey { + + private typealias FormattedVarArgs = (CVarArg, Formatter?) + + var localized: String? { + localized() + } + + var key: String? { + Mirror(reflecting: self).descendant("key") as? String + } + + /// Returns the localized value of this `LocalizedStringKey` using reflection. + /// - Important: SwiftUI `format` for interpolated values is ignored. + func localized(locale: Locale = .current, bundle: Bundle = .main, tableName: String? = nil, explicitKey: String? = nil) -> String? { + let bundle = bundle.localized(locale: locale) + let mirror = Mirror(reflecting: self) + let value = mirror.descendant("key") as? String + let key: String? = explicitKey ?? value + + guard let key else { + return nil + } + + var formattedVarArgs: [FormattedVarArgs] = [] + + if let arguments = mirror.descendant("arguments") as? Array { + for argument in arguments { + let argumentMirror = Mirror(reflecting: argument) + + if let storage = argumentMirror.descendant("storage") { + let storageMirror = Mirror(reflecting: storage) + + if let formatStyleValue = storageMirror.descendant("formatStyleValue") { + let formatStyleValueMirror = Mirror(reflecting: formatStyleValue) + + guard var input = formatStyleValueMirror.descendant("input") as? CVarArg else { + continue + } + + let formatter: Formatter? = nil + + // TODO: Create relevant formatters + if let _ = formatStyleValueMirror.descendant("format") { + // Cast input to String + input = String(describing: input) as CVarArg + } + + formattedVarArgs.append((input, formatter)) + } else if let storageValue = storageMirror.descendant("value") as? FormattedVarArgs { + formattedVarArgs.append(storageValue) + } + } + } + } + + let string = NSLocalizedString(key, tableName: tableName, bundle: bundle, value: value ?? "", comment: "") + + if mirror.descendant("hasFormatting") as? Bool ?? false { + return String.localizedStringWithFormat( + string, + formattedVarArgs.map { arg, formatter in + formatter?.string(for: arg) ?? arg + } + ) + } else { + return string + } + } +} diff --git a/Sources/Orbit/Support/Text/TextRepresentable.swift b/Sources/Orbit/Support/Text/TextRepresentable.swift index 883d025c597..691570a3555 100644 --- a/Sources/Orbit/Support/Text/TextRepresentable.swift +++ b/Sources/Orbit/Support/Text/TextRepresentable.swift @@ -14,6 +14,7 @@ public struct TextRepresentableEnvironment { public let iconColor: Color? public let iconSize: CGFloat? + public let locale: Locale public let textAccentColor: Color? public let textColor: Color? public let textFontWeight: Font.Weight? @@ -24,6 +25,7 @@ public struct TextRepresentableEnvironment { public init( iconColor: Color?, iconSize: CGFloat?, + locale: Locale, textAccentColor: Color?, textColor: Color?, textFontWeight: Font.Weight?, @@ -33,6 +35,7 @@ public struct TextRepresentableEnvironment { ) { self.iconColor = iconColor self.iconSize = iconSize + self.locale = locale self.textAccentColor = textAccentColor self.textColor = textColor self.textFontWeight = textFontWeight diff --git a/Sources/OrbitIllustrations/Components/Illustration.swift b/Sources/OrbitIllustrations/Components/Illustration.swift index f46fc5ad752..d0d463289ff 100644 --- a/Sources/OrbitIllustrations/Components/Illustration.swift +++ b/Sources/OrbitIllustrations/Components/Illustration.swift @@ -1,6 +1,8 @@ import SwiftUI import Orbit +typealias Text = Orbit.Text + /// Orbit component that displays an illustration. /// /// An ``Illustration`` is created using Orbit or custom resource. diff --git a/Sources/OrbitStorybook/Storybook.swift b/Sources/OrbitStorybook/Storybook.swift index dfe288097ec..68005846038 100644 --- a/Sources/OrbitStorybook/Storybook.swift +++ b/Sources/OrbitStorybook/Storybook.swift @@ -1,6 +1,8 @@ import SwiftUI import Orbit +typealias Text = Orbit.Text + public struct Storybook: View { static var userInterfaceStyleOverride : UIUserInterfaceStyle {