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 94ec5cccf..9a08deb78 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -40,6 +40,8 @@ public final class BlueprintView: UIView { private var sizesThatFit: [SizeConstraint: CGSize] = [:] + private lazy var viewCache: TypeKeyedCache = .init() + /// A base environment used when laying out and rendering the element tree. /// /// Some keys will be overridden with the traits from the view itself. Eg, `windowSize`, `safeAreaInsets`, etc. @@ -525,6 +527,13 @@ 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 we didn't inherit a measurement cache. + + if environment.inheritedViewCache == nil { + environment.viewCache = viewCache + } + 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..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 - UIViewElementMeasurer.shared.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,54 +139,3 @@ public struct UIViewElementContext { /// The environment the element is rendered in. public var environment: Environment } - -/// 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() - - /// 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(for: element) - - /// 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) - } - - func measurementView(for element: ViewElement) -> ViewElement.UIViewType { - let key = Key( - elementType: ObjectIdentifier(ViewElement.self) - ) - - if let existing = views[key] { - return existing as! ViewElement.UIViewType - } else { - let new = element.makeUIView() - views[key] = new - return new - } - } - - private var views: [Key: UIView] = [:] - - private struct Key: Hashable { - let elementType: ObjectIdentifier - } -} - diff --git a/BlueprintUI/Sources/Environment/Keys/ViewCacheKey.swift b/BlueprintUI/Sources/Environment/Keys/ViewCacheKey.swift new file mode 100644 index 000000000..5415dbd2c --- /dev/null +++ b/BlueprintUI/Sources/Environment/Keys/ViewCacheKey.swift @@ -0,0 +1,55 @@ +import Foundation + +extension Environment { + private enum ViewCacheKey: EnvironmentKey { + static let defaultValue: TypeKeyedCache? = nil + } + + private static let fallback = TypeKeyedCache() + + var inheritedViewCache: TypeKeyedCache? { + self[ViewCacheKey.self] + } + + public internal(set) var viewCache: TypeKeyedCache { + get { + if let inheritedViewCache { + return inheritedViewCache + } 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 ViewCacheErrors.fallingBackToStaticCache + } catch { + + /// **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. + /// + /// 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[ViewCacheKey.self] = newValue + } + } + + private enum ViewCacheErrors: Error { + case fallingBackToStaticCache + } +} 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 a35dc24ae..d4f72768c 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -128,16 +128,22 @@ 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) + let label = environment.viewCache.value { + LabelView() + } + + label.update( + model: self, + text: text, + environment: environment, + isMeasuring: true + ) return label.sizeThatFits(constraint.maximum) } } 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() } } } 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")) {