Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
74 changes: 67 additions & 7 deletions BlueprintUI/Sources/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,31 @@ 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] = [:]

private var internalValues: [ObjectIdentifier: Any] = [:]

Comment on lines 42 to 47
Copy link
Contributor

Choose a reason for hiding this comment

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

The spec doc does a nice job outlining that internal key/values are "hidden from consumers and not considered in equivalency checks." Do you think we could carry that into docs here, to outline the differences between these two dictionaries?

/// 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 +81,58 @@ public struct Environment {
merged.values.merge(other.values) { $1 }
return merged
}


}

extension Environment: ContextuallyEquivalent {

public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> 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?, EquivalencyContext) -> 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: EquivalencyContext) -> Bool
Copy link
Collaborator

Choose a reason for hiding this comment

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

If this is required I think there should be a default implementation returning false.

  • default impl on isEquivalent

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Discused offline earlier but feel pretty strongly we shouldn't have that – there's default implementations when values are equatable and also convenience implementations like this that are one-liners https://github.com/square/Blueprint/pull/568/files#diff-e664d2d763ebe3ca35c985ddc39c338eff03d90a5dd5f6d90091b7612d02ce4aR55 but IMO new environmentkeys should be forced to consider this.


}

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.
/// - 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<EquivalencyContext>,
lhs: Value,
rhs: Value,
evaluatingContext: EquivalencyContext
) -> Bool {
if contexts.contains(evaluatingContext) {
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.
/// - 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<EquivalencyContext>,
lhs: Value,
rhs: Value,
evaluatingContext: EquivalencyContext
) -> Bool {
if contexts.contains(evaluatingContext) {
true
} else {
lhs.isEquivalent(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<EquivalencyContext>,
evaluatingContext: EquivalencyContext
) -> 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: EquivalencyContext) -> Bool {
alwaysEquivalentIn([.elementSizing], lhs: lhs, rhs: rhs, evaluatingContext: context)
}
}

/// The localised accessibility label elements should use when handling links.
Expand Down
72 changes: 72 additions & 0 deletions BlueprintUI/Sources/Internal/Equivalency.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 {
Copy link
Collaborator

@watt watt Sep 30, 2025

Choose a reason for hiding this comment

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

A few minor things:

  • you've got this in Internal but it's public

  • strong preference from me to have 1:1 files to types if possible rather than Equivalency.swift. Make a folder named Equivalency if the grouping is important.

  • naming-wise ContextuallyEquivalent is pretty generic for something that's still very Blueprint specific. I'll float something like ElementContextComparable as an alternative.

  • find a new name for ContextuallyEquivalent

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's slightly more broad than element-specific (includes env values too) and I think comparable has a connotation of being sortable given the stl usage of it – maybe something like CacheEquivalent? Or could just go the other way and be less concretely tied to that specific method it requires and be something more tied to purpose like CrossLayoutCacheable?

Copy link
Contributor

@soroushsq soroushsq Oct 21, 2025

Choose a reason for hiding this comment

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

Some ideas that max and I had:

MultipassLayoutEquivalent, LayoutContextuallyEquivalent.

We also considered flipping polarity of function, so it can't be easily confused with Equatable or Comparable. In that world, instead of asking if the two objects are functionally equivalent, you would ask if one object invalidates another compared to the previous version.

func invalidatesLayout(from previous: Self?, in context: LayoutCacheContext) -> Bool

For that, we had protocol name ideas of LayoutDependency and LayoutInvalidatingDependency.

Copy link
Collaborator

Choose a reason for hiding this comment

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

For me, Equivalent is out for the same reason as Comparable (connotations of more generic concepts).

I like CrossLayoutCacheable, or anything similar with the Cacheable suffix, since that really dials in on the distinct purpose here.

"Dependency" and "invalidates" both kind of muddy the waters for me. In the status quo, everything invalidates layout.


/// 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)
}

}

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: EquivalencyContext) -> 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: EquivalencyContext) -> 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.EquivalencyContext) -> Bool {
alwaysEquivalentIn([.elementSizing], evaluatingContext: context)
}

static let defaultValue = FeedTheme(authorColor: .black)
}

Expand Down
Loading