From 961639f61714a719693e524c3858fc75ae09a9eb Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 1 Jun 2026 23:57:40 +0800 Subject: [PATCH 1/3] Implement Text formatter storage --- .../FormatStyle/SizeAdaptiveFormatStyle.swift | 48 +++ .../View/Text/Text/Text+Formatter.swift | 274 +++++++++++++++++- .../View/Text/TextFormatterTests.swift | 83 ++++++ 3 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 Sources/OpenSwiftUICore/View/Text/FormatStyle/SizeAdaptiveFormatStyle.swift create mode 100644 Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift diff --git a/Sources/OpenSwiftUICore/View/Text/FormatStyle/SizeAdaptiveFormatStyle.swift b/Sources/OpenSwiftUICore/View/Text/FormatStyle/SizeAdaptiveFormatStyle.swift new file mode 100644 index 000000000..0ce17e2ef --- /dev/null +++ b/Sources/OpenSwiftUICore/View/Text/FormatStyle/SizeAdaptiveFormatStyle.swift @@ -0,0 +1,48 @@ +// +// SizeAdaptiveFormatStyle.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: WIP (Blocked by SystemFormatStyle) + +package import Foundation + +protocol SizeAdaptiveFormatStyle: FormatStyle { + func withSizeVariant(_ sizeVariant: TextSizeVariant) -> (style: Self, exact: Bool) +} + +extension FormatStyle { + package func exactSizeVariant(_ sizeVariant: TextSizeVariant) -> (style: Self, exact: Bool) { + guard let style = self as? any SizeAdaptiveFormatStyle else { + return (self, sizeVariant == .regular) + } + let resolved = style.withSizeVariant(sizeVariant) + return (resolved.style as! Self, resolved.exact) + } + + package func sizeVariant(_ sizeVariant: TextSizeVariant) -> Self { + exactSizeVariant(sizeVariant).style + } +} + +extension TextSizeVariant { + @discardableResult + package mutating func adjust() -> Bool { + if rawValue != 0 { + rawValue -= 1 + } + return rawValue == 0 + } +} + +// TODO: Add concrete conformance implementations when the matching format +// styles land: +// Date.FormatStyle +// Date.FormatStyle.Attributed +// Date.AnchoredRelativeFormatStyle +// Date.ComponentsFormatStyle +// Date.ISO8601FormatStyle +// Duration.UnitsFormatStyle +// Duration.UnitsFormatStyle.Attributed +// WhitespaceRemovingFormatStyle where A: SizeAdaptiveFormatStyle +// SystemFormatStyle.DateReference diff --git a/Sources/OpenSwiftUICore/View/Text/Text/Text+Formatter.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text+Formatter.swift index cfc0f525b..70e61b037 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text+Formatter.swift +++ b/Sources/OpenSwiftUICore/View/Text/Text/Text+Formatter.swift @@ -3,27 +3,295 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: WIP +// Status: Complete // ID: 7267202B6A40C9B73733978AB256B462 (SwiftUICore) public import Foundation +// MARK: - Text + Formatter + +@available(OpenSwiftUI_v2_0, *) +extension Text { + /// Creates a text view that displays the formatted representation + /// of a reference-convertible value. + /// + /// Use this initializer to create a text view that formats `subject` + /// using `formatter`. + /// - Parameters: + /// - subject: A + /// [ReferenceConvertible](https://developer.apple.com/documentation/foundation/referenceconvertible) + /// instance compatible with `formatter`. + /// - formatter: A + /// [Formatter](https://developer.apple.com/documentation/foundation/formatter) + /// capable of converting `subject` into a string representation. + public init( + _ subject: Subject, + formatter: Formatter + ) where Subject: ReferenceConvertible { + self.init( + anyTextStorage: FormatterTextStorage( + object: subject as! Subject.ReferenceType, + formatter: formatter + ) + ) + } + + /// Creates a text view that displays the formatted representation + /// of a Foundation object. + /// + /// Use this initializer to create a text view that formats `subject` + /// using `formatter`. + /// - Parameters: + /// - subject: An + /// [NSObject](https://developer.apple.com/documentation/objectivec/nsobject) + /// instance compatible with `formatter`. + /// - formatter: A + /// [Formatter](https://developer.apple.com/documentation/foundation/formatter) + /// capable of converting `subject` into a string representation. + public init( + _ subject: Subject, + formatter: Formatter + ) where Subject: NSObject { + self.init( + anyTextStorage: FormatterTextStorage( + object: subject, + formatter: formatter + ) + ) + } +} + +private final class FormatterTextStorage: AnyTextStorage, @unchecked Sendable { + let object: NSObject + let formatter: Formatter + + init(object: NSObject, formatter: Formatter) { + self.object = object + self.formatter = formatter + } + + override func resolve( + into result: inout T, + in environment: EnvironmentValues, + with options: Text.ResolveOptions + ) where T: ResolvedTextContainer { + (formatter as? EnvironmentConfigurableFormatter)?.configure(in: environment) + guard let string = formatter.string(for: object) else { + return + } + result.append( + string, + in: environment, + with: options + ) + } + + override func isEqual(to other: AnyTextStorage) -> Bool { + guard let other = other as? FormatterTextStorage else { + return false + } + return object == other.object && formatter == other.formatter + } + + override func isStyled(options: Text.ResolveOptions) -> Bool { + false + } +} + +// MARK: - Text + FormatStyle + @available(OpenSwiftUI_v3_0, *) extension Text { + /// Creates a text view that displays the formatted representation + /// of a nonstring type supported by a corresponding format style. + /// + /// Use this initializer to create a text view backed by a nonstring + /// value, using a + /// [FormatStyle](https://developer.apple.com/documentation/foundation/formatstyle) + /// to convert the type to a string representation. Any changes to the value + /// update the string displayed by the text view. + /// + /// In the following example, three ``Text`` views present a date with + /// different combinations of date and time fields, by using different + /// [Date.FormatStyle](https://developer.apple.com/documentation/foundation/date/formatstyle) + /// options. + /// + /// @State private var myDate = Date() + /// var body: some View { + /// VStack { + /// Text(myDate, format: Date.FormatStyle(date: .numeric, time: .omitted)) + /// Text(myDate, format: Date.FormatStyle(date: .complete, time: .complete)) + /// Text(myDate, format: Date.FormatStyle().hour(.defaultDigitsNoAMPM).minute()) + /// } + /// } + /// + /// ![Three vertically stacked text views showing the date with different + /// levels of detail: 4/1/1976; April 1, 1976; Thursday, April 1, + /// 1976.](Text-init-format-1) + /// + /// - Parameters: + /// - input: The underlying value to display. + /// - format: A format style of type `F` to convert the underlying value + /// of type `F.FormatInput` to a string representation. public init( _ input: F.FormatInput, format: F ) where F: FormatStyle, F.FormatInput: Equatable, F.FormatOutput == String { - _openSwiftUIUnimplementedFailure() + self.init(anyTextStorage: FormatStyleStorage(input: input, format: format)) } } @available(OpenSwiftUI_v6_0, *) extension Text { + /// Creates a text view that displays the formatted representation + /// of a nonstring type supported by a corresponding format style. + /// + /// Use this initializer to create a text view backed by a nonstring + /// value, using a + /// [FormatStyle](https://developer.apple.com/documentation/foundation/formatstyle) + /// to convert the type to an attributed string representation. Any changes to the value + /// update the string displayed by the text view. + /// + /// In the following example, three ``Text`` views present a date with + /// different combinations of date and time fields, by using different + /// [Date.FormatStyle](https://developer.apple.com/documentation/foundation/date/formatstyle) + /// options. + /// + /// @State private var myDate = Date() + /// var body: some View { + /// VStack { + /// Text(myDate, format: Date.FormatStyle(date: .numeric, time: .omitted).attributedStyle) + /// Text(myDate, format: Date.FormatStyle(date: .complete, time: .complete).attributedStyle) + /// Text(myDate, format: Date.FormatStyle().hour(.defaultDigitsNoAMPM).minute().attributedStyle) + /// } + /// } + /// + /// ![Three vertically stacked text views showing the date with different + /// levels of detail: 4/1/1976; April 1, 1976; Thursday, April 1, + /// 1976.](Text-init-format-1) + /// + /// - Parameters: + /// - input: The underlying value to display. + /// - format: A format style of type `F` to convert the underlying value + /// of type `F.FormatInput` to an attributed string representation. public init( _ input: F.FormatInput, format: F ) where F: FormatStyle, F.FormatInput: Equatable, F.FormatOutput == AttributedString { - _openSwiftUIUnimplementedFailure() + self.init(anyTextStorage: FormatStyleStorage(input: input, format: format)) + } +} + +private class FormatStyleBoxBase { + func isEqual(to other: FormatStyleBoxBase) -> Bool { + _openSwiftUIBaseClassAbstractMethod() + } + + func format( + in environment: EnvironmentValues, + idiom: AnyInterfaceIdiom? + ) -> (output: AttributedString, exact: Bool) { + _openSwiftUIBaseClassAbstractMethod() + } +} + +private final class FormatStyleBox: FormatStyleBoxBase where + F: FormatStyle, + F.FormatInput: Equatable, + F.FormatOutput: AttributedStringConvertible +{ + let input: F.FormatInput + let format: F + + init(input: F.FormatInput, format: F) { + self.input = input + self.format = format + } + + override func isEqual(to other: FormatStyleBoxBase) -> Bool { + guard let other = other as? FormatStyleBox else { + return false + } + return input == other.input && format == other.format + } + + override func format( + in environment: EnvironmentValues, + idiom: AnyInterfaceIdiom? + ) -> (output: AttributedString, exact: Bool) { + var resolvedFormat = format.locale(environment.locale) + if isLinkedOnOrAfter(.v6) { + resolvedFormat = resolvedFormat + .calendar(environment.calendar) + .timeZone(environment.timeZone) + } + if let dependentFormat = resolvedFormat as? any InterfaceIdiomDependentFormatStyle { + let resolvedIdiom: AnyInterfaceIdiom + if let idiom { + resolvedIdiom = idiom + } else { + Log.internalWarning("FormatStyleStorage was resolved without idiom!") + resolvedIdiom = _GraphInputs.defaultInterfaceIdiom + } + resolvedFormat = dependentFormat.interfaceIdiom(resolvedIdiom) as! F + } + if let dependentFormat = resolvedFormat as? any TextAlignmentDependentFormatStyle { + resolvedFormat = dependentFormat.textAlignment(environment.multilineTextAlignment) as! F + } + if isLinkedOnOrAfter(.v6), + let dependentFormat = resolvedFormat as? any CapitalizationContextDependentFormatStyle { + resolvedFormat = dependentFormat.capitalizationContext(environment.capitalizationContext.resolved) as! F + } + let resolved = resolvedFormat.exactSizeVariant(environment.textSizeVariant) + let output = resolved.style.format(input).attributedString + return (output, resolved.exact) + } +} + +private final class FormatStyleStorage: AnyTextStorage, @unchecked Sendable { + let storage: FormatStyleBoxBase + + init( + input: F.FormatInput, + format: F + ) where + F: FormatStyle, + F.FormatInput: Equatable, + F.FormatOutput: AttributedStringConvertible + { + storage = FormatStyleBox(input: input, format: format) + } + + override func resolve( + into result: inout T, + in environment: EnvironmentValues, + with options: Text.ResolveOptions + ) where T: ResolvedTextContainer { + let resolved = storage.format(in: environment, idiom: result.idiom) + result.append( + NSAttributedString(resolved.output), + in: environment, + with: options, + isUniqueSizeVariant: resolved.exact + ) + } + + override func isEqual(to other: AnyTextStorage) -> Bool { + guard let other = other as? FormatStyleStorage else { + return false + } + return storage.isEqual(to: other.storage) + } + + override func isStyled(options: Text.ResolveOptions) -> Bool { + false + } +} + +#if !canImport(Darwin) +extension NSAttributedString { + fileprivate convenience init(_ attributedString: AttributedString) { + self.init(string: String(attributedString)) } } +#endif diff --git a/Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift b/Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift new file mode 100644 index 000000000..4717da29a --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift @@ -0,0 +1,83 @@ +// +// TextFormatterTests.swift +// OpenSwiftUICoreTests + +import Foundation +@testable import OpenSwiftUICore +import Testing + +struct TextFormatterTests { + @Test + func referenceConvertibleFormatterResolves() { + let date = Date(timeIntervalSinceReferenceDate: 0) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + let text = Text(date, formatter: formatter) + let environment = fixedEnvironment() + + #expect(text.resolveString(in: environment) == "2001-01-01 00:00:00") + #expect(formatter.locale == environment.locale) + #expect(formatter.calendar == environment.calendar) + #expect(formatter.timeZone == environment.timeZone) + } + + @Test + func objectFormatterResolves() { + let number = NSNumber(value: 1234.5) + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + + var environment = EnvironmentValues() + environment.locale = Locale(identifier: "fr_FR") + + let text = Text(number, formatter: formatter) + let output = text.resolveString(in: environment) + + #expect(formatter.locale == environment.locale) + #expect(output == formatter.string(from: number)) + } + + @Test + func stringFormatStyleResolves() { + let input = 1234.5 + let format = FloatingPointFormatStyle.number.precision(.fractionLength(1)) + + var environment = EnvironmentValues() + environment.locale = Locale(identifier: "en_US_POSIX") + + let text = Text(input, format: format) + + #expect(text.resolveString(in: environment) == format.locale(environment.locale).format(input)) + } + + @Test + func attributedStringFormatStyleResolves() { + let text = Text(42, format: AttributedEchoStyle()) + + #expect(text.resolveString(in: EnvironmentValues()) == "value 42") + } + + @Test + func formatStyleStorageParticipatesInEquality() { + #expect(Text(42, format: AttributedEchoStyle()) == Text(42, format: AttributedEchoStyle())) + #expect(Text(41, format: AttributedEchoStyle()) != Text(42, format: AttributedEchoStyle())) + } + + private func fixedEnvironment() -> EnvironmentValues { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + + var environment = EnvironmentValues() + environment.locale = Locale(identifier: "en_US_POSIX") + environment.calendar = calendar + environment.timeZone = calendar.timeZone + return environment + } +} + +private struct AttributedEchoStyle: FormatStyle, Hashable { + func format(_ value: Int) -> AttributedString { + AttributedString("value \(value)") + } +} From 6cbab0f19699c38a3b99acb214ec19e9e6ffbadb Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 2 Jun 2026 01:27:19 +0800 Subject: [PATCH 2/3] Add Text format style example UI test --- .../View/Text/TextFormatStyleUITests.swift | 16 +++++++++++++ .../View/Text/TextFormatStyleExample.swift | 23 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 Example/OpenSwiftUIUITests/View/Text/TextFormatStyleUITests.swift create mode 100644 Example/Shared/View/Text/TextFormatStyleExample.swift diff --git a/Example/OpenSwiftUIUITests/View/Text/TextFormatStyleUITests.swift b/Example/OpenSwiftUIUITests/View/Text/TextFormatStyleUITests.swift new file mode 100644 index 000000000..7f3ee510d --- /dev/null +++ b/Example/OpenSwiftUIUITests/View/Text/TextFormatStyleUITests.swift @@ -0,0 +1,16 @@ +// +// TextFormatStyleUITests.swift +// OpenSwiftUIUITests + +import SnapshotTesting +import Testing +@testable import TestingHost + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct TextFormatStyleUITests { + @Test(.disabled("Text layout is not ready")) + func dateFormatStyleExample() { + openSwiftUIAssertSnapshot(of: TextFormatStyleExample()) + } +} diff --git a/Example/Shared/View/Text/TextFormatStyleExample.swift b/Example/Shared/View/Text/TextFormatStyleExample.swift new file mode 100644 index 000000000..2a5184ed1 --- /dev/null +++ b/Example/Shared/View/Text/TextFormatStyleExample.swift @@ -0,0 +1,23 @@ +// +// TextFormatStyleExample.swift +// Shared + +import Foundation + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct TextFormatStyleExample: View { + @State private var myDate = Date() + + var body: some View { + VStack { + Text(myDate, format: Date.FormatStyle(date: .numeric, time: .omitted)) + Text(myDate, format: Date.FormatStyle(date: .complete, time: .complete)) + Text(myDate, format: Date.FormatStyle().hour(.defaultDigitsNoAMPM).minute()) + } + } +} From affe110887e20b8d078362043733dda12cea65ce Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 2 Jun 2026 02:06:17 +0800 Subject: [PATCH 3/3] Fix formatter date test on Linux --- .../View/Text/TextFormatterTests.swift | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift b/Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift index 4717da29a..ef4676aab 100644 --- a/Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift +++ b/Tests/OpenSwiftUICoreTests/View/Text/TextFormatterTests.swift @@ -7,19 +7,24 @@ import Foundation import Testing struct TextFormatterTests { + #if canImport(ObjectiveC) @Test func referenceConvertibleFormatterResolves() { let date = Date(timeIntervalSinceReferenceDate: 0) let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - let text = Text(date, formatter: formatter) - let environment = fixedEnvironment() + expectDateFormatterText(Text(date, formatter: formatter), formatter: formatter) + } + #endif - #expect(text.resolveString(in: environment) == "2001-01-01 00:00:00") - #expect(formatter.locale == environment.locale) - #expect(formatter.calendar == environment.calendar) - #expect(formatter.timeZone == environment.timeZone) + @Test + func dateObjectFormatterResolves() { + let date = NSDate(timeIntervalSinceReferenceDate: 0) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + expectDateFormatterText(Text(date, formatter: formatter), formatter: formatter) } @Test @@ -74,6 +79,15 @@ struct TextFormatterTests { environment.timeZone = calendar.timeZone return environment } + + private func expectDateFormatterText(_ text: Text, formatter: DateFormatter) { + let environment = fixedEnvironment() + + #expect(text.resolveString(in: environment) == "2001-01-01 00:00:00") + #expect(formatter.locale == environment.locale) + #expect(formatter.calendar == environment.calendar) + #expect(formatter.timeZone == environment.timeZone) + } } private struct AttributedEchoStyle: FormatStyle, Hashable {