Skip to content

Commit 1d01474

Browse files
authored
Add Text formatter storage support (#891)
1 parent 7f79072 commit 1d01474

5 files changed

Lines changed: 455 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// TextFormatStyleUITests.swift
3+
// OpenSwiftUIUITests
4+
5+
import SnapshotTesting
6+
import Testing
7+
@testable import TestingHost
8+
9+
@MainActor
10+
@Suite(.snapshots(record: .never, diffTool: diffTool))
11+
struct TextFormatStyleUITests {
12+
@Test(.disabled("Text layout is not ready"))
13+
func dateFormatStyleExample() {
14+
openSwiftUIAssertSnapshot(of: TextFormatStyleExample())
15+
}
16+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// TextFormatStyleExample.swift
3+
// Shared
4+
5+
import Foundation
6+
7+
#if OPENSWIFTUI
8+
import OpenSwiftUI
9+
#else
10+
import SwiftUI
11+
#endif
12+
13+
struct TextFormatStyleExample: View {
14+
@State private var myDate = Date()
15+
16+
var body: some View {
17+
VStack {
18+
Text(myDate, format: Date.FormatStyle(date: .numeric, time: .omitted))
19+
Text(myDate, format: Date.FormatStyle(date: .complete, time: .complete))
20+
Text(myDate, format: Date.FormatStyle().hour(.defaultDigitsNoAMPM).minute())
21+
}
22+
}
23+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// SizeAdaptiveFormatStyle.swift
3+
// OpenSwiftUICore
4+
//
5+
// Audited for 6.5.4
6+
// Status: WIP (Blocked by SystemFormatStyle)
7+
8+
package import Foundation
9+
10+
protocol SizeAdaptiveFormatStyle: FormatStyle {
11+
func withSizeVariant(_ sizeVariant: TextSizeVariant) -> (style: Self, exact: Bool)
12+
}
13+
14+
extension FormatStyle {
15+
package func exactSizeVariant(_ sizeVariant: TextSizeVariant) -> (style: Self, exact: Bool) {
16+
guard let style = self as? any SizeAdaptiveFormatStyle else {
17+
return (self, sizeVariant == .regular)
18+
}
19+
let resolved = style.withSizeVariant(sizeVariant)
20+
return (resolved.style as! Self, resolved.exact)
21+
}
22+
23+
package func sizeVariant(_ sizeVariant: TextSizeVariant) -> Self {
24+
exactSizeVariant(sizeVariant).style
25+
}
26+
}
27+
28+
extension TextSizeVariant {
29+
@discardableResult
30+
package mutating func adjust() -> Bool {
31+
if rawValue != 0 {
32+
rawValue -= 1
33+
}
34+
return rawValue == 0
35+
}
36+
}
37+
38+
// TODO: Add concrete conformance implementations when the matching format
39+
// styles land:
40+
// Date.FormatStyle
41+
// Date.FormatStyle.Attributed
42+
// Date.AnchoredRelativeFormatStyle
43+
// Date.ComponentsFormatStyle
44+
// Date.ISO8601FormatStyle
45+
// Duration.UnitsFormatStyle
46+
// Duration.UnitsFormatStyle.Attributed
47+
// WhitespaceRemovingFormatStyle where A: SizeAdaptiveFormatStyle
48+
// SystemFormatStyle.DateReference

Sources/OpenSwiftUICore/View/Text/Text/Text+Formatter.swift

Lines changed: 271 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,295 @@
33
// OpenSwiftUICore
44
//
55
// Audited for 6.5.4
6-
// Status: WIP
6+
// Status: Complete
77
// ID: 7267202B6A40C9B73733978AB256B462 (SwiftUICore)
88

99
public import Foundation
1010

11+
// MARK: - Text + Formatter
12+
13+
@available(OpenSwiftUI_v2_0, *)
14+
extension Text {
15+
/// Creates a text view that displays the formatted representation
16+
/// of a reference-convertible value.
17+
///
18+
/// Use this initializer to create a text view that formats `subject`
19+
/// using `formatter`.
20+
/// - Parameters:
21+
/// - subject: A
22+
/// [ReferenceConvertible](https://developer.apple.com/documentation/foundation/referenceconvertible)
23+
/// instance compatible with `formatter`.
24+
/// - formatter: A
25+
/// [Formatter](https://developer.apple.com/documentation/foundation/formatter)
26+
/// capable of converting `subject` into a string representation.
27+
public init<Subject>(
28+
_ subject: Subject,
29+
formatter: Formatter
30+
) where Subject: ReferenceConvertible {
31+
self.init(
32+
anyTextStorage: FormatterTextStorage(
33+
object: subject as! Subject.ReferenceType,
34+
formatter: formatter
35+
)
36+
)
37+
}
38+
39+
/// Creates a text view that displays the formatted representation
40+
/// of a Foundation object.
41+
///
42+
/// Use this initializer to create a text view that formats `subject`
43+
/// using `formatter`.
44+
/// - Parameters:
45+
/// - subject: An
46+
/// [NSObject](https://developer.apple.com/documentation/objectivec/nsobject)
47+
/// instance compatible with `formatter`.
48+
/// - formatter: A
49+
/// [Formatter](https://developer.apple.com/documentation/foundation/formatter)
50+
/// capable of converting `subject` into a string representation.
51+
public init<Subject>(
52+
_ subject: Subject,
53+
formatter: Formatter
54+
) where Subject: NSObject {
55+
self.init(
56+
anyTextStorage: FormatterTextStorage(
57+
object: subject,
58+
formatter: formatter
59+
)
60+
)
61+
}
62+
}
63+
64+
private final class FormatterTextStorage: AnyTextStorage, @unchecked Sendable {
65+
let object: NSObject
66+
let formatter: Formatter
67+
68+
init(object: NSObject, formatter: Formatter) {
69+
self.object = object
70+
self.formatter = formatter
71+
}
72+
73+
override func resolve<T>(
74+
into result: inout T,
75+
in environment: EnvironmentValues,
76+
with options: Text.ResolveOptions
77+
) where T: ResolvedTextContainer {
78+
(formatter as? EnvironmentConfigurableFormatter)?.configure(in: environment)
79+
guard let string = formatter.string(for: object) else {
80+
return
81+
}
82+
result.append(
83+
string,
84+
in: environment,
85+
with: options
86+
)
87+
}
88+
89+
override func isEqual(to other: AnyTextStorage) -> Bool {
90+
guard let other = other as? FormatterTextStorage else {
91+
return false
92+
}
93+
return object == other.object && formatter == other.formatter
94+
}
95+
96+
override func isStyled(options: Text.ResolveOptions) -> Bool {
97+
false
98+
}
99+
}
100+
101+
// MARK: - Text + FormatStyle
102+
11103
@available(OpenSwiftUI_v3_0, *)
12104
extension Text {
105+
/// Creates a text view that displays the formatted representation
106+
/// of a nonstring type supported by a corresponding format style.
107+
///
108+
/// Use this initializer to create a text view backed by a nonstring
109+
/// value, using a
110+
/// [FormatStyle](https://developer.apple.com/documentation/foundation/formatstyle)
111+
/// to convert the type to a string representation. Any changes to the value
112+
/// update the string displayed by the text view.
113+
///
114+
/// In the following example, three ``Text`` views present a date with
115+
/// different combinations of date and time fields, by using different
116+
/// [Date.FormatStyle](https://developer.apple.com/documentation/foundation/date/formatstyle)
117+
/// options.
118+
///
119+
/// @State private var myDate = Date()
120+
/// var body: some View {
121+
/// VStack {
122+
/// Text(myDate, format: Date.FormatStyle(date: .numeric, time: .omitted))
123+
/// Text(myDate, format: Date.FormatStyle(date: .complete, time: .complete))
124+
/// Text(myDate, format: Date.FormatStyle().hour(.defaultDigitsNoAMPM).minute())
125+
/// }
126+
/// }
127+
///
128+
/// ![Three vertically stacked text views showing the date with different
129+
/// levels of detail: 4/1/1976; April 1, 1976; Thursday, April 1,
130+
/// 1976.](Text-init-format-1)
131+
///
132+
/// - Parameters:
133+
/// - input: The underlying value to display.
134+
/// - format: A format style of type `F` to convert the underlying value
135+
/// of type `F.FormatInput` to a string representation.
13136
public init<F>(
14137
_ input: F.FormatInput,
15138
format: F
16139
) where F: FormatStyle, F.FormatInput: Equatable, F.FormatOutput == String {
17-
_openSwiftUIUnimplementedFailure()
140+
self.init(anyTextStorage: FormatStyleStorage(input: input, format: format))
18141
}
19142
}
20143

21144
@available(OpenSwiftUI_v6_0, *)
22145
extension Text {
146+
/// Creates a text view that displays the formatted representation
147+
/// of a nonstring type supported by a corresponding format style.
148+
///
149+
/// Use this initializer to create a text view backed by a nonstring
150+
/// value, using a
151+
/// [FormatStyle](https://developer.apple.com/documentation/foundation/formatstyle)
152+
/// to convert the type to an attributed string representation. Any changes to the value
153+
/// update the string displayed by the text view.
154+
///
155+
/// In the following example, three ``Text`` views present a date with
156+
/// different combinations of date and time fields, by using different
157+
/// [Date.FormatStyle](https://developer.apple.com/documentation/foundation/date/formatstyle)
158+
/// options.
159+
///
160+
/// @State private var myDate = Date()
161+
/// var body: some View {
162+
/// VStack {
163+
/// Text(myDate, format: Date.FormatStyle(date: .numeric, time: .omitted).attributedStyle)
164+
/// Text(myDate, format: Date.FormatStyle(date: .complete, time: .complete).attributedStyle)
165+
/// Text(myDate, format: Date.FormatStyle().hour(.defaultDigitsNoAMPM).minute().attributedStyle)
166+
/// }
167+
/// }
168+
///
169+
/// ![Three vertically stacked text views showing the date with different
170+
/// levels of detail: 4/1/1976; April 1, 1976; Thursday, April 1,
171+
/// 1976.](Text-init-format-1)
172+
///
173+
/// - Parameters:
174+
/// - input: The underlying value to display.
175+
/// - format: A format style of type `F` to convert the underlying value
176+
/// of type `F.FormatInput` to an attributed string representation.
23177
public init<F>(
24178
_ input: F.FormatInput,
25179
format: F
26180
) where F: FormatStyle, F.FormatInput: Equatable, F.FormatOutput == AttributedString {
27-
_openSwiftUIUnimplementedFailure()
181+
self.init(anyTextStorage: FormatStyleStorage(input: input, format: format))
182+
}
183+
}
184+
185+
private class FormatStyleBoxBase {
186+
func isEqual(to other: FormatStyleBoxBase) -> Bool {
187+
_openSwiftUIBaseClassAbstractMethod()
188+
}
189+
190+
func format(
191+
in environment: EnvironmentValues,
192+
idiom: AnyInterfaceIdiom?
193+
) -> (output: AttributedString, exact: Bool) {
194+
_openSwiftUIBaseClassAbstractMethod()
195+
}
196+
}
197+
198+
private final class FormatStyleBox<F>: FormatStyleBoxBase where
199+
F: FormatStyle,
200+
F.FormatInput: Equatable,
201+
F.FormatOutput: AttributedStringConvertible
202+
{
203+
let input: F.FormatInput
204+
let format: F
205+
206+
init(input: F.FormatInput, format: F) {
207+
self.input = input
208+
self.format = format
209+
}
210+
211+
override func isEqual(to other: FormatStyleBoxBase) -> Bool {
212+
guard let other = other as? FormatStyleBox<F> else {
213+
return false
214+
}
215+
return input == other.input && format == other.format
216+
}
217+
218+
override func format(
219+
in environment: EnvironmentValues,
220+
idiom: AnyInterfaceIdiom?
221+
) -> (output: AttributedString, exact: Bool) {
222+
var resolvedFormat = format.locale(environment.locale)
223+
if isLinkedOnOrAfter(.v6) {
224+
resolvedFormat = resolvedFormat
225+
.calendar(environment.calendar)
226+
.timeZone(environment.timeZone)
227+
}
228+
if let dependentFormat = resolvedFormat as? any InterfaceIdiomDependentFormatStyle {
229+
let resolvedIdiom: AnyInterfaceIdiom
230+
if let idiom {
231+
resolvedIdiom = idiom
232+
} else {
233+
Log.internalWarning("FormatStyleStorage was resolved without idiom!")
234+
resolvedIdiom = _GraphInputs.defaultInterfaceIdiom
235+
}
236+
resolvedFormat = dependentFormat.interfaceIdiom(resolvedIdiom) as! F
237+
}
238+
if let dependentFormat = resolvedFormat as? any TextAlignmentDependentFormatStyle {
239+
resolvedFormat = dependentFormat.textAlignment(environment.multilineTextAlignment) as! F
240+
}
241+
if isLinkedOnOrAfter(.v6),
242+
let dependentFormat = resolvedFormat as? any CapitalizationContextDependentFormatStyle {
243+
resolvedFormat = dependentFormat.capitalizationContext(environment.capitalizationContext.resolved) as! F
244+
}
245+
let resolved = resolvedFormat.exactSizeVariant(environment.textSizeVariant)
246+
let output = resolved.style.format(input).attributedString
247+
return (output, resolved.exact)
248+
}
249+
}
250+
251+
private final class FormatStyleStorage: AnyTextStorage, @unchecked Sendable {
252+
let storage: FormatStyleBoxBase
253+
254+
init<F>(
255+
input: F.FormatInput,
256+
format: F
257+
) where
258+
F: FormatStyle,
259+
F.FormatInput: Equatable,
260+
F.FormatOutput: AttributedStringConvertible
261+
{
262+
storage = FormatStyleBox(input: input, format: format)
263+
}
264+
265+
override func resolve<T>(
266+
into result: inout T,
267+
in environment: EnvironmentValues,
268+
with options: Text.ResolveOptions
269+
) where T: ResolvedTextContainer {
270+
let resolved = storage.format(in: environment, idiom: result.idiom)
271+
result.append(
272+
NSAttributedString(resolved.output),
273+
in: environment,
274+
with: options,
275+
isUniqueSizeVariant: resolved.exact
276+
)
277+
}
278+
279+
override func isEqual(to other: AnyTextStorage) -> Bool {
280+
guard let other = other as? FormatStyleStorage else {
281+
return false
282+
}
283+
return storage.isEqual(to: other.storage)
284+
}
285+
286+
override func isStyled(options: Text.ResolveOptions) -> Bool {
287+
false
288+
}
289+
}
290+
291+
#if !canImport(Darwin)
292+
extension NSAttributedString {
293+
fileprivate convenience init(_ attributedString: AttributedString) {
294+
self.init(string: String(attributedString))
28295
}
29296
}
297+
#endif

0 commit comments

Comments
 (0)