Skip to content

Commit d228601

Browse files
stephencelismluisbrown
authored andcommitted
Localizable Alerts and Action Sheets (#275)
* Localizable Alerts and Action Sheets Fixes #237. * Tests/fixes * Update LocalizedStringKey.swift * Fix
1 parent 4cb5144 commit d228601

File tree

11 files changed

+117
-37
lines changed

11 files changed

+117
-37
lines changed

Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ extension Reducer {
7979
.cancellable(id: FavoriteCancelId(id: state.id), cancelInFlight: true)
8080

8181
case let .response(.failure(error)):
82-
state.alert = .init(title: error.localizedDescription)
82+
state.alert = .init(title: .init(error.localizedDescription))
8383
return .none
8484

8585
case let .response(.success(isFavorite)):

Examples/TicTacToe/Sources/Core/LoginCore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public let loginReducer =
7373
return .none
7474

7575
case let .loginResponse(.failure(error)):
76-
state.alert = .init(title: error.localizedDescription)
76+
state.alert = .init(title: .init(error.localizedDescription))
7777
state.isLoginRequestInFlight = false
7878
return .none
7979

Examples/TicTacToe/Sources/Core/TwoFactorCore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public let twoFactorReducer = Reducer<TwoFactorState, TwoFactorAction, TwoFactor
5858
.map(TwoFactorAction.twoFactorResponse)
5959

6060
case let .twoFactorResponse(.failure(error)):
61-
state.alert = .init(title: error.localizedDescription)
61+
state.alert = .init(title: .init(error.localizedDescription))
6262
state.isTwoFactorRequestInFlight = false
6363
return .none
6464

Examples/TicTacToe/Sources/Views-UIKit/LoginViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ class LoginViewController: UIViewController {
129129
guard let alert = alert else { return }
130130

131131
let alertController = UIAlertController(
132-
title: alert.title, message: nil, preferredStyle: .alert)
132+
title: alert.title.formatted(), message: nil, preferredStyle: .alert)
133133
alertController.addAction(
134134
UIAlertAction(
135135
title: "Ok", style: .default,

Examples/TicTacToe/Sources/Views-UIKit/TwoFactorViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public final class TwoFactorViewController: UIViewController {
8888
guard let alert = alert else { return }
8989

9090
let alertController = UIAlertController(
91-
title: alert.title, message: nil, preferredStyle: .alert)
91+
title: alert.title.formatted(), message: nil, preferredStyle: .alert)
9292
alertController.addAction(
9393
UIAlertAction(
9494
title: "Ok", style: .default,

Examples/TicTacToe/Tests/LoginSwiftUITests.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,7 @@ class LoginSwiftUITests: XCTestCase {
119119
self.scheduler.advance()
120120
},
121121
.receive(.loginResponse(.failure(.invalidUserPassword))) {
122-
$0.alert = .init(
123-
title: AuthenticationError.invalidUserPassword.localizedDescription)
122+
$0.alert = .init(title: .init(AuthenticationError.invalidUserPassword.localizedDescription))
124123
$0.isActivityIndicatorVisible = false
125124
$0.isFormDisabled = false
126125
},

Examples/TicTacToe/Tests/TwoFactorSwiftUITests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ class TwoFactorSwiftUITests: XCTestCase {
8888
self.scheduler.advance()
8989
},
9090
.receive(.twoFactorResponse(.failure(.invalidTwoFactor))) {
91-
$0.alert = .init(title: AuthenticationError.invalidTwoFactor.localizedDescription)
91+
$0.alert = .init(title: .init(AuthenticationError.invalidTwoFactor.localizedDescription))
9292
$0.isActivityIndicatorVisible = false
9393
$0.isFormDisabled = false
9494
},
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import SwiftUI
2+
3+
extension LocalizedStringKey: CustomDebugOutputConvertible {
4+
// NB: `LocalizedStringKey` conforms to `Equatable` but returns false for equivalent format strings.
5+
public func formatted(locale: Locale? = nil) -> String {
6+
let children = Array(Mirror(reflecting: self).children)
7+
let key = children[0].value as! String
8+
let arguments: [CVarArg] = Array(Mirror(reflecting: children[2].value).children)
9+
.compactMap {
10+
let children = Array(Mirror(reflecting: $0.value).children)
11+
let value: Any
12+
let formatter: Formatter?
13+
// `LocalizedStringKey.FormatArgument` differs depending on OS/platform.
14+
if children[0].label == "storage" {
15+
(value, formatter) = Array(Mirror(reflecting: children[0].value).children)[0].value as! (Any, Formatter?)
16+
} else {
17+
value = children[0].value
18+
formatter = children[1].value as? Formatter
19+
}
20+
return formatter?.string(for: value) ?? value as! CVarArg
21+
}
22+
23+
return String(format: key, locale: nil, arguments: arguments)
24+
}
25+
26+
public var debugOutput: String {
27+
self.formatted().debugDescription
28+
}
29+
}

Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import SwiftUI
44
/// `Action` generic is the type of actions that can be sent from tapping on a button in the sheet.
55
///
66
/// This type can be used in your application's state in order to control the presentation or
7-
/// dismissal of action sheets. It is preferrable to use this API instead of the default SwiftUI API
7+
/// dismissal of action sheets. It is preferable to use this API instead of the default SwiftUI API
88
/// for action sheets because SwiftUI uses 2-way bindings in order to control the showing and
99
/// dismissal of sheets, and that does not play nicely with the Composable Architecture. The library
1010
/// requires that all state mutations happen by sending an action so that a reducer can handle that
@@ -107,12 +107,12 @@ import SwiftUI
107107
public struct ActionSheetState<Action> {
108108
public let id = UUID()
109109
public var buttons: [Button]
110-
public var message: String?
111-
public var title: String
110+
public var message: LocalizedStringKey?
111+
public var title: LocalizedStringKey
112112

113113
public init(
114-
title: String,
115-
message: String? = nil,
114+
title: LocalizedStringKey,
115+
message: LocalizedStringKey? = nil,
116116
buttons: [Button]
117117
) {
118118
self.buttons = buttons
@@ -146,8 +146,8 @@ extension ActionSheetState: CustomDebugOutputConvertible {
146146
@available(watchOS 6, *)
147147
extension ActionSheetState: Equatable where Action: Equatable {
148148
public static func == (lhs: Self, rhs: Self) -> Bool {
149-
lhs.title == rhs.title
150-
&& lhs.message == rhs.message
149+
lhs.title.formatted() == rhs.title.formatted()
150+
&& lhs.message?.formatted() == rhs.message?.formatted()
151151
&& lhs.buttons == rhs.buttons
152152
}
153153
}
@@ -159,8 +159,8 @@ extension ActionSheetState: Equatable where Action: Equatable {
159159
@available(watchOS 6, *)
160160
extension ActionSheetState: Hashable where Action: Hashable {
161161
public func hash(into hasher: inout Hasher) {
162-
hasher.combine(self.title)
163-
hasher.combine(self.message)
162+
hasher.combine(self.title.formatted())
163+
hasher.combine(self.message?.formatted())
164164
hasher.combine(self.buttons)
165165
}
166166
}

Sources/ComposableArchitecture/SwiftUI/Alert.swift

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import SwiftUI
44
/// generic is the type of actions that can be sent from tapping on a button in the alert.
55
///
66
/// This type can be used in your application's state in order to control the presentation or
7-
/// dismissal of alerts. It is preferrable to use this API instead of the default SwiftUI API
7+
/// dismissal of alerts. It is preferable to use this API instead of the default SwiftUI API
88
/// for alerts because SwiftUI uses 2-way bindings in order to control the showing and dismissal
99
/// of alerts, and that does not play nicely with the Composable Architecture. The library requires
1010
/// that all state mutations happen by sending an action so that a reducer can handle that logic,
@@ -92,14 +92,14 @@ import SwiftUI
9292
///
9393
public struct AlertState<Action> {
9494
public let id = UUID()
95-
public var message: String?
95+
public var message: LocalizedStringKey?
9696
public var primaryButton: Button?
9797
public var secondaryButton: Button?
98-
public var title: String
98+
public var title: LocalizedStringKey
9999

100100
public init(
101-
title: String,
102-
message: String? = nil,
101+
title: LocalizedStringKey,
102+
message: LocalizedStringKey? = nil,
103103
dismissButton: Button? = nil
104104
) {
105105
self.title = title
@@ -108,8 +108,8 @@ public struct AlertState<Action> {
108108
}
109109

110110
public init(
111-
title: String,
112-
message: String? = nil,
111+
title: LocalizedStringKey,
112+
message: LocalizedStringKey? = nil,
113113
primaryButton: Button,
114114
secondaryButton: Button
115115
) {
@@ -124,7 +124,7 @@ public struct AlertState<Action> {
124124
public var type: `Type`
125125

126126
public static func cancel(
127-
_ label: String,
127+
_ label: LocalizedStringKey,
128128
send action: Action? = nil
129129
) -> Self {
130130
Self(action: action, type: .cancel(label: label))
@@ -137,23 +137,23 @@ public struct AlertState<Action> {
137137
}
138138

139139
public static func `default`(
140-
_ label: String,
140+
_ label: LocalizedStringKey,
141141
send action: Action? = nil
142142
) -> Self {
143143
Self(action: action, type: .default(label: label))
144144
}
145145

146146
public static func destructive(
147-
_ label: String,
147+
_ label: LocalizedStringKey,
148148
send action: Action? = nil
149149
) -> Self {
150150
Self(action: action, type: .destructive(label: label))
151151
}
152152

153-
public enum `Type`: Hashable {
154-
case cancel(label: String?)
155-
case `default`(label: String)
156-
case destructive(label: String)
153+
public enum `Type` {
154+
case cancel(label: LocalizedStringKey?)
155+
case `default`(label: LocalizedStringKey)
156+
case destructive(label: LocalizedStringKey)
157157
}
158158
}
159159
}
@@ -199,24 +199,56 @@ extension AlertState: CustomDebugOutputConvertible {
199199

200200
extension AlertState: Equatable where Action: Equatable {
201201
public static func == (lhs: Self, rhs: Self) -> Bool {
202-
lhs.title == rhs.title
203-
&& lhs.message == rhs.message
202+
lhs.title.formatted() == rhs.title.formatted()
203+
&& lhs.message?.formatted() == rhs.message?.formatted()
204204
&& lhs.primaryButton == rhs.primaryButton
205205
&& lhs.secondaryButton == rhs.secondaryButton
206206
}
207207
}
208208
extension AlertState: Hashable where Action: Hashable {
209209
public func hash(into hasher: inout Hasher) {
210-
hasher.combine(self.title)
211-
hasher.combine(self.message)
210+
hasher.combine(self.title.formatted())
211+
hasher.combine(self.message?.formatted())
212212
hasher.combine(self.primaryButton)
213213
hasher.combine(self.secondaryButton)
214214
}
215215
}
216216
extension AlertState: Identifiable {}
217217

218-
extension AlertState.Button: Equatable where Action: Equatable {}
219-
extension AlertState.Button: Hashable where Action: Hashable {}
218+
extension AlertState.Button.`Type`: Equatable {
219+
public static func == (lhs: Self, rhs: Self) -> Bool {
220+
switch (lhs, rhs) {
221+
case let (.cancel(lhs), .cancel(rhs)):
222+
return lhs?.formatted() == rhs?.formatted()
223+
case let (.default(lhs), .default(rhs)), let (.destructive(lhs), .destructive(rhs)):
224+
return lhs.formatted() == rhs.formatted()
225+
default:
226+
return false
227+
}
228+
}
229+
}
230+
extension AlertState.Button: Equatable where Action: Equatable {
231+
public static func == (lhs: Self, rhs: Self) -> Bool {
232+
return lhs.action == rhs.action && lhs.type == rhs.type
233+
}
234+
}
235+
236+
extension AlertState.Button.`Type`: Hashable {
237+
public func hash(into hasher: inout Hasher) {
238+
switch self {
239+
case let .cancel(label):
240+
hasher.combine(label?.formatted())
241+
case let .default(label), let .destructive(label):
242+
hasher.combine(label.formatted())
243+
}
244+
}
245+
}
246+
extension AlertState.Button: Hashable where Action: Hashable {
247+
public func hash(into hasher: inout Hasher) {
248+
hasher.combine(self.action)
249+
hasher.combine(self.type)
250+
}
251+
}
220252

221253
extension AlertState.Button {
222254
@available(iOS 13, *)

0 commit comments

Comments
 (0)