Skip to content

Commit 2b0f13d

Browse files
author
Nikos Vasileiou
authored
Merge pull request #66 from stelabouras/feature/swiftui
SwiftUI support
2 parents b85d7c8 + 6b044bd commit 2b0f13d

File tree

11 files changed

+303
-50
lines changed

11 files changed

+303
-50
lines changed

.github/workflows/swift.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99
jobs:
1010
build:
1111

12-
runs-on: macos-12
12+
runs-on: macos-14
1313

1414
steps:
1515
- uses: actions/checkout@v3

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,9 @@ logic now normalizes the locale name to match the format that iOS accepts.
117117
- Adds full support for String Catalogs support.
118118
- Adds support for substitution phrases on old Strings Dictionary file format.
119119
- Updates unit tests.
120+
121+
## Transifex iOS SDK 2.0.3
122+
123+
*June 3, 2024*
124+
125+
- Adds SwiftUI support via attributed string swizzling.

Package.swift

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ let package = Package(
3535
.testTarget(
3636
name: "TransifexObjCTests",
3737
dependencies: [
38+
"Transifex",
3839
"TransifexObjCRuntime",
3940
]
4041
),

README.md

-4
Original file line numberDiff line numberDiff line change
@@ -378,10 +378,6 @@ Description strings) that are included in the `InfoPList.strings` file.
378378
* Localized entried found in the `Root.plist` of the `Settings.bundle` of an app that
379379
exposes its Settings to the iOS Settings app that are included in the `Root.strings` file.
380380

381-
### SwiftUI
382-
383-
The SDK does not currently support SwiftUI views.
384-
385381
### ICU support
386382

387383
Also, currently SDK supports only supports the platform rendering strategy, so if the ICU

Sources/Transifex/Core.swift

+12-3
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ render '\(stringToRender)' locale code: \(localeCode) params: \(params). Error:
361361
/// A static class that is the main point of entry for all the functionality of Transifex Native throughout the SDK.
362362
public final class TXNative : NSObject {
363363
/// The SDK version
364-
internal static let version = "2.0.2"
364+
internal static let version = "2.0.3"
365365

366366
/// The filename of the file that holds the translated strings and it's bundled inside the app.
367367
public static let STRINGS_FILENAME = "txstrings.json"
@@ -501,7 +501,14 @@ token: \(token)
501501

502502
Swizzler.activate(bundles: [bundle])
503503
}
504-
504+
505+
/// Deactivates swizzling for the Bundle previously passed in the `activate(bundle:)` method.
506+
/// - Parameter bundle: the bundle to be deactivated.
507+
@objc
508+
public static func deactivate(bundle: Bundle) {
509+
Swizzler.deactivate(bundles: [bundle])
510+
}
511+
505512
/// Return the translation of the given source string on a certain locale.
506513
///
507514
/// - Parameters:
@@ -599,9 +606,11 @@ token: \(token)
599606
tx?.forceCacheInvalidation(completionHandler: completionHandler)
600607
}
601608

602-
/// Destructs the TXNative singleton instance so that another one can be used.
609+
/// Destructs the TXNative singleton instance so that another one can be used. Reverts swizzled
610+
/// classes and methods.
603611
@objc
604612
public static func dispose() {
613+
Swizzler.deactivate()
605614
tx = nil
606615
}
607616
}

Sources/Transifex/RenderingStrategy.swift

