Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add localization support to Text component #781

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions Sources/Orbit/Components/Heading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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 `<strong>`, `<u>`, `<ref>`, `<a href>` and `<applink>`.
/// - 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
Expand Down
2 changes: 2 additions & 0 deletions Sources/Orbit/Components/Icon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,6 +111,7 @@ public struct Icon: View, TextBuildable, PotentiallyEmptyView {
.init(
iconColor: iconColor,
iconSize: iconSize,
locale: locale,
textAccentColor: nil,
textColor: textColor,
textFontWeight: textFontWeight,
Expand Down
162 changes: 143 additions & 19 deletions Sources/Orbit/Components/Text.swift
Original file line number Diff line number Diff line change
@@ -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 `<strong>`, `<u>`, `<ref>`, `<br>` to customize formatting.
/// A ``Text`` is created using the `String` verbatim or localizable content that can include
/// html tags `<strong>`, `<u>`, `<ref>`, `<br>` to customize formatting.
/// Interactive tags `<a href>` and `<applinkX>` can be used to insert ``TextLink``s in the text.
///
/// ```swift
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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?
Expand All @@ -107,15 +124,15 @@ 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)
}
}

@ViewBuilder private var textLinks: some View {
if content.containsTextLinks {
if content(locale).containsTextLinks {
TextLink(textLinkAttributedString(textRepresentableEnvironment: textRepresentableEnvironment))
}
}
Expand All @@ -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(
Expand All @@ -146,19 +163,43 @@ public struct Text: View, FormattedTextBuildable, PotentiallyEmptyView {
return modifierWrapper(
fontWeightWrapper(
boldWrapper(
SwiftUI.Text(verbatim: content)
SwiftUI.Text(verbatim: content(textRepresentableEnvironment.locale))
.foregroundColor(textRepresentableEnvironment.resolvedColor(color))
)
)
)
.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,
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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 `<strong>`, `<u>`, `<ref>`, `<a href>` and `<applink>`.
/// - 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<S: StringProtocol>(_ 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
}
}

Expand Down Expand Up @@ -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 {

Expand All @@ -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 {

Expand Down Expand Up @@ -667,7 +791,7 @@ Multiline <applink1>text</applink1> <u>underlined</u>, <strong>strong</strong>,

var body: some View {
HStack(spacing: .xxSmall) {
Text("\(sizeText) \(formatted ? "<applink1>" : "")\(size)\(formatted ? "</applink1>" : "")")
Text("\(sizeText) \(formatted ? "<applink1>" : "")\(String(describing: size))\(formatted ? "</applink1>" : "")")
.textSize(size)
.textAccentColor(Status.info.darkColor)
.fixedSize()
Expand Down
29 changes: 29 additions & 0 deletions Sources/Orbit/Support/Environment Keys/LocalizationBundle.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading