Skip to content

Commit

Permalink
Merge pull request #781 from kiwicom/764-add-localizationbundle-envir…
Browse files Browse the repository at this point in the history
…onment-for-localization-support

Add localization support to Text component
  • Loading branch information
PavelHolec authored Mar 18, 2024
2 parents dcaf60e + 2015905 commit 696fa91
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 29 deletions.
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

0 comments on commit 696fa91

Please sign in to comment.