Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}

}

Original file line number Diff line number Diff line change
@@ -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
}

}
Original file line number Diff line number Diff line change
@@ -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
}
75 changes: 68 additions & 7 deletions BlueprintUI/Sources/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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<Key>(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
}
}

Expand All @@ -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<EnvironmentKeyType: EnvironmentKey>(_ 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 {

Expand Down
84 changes: 84 additions & 0 deletions BlueprintUI/Sources/Environment/EnvironmentKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd still prefer to see a default implementation here, but it's not a blocker.


}

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<CrossLayoutCacheableContext>,
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<CrossLayoutCacheableContext>,
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<CrossLayoutCacheableContext>,
evaluatingContext: CrossLayoutCacheableContext
) -> Bool {
contexts.contains(evaluatingContext)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion BlueprintUI/Tests/BlueprintViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ class BlueprintViewTests: XCTestCase {
}

func test_baseEnvironment() {
enum TestValue {
enum TestValue: Equatable {
case defaultValue
case right
}
Expand Down
2 changes: 1 addition & 1 deletion BlueprintUI/Tests/EnvironmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions BlueprintUI/Tests/UIViewElementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions SampleApp/Sources/PostsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading