swiftui-navigation/Sources/_SwiftUINavigationState/TextState.swift

742 lines
24 KiB
Swift
Raw Normal View History

import CustomDump
import SwiftUI
/// An equatable description of SwiftUI `Text`. Useful for storing rich text in feature models
/// that can still be tested for equality.
///
/// Although `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` are value types that conform to
/// `Equatable`, their `==` do not return `true` when used with seemingly equal values. If we were
/// to naively store these values in state, our tests may begin to fail.
///
/// ``TextState`` solves this problem by providing an interface similar to `SwiftUI.Text` that can
/// be held in state and asserted against.
///
/// Let's say you wanted to hold some dynamic, styled text content in your app state. You could use
/// ``TextState``:
///
/// ```swift
/// class Model: Equatable {
/// @Published var label = TextState("")
/// }
/// ```
///
/// Your model can then assign a value to this state using an API similar to that of `SwiftUI.Text`.
///
/// ```swift
/// self.label = TextState("Hello, ") + TextState(name).bold() + TextState("!")
/// ```
///
/// And your view can render it by passing it to a `SwiftUI.Text` initializer:
///
/// ```swift
/// var body: some View {
/// Text(self.model.label)
/// }
/// ```
///
/// SwiftUI Navigation comes with a few convenience APIs for alerts and dialogs that wrap
/// ``TextState`` under the hood. See ``AlertState`` and ``ConfirmationDialogState`` accordingly.
///
/// In the future, should `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` reliably conform to
/// `Equatable`, ``TextState`` may be deprecated.
///
/// - Note: ``TextState`` does not support _all_ `LocalizedStringKey` permutations at this time
/// (interpolated `SwiftUI.Image`s, for example). ``TextState`` also uses reflection to determine
/// `LocalizedStringKey` equatability, so be mindful of edge cases.
public struct TextState: Equatable, Hashable {
fileprivate var modifiers: [Modifier] = []
fileprivate let storage: Storage
fileprivate enum Modifier: Equatable, Hashable {
case accessibilityHeading(AccessibilityHeadingLevel)
case accessibilityLabel(TextState)
case accessibilityTextContentType(AccessibilityTextContentType)
case baselineOffset(CGFloat)
case bold(isActive: Bool)
case font(Font?)
case fontDesign(Font.Design?)
case fontWeight(Font.Weight?)
case fontWidth(FontWidth?)
case foregroundColor(Color?)
case italic(isActive: Bool)
case kerning(CGFloat)
case monospacedDigit
case speechAdjustedPitch(Double)
case speechAlwaysIncludesPunctuation(Bool)
case speechAnnouncementsQueued(Bool)
case speechSpellsOutCharacters(Bool)
case strikethrough(isActive: Bool, pattern: LineStylePattern?, color: Color?)
case tracking(CGFloat)
case underline(isActive: Bool, pattern: LineStylePattern?, color: Color?)
}
public enum FontWidth: String, Equatable, Hashable {
case compressed
case condensed
case expanded
case standard
#if swift(>=5.7.1)
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
var toSwiftUI: SwiftUI.Font.Width {
switch self {
case .compressed: return .compressed
case .condensed: return .condensed
case .expanded: return .expanded
case .standard: return .standard
}
}
#endif
}
public enum LineStylePattern: String, Equatable, Hashable {
case dash
case dashDot
case dashDotDot
case dot
case solid
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
var toSwiftUI: SwiftUI.Text.LineStyle.Pattern {
switch self {
case .dash: return .dash
case .dashDot: return .dashDot
case .dashDotDot: return .dashDotDot
case .dot: return .dot
case .solid: return .solid
}
}
}
fileprivate enum Storage: Equatable, Hashable {
indirect case concatenated(TextState, TextState)
case localized(LocalizedStringKey, tableName: String?, bundle: Bundle?, comment: StaticString?)
case verbatim(String)
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case let (.concatenated(l1, l2), .concatenated(r1, r2)):
return l1 == r1 && l2 == r2
case let (.localized(lk, lt, lb, lc), .localized(rk, rt, rb, rc)):
return lk.formatted(tableName: lt, bundle: lb, comment: lc)
2022-11-22 02:42:28 +08:00
== rk.formatted(tableName: rt, bundle: rb, comment: rc)
case let (.verbatim(lhs), .verbatim(rhs)):
return lhs == rhs
case let (.localized(key, tableName, bundle, comment), .verbatim(string)),
let (.verbatim(string), .localized(key, tableName, bundle, comment)):
return key.formatted(tableName: tableName, bundle: bundle, comment: comment) == string
2022-11-22 02:42:28 +08:00
// NB: We do not attempt to equate concatenated cases.
default:
return false
}
}
func hash(into hasher: inout Hasher) {
enum Key {
case concatenated
case localized
case verbatim
}
switch self {
case let (.concatenated(first, second)):
hasher.combine(Key.concatenated)
hasher.combine(first)
hasher.combine(second)
case let .localized(key, tableName, bundle, comment):
hasher.combine(Key.localized)
hasher.combine(key.formatted(tableName: tableName, bundle: bundle, comment: comment))
case let .verbatim(string):
hasher.combine(Key.verbatim)
hasher.combine(string)
}
}
}
}
// MARK: - API
extension TextState {
public init(verbatim content: String) {
self.storage = .verbatim(content)
}
@_disfavoredOverload
public init<S: StringProtocol>(_ content: S) {
self.init(verbatim: String(content))
}
public init(
_ key: LocalizedStringKey,
tableName: String? = nil,
bundle: Bundle? = nil,
comment: StaticString? = nil
) {
self.storage = .localized(key, tableName: tableName, bundle: bundle, comment: comment)
}
public static func + (lhs: Self, rhs: Self) -> Self {
.init(storage: .concatenated(lhs, rhs))
}
public func baselineOffset(_ baselineOffset: CGFloat) -> Self {
var `self` = self
`self`.modifiers.append(.baselineOffset(baselineOffset))
return `self`
}
public func bold() -> Self {
var `self` = self
`self`.modifiers.append(.bold(isActive: true))
return `self`
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func bold(isActive: Bool) -> Self {
var `self` = self
`self`.modifiers.append(.bold(isActive: isActive))
return `self`
}
public func font(_ font: Font?) -> Self {
var `self` = self
`self`.modifiers.append(.font(font))
return `self`
}
public func fontDesign(_ design: Font.Design?) -> Self {
var `self` = self
`self`.modifiers.append(.fontDesign(design))
return `self`
}
public func fontWeight(_ weight: Font.Weight?) -> Self {
var `self` = self
`self`.modifiers.append(.fontWeight(weight))
return `self`
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func fontWidth(_ width: FontWidth?) -> Self {
var `self` = self
`self`.modifiers.append(.fontWidth(width))
return `self`
}
public func foregroundColor(_ color: Color?) -> Self {
var `self` = self
`self`.modifiers.append(.foregroundColor(color))
return `self`
}
public func italic() -> Self {
var `self` = self
`self`.modifiers.append(.italic(isActive: true))
return `self`
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func italic(isActive: Bool) -> Self {
var `self` = self
`self`.modifiers.append(.italic(isActive: isActive))
return `self`
}
public func kerning(_ kerning: CGFloat) -> Self {
var `self` = self
`self`.modifiers.append(.kerning(kerning))
return `self`
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func monospacedDigit() -> Self {
var `self` = self
`self`.modifiers.append(.monospacedDigit)
return `self`
}
public func strikethrough(_ isActive: Bool = true, color: Color? = nil) -> Self {
var `self` = self
`self`.modifiers.append(.strikethrough(isActive: isActive, pattern: .solid, color: color))
return `self`
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func strikethrough(
_ isActive: Bool = true,
pattern: LineStylePattern,
color: Color? = nil
) -> Self {
var `self` = self
`self`.modifiers.append(.strikethrough(isActive: isActive, pattern: pattern, color: color))
return `self`
}
public func tracking(_ tracking: CGFloat) -> Self {
var `self` = self
`self`.modifiers.append(.tracking(tracking))
return `self`
}
public func underline(_ isActive: Bool = true, color: Color? = nil) -> Self {
var `self` = self
`self`.modifiers.append(.underline(isActive: isActive, pattern: .solid, color: color))
return `self`
}
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
public func underline(
_ isActive: Bool = true,
pattern: LineStylePattern,
color: Color? = nil
) -> Self {
var `self` = self
`self`.modifiers.append(.underline(isActive: isActive, pattern: pattern, color: color))
return `self`
}
}
// MARK: Accessibility
extension TextState {
public enum AccessibilityTextContentType: String, Equatable, Hashable {
case console, fileSystem, messaging, narrative, plain, sourceCode, spreadsheet, wordProcessing
#if compiler(>=5.5.1)
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
var toSwiftUI: SwiftUI.AccessibilityTextContentType {
switch self {
case .console: return .console
case .fileSystem: return .fileSystem
case .messaging: return .messaging
case .narrative: return .narrative
case .plain: return .plain
case .sourceCode: return .sourceCode
case .spreadsheet: return .spreadsheet
case .wordProcessing: return .wordProcessing
}
}
#endif
}
public enum AccessibilityHeadingLevel: String, Equatable, Hashable {
case h1, h2, h3, h4, h5, h6, unspecified
#if compiler(>=5.5.1)
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
var toSwiftUI: SwiftUI.AccessibilityHeadingLevel {
switch self {
case .h1: return .h1
case .h2: return .h2
case .h3: return .h3
case .h4: return .h4
case .h5: return .h5
case .h6: return .h6
case .unspecified: return .unspecified
}
}
#endif
}
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
extension TextState {
public func accessibilityHeading(_ headingLevel: AccessibilityHeadingLevel) -> Self {
var `self` = self
`self`.modifiers.append(.accessibilityHeading(headingLevel))
return `self`
}
public func accessibilityLabel(_ label: Self) -> Self {
var `self` = self
`self`.modifiers.append(.accessibilityLabel(label))
return `self`
}
public func accessibilityLabel(_ string: String) -> Self {
var `self` = self
`self`.modifiers.append(.accessibilityLabel(.init(string)))
return `self`
}
public func accessibilityLabel<S: StringProtocol>(_ string: S) -> Self {
var `self` = self
`self`.modifiers.append(.accessibilityLabel(.init(string)))
return `self`
}
public func accessibilityLabel(
_ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil,
comment: StaticString? = nil
) -> Self {
var `self` = self
`self`.modifiers.append(
.accessibilityLabel(.init(key, tableName: tableName, bundle: bundle, comment: comment)))
return `self`
}
public func accessibilityTextContentType(_ type: AccessibilityTextContentType) -> Self {
var `self` = self
`self`.modifiers.append(.accessibilityTextContentType(type))
return `self`
}
public func speechAdjustedPitch(_ value: Double) -> Self {
var `self` = self
`self`.modifiers.append(.speechAdjustedPitch(value))
return `self`
}
public func speechAlwaysIncludesPunctuation(_ value: Bool = true) -> Self {
var `self` = self
`self`.modifiers.append(.speechAlwaysIncludesPunctuation(value))
return `self`
}
public func speechAnnouncementsQueued(_ value: Bool = true) -> Self {
var `self` = self
`self`.modifiers.append(.speechAnnouncementsQueued(value))
return `self`
}
public func speechSpellsOutCharacters(_ value: Bool = true) -> Self {
var `self` = self
`self`.modifiers.append(.speechSpellsOutCharacters(value))
return `self`
}
}
extension Text {
public init(_ state: TextState) {
let text: Text
switch state.storage {
case let .concatenated(first, second):
text = Text(first) + Text(second)
case let .localized(content, tableName, bundle, comment):
text = .init(content, tableName: tableName, bundle: bundle, comment: comment)
case let .verbatim(content):
text = .init(verbatim: content)
}
self = state.modifiers.reduce(text) { text, modifier in
switch modifier {
#if compiler(>=5.5.1)
case let .accessibilityHeading(level):
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return text.accessibilityHeading(level.toSwiftUI)
} else {
return text
}
case let .accessibilityLabel(value):
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
switch value.storage {
case let .verbatim(string):
return text.accessibilityLabel(string)
case let .localized(key, tableName, bundle, comment):
return text.accessibilityLabel(
Text(key, tableName: tableName, bundle: bundle, comment: comment))
case .concatenated(_, _):
2022-12-06 04:29:23 +08:00
assertionFailure("`.accessibilityLabel` does not support concatenated `TextState`")
return text
}
} else {
return text
}
case let .accessibilityTextContentType(type):
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return text.accessibilityTextContentType(type.toSwiftUI)
} else {
return text
}
#else
case .accessibilityHeading,
2022-11-22 02:42:28 +08:00
.accessibilityLabel,
.accessibilityTextContentType:
return text
#endif
case let .baselineOffset(baselineOffset):
return text.baselineOffset(baselineOffset)
case let .bold(isActive):
#if swift(>=5.7.1)
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
return text.bold(isActive)
} else {
return text.bold()
}
#else
_ = isActive
return text.bold()
#endif
case let .font(font):
return text.font(font)
case let .fontDesign(design):
#if swift(>=5.7.1)
if #available(iOS 16.1, macOS 13, tvOS 16.1, watchOS 9.1, *) {
return text.fontDesign(design)
} else {
return text
}
#else
_ = design
return text
#endif
case let .fontWeight(weight):
return text.fontWeight(weight)
case let .fontWidth(width):
#if swift(>=5.7.1)
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
return text.fontWidth(width?.toSwiftUI)
} else {
return text
}
#else
_ = width
return text
#endif
case let .foregroundColor(color):
return text.foregroundColor(color)
case let .italic(isActive):
#if swift(>=5.7.1)
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) {
return text.italic(isActive)
} else {
return text.italic()
}
#else
_ = isActive
return text.italic()
#endif
case let .kerning(kerning):
return text.kerning(kerning)
case .monospacedDigit:
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return text.monospacedDigit()
} else {
return text
}
case let .speechAdjustedPitch(value):
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return text.speechAdjustedPitch(value)
} else {
return text
}
case let .speechAlwaysIncludesPunctuation(value):
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return text.speechAlwaysIncludesPunctuation(value)
} else {
return text
}
case let .speechAnnouncementsQueued(value):
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return text.speechAnnouncementsQueued(value)
} else {
return text
}
case let .speechSpellsOutCharacters(value):
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return text.speechSpellsOutCharacters(value)
} else {
return text
}
case let .strikethrough(isActive, pattern, color):
#if swift(>=5.7.1)
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern {
return text.strikethrough(isActive, pattern: pattern.toSwiftUI, color: color)
} else {
return text.strikethrough(isActive, color: color)
}
#else
_ = pattern
return text.strikethrough(isActive, color: color)
#endif
case let .tracking(tracking):
return text.tracking(tracking)
case let .underline(isActive, pattern, color):
#if swift(>=5.7.1)
if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *), let pattern = pattern {
return text.underline(isActive, pattern: pattern.toSwiftUI, color: color)
} else {
return text.underline(isActive, color: color)
}
#else
_ = pattern
return text.strikethrough(isActive, color: color)
#endif
}
}
}
}
extension String {
public init(state: TextState, locale: Locale? = nil) {
switch state.storage {
case let .concatenated(lhs, rhs):
self = String(state: lhs, locale: locale) + String(state: rhs, locale: locale)
case let .localized(key, tableName, bundle, comment):
self = key.formatted(
locale: locale,
tableName: tableName,
bundle: bundle,
comment: comment
)
case let .verbatim(string):
self = string
}
}
}
extension LocalizedStringKey {
// NB: `LocalizedStringKey` conforms to `Equatable` but returns false for equivalent format
// strings. To account for this we reflect on it to extract and string-format its storage.
fileprivate func formatted(
locale: Locale? = nil,
tableName: String? = nil,
bundle: Bundle? = nil,
comment: StaticString? = nil
) -> String {
let children = Array(Mirror(reflecting: self).children)
let key = children[0].value as! String
let arguments: [CVarArg] = Array(Mirror(reflecting: children[2].value).children)
.compactMap {
let children = Array(Mirror(reflecting: $0.value).children)
let value: Any
let formatter: Formatter?
// `LocalizedStringKey.FormatArgument` differs depending on OS/platform.
if children[0].label == "storage" {
(value, formatter) =
2022-11-22 02:42:28 +08:00
Array(Mirror(reflecting: children[0].value).children)[0].value as! (Any, Formatter?)
} else {
value = children[0].value
formatter = children[1].value as? Formatter
}
return formatter?.string(for: value) ?? value as! CVarArg
}
let format = NSLocalizedString(
key,
tableName: tableName,
bundle: bundle ?? .main,
value: "",
comment: comment.map(String.init) ?? ""
)
return String(format: format, locale: locale, arguments: arguments)
}
}
// MARK: - CustomDumpRepresentable
extension TextState: CustomDumpRepresentable {
public var customDumpValue: Any {
func dumpHelp(_ textState: Self) -> String {
var output: String
switch textState.storage {
case let .concatenated(lhs, rhs):
output = dumpHelp(lhs) + dumpHelp(rhs)
case let .localized(key, tableName, bundle, comment):
output = key.formatted(tableName: tableName, bundle: bundle, comment: comment)
case let .verbatim(string):
output = string
}
func tag(_ name: String, attribute: String? = nil, _ value: String? = nil) {
output = """
<\(name)\(attribute.map { " \($0)" } ?? "")\(value.map { "=\($0)" } ?? "")>\
\(output)\
</\(name)>
"""
}
for modifier in textState.modifiers {
switch modifier {
case let .accessibilityHeading(headingLevel):
tag("accessibility-heading-level", headingLevel.rawValue)
case let .accessibilityLabel(value):
tag("accessibility-label", dumpHelp(value))
case let .accessibilityTextContentType(type):
tag("accessibility-text-content-type", type.rawValue)
case let .baselineOffset(baselineOffset):
tag("baseline-offset", "\(baselineOffset)")
case .bold(isActive: true), .fontWeight(.some(.bold)):
output = "**\(output)**"
case .font(.some):
break // TODO: capture Font description using DSL similar to TextState and print here
case let .fontDesign(.some(design)):
func describe(design: Font.Design) -> String {
switch design {
case .default: return "default"
case .serif: return "serif"
case .rounded: return "rounded"
case .monospaced: return "monospaced"
@unknown default: return "\(design)"
}
}
tag("font-design", describe(design: design))
case let .fontWeight(.some(weight)):
func describe(weight: Font.Weight) -> String {
switch weight {
case .black: return "black"
case .bold: return "bold"
case .heavy: return "heavy"
case .light: return "light"
case .medium: return "medium"
case .regular: return "regular"
case .semibold: return "semibold"
case .thin: return "thin"
default: return "\(weight)"
}
}
tag("font-weight", describe(weight: weight))
case let .fontWidth(.some(width)):
tag("font-width", width.rawValue)
case let .foregroundColor(.some(color)):
tag("foreground-color", "\(color)")
case .italic(isActive: true):
output = "_\(output)_"
case let .kerning(kerning):
tag("kerning", "\(kerning)")
case let .speechAdjustedPitch(value):
tag("speech-adjusted-pitch", "\(value)")
case .speechAlwaysIncludesPunctuation(true):
tag("speech-always-includes-punctuation")
case .speechAnnouncementsQueued(true):
tag("speech-announcements-queued")
case .speechSpellsOutCharacters(true):
tag("speech-spells-out-characters")
case let .strikethrough(isActive: true, pattern: _, color: .some(color)):
tag("s", attribute: "color", "\(color)")
case .strikethrough(isActive: true, pattern: _, color: .none):
output = "~~\(output)~~"
case let .tracking(tracking):
tag("tracking", "\(tracking)")
case let .underline(isActive: true, pattern: _, .some(color)):
tag("u", attribute: "color", "\(color)")
case .underline(isActive: true, pattern: _, color: .none):
tag("u")
case .bold(isActive: false),
2022-11-22 02:42:28 +08:00
.font(.none),
.fontDesign(.none),
.fontWeight(.none),
.fontWidth(.none),
.foregroundColor(.none),
.italic(isActive: false),
.monospacedDigit,
.speechAlwaysIncludesPunctuation(false),
.speechAnnouncementsQueued(false),
.speechSpellsOutCharacters(false),
.strikethrough(isActive: false, pattern: _, color: _),
.underline(isActive: false, pattern: _, color: _):
break
}
}
return output
}
return dumpHelp(self)
}
}