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 ed948eef2..3a122b38d 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,21 +40,32 @@ 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] = [:] + + // 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. public subscript(key: Key.Type) -> Key.Value where Key: EnvironmentKey { get { - let objectId = ObjectIdentifier(key) + self[Keybox(key)] as! Key.Value + } + set { + let keybox = Keybox(key) + values[keybox] = newValue + } + } - if let value = values[objectId] { - return value as! Key.Value - } + private subscript(keybox: Keybox) -> Any { + values[keybox, default: keybox.type.defaultValue] + } - return key.defaultValue + public subscript(internal key: Key.Type) -> Key.Value where Key: EnvironmentKey { + get { + internalValues[ObjectIdentifier(key), default: key.defaultValue] as! Key.Value } set { - values[ObjectIdentifier(key)] = newValue + internalValues[ObjectIdentifier(key)] = newValue } } @@ -71,8 +82,58 @@ public struct Environment { merged.values.merge(other.values) { $1 } return merged } + + +} + +extension Environment: CrossLayoutCacheable { + + 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 { + guard key.isEquivalent(self[key], other[key], context) else { + return false + } + } + return true + } + } +extension Environment { + + /// Lightweight key type eraser. + struct Keybox: Hashable, CustomStringConvertible { + + let objectIdentifier: ObjectIdentifier + let type: any EnvironmentKey.Type + let isEquivalent: (Any?, Any?, CrossLayoutCacheableContext) -> 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..649f25107 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: CrossLayoutCacheableContext) -> Bool + +} + +extension EnvironmentKey where Value: Equatable { + + public static func isEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> 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. + /// - 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, + evaluatingContext: CrossLayoutCacheableContext + ) -> Bool { + if contexts.contains(evaluatingContext) { + true + } else { + lhs == rhs + } + } + +} + +extension EnvironmentKey where Value: CrossLayoutCacheable { + + 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 CrossLayoutCacheable. + /// - 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. + /// - 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, + evaluatingContext: CrossLayoutCacheableContext + ) -> Bool { + if contexts.contains(evaluatingContext) { + true + } else { + lhs.isCacheablyEquivalent(to: rhs, in: evaluatingContext) + } + } + +} + +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, + evaluatingContext: CrossLayoutCacheableContext + ) -> Bool { + contexts.contains(evaluatingContext) + } + } diff --git a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift index 3f0a5cbe9..3a59c4493 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: CrossLayoutCacheableContext) -> Bool { + alwaysEquivalentIn([.elementSizing], lhs: lhs, rhs: rhs, evaluatingContext: context) + } } /// The localised accessibility label elements should use when handling links. 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/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 diff --git a/BlueprintUI/Tests/UIViewElementTests.swift b/BlueprintUI/Tests/UIViewElementTests.swift index 3c7f25874..5b34b6272 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: CrossLayoutCacheableContext) -> Bool { + lhs == nil && rhs == nil || rhs != nil && lhs != nil + } } @propertyWrapper diff --git a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift index ac81cb1f9..328c5b5b8 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: CrossLayoutCacheableContext) -> Bool { + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) + } } extension Environment { diff --git a/SampleApp/Sources/PostsViewController.swift b/SampleApp/Sources/PostsViewController.swift index 2e308385e..27560b748 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.CrossLayoutCacheableContext) -> Bool { + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) + } + static let defaultValue = FeedTheme(authorColor: .black) }