From a63d20d981033bcb9288ca2c35039ab8e210092d Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 15:40:47 -0700 Subject: [PATCH 1/5] Add equivalency protocols and implementations --- .../Sources/Environment/Environment.swift | 72 ++++++++++++++-- .../Sources/Environment/EnvironmentKey.swift | 84 +++++++++++++++++++ .../Keys/AccessibilityLinkKey.swift | 4 + .../Sources/Internal/Equivalency.swift | 42 ++++++++++ .../Sources/AttributedLabel+Environment.swift | 4 + SampleApp/Sources/PostsViewController.swift | 5 ++ 6 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 BlueprintUI/Sources/Internal/Equivalency.swift diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index ed948eef2..2fb33df8a 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,24 +40,29 @@ public struct Environment { /// Each key will return its default value. public static let empty = Environment() - private var values: [ObjectIdentifier: Any] = [:] + private var values: [Keybox: Any] = [:] { + didSet { + fingerprint = UUID() + } + } + + // Fingerprint used for referencing previously compared environments. + fileprivate var fingerprint: UUID = UUID() /// Gets or sets an environment value by its key. public subscript(key: Key.Type) -> Key.Value where Key: EnvironmentKey { get { - let objectId = ObjectIdentifier(key) - - if let value = values[objectId] { - return value as! Key.Value - } - - return key.defaultValue + self[Keybox(key)] as! Key.Value } set { - values[ObjectIdentifier(key)] = newValue + values[Keybox(key)] = newValue } } + private subscript(keybox: Keybox) -> Any { + values[keybox, default: keybox.type.defaultValue] + } + /// If the `Environment` contains any values. var isEmpty: Bool { values.isEmpty @@ -73,6 +78,55 @@ public struct Environment { } } +extension Environment: ContextuallyEquivalent { + + public func isEquivalent(to other: Environment?, in context: EquivalencyContext) -> Bool { + guard let other else { return false } + if fingerprint == other.fingerprint { return true } + let keys = Set(values.keys).union(other.values.keys) + for key in keys { + guard key.isEquivalent(self[key], other[key], context) else { + return false + } + } + return true + } + +} + +extension Environment { + + /// Lightweight key type eraser. + fileprivate struct Keybox: Hashable, CustomStringConvertible { + + let objectIdentifier: ObjectIdentifier + let type: any EnvironmentKey.Type + let isEquivalent: (Any?, Any?, EquivalencyContext) -> Bool + + init(_ type: EnvironmentKeyType.Type) { + objectIdentifier = ObjectIdentifier(type) + self.type = type + isEquivalent = { + guard let lhs = $0 as? EnvironmentKeyType.Value, let rhs = $1 as? EnvironmentKeyType.Value else { return false } + return type.isEquivalent(lhs: lhs, rhs: rhs, in: $2) + } + } + + func hash(into hasher: inout Hasher) { + objectIdentifier.hash(into: &hasher) + } + + static func == (lhs: Keybox, rhs: Keybox) -> Bool { + lhs.objectIdentifier == rhs.objectIdentifier + } + + var description: String { + String(describing: type) + } + + } + +} extension UIView { diff --git a/BlueprintUI/Sources/Environment/EnvironmentKey.swift b/BlueprintUI/Sources/Environment/EnvironmentKey.swift index e7d15b216..14cf5ba49 100644 --- a/BlueprintUI/Sources/Environment/EnvironmentKey.swift +++ b/BlueprintUI/Sources/Environment/EnvironmentKey.swift @@ -26,4 +26,88 @@ public protocol EnvironmentKey { /// The default value that will be vended by an `Environment` for this key if no other value /// has been set. static var defaultValue: Self.Value { get } + + + /// Compares two environment values without direct conformance of the values. + /// - Parameters: + /// - lhs: The left hand side value being compared. + /// - rhs: The right hand side value being compared. + /// - context: The context to evaluate the equivalency. + /// - Returns: Whether or not the two values are equivalent in the specified context. + static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool + +} + +extension EnvironmentKey where Value: Equatable { + + public static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { + lhs == rhs + } + + /// Convenience implementation returning that the values are always equivalent in the specified contexts, and otherwise evaluates using Equality. + /// - Parameters: + /// - contexts: Contexts in which to always return true for equivalency. + /// - lhs: The left hand side value being compared. + /// - rhs: The right hand side value being compared. + /// - context: The context in which the values are currently being compared. + /// - Returns: Whether or not the two values are equivalent in the specified context. + /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. + public static func alwaysEquivalentIn( + _ contexts: Set, + lhs: Value, + rhs: Value, + context context: EquivalencyContext + ) -> Bool { + if contexts.contains(context) { + true + } else { + lhs == rhs + } + } + +} + +extension EnvironmentKey where Value: ContextuallyEquivalent { + + public static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { + lhs.isEquivalent(to: rhs, in: context) + } + + /// Convenience implementation returning that the values are always equivalent in the specified contexts, and otherwise evaluates using ContextuallyEquivalent. + /// - Parameters: + /// - contexts: Contexts in which to always return true for equivalency. + /// - lhs: The left hand side value being compared. + /// - rhs: The right hand side value being compared. + /// - context: The context in which the values are currently being compared. + /// - Returns: Whether or not the two values are equivalent in the specified context. + /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. + public static func alwaysEquivalentIn( + _ contexts: Set, + lhs: Value, + rhs: Value, + context context: EquivalencyContext + ) -> Bool { + if contexts.contains(context) { + true + } else { + lhs.isEquivalent(to: rhs, in: context) + } + } + +} + +extension EnvironmentKey { + + /// Convenience comparison to express default equality in specific contexts. + /// - Parameters: + /// - contexts: The contexts in which the values are always equilvalent. + /// - evaluatingContext: The context being evaulated. + /// - Returns: Whether or not the value is equivalent in the context. + public static func alwaysEquivalentIn( + _ contexts: Set, + in evaluatingContext: EquivalencyContext + ) -> Bool { + contexts.contains(evaluatingContext) + } + } diff --git a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift index 3f0a5cbe9..bd667b3e0 100644 --- a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift +++ b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift @@ -5,6 +5,10 @@ extension Environment { static var defaultValue: String? { UIImage(systemName: "link")?.accessibilityLabel } + + static func isEquivalent(lhs: String?, rhs: String?, in context: EquivalencyContext) -> Bool { + alwaysEquivalentIn([.overallLayout, .internalElementLayout], lhs: lhs, rhs: rhs, context: context) + } } /// The localised accessibility label elements should use when handling links. diff --git a/BlueprintUI/Sources/Internal/Equivalency.swift b/BlueprintUI/Sources/Internal/Equivalency.swift new file mode 100644 index 000000000..8f70fc237 --- /dev/null +++ b/BlueprintUI/Sources/Internal/Equivalency.swift @@ -0,0 +1,42 @@ +import Foundation + +// A context in which to evaluate whether or not two values are equivalent. +public enum EquivalencyContext: Hashable, Sendable, CaseIterable { + // The two values are identicial in every respect. + case all + // The two values are equivalent in all aspects that would affect layout. + case overallLayout + // The two values are equivalent in all aspects that would affect layout internally. + case internalElementLayout +} + +public protocol ContextuallyEquivalent { + + /// Allows a type to express equivilancy within certain contexts. For example, an Environment that represents dark mode would be equivilant to an Environment that represents light mode in a `layout` context, but not in `all` contexts. + /// - Parameters: + /// - other: The instance of the type being compared against. + /// - context: The context to compare within. + /// - Returns: Whether or not the other instance is equivalent in the specified context. + func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool + +} + + +extension ContextuallyEquivalent { + + // Allows comparison between types which may or may not be equivalent. + @_disfavoredOverload + func isEquivalent(to other: (any ContextuallyEquivalent)?, in context: EquivalencyContext) -> Bool { + isEquivalent(to: other as? Self, in: context) + } + +} + +// Default implementation that always returns strict equivalency. +extension ContextuallyEquivalent where Self: Equatable { + + func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { + self == other + } + +} diff --git a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift index ac81cb1f9..425bf85bc 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift @@ -32,6 +32,10 @@ public struct URLHandlerEnvironmentKey: EnvironmentKey { return DefaultURLHandler() } }() + + public static func isEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: EquivalencyContext) -> Bool { + alwaysEquivalentIn([.overallLayout, .internalElementLayout], in: context) + } } extension Environment { diff --git a/SampleApp/Sources/PostsViewController.swift b/SampleApp/Sources/PostsViewController.swift index 2e308385e..08d8292de 100644 --- a/SampleApp/Sources/PostsViewController.swift +++ b/SampleApp/Sources/PostsViewController.swift @@ -122,7 +122,12 @@ final class PostsViewController: UIViewController { } extension Environment { + private enum FeedThemeKey: EnvironmentKey { + static func isEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.EquivalencyContext) -> Bool { + alwaysEquivalentIn([.internalElementLayout, .overallLayout], in: context) + } + static let defaultValue = FeedTheme(authorColor: .black) } From 0e78167b58527d7d5c43f4d389f4d1d92e680c1b Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 16:25:23 -0700 Subject: [PATCH 2/5] Fix testkey --- BlueprintUI/Tests/EnvironmentTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BlueprintUI/Tests/EnvironmentTests.swift b/BlueprintUI/Tests/EnvironmentTests.swift index f354c6beb..7889ce8a7 100644 --- a/BlueprintUI/Tests/EnvironmentTests.swift +++ b/BlueprintUI/Tests/EnvironmentTests.swift @@ -307,7 +307,7 @@ private class TestView: UIView { var testValue = TestValue.defaultValue } -private enum TestValue { +private enum TestValue: Equatable { case defaultValue case wrong case right From 8a39fcee3af096e5f2d4522d8d8ab6c2e512e090 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 16 Jul 2025 16:49:40 -0700 Subject: [PATCH 3/5] include other TestKeys --- BlueprintUI/Tests/BlueprintViewTests.swift | 2 +- BlueprintUI/Tests/UIViewElementTests.swift | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/BlueprintUI/Tests/BlueprintViewTests.swift b/BlueprintUI/Tests/BlueprintViewTests.swift index 0ee4aa674..72a4d7a8f 100755 --- a/BlueprintUI/Tests/BlueprintViewTests.swift +++ b/BlueprintUI/Tests/BlueprintViewTests.swift @@ -228,7 +228,7 @@ class BlueprintViewTests: XCTestCase { } func test_baseEnvironment() { - enum TestValue { + enum TestValue: Equatable { case defaultValue case right } diff --git a/BlueprintUI/Tests/UIViewElementTests.swift b/BlueprintUI/Tests/UIViewElementTests.swift index 3c7f25874..71394b3c8 100644 --- a/BlueprintUI/Tests/UIViewElementTests.swift +++ b/BlueprintUI/Tests/UIViewElementTests.swift @@ -109,6 +109,9 @@ class UIViewElementTests: XCTestCase { func test_environment() { enum TestKey: EnvironmentKey { static let defaultValue: Void? = nil + static func isEquivalent(lhs: Void?, rhs: Void?, in context: EquivalencyContext) -> Bool { + lhs == nil && rhs == nil || rhs != nil && lhs != nil + } } @propertyWrapper From deb85cb5666b1160ab9139c7bd972d389eddeb4e Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Fri, 25 Jul 2025 00:32:35 -0700 Subject: [PATCH 4/5] Cleanup --- .../Sources/Environment/Environment.swift | 28 ++++++----- .../Sources/Environment/EnvironmentKey.swift | 16 +++---- .../Keys/AccessibilityLinkKey.swift | 2 +- .../Sources/Internal/Equivalency.swift | 46 +++++++++++++++---- .../Sources/AttributedLabel+Environment.swift | 2 +- SampleApp/Sources/PostsViewController.swift | 2 +- 6 files changed, 66 insertions(+), 30 deletions(-) diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index 2fb33df8a..df407a9d2 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,14 +40,9 @@ public struct Environment { /// Each key will return its default value. public static let empty = Environment() - private var values: [Keybox: Any] = [:] { - didSet { - fingerprint = UUID() - } - } + private var values: [Keybox: Any] = [:] - // Fingerprint used for referencing previously compared environments. - fileprivate var fingerprint: UUID = UUID() + private var internalValues: [ObjectIdentifier: Any] = [:] /// Gets or sets an environment value by its key. public subscript(key: Key.Type) -> Key.Value where Key: EnvironmentKey { @@ -55,7 +50,8 @@ public struct Environment { self[Keybox(key)] as! Key.Value } set { - values[Keybox(key)] = newValue + let keybox = Keybox(key) + values[keybox] = newValue } } @@ -63,6 +59,15 @@ public struct Environment { values[keybox, default: keybox.type.defaultValue] } + public subscript(internal key: Key.Type) -> Key.Value where Key: EnvironmentKey { + get { + internalValues[ObjectIdentifier(key), default: key.defaultValue] as! Key.Value + } + set { + internalValues[ObjectIdentifier(key)] = newValue + } + } + /// If the `Environment` contains any values. var isEmpty: Bool { values.isEmpty @@ -76,13 +81,14 @@ public struct Environment { merged.values.merge(other.values) { $1 } return merged } + + } extension Environment: ContextuallyEquivalent { - public func isEquivalent(to other: Environment?, in context: EquivalencyContext) -> Bool { + public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { guard let other else { return false } - if fingerprint == other.fingerprint { return true } let keys = Set(values.keys).union(other.values.keys) for key in keys { guard key.isEquivalent(self[key], other[key], context) else { @@ -97,7 +103,7 @@ extension Environment: ContextuallyEquivalent { extension Environment { /// Lightweight key type eraser. - fileprivate struct Keybox: Hashable, CustomStringConvertible { + struct Keybox: Hashable, CustomStringConvertible { let objectIdentifier: ObjectIdentifier let type: any EnvironmentKey.Type diff --git a/BlueprintUI/Sources/Environment/EnvironmentKey.swift b/BlueprintUI/Sources/Environment/EnvironmentKey.swift index 14cf5ba49..a61b0a487 100644 --- a/BlueprintUI/Sources/Environment/EnvironmentKey.swift +++ b/BlueprintUI/Sources/Environment/EnvironmentKey.swift @@ -49,16 +49,16 @@ extension EnvironmentKey where Value: Equatable { /// - contexts: Contexts in which to always return true for equivalency. /// - lhs: The left hand side value being compared. /// - rhs: The right hand side value being compared. - /// - context: The context in which the values are currently being compared. + /// - evaluatingContext: The context in which the values are currently being compared. /// - Returns: Whether or not the two values are equivalent in the specified context. /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. public static func alwaysEquivalentIn( _ contexts: Set, lhs: Value, rhs: Value, - context context: EquivalencyContext + evaluatingContext: EquivalencyContext ) -> Bool { - if contexts.contains(context) { + if contexts.contains(evaluatingContext) { true } else { lhs == rhs @@ -78,19 +78,19 @@ extension EnvironmentKey where Value: ContextuallyEquivalent { /// - contexts: Contexts in which to always return true for equivalency. /// - lhs: The left hand side value being compared. /// - rhs: The right hand side value being compared. - /// - context: The context in which the values are currently being compared. + /// - evaluatingContext: The context in which the values are currently being compared. /// - Returns: Whether or not the two values are equivalent in the specified context. /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. public static func alwaysEquivalentIn( _ contexts: Set, lhs: Value, rhs: Value, - context context: EquivalencyContext + evaluatingContext: EquivalencyContext ) -> Bool { - if contexts.contains(context) { + if contexts.contains(evaluatingContext) { true } else { - lhs.isEquivalent(to: rhs, in: context) + lhs.isEquivalent(to: rhs, in: evaluatingContext) } } @@ -105,7 +105,7 @@ extension EnvironmentKey { /// - Returns: Whether or not the value is equivalent in the context. public static func alwaysEquivalentIn( _ contexts: Set, - in evaluatingContext: EquivalencyContext + evaluatingContext: EquivalencyContext ) -> Bool { contexts.contains(evaluatingContext) } diff --git a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift index bd667b3e0..71bb54a57 100644 --- a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift +++ b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift @@ -7,7 +7,7 @@ extension Environment { } static func isEquivalent(lhs: String?, rhs: String?, in context: EquivalencyContext) -> Bool { - alwaysEquivalentIn([.overallLayout, .internalElementLayout], lhs: lhs, rhs: rhs, context: context) + alwaysEquivalentIn([.elementSizing], lhs: lhs, rhs: rhs, evaluatingContext: context) } } diff --git a/BlueprintUI/Sources/Internal/Equivalency.swift b/BlueprintUI/Sources/Internal/Equivalency.swift index 8f70fc237..62693f7b2 100644 --- a/BlueprintUI/Sources/Internal/Equivalency.swift +++ b/BlueprintUI/Sources/Internal/Equivalency.swift @@ -2,31 +2,45 @@ import Foundation // A context in which to evaluate whether or not two values are equivalent. public enum EquivalencyContext: Hashable, Sendable, CaseIterable { - // The two values are identicial in every respect. + + /// The two values are identicial in every respect that could affect displayed output. case all - // The two values are equivalent in all aspects that would affect layout. - case overallLayout - // The two values are equivalent in all aspects that would affect layout internally. - case internalElementLayout + + // More fine-grained contexts: + + /// The two values are equivalent in all aspects that would affect the size of the element. + /// - Warning:Non-obvious things may affect element-sizing – for example, setting a time zone may seem like something that would only affect date calculations, but can result in different text being displayed, and therefore affect sizing. Consider carefully whether you are truly affecting sizing or not. + case elementSizing } public protocol ContextuallyEquivalent { - /// Allows a type to express equivilancy within certain contexts. For example, an Environment that represents dark mode would be equivilant to an Environment that represents light mode in a `layout` context, but not in `all` contexts. + /// Allows a type to express equivilancy within certain contexts. For example, an Environment that represents dark mode would be equivalent to an Environment that represents light mode in a `elementSizing` context, but not in `all` contexts. /// - Parameters: /// - other: The instance of the type being compared against. /// - context: The context to compare within. /// - Returns: Whether or not the other instance is equivalent in the specified context. + /// - Note: Equivilancy within a given context is transitive – that is, if value A is equivalent to value B in a given context, and B is equivalent to C in that same context, A will be considered equivalent to C with that context. func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool } +extension ContextuallyEquivalent { + + /// Convenience equivalency check passing in .all for context. + /// - other: The instance of the type being compared against. + /// - Returns: Whether or not the other instance is equivalent in all contexts. + public func isEquivalent(to other: Self?) -> Bool { + isEquivalent(to: other, in: .all) + } + +} extension ContextuallyEquivalent { // Allows comparison between types which may or may not be equivalent. @_disfavoredOverload - func isEquivalent(to other: (any ContextuallyEquivalent)?, in context: EquivalencyContext) -> Bool { + public func isEquivalent(to other: (any ContextuallyEquivalent)?, in context: EquivalencyContext) -> Bool { isEquivalent(to: other as? Self, in: context) } @@ -35,8 +49,24 @@ extension ContextuallyEquivalent { // Default implementation that always returns strict equivalency. extension ContextuallyEquivalent where Self: Equatable { - func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { + public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { self == other } } + +public struct AnyContextuallyEquivalent: ContextuallyEquivalent { + + let base: Any + + public init(_ value: some ContextuallyEquivalent) { + base = value + } + + public func isEquivalent(to other: AnyContextuallyEquivalent?, in context: EquivalencyContext) -> Bool { + guard let base = (base as? any ContextuallyEquivalent) else { return false } + return base.isEquivalent(to: other?.base as? ContextuallyEquivalent, in: context) + } + +} + diff --git a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift index 425bf85bc..4116d2698 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift @@ -34,7 +34,7 @@ public struct URLHandlerEnvironmentKey: EnvironmentKey { }() public static func isEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: EquivalencyContext) -> Bool { - alwaysEquivalentIn([.overallLayout, .internalElementLayout], in: context) + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } } diff --git a/SampleApp/Sources/PostsViewController.swift b/SampleApp/Sources/PostsViewController.swift index 08d8292de..b40c428ba 100644 --- a/SampleApp/Sources/PostsViewController.swift +++ b/SampleApp/Sources/PostsViewController.swift @@ -125,7 +125,7 @@ extension Environment { private enum FeedThemeKey: EnvironmentKey { static func isEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.EquivalencyContext) -> Bool { - alwaysEquivalentIn([.internalElementLayout, .overallLayout], in: context) + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } static let defaultValue = FeedTheme(authorColor: .black) From d4418a895416c06dedd76c79a6ee3b10dd4e7ad9 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 26 Nov 2025 16:37:20 -0800 Subject: [PATCH 5/5] PR feedback cleanup --- .../AnyCrossLayoutCacheable.swift | 18 +++++ .../CrossLayoutCacheable.swift | 44 ++++++++++++ .../CrossLayoutCacheableContext.swift | 14 ++++ .../Sources/Environment/Environment.swift | 7 +- .../Sources/Environment/EnvironmentKey.swift | 26 +++---- .../Keys/AccessibilityLinkKey.swift | 2 +- .../Sources/Internal/Equivalency.swift | 72 ------------------- BlueprintUI/Tests/UIViewElementTests.swift | 2 +- .../Sources/AttributedLabel+Environment.swift | 2 +- SampleApp/Sources/PostsViewController.swift | 2 +- 10 files changed, 97 insertions(+), 92 deletions(-) create mode 100644 BlueprintUI/Sources/CrossLayoutCaching/AnyCrossLayoutCacheable.swift create mode 100644 BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheable.swift create mode 100644 BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheableContext.swift delete mode 100644 BlueprintUI/Sources/Internal/Equivalency.swift diff --git a/BlueprintUI/Sources/CrossLayoutCaching/AnyCrossLayoutCacheable.swift b/BlueprintUI/Sources/CrossLayoutCaching/AnyCrossLayoutCacheable.swift new file mode 100644 index 000000000..3915460da --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/AnyCrossLayoutCacheable.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Type eraser for CrossLayoutCacheable. +public struct AnyCrossLayoutCacheable: CrossLayoutCacheable { + + let base: Any + + public init(_ value: some CrossLayoutCacheable) { + base = value + } + + public func isCacheablyEquivalent(to other: AnyCrossLayoutCacheable?, in context: CrossLayoutCacheableContext) -> Bool { + guard let base = (base as? any CrossLayoutCacheable) else { return false } + return base.isCacheablyEquivalent(to: other?.base as? CrossLayoutCacheable, in: context) + } + +} + diff --git a/BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheable.swift b/BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheable.swift new file mode 100644 index 000000000..fe2598efd --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheable.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Protocol that allows a value to be cached between layout passes. +public protocol CrossLayoutCacheable { + + /// Allows a type to express cacheability of a value within certain contexts. For example, an Environment that represents dark mode would be equivalent to an Environment that represents light mode in a `elementSizing` context, but not in `all` contexts. + /// - Parameters: + /// - other: The instance of the type being compared against. + /// - context: The context to compare within. + /// - Returns: Whether or not the other instance is equivalent in the specified context. + /// - Note: Equivilancy within a given context is transitive – that is, if value A is equivalent to value B in a given context, and B is equivalent to C in that same context, A will be considered equivalent to C with that context. + func isCacheablyEquivalent(to other: Self?, in context: CrossLayoutCacheableContext) -> Bool + +} + +extension CrossLayoutCacheable { + + /// Convenience equivalency check passing in .all for context. + /// - other: The instance of the type being compared against. + /// - Returns: Whether or not the other instance is equivalent in all contexts. + public func isCacheablyEquivalent(to other: Self?) -> Bool { + isCacheablyEquivalent(to: other, in: .all) + } + +} + +extension CrossLayoutCacheable { + + // Allows comparison between types which may or may not be equivalent. + @_disfavoredOverload + public func isCacheablyEquivalent(to other: (any CrossLayoutCacheable)?, in context: CrossLayoutCacheableContext) -> Bool { + isCacheablyEquivalent(to: other as? Self, in: context) + } + +} + +// Default implementation that always returns strict equivalency. +extension CrossLayoutCacheable where Self: Equatable { + + public func isCacheablyEquivalent(to other: Self?, in context: CrossLayoutCacheableContext) -> Bool { + self == other + } + +} diff --git a/BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheableContext.swift b/BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheableContext.swift new file mode 100644 index 000000000..a7779facb --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/CrossLayoutCacheableContext.swift @@ -0,0 +1,14 @@ +import Foundation + +// A context in which to evaluate whether or not a value is cacheable. +public enum CrossLayoutCacheableContext: Hashable, Sendable, CaseIterable { + + /// The two values are identicial in every respect that could affect displayed output. + case all + + // More fine-grained contexts: + + /// The two values are equivalent in all aspects that would affect the size of the element. + /// - Warning:Non-obvious things may affect element-sizing – for example, setting a time zone may seem like something that would only affect date calculations, but can result in different text being displayed, and therefore affect sizing. Consider carefully whether you are truly affecting sizing or not. + case elementSizing +} diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index df407a9d2..3a122b38d 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -42,6 +42,7 @@ public struct Environment { private var values: [Keybox: Any] = [:] + // Internal values are hidden from consumers and do not participate in cross-layout cacheability checks. private var internalValues: [ObjectIdentifier: Any] = [:] /// Gets or sets an environment value by its key. @@ -85,9 +86,9 @@ public struct Environment { } -extension Environment: ContextuallyEquivalent { +extension Environment: CrossLayoutCacheable { - public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { + public func isCacheablyEquivalent(to other: Self?, in context: CrossLayoutCacheableContext) -> Bool { guard let other else { return false } let keys = Set(values.keys).union(other.values.keys) for key in keys { @@ -107,7 +108,7 @@ extension Environment { let objectIdentifier: ObjectIdentifier let type: any EnvironmentKey.Type - let isEquivalent: (Any?, Any?, EquivalencyContext) -> Bool + let isEquivalent: (Any?, Any?, CrossLayoutCacheableContext) -> Bool init(_ type: EnvironmentKeyType.Type) { objectIdentifier = ObjectIdentifier(type) diff --git a/BlueprintUI/Sources/Environment/EnvironmentKey.swift b/BlueprintUI/Sources/Environment/EnvironmentKey.swift index a61b0a487..649f25107 100644 --- a/BlueprintUI/Sources/Environment/EnvironmentKey.swift +++ b/BlueprintUI/Sources/Environment/EnvironmentKey.swift @@ -34,13 +34,13 @@ public protocol EnvironmentKey { /// - rhs: The right hand side value being compared. /// - context: The context to evaluate the equivalency. /// - Returns: Whether or not the two values are equivalent in the specified context. - static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool + static func isEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> Bool } extension EnvironmentKey where Value: Equatable { - public static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { + public static func isEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> Bool { lhs == rhs } @@ -53,10 +53,10 @@ extension EnvironmentKey where Value: Equatable { /// - Returns: Whether or not the two values are equivalent in the specified context. /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. public static func alwaysEquivalentIn( - _ contexts: Set, + _ contexts: Set, lhs: Value, rhs: Value, - evaluatingContext: EquivalencyContext + evaluatingContext: CrossLayoutCacheableContext ) -> Bool { if contexts.contains(evaluatingContext) { true @@ -67,13 +67,13 @@ extension EnvironmentKey where Value: Equatable { } -extension EnvironmentKey where Value: ContextuallyEquivalent { +extension EnvironmentKey where Value: CrossLayoutCacheable { - public static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { - lhs.isEquivalent(to: rhs, in: context) + public static func isCacheablyEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> Bool { + lhs.isCacheablyEquivalent(to: rhs, in: context) } - /// Convenience implementation returning that the values are always equivalent in the specified contexts, and otherwise evaluates using ContextuallyEquivalent. + /// Convenience implementation returning that the values are always equivalent in the specified contexts, and otherwise evaluates using CrossLayoutCacheable. /// - Parameters: /// - contexts: Contexts in which to always return true for equivalency. /// - lhs: The left hand side value being compared. @@ -82,15 +82,15 @@ extension EnvironmentKey where Value: ContextuallyEquivalent { /// - Returns: Whether or not the two values are equivalent in the specified context. /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. public static func alwaysEquivalentIn( - _ contexts: Set, + _ contexts: Set, lhs: Value, rhs: Value, - evaluatingContext: EquivalencyContext + evaluatingContext: CrossLayoutCacheableContext ) -> Bool { if contexts.contains(evaluatingContext) { true } else { - lhs.isEquivalent(to: rhs, in: evaluatingContext) + lhs.isCacheablyEquivalent(to: rhs, in: evaluatingContext) } } @@ -104,8 +104,8 @@ extension EnvironmentKey { /// - evaluatingContext: The context being evaulated. /// - Returns: Whether or not the value is equivalent in the context. public static func alwaysEquivalentIn( - _ contexts: Set, - evaluatingContext: EquivalencyContext + _ contexts: Set, + evaluatingContext: CrossLayoutCacheableContext ) -> Bool { contexts.contains(evaluatingContext) } diff --git a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift index 71bb54a57..3a59c4493 100644 --- a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift +++ b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift @@ -6,7 +6,7 @@ extension Environment { UIImage(systemName: "link")?.accessibilityLabel } - static func isEquivalent(lhs: String?, rhs: String?, in context: EquivalencyContext) -> Bool { + static func isEquivalent(lhs: String?, rhs: String?, in context: CrossLayoutCacheableContext) -> Bool { alwaysEquivalentIn([.elementSizing], lhs: lhs, rhs: rhs, evaluatingContext: context) } } diff --git a/BlueprintUI/Sources/Internal/Equivalency.swift b/BlueprintUI/Sources/Internal/Equivalency.swift deleted file mode 100644 index 62693f7b2..000000000 --- a/BlueprintUI/Sources/Internal/Equivalency.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -// A context in which to evaluate whether or not two values are equivalent. -public enum EquivalencyContext: Hashable, Sendable, CaseIterable { - - /// The two values are identicial in every respect that could affect displayed output. - case all - - // More fine-grained contexts: - - /// The two values are equivalent in all aspects that would affect the size of the element. - /// - Warning:Non-obvious things may affect element-sizing – for example, setting a time zone may seem like something that would only affect date calculations, but can result in different text being displayed, and therefore affect sizing. Consider carefully whether you are truly affecting sizing or not. - case elementSizing -} - -public protocol ContextuallyEquivalent { - - /// Allows a type to express equivilancy within certain contexts. For example, an Environment that represents dark mode would be equivalent to an Environment that represents light mode in a `elementSizing` context, but not in `all` contexts. - /// - Parameters: - /// - other: The instance of the type being compared against. - /// - context: The context to compare within. - /// - Returns: Whether or not the other instance is equivalent in the specified context. - /// - Note: Equivilancy within a given context is transitive – that is, if value A is equivalent to value B in a given context, and B is equivalent to C in that same context, A will be considered equivalent to C with that context. - func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool - -} - -extension ContextuallyEquivalent { - - /// Convenience equivalency check passing in .all for context. - /// - other: The instance of the type being compared against. - /// - Returns: Whether or not the other instance is equivalent in all contexts. - public func isEquivalent(to other: Self?) -> Bool { - isEquivalent(to: other, in: .all) - } - -} - -extension ContextuallyEquivalent { - - // Allows comparison between types which may or may not be equivalent. - @_disfavoredOverload - public func isEquivalent(to other: (any ContextuallyEquivalent)?, in context: EquivalencyContext) -> Bool { - isEquivalent(to: other as? Self, in: context) - } - -} - -// Default implementation that always returns strict equivalency. -extension ContextuallyEquivalent where Self: Equatable { - - public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { - self == other - } - -} - -public struct AnyContextuallyEquivalent: ContextuallyEquivalent { - - let base: Any - - public init(_ value: some ContextuallyEquivalent) { - base = value - } - - public func isEquivalent(to other: AnyContextuallyEquivalent?, in context: EquivalencyContext) -> Bool { - guard let base = (base as? any ContextuallyEquivalent) else { return false } - return base.isEquivalent(to: other?.base as? ContextuallyEquivalent, in: context) - } - -} - diff --git a/BlueprintUI/Tests/UIViewElementTests.swift b/BlueprintUI/Tests/UIViewElementTests.swift index 71394b3c8..5b34b6272 100644 --- a/BlueprintUI/Tests/UIViewElementTests.swift +++ b/BlueprintUI/Tests/UIViewElementTests.swift @@ -109,7 +109,7 @@ class UIViewElementTests: XCTestCase { func test_environment() { enum TestKey: EnvironmentKey { static let defaultValue: Void? = nil - static func isEquivalent(lhs: Void?, rhs: Void?, in context: EquivalencyContext) -> Bool { + static func isEquivalent(lhs: Void?, rhs: Void?, in context: CrossLayoutCacheableContext) -> Bool { lhs == nil && rhs == nil || rhs != nil && lhs != nil } } diff --git a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift index 4116d2698..328c5b5b8 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift @@ -33,7 +33,7 @@ public struct URLHandlerEnvironmentKey: EnvironmentKey { } }() - public static func isEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: EquivalencyContext) -> Bool { + public static func isEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: CrossLayoutCacheableContext) -> Bool { alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } } diff --git a/SampleApp/Sources/PostsViewController.swift b/SampleApp/Sources/PostsViewController.swift index b40c428ba..27560b748 100644 --- a/SampleApp/Sources/PostsViewController.swift +++ b/SampleApp/Sources/PostsViewController.swift @@ -124,7 +124,7 @@ final class PostsViewController: UIViewController { extension Environment { private enum FeedThemeKey: EnvironmentKey { - static func isEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.EquivalencyContext) -> Bool { + static func isEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.CrossLayoutCacheableContext) -> Bool { alwaysEquivalentIn([.elementSizing], evaluatingContext: context) }