From 54f9c6d343432e18548303b825434f050ebf8611 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 9 Oct 2024 17:37:41 -0700 Subject: [PATCH 1/8] Move view caching to the environment --- .gitignore | 3 + .../Sources/BlueprintView/BlueprintView.swift | 8 +++ .../Sources/Element/UIViewElement.swift | 62 +++++++++++++++---- .../Sources/AttributedLabel.swift | 11 ++-- 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index b5d85db42..f3d32c73d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ xcuserdata/ Pods/ *.xcworkspace +# Tuist +Derived/ + # SPM # Disabled so that we can commit the generated schemes that live in diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 3b978ed03..da31d438a 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -39,6 +39,8 @@ public final class BlueprintView: UIView { private var layoutResult: LayoutResultNode? private var sizesThatFit: [SizeConstraint: CGSize] = [:] + + private let viewMeasurer : UIViewElementMeasurer = .init() /// A base environment used when laying out and rendering the element tree. /// @@ -505,6 +507,12 @@ public final class BlueprintView: UIView { }() var environment = inherited.merged(prioritizing: environment) + + /// We're a root `BlueprintView`, so pass in the view measurer for our downstream dependencies. + + if environment.inheritedElementMeasurer == nil { + environment.elementMeasurer = self.viewMeasurer + } if let displayScale = window?.screen.scale { environment.displayScale = displayScale diff --git a/BlueprintUI/Sources/Element/UIViewElement.swift b/BlueprintUI/Sources/Element/UIViewElement.swift index bbf0f6319..fab25165e 100644 --- a/BlueprintUI/Sources/Element/UIViewElement.swift +++ b/BlueprintUI/Sources/Element/UIViewElement.swift @@ -92,7 +92,7 @@ extension UIViewElement { public var content: ElementContent { ElementContent { constraint, environment in - UIViewElementMeasurer.shared.measure( + environment.elementMeasurer.measure( element: self, constraint: constraint, environment: environment @@ -131,11 +131,16 @@ public struct UIViewElementContext { } /// An private type which caches `UIViewElement` views to be reused for sizing and measurement. -private final class UIViewElementMeasurer { - - /// The standard shared cache. - static let shared = UIViewElementMeasurer() - +public final class UIViewElementMeasurer { + + /// Provides access to a view in the provided block. + public func accessMeasurementView( + perform : (View) -> Result, + create : () -> View + ) -> Result { + perform(measurementView(create)) + } + /// Provides the size for the provided element by using a cached measurement view. func measure( element: some UIViewElement, @@ -145,7 +150,9 @@ private final class UIViewElementMeasurer { let bounds = CGRect(origin: .zero, size: constraint.maximum) - let view = measurementView(for: element) + let view = measurementView { + element.makeUIView() + } /// Ensure that during measurement / sizing, the inherited `Environment` is available /// to any child `BlueprintView`s. We must manually wire this property up, as the @@ -159,15 +166,16 @@ private final class UIViewElementMeasurer { return element.size(bounds.size, thatFits: view) } - func measurementView(for element: ViewElement) -> ViewElement.UIViewType { + private func measurementView(_ create : () -> View) -> View { + let key = Key( - elementType: ObjectIdentifier(ViewElement.self) + elementType: ObjectIdentifier(View.self) ) if let existing = views[key] { - return existing as! ViewElement.UIViewType + return existing as! View } else { - let new = element.makeUIView() + let new = create() views[key] = new return new } @@ -180,3 +188,35 @@ private final class UIViewElementMeasurer { } } + +extension Environment { + + private static let fallback = UIViewElementMeasurer() + + public internal(set) var elementMeasurer : UIViewElementMeasurer { + get { + if let inheritedElementMeasurer { + return inheritedElementMeasurer + } else { + assertionFailure( + """ + WARNING: Blueprint is falling back to a static `UIViewElementMeasurer`, \ + which may result in longer object lifetimes than expected. + """ + ) + + return Self.fallback + } + } + + set { self[ElementMeasurerKey.self] = newValue } + } + + var inheritedElementMeasurer : UIViewElementMeasurer? { + get { self[ElementMeasurerKey.self] } + } + + private enum ElementMeasurerKey : EnvironmentKey { + static let defaultValue: UIViewElementMeasurer? = nil + } +} diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index ea91fc2f9..59674ab76 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -125,17 +125,18 @@ public struct AttributedLabel: Element, Hashable { /// You can check if this value should be false via `NSAttributedString.needsNormalizingForView(...)` public var needsTextNormalization: Bool = true - private static let prototypeLabel = LabelView() - public var content: ElementContent { // We create this outside of the measurement block so it's called fewer times. let text = displayableAttributedText return ElementContent { constraint, environment -> CGSize in - let label = Self.prototypeLabel - label.update(model: self, text: text, environment: environment, isMeasuring: true) - return label.sizeThatFits(constraint.maximum) + environment.elementMeasurer.accessMeasurementView { label in + label.update(model: self, text: text, environment: environment, isMeasuring: true) + return label.sizeThatFits(constraint.maximum) + } create: { + LabelView() + } } } From cd61a362f4d920eec66d72b89b3e4b9b32ea3904 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 12 Dec 2024 16:03:28 -0800 Subject: [PATCH 2/8] Update to make cache generic --- .../Sources/BlueprintView/BlueprintView.swift | 10 +- .../Sources/Element/UIViewElement.swift | 108 ++++++------------ .../Sources/Measuring/MeasurementCache.swift | 35 ++++++ .../Sources/AttributedLabel.swift | 2 +- 4 files changed, 79 insertions(+), 76 deletions(-) create mode 100644 BlueprintUI/Sources/Measuring/MeasurementCache.swift diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index ee7b1e594..82a15f753 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -39,8 +39,8 @@ public final class BlueprintView: UIView { private var layoutResult: LayoutResultNode? private var sizesThatFit: [SizeConstraint: CGSize] = [:] - - private let viewMeasurer : UIViewElementMeasurer = .init() + + private let viewMeasurer: MeasurementCache = .init() /// A base environment used when laying out and rendering the element tree. /// @@ -526,11 +526,11 @@ public final class BlueprintView: UIView { }() var environment = inherited.merged(prioritizing: environment) - + /// We're a root `BlueprintView`, so pass in the view measurer for our downstream dependencies. - + if environment.inheritedElementMeasurer == nil { - environment.elementMeasurer = self.viewMeasurer + environment.elementMeasurer = viewMeasurer } if let displayScale = window?.screen.scale { diff --git a/BlueprintUI/Sources/Element/UIViewElement.swift b/BlueprintUI/Sources/Element/UIViewElement.swift index fab25165e..795ddc86f 100644 --- a/BlueprintUI/Sources/Element/UIViewElement.swift +++ b/BlueprintUI/Sources/Element/UIViewElement.swift @@ -130,93 +130,61 @@ public struct UIViewElementContext { public var environment: Environment } -/// An private type which caches `UIViewElement` views to be reused for sizing and measurement. -public final class UIViewElementMeasurer { - - /// Provides access to a view in the provided block. - public func accessMeasurementView( - perform : (View) -> Result, - create : () -> View - ) -> Result { - perform(measurementView(create)) - } - - /// Provides the size for the provided element by using a cached measurement view. - func measure( - element: some UIViewElement, - constraint: SizeConstraint, - environment: Environment - ) -> CGSize { - - let bounds = CGRect(origin: .zero, size: constraint.maximum) - - let view = measurementView { - element.makeUIView() - } - - /// Ensure that during measurement / sizing, the inherited `Environment` is available - /// to any child `BlueprintView`s. We must manually wire this property up, as the - /// measurement views are not in the view hierarchy. - view.nativeViewNodeBlueprintEnvironment = environment - defer { view.nativeViewNodeBlueprintEnvironment = nil } - - element.updateUIView(view, with: .init(isMeasuring: true, environment: environment)) - - return element.size(bounds.size, thatFits: view) - } - - private func measurementView(_ create : () -> View) -> View { - - let key = Key( - elementType: ObjectIdentifier(View.self) - ) - - if let existing = views[key] { - return existing as! View - } else { - let new = create() - views[key] = new - return new - } - } - - private var views: [Key: UIView] = [:] - - private struct Key: Hashable { - let elementType: ObjectIdentifier - } -} +extension Environment { + private static let fallback = MeasurementCache() -extension Environment { - - private static let fallback = UIViewElementMeasurer() - - public internal(set) var elementMeasurer : UIViewElementMeasurer { + public internal(set) var elementMeasurer: MeasurementCache { get { if let inheritedElementMeasurer { return inheritedElementMeasurer } else { assertionFailure( """ - WARNING: Blueprint is falling back to a static `UIViewElementMeasurer`, \ + WARNING: Blueprint is falling back to a static `MeasurementCache`, \ which may result in longer object lifetimes than expected. """ ) - + return Self.fallback } } - + set { self[ElementMeasurerKey.self] = newValue } } - - var inheritedElementMeasurer : UIViewElementMeasurer? { - get { self[ElementMeasurerKey.self] } + + var inheritedElementMeasurer: MeasurementCache? { self[ElementMeasurerKey.self] } + + private enum ElementMeasurerKey: EnvironmentKey { + static let defaultValue: MeasurementCache? = nil } - - private enum ElementMeasurerKey : EnvironmentKey { - static let defaultValue: UIViewElementMeasurer? = nil +} + + +extension MeasurementCache { + + /// Provides the size for the provided element by using a cached measurement view. + func measure( + element: ViewElement, + constraint: SizeConstraint, + environment: Environment + ) -> CGSize { + access(type: ViewElement.UIViewType.self) { view in + let bounds = CGRect(origin: .zero, size: constraint.maximum) + + /// Ensure that during measurement / sizing, the inherited `Environment` is available + /// to any child `BlueprintView`s. We must manually wire this property up, as the + /// measurement views are not in the view hierarchy. + + view.nativeViewNodeBlueprintEnvironment = environment + defer { view.nativeViewNodeBlueprintEnvironment = nil } + + element.updateUIView(view, with: .init(isMeasuring: true, environment: environment)) + + return element.size(bounds.size, thatFits: view) + } create: { + element.makeUIView() + } } } diff --git a/BlueprintUI/Sources/Measuring/MeasurementCache.swift b/BlueprintUI/Sources/Measuring/MeasurementCache.swift new file mode 100644 index 000000000..7ad75c718 --- /dev/null +++ b/BlueprintUI/Sources/Measuring/MeasurementCache.swift @@ -0,0 +1,35 @@ +import UIKit + + +public final class MeasurementCache { + + /// Provides access to a view in the provided block. + public func access( + type: Value.Type, + perform: (Value) -> Result, + create: () -> Value + ) -> Result { + perform(cachedValue(create)) + } + + private func cachedValue(_ create: () -> Value) -> Value { + + let key = Key( + elementType: ObjectIdentifier(Value.self) + ) + + if let existing = views[key] { + return existing as! Value + } else { + let new = create() + views[key] = new + return new + } + } + + private var views: [Key: AnyObject] = [:] + + private struct Key: Hashable { + let elementType: ObjectIdentifier + } +} diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index f9bd51557..3040a35eb 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -134,7 +134,7 @@ public struct AttributedLabel: Element, Hashable { let text = displayableAttributedText return ElementContent { constraint, environment -> CGSize in - environment.elementMeasurer.accessMeasurementView { label in + environment.elementMeasurer.access(type: LabelView.self) { label in label.update(model: self, text: text, environment: environment, isMeasuring: true) return label.sizeThatFits(constraint.maximum) } create: { From 775057408565f04dd11ba7c84895222b96994fbb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 13 Dec 2024 00:21:19 -0800 Subject: [PATCH 3/8] more --- .../Sources/BlueprintView/BlueprintView.swift | 3 +- .../Sources/Element/UIViewElement.swift | 33 +---------- .../Sources/Measuring/MeasurementCache.swift | 56 +++++++++++++++++++ 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 82a15f753..eafebd108 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -527,7 +527,8 @@ public final class BlueprintView: UIView { var environment = inherited.merged(prioritizing: environment) - /// We're a root `BlueprintView`, so pass in the view measurer for our downstream dependencies. + /// We're a root `BlueprintView`, so pass in the view measurer for our downstream + /// dependencies if we didn't inherit a measurement cache. if environment.inheritedElementMeasurer == nil { environment.elementMeasurer = viewMeasurer diff --git a/BlueprintUI/Sources/Element/UIViewElement.swift b/BlueprintUI/Sources/Element/UIViewElement.swift index 795ddc86f..0359ca33c 100644 --- a/BlueprintUI/Sources/Element/UIViewElement.swift +++ b/BlueprintUI/Sources/Element/UIViewElement.swift @@ -131,41 +131,10 @@ public struct UIViewElementContext { } -extension Environment { - - private static let fallback = MeasurementCache() - - public internal(set) var elementMeasurer: MeasurementCache { - get { - if let inheritedElementMeasurer { - return inheritedElementMeasurer - } else { - assertionFailure( - """ - WARNING: Blueprint is falling back to a static `MeasurementCache`, \ - which may result in longer object lifetimes than expected. - """ - ) - - return Self.fallback - } - } - - set { self[ElementMeasurerKey.self] = newValue } - } - - var inheritedElementMeasurer: MeasurementCache? { self[ElementMeasurerKey.self] } - - private enum ElementMeasurerKey: EnvironmentKey { - static let defaultValue: MeasurementCache? = nil - } -} - - extension MeasurementCache { /// Provides the size for the provided element by using a cached measurement view. - func measure( + fileprivate func measure( element: ViewElement, constraint: SizeConstraint, environment: Environment diff --git a/BlueprintUI/Sources/Measuring/MeasurementCache.swift b/BlueprintUI/Sources/Measuring/MeasurementCache.swift index 7ad75c718..5237f4e40 100644 --- a/BlueprintUI/Sources/Measuring/MeasurementCache.swift +++ b/BlueprintUI/Sources/Measuring/MeasurementCache.swift @@ -33,3 +33,59 @@ public final class MeasurementCache { let elementType: ObjectIdentifier } } + + +extension Environment { + + private static let fallback = MeasurementCache() + + public internal(set) var elementMeasurer: MeasurementCache { + get { + if let inheritedElementMeasurer { + return inheritedElementMeasurer + } else { + #if DEBUG + do { + /// Set a breakpoint on this `throw` if you'd like to understand where this error is occurring. + /// + /// We throw a caught error so that program execution can continue, and folks can opt + /// in or out of stopping on the error. + throw MeasurementErrors.fallingBackToStaticCache + } catch { + + /// **Warning**: Blueprint is falling back to a static `MeasurementCache`, + /// which will result in prototype measurement values being retained for + /// the lifetime of the application, which can result in memory leaks. + /// + /// If you are seeing this error, ensure you're passing the Blueprint `Environment` + /// properly through your element hierarchies – you should almost _never_ be + /// passing an `.empty` environment to methods, and instead passing an inherited + /// environment which will be passed to you by callers or a parent view controller, + /// screen, or element. + /// + /// To learn more, see https://github.com/square/Blueprint/tree/main/Documentation/TODO.md. + + } + #endif + + return Self.fallback + } + } + + set { + self[ElementMeasurerKey.self] = newValue + } + } + + public enum MeasurementErrors: Error { + case fallingBackToStaticCache + } + + var inheritedElementMeasurer: MeasurementCache? { + self[ElementMeasurerKey.self] + } + + private enum ElementMeasurerKey: EnvironmentKey { + static let defaultValue: MeasurementCache? = nil + } +} From c7f0ad7e6e230cf64a442a3d7b679557ea54eb91 Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Fri, 13 Dec 2024 10:16:10 -0600 Subject: [PATCH 4/8] Renamed `MeasurementCache` to `TypeKeyedCache` Renamed `elementMeasurer` to `viewCache`. TypeKeyedCache now exposes a `value` function for retrieving the value --- .../Sources/BlueprintView/BlueprintView.swift | 6 +- .../Sources/Element/UIViewElement.swift | 50 +++++---------- .../Keys/ViewCacheKey.swift} | 64 ++++--------------- .../Sources/Measuring/TypeKeyedCache.swift | 34 ++++++++++ .../Sources/AttributedLabel.swift | 13 ++-- 5 files changed, 76 insertions(+), 91 deletions(-) rename BlueprintUI/Sources/{Measuring/MeasurementCache.swift => Environment/Keys/ViewCacheKey.swift} (51%) create mode 100644 BlueprintUI/Sources/Measuring/TypeKeyedCache.swift diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index eafebd108..08efdc66a 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -40,7 +40,7 @@ public final class BlueprintView: UIView { private var sizesThatFit: [SizeConstraint: CGSize] = [:] - private let viewMeasurer: MeasurementCache = .init() + private let viewMeasurer: TypeKeyedCache = .init() /// A base environment used when laying out and rendering the element tree. /// @@ -530,8 +530,8 @@ public final class BlueprintView: UIView { /// We're a root `BlueprintView`, so pass in the view measurer for our downstream /// dependencies if we didn't inherit a measurement cache. - if environment.inheritedElementMeasurer == nil { - environment.elementMeasurer = viewMeasurer + if environment.inheritedViewCache == nil { + environment.viewCache = viewMeasurer } if let displayScale = window?.screen.scale { diff --git a/BlueprintUI/Sources/Element/UIViewElement.swift b/BlueprintUI/Sources/Element/UIViewElement.swift index 0359ca33c..a00e6f3d0 100644 --- a/BlueprintUI/Sources/Element/UIViewElement.swift +++ b/BlueprintUI/Sources/Element/UIViewElement.swift @@ -90,13 +90,23 @@ extension UIViewElement { /// Defer to the reused measurement view to provide the size of the element. public var content: ElementContent { - ElementContent { constraint, environment in - environment.elementMeasurer.measure( - element: self, - constraint: constraint, - environment: environment - ) + let view = environment.viewCache.value { + makeUIView() + } + + let bounds = CGRect(origin: .zero, size: constraint.maximum) + + /// Ensure that during measurement / sizing, the inherited `Environment` is available + /// to any child `BlueprintView`s. We must manually wire this property up, as the + /// measurement views are not in the view hierarchy. + + view.nativeViewNodeBlueprintEnvironment = environment + defer { view.nativeViewNodeBlueprintEnvironment = nil } + + updateUIView(view, with: UIViewElementContext(isMeasuring: true, environment: environment)) + + return size(bounds.size, thatFits: view) } } @@ -129,31 +139,3 @@ public struct UIViewElementContext { /// The environment the element is rendered in. public var environment: Environment } - - -extension MeasurementCache { - - /// Provides the size for the provided element by using a cached measurement view. - fileprivate func measure( - element: ViewElement, - constraint: SizeConstraint, - environment: Environment - ) -> CGSize { - access(type: ViewElement.UIViewType.self) { view in - let bounds = CGRect(origin: .zero, size: constraint.maximum) - - /// Ensure that during measurement / sizing, the inherited `Environment` is available - /// to any child `BlueprintView`s. We must manually wire this property up, as the - /// measurement views are not in the view hierarchy. - - view.nativeViewNodeBlueprintEnvironment = environment - defer { view.nativeViewNodeBlueprintEnvironment = nil } - - element.updateUIView(view, with: .init(isMeasuring: true, environment: environment)) - - return element.size(bounds.size, thatFits: view) - } create: { - element.makeUIView() - } - } -} diff --git a/BlueprintUI/Sources/Measuring/MeasurementCache.swift b/BlueprintUI/Sources/Environment/Keys/ViewCacheKey.swift similarity index 51% rename from BlueprintUI/Sources/Measuring/MeasurementCache.swift rename to BlueprintUI/Sources/Environment/Keys/ViewCacheKey.swift index 5237f4e40..5415dbd2c 100644 --- a/BlueprintUI/Sources/Measuring/MeasurementCache.swift +++ b/BlueprintUI/Sources/Environment/Keys/ViewCacheKey.swift @@ -1,48 +1,20 @@ -import UIKit +import Foundation - -public final class MeasurementCache { - - /// Provides access to a view in the provided block. - public func access( - type: Value.Type, - perform: (Value) -> Result, - create: () -> Value - ) -> Result { - perform(cachedValue(create)) - } - - private func cachedValue(_ create: () -> Value) -> Value { - - let key = Key( - elementType: ObjectIdentifier(Value.self) - ) - - if let existing = views[key] { - return existing as! Value - } else { - let new = create() - views[key] = new - return new - } +extension Environment { + private enum ViewCacheKey: EnvironmentKey { + static let defaultValue: TypeKeyedCache? = nil } - private var views: [Key: AnyObject] = [:] + private static let fallback = TypeKeyedCache() - private struct Key: Hashable { - let elementType: ObjectIdentifier + var inheritedViewCache: TypeKeyedCache? { + self[ViewCacheKey.self] } -} - -extension Environment { - - private static let fallback = MeasurementCache() - - public internal(set) var elementMeasurer: MeasurementCache { + public internal(set) var viewCache: TypeKeyedCache { get { - if let inheritedElementMeasurer { - return inheritedElementMeasurer + if let inheritedViewCache { + return inheritedViewCache } else { #if DEBUG do { @@ -50,10 +22,10 @@ extension Environment { /// /// We throw a caught error so that program execution can continue, and folks can opt /// in or out of stopping on the error. - throw MeasurementErrors.fallingBackToStaticCache + throw ViewCacheErrors.fallingBackToStaticCache } catch { - /// **Warning**: Blueprint is falling back to a static `MeasurementCache`, + /// **Warning**: Blueprint is falling back to a static `TypeKeyedCache`, /// which will result in prototype measurement values being retained for /// the lifetime of the application, which can result in memory leaks. /// @@ -73,19 +45,11 @@ extension Environment { } set { - self[ElementMeasurerKey.self] = newValue + self[ViewCacheKey.self] = newValue } } - public enum MeasurementErrors: Error { + private enum ViewCacheErrors: Error { case fallingBackToStaticCache } - - var inheritedElementMeasurer: MeasurementCache? { - self[ElementMeasurerKey.self] - } - - private enum ElementMeasurerKey: EnvironmentKey { - static let defaultValue: MeasurementCache? = nil - } } diff --git a/BlueprintUI/Sources/Measuring/TypeKeyedCache.swift b/BlueprintUI/Sources/Measuring/TypeKeyedCache.swift new file mode 100644 index 000000000..c24d1ed5e --- /dev/null +++ b/BlueprintUI/Sources/Measuring/TypeKeyedCache.swift @@ -0,0 +1,34 @@ +import UIKit + +/// A cache that uses the value's type as the key. +public final class TypeKeyedCache { + + private var views: [Key: AnyObject] = [:] + + // Intentionally internal. Not intended for public instantiation. + init() {} + + // Returns the cached value for the Value type if it exists. If it doesn't + // exist, it creates a new instance of Value, caches it, and returns it. + // + // - Parameter create: A closure that creates a new instance of Value. It + // is only invoked when a cached value does not exist. The created value is + // cached for future usage. + public func value(_ create: () -> Value) -> Value { + let key = Key( + elementType: ObjectIdentifier(Value.self) + ) + + if let existing = views[key] { + return existing as! Value + } else { + let new = create() + views[key] = new + return new + } + } + + private struct Key: Hashable { + let elementType: ObjectIdentifier + } +} diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 3040a35eb..d4f72768c 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -134,12 +134,17 @@ public struct AttributedLabel: Element, Hashable { let text = displayableAttributedText return ElementContent { constraint, environment -> CGSize in - environment.elementMeasurer.access(type: LabelView.self) { label in - label.update(model: self, text: text, environment: environment, isMeasuring: true) - return label.sizeThatFits(constraint.maximum) - } create: { + let label = environment.viewCache.value { LabelView() } + + label.update( + model: self, + text: text, + environment: environment, + isMeasuring: true + ) + return label.sizeThatFits(constraint.maximum) } } From c76225db9e17d3b7430dc5561de157902be0c271 Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Fri, 13 Dec 2024 10:49:23 -0600 Subject: [PATCH 5/8] Fix leak of AccessibilityViewController demo --- SampleApp/Sources/AccessibilityViewController.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SampleApp/Sources/AccessibilityViewController.swift b/SampleApp/Sources/AccessibilityViewController.swift index a91b63272..4c28cfa42 100644 --- a/SampleApp/Sources/AccessibilityViewController.swift +++ b/SampleApp/Sources/AccessibilityViewController.swift @@ -34,8 +34,8 @@ final class AccessibilityViewController: UIViewController { Row { Button( - onTap: { - self.firstTrigger.focus() + onTap: { [weak self] in + self?.firstTrigger.focus() }, wrapping: Label(text: "Focus on First", configure: { label in label.color = .systemBlue @@ -75,9 +75,9 @@ final class AccessibilityViewController: UIViewController { .accessibilityContainer() .inset(uniform: 20) .centered() - .onAppear { + .onAppear { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - self.secondTrigger.focus() + self?.secondTrigger.focus() } } } From 07ffd858a8a7e9a11a0a032c90f731d79e6696a2 Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Fri, 13 Dec 2024 11:12:01 -0600 Subject: [PATCH 6/8] Rename `BlueprintView.viewMeasurer` to `viewCache` --- BlueprintUI/Sources/BlueprintView/BlueprintView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 08efdc66a..39153df19 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -40,7 +40,7 @@ public final class BlueprintView: UIView { private var sizesThatFit: [SizeConstraint: CGSize] = [:] - private let viewMeasurer: TypeKeyedCache = .init() + private let viewCache: TypeKeyedCache = .init() /// A base environment used when laying out and rendering the element tree. /// @@ -531,7 +531,7 @@ public final class BlueprintView: UIView { /// dependencies if we didn't inherit a measurement cache. if environment.inheritedViewCache == nil { - environment.viewCache = viewMeasurer + environment.viewCache = viewCache } if let displayScale = window?.screen.scale { From 36fff04d2571a1640207cbe8f5df86ce6e125f4f Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Fri, 13 Dec 2024 11:27:46 -0600 Subject: [PATCH 7/8] Fix leak in TextLinkViewController demo --- SampleApp/Sources/TextLinkViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SampleApp/Sources/TextLinkViewController.swift b/SampleApp/Sources/TextLinkViewController.swift index 7bf3b1960..a37b819b0 100644 --- a/SampleApp/Sources/TextLinkViewController.swift +++ b/SampleApp/Sources/TextLinkViewController.swift @@ -33,8 +33,8 @@ final class TextLinkViewController: UIViewController { return Column(alignment: .fill, minimumSpacing: 20) { label Label(text: "Custom link handling:") - label.onLinkTapped { - self.presentAlert(message: $0.absoluteString) + label.onLinkTapped { [weak self] in + self?.presentAlert(message: $0.absoluteString) } AttributedLabel(attributedText: NSAttributedString(string: "https://squareup.com")) { From 5a464a5f674a852cdbcafc41e4d38a0714f26252 Mon Sep 17 00:00:00 2001 From: Robert MacEachern Date: Fri, 13 Dec 2024 11:54:36 -0600 Subject: [PATCH 8/8] Lazy view cache --- BlueprintUI/Sources/BlueprintView/BlueprintView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index 39153df19..9a08deb78 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -40,7 +40,7 @@ public final class BlueprintView: UIView { private var sizesThatFit: [SizeConstraint: CGSize] = [:] - private let viewCache: TypeKeyedCache = .init() + private lazy var viewCache: TypeKeyedCache = .init() /// A base environment used when laying out and rendering the element tree. ///