+3-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ class ICUMessageFormat : RenderingStrategyFormatter {
4040

4141
/// The platform rendering strategy
4242
class PlatformFormat : RenderingStrategyFormatter {
43-
43+
internal static let TRANSIFEX_STRINGSDICT_KEY = "Transifex.StringsDict.TestKey.%d"
44+
4445
/// Returns the proper plural rule to use based on the given locale and arguments.
4546
///
4647
/// In order to find the correct rule, it takes advantage of Apple's localization framework
@@ -50,7 +51,7 @@ class PlatformFormat : RenderingStrategyFormatter {
5051
/// business logic from scratch.
5152
static func extractPluralizationRule(locale: Locale,
5253
argument: CVarArg) -> PluralizationRule {
53-
let key = NSLocalizedString("Transifex.StringsDict.TestKey.%d",
54+
let key = NSLocalizedString(TRANSIFEX_STRINGSDICT_KEY,
5455
bundle: Bundle.module,
5556
comment: "")
5657
let pluralizationRule = String(format: key,

Sources/Transifex/Swizzler.swift

+124-8
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,34 @@ import TransifexObjCRuntime
1212
/// Swizzles all localizedString() calls made either by Storyboard files or by the use of NSLocalizedString()
1313
/// function in code.
1414
class SwizzledBundle : Bundle {
15+
// NOTE:
16+
// We can't override the `localizedAttributedString(forKey:value:table:)`
17+
// method here, as it's not exposed in Swift.
18+
// We can neither use swizzling in Swift for the same reason.
19+
// In order to intercept this method (that SwiftUI uses for all its text
20+
// components), we rely on the `TXNativeObjecSwizzler` to swizzle that
21+
// method that is also going to work on runtime for Swift/SwiftUI.
1522
override func localizedString(forKey key: String,
1623
value: String?,
1724
table tableName: String?) -> String {
18-
if Swizzler.activated && value != Swizzler.SKIP_SWIZZLING_VALUE {
25+
// Apply the swizzled method only if:
26+
// * Swizzler has been activated.
27+
// * Swizzling was not disabled temporarily using the
28+
// `SKIP_SWIZZLING_VALUE` flag.
29+
// * The key does not match the reserved Transifex StringsDict key that
30+
// is used to extract the proper pluralization rule.
31+
// NOTE: While running the Unit Test suite of the Transifex SDK, we
32+
// noticed that certain unit tests (e.g. `testPlatformFormat`,
33+
// `testPlatformFormatMultiple`) were triggering the Transifex module
34+
// bundle to load to due to the call of the `extractPluralizationRule`
35+
// method. Even though the loading of the module bundling was
36+
// happening after the Swizzler was activated, subsequent unit tests
37+
// had the bundle already loaded in the `Bundle.allBundles` array,
38+
// causing the bundle to also be swizzled, thus preventing the
39+
// `Localizable.stringsdict` to report the correct pluralization rule.
40+
if Swizzler.activated
41+
&& value != Swizzler.SKIP_SWIZZLING_VALUE
42+
&& key != PlatformFormat.TRANSIFEX_STRINGSDICT_KEY {
1943
return Swizzler.localizedString(forKey: key,
2044
value: value,
2145
table: tableName)
@@ -55,20 +79,100 @@ class Swizzler {
5579
}
5680

5781
self.translationProvider = translationProvider
58-
82+
83+
// Swizzle `NSBundle.localizedString(String,String?,String?)` method
84+
// for Swift.
5985
activate(bundles: Bundle.allBundles)
60-
61-
TXNativeObjcSwizzler.activate {
86+
87+
// Swizzle `-[NSString localizedStringWithFormat:]` method for
88+
// Objective-C.
89+
TXNativeObjcSwizzler.swizzleLocalizedString {
6290
return self.localizedString(format: $0,
6391
arguments: $1)
6492
}
93+
94+
// Swizzle `-[NSBundle localizedAttributedStringForKey:value:table:]`
95+
// method for Objective-C, Swift and SwiftUI.
96+
TXNativeObjcSwizzler.swizzleLocalizedAttributedString(self,
97+
selector: swizzledLocalizedAttributedStringSelector())
6598

6699
activated = true
67100
}
68-
101+
102+
/// Deactivates Swizzler
103+
internal static func deactivate() {
104+
guard activated else {
105+
return
106+
}
107+
108+
// Deactivate swizzled bundles
109+
deactivate(bundles: Bundle.allBundles)
110+
111+
// Deactivate swizzling in:
112+
// * `-[NSString localizedStringWithFormat:]`
113+
// * `-[NSBundle localizedAttributedStringForKey:value:table:]`
114+
TXNativeObjcSwizzler.revertLocalizedString()
115+
TXNativeObjcSwizzler.revertLocalizedAttributedString(self,
116+
selector: swizzledLocalizedAttributedStringSelector())
117+
118+
translationProvider = nil
119+
120+
activated = false
121+
}
122+
123+
private static func swizzledLocalizedAttributedStringSelector() -> Selector {
124+
return #selector(swizzledLocalizedAttributedString(forKey:value:table:))
125+
}
126+
127+
@objc
128+
private func swizzledLocalizedAttributedString(forKey key: String,
129+
value: String?,
130+
table tableName: String?) -> NSAttributedString {
131+
let swizzledString = Swizzler.localizedString(forKey: key,
132+
value: value,
133+
table: tableName)
134+
// On supported platforms, attempt to decode the attributed string as
135+
// markdown in case it contains style decorators (e.g. *italic*,
136+
// **bold** etc).
137+
#if os(iOS)
138+
if #available(iOS 15, *) {
139+
return Self.attributedString(with: swizzledString)
140+
}
141+
#elseif os(watchOS)
142+
if #available(watchOS 8, *) {
143+
return Self.attributedString(with: swizzledString)
144+
}
145+
#elseif os(tvOS)
146+
if #available(tvOS 15, *) {
147+
return Self.attributedString(with: swizzledString)
148+
}
149+
#elseif os(macOS)
150+
if #available(macOS 12, *) {
151+
return Self.attributedString(with: swizzledString)
152+
}
153+
#endif
154+
// Otherwise, return a simple attributed string
155+
return NSAttributedString(string: swizzledString)
156+
}
157+
158+
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
159+
private static func attributedString(with swizzledString: String) -> NSAttributedString {
160+
var string: AttributedString
161+
do {
162+
string = try AttributedString(markdown: swizzledString)
163+
}
164+
catch {
165+
// Fallback to the non-Markdown version in case of an error
166+
// during Markdown parsing.
167+
return NSAttributedString(string: swizzledString)
168+
}
169+
// If successful, return the Markdown-styled string
170+
return NSAttributedString(string)
171+
}
172+
69173
/// Swizzles the passed bundles so that their localization methods are intercepted.
70174
///
71-
/// - Parameter bundles: The Bundle that will be swizzled
175+
/// - Parameter bundles: The bundles to be swizzled
72176
internal static func activate(bundles: [Bundle]) {
73177
bundles.forEach({ (bundle) in
74178
guard !bundle.isKind(of: SwizzledBundle.self) else {
@@ -78,15 +182,27 @@ class Swizzler {
78182
})
79183
}
80184

185+
/// Reverts the class of the passed swizzled bundles to original `Bundle` class.
186+
///
187+
/// - Parameter bundles: The bundles to be reverted.
188+
internal static func deactivate(bundles: [Bundle]) {
189+
bundles.forEach({ (bundle) in
190+
guard bundle.isKind(of: SwizzledBundle.self) else {
191+
return
192+
}
193+
object_setClass(bundle, Bundle.self)
194+
})
195+
}
196+
81197
/// Centralized method that all swizzled or overriden localizedStringWithFormat: methods will call once
82198
/// Swizzler is activated.
83199
///
84200
/// - Parameters:
85201
/// - format: The format string
86202
/// - arguments: An array of arguments of arbitrary type
87203
/// - Returns: The final string
88-
static func localizedString(format: String,
89-
arguments: [Any]) -> String {
204+
internal static func localizedString(format: String,
205+
arguments: [Any]) -> String {
90206
guard let translationProvider = translationProvider else {
91207
return MISSING_PROVIDER
92208
}

Sources/TransifexObjCRuntime/TXNativeObjcSwizzler.m

+27-11
Original file line numberDiff line numberDiff line change
@@ -140,17 +140,33 @@ @implementation TXNativeObjcArgument
140140

141141
@implementation TXNativeObjcSwizzler
142142

143-
+ (void)activateWithClosure:(NSString* (^)(NSString *format,
144-
NSArray <TXNativeObjcArgument *> *arguments))closure {
145-
static dispatch_once_t onceToken;
146-
dispatch_once(&onceToken, ^{
147-
TXNativeObjcSwizzlerClosure = closure;
148-
149-
Class class = NSString.class;
150-
Method m1 = class_getClassMethod(class, @selector(localizedStringWithFormat:));
151-
Method m2 = class_getClassMethod(class, @selector(swizzledLocalizedStringWithFormat:));
152-
method_exchangeImplementations(m1, m2);
153-
});
143+
+ (void)swizzleLocalizedStringWithClosure:(NSString* (^)(NSString *format,
144+
NSArray <TXNativeObjcArgument *> *arguments))closure {
145+
TXNativeObjcSwizzlerClosure = closure;
146+
147+
Method m1 = class_getClassMethod(NSString.class, @selector(localizedStringWithFormat:));
148+
Method m2 = class_getClassMethod(NSString.class, @selector(swizzledLocalizedStringWithFormat:));
149+
method_exchangeImplementations(m1, m2);
150+
}
151+
152+
+ (void)revertLocalizedString {
153+
TXNativeObjcSwizzlerClosure = nil;
154+
155+
Method m1 = class_getClassMethod(NSString.class, @selector(localizedStringWithFormat:));
156+
Method m2 = class_getClassMethod(NSString.class, @selector(swizzledLocalizedStringWithFormat:));
157+
method_exchangeImplementations(m2, m1);
158+
}
159+
160+
+ (void)swizzleLocalizedAttributedString:(Class)class selector:(SEL)selector {
161+
Method m1 = class_getInstanceMethod(NSBundle.class, @selector(localizedAttributedStringForKey:value:table:));
162+
Method m2 = class_getInstanceMethod(class, selector);
163+
method_exchangeImplementations(m1, m2);
164+
}
165+
166+
+ (void)revertLocalizedAttributedString:(Class)class selector:(SEL)selector {
167+
Method m1 = class_getInstanceMethod(NSBundle.class, @selector(localizedAttributedStringForKey:value:table:));
168+
Method m2 = class_getInstanceMethod(class, selector);
169+
method_exchangeImplementations(m2, m1);
154170
}
155171

156172
@end

Sources/TransifexObjCRuntime/include/TXNativeObjcSwizzler.h

+19-2
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,25 @@ typedef NS_ENUM(NSInteger, TXNativeObjcArgumentType) {
4141
///
4242
/// @param closure A provided block that will be called when the localizedStringWithFormat: method
4343
/// is called.
44-
+ (void)activateWithClosure:(NSString* (^)(NSString *format,
45-
NSArray <TXNativeObjcArgument *> *arguments))closure;
44+
+ (void)swizzleLocalizedStringWithClosure:(NSString* (^)(NSString *format,
45+
NSArray <TXNativeObjcArgument *> *arguments))closure;
46+
47+
/// Deactivate swizzling for Objective C NSString.localizedStringWithFormat: method.
48+
+ (void)revertLocalizedString;
49+
50+
/// Swizzle the `localizedAttributedStringForKey:value:table:` NSBundle method using
51+
/// the provided class and method from the caller.
52+
///
53+
/// @param class The caller class that contains the swizzled selector.
54+
/// @param selector The swizzled selector.
55+
+ (void)swizzleLocalizedAttributedString:(Class)class selector:(SEL)selector;
56+
57+
/// Deactivate swizzling for `localizedAttributedStringForKey:value:table:` NSBundle
58+
/// method.
59+
///
60+
/// @param class The caller class that contains the swizzled selector.
61+
/// @param selector The swizzled selector.
62+
+ (void)revertLocalizedAttributedString:(Class)class selector:(SEL)selector;
4663

4764
@end
4865

0 commit comments

Comments
 (0)