-
Notifications
You must be signed in to change notification settings - Fork 44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[feat]: add imperative environment customization support #231
Changes from 5 commits
c67b999
bc6dfa7
b3b6e32
a693580
ae66e1e
7242f1f
5b7a48d
39d493d
8e7df56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -119,6 +119,10 @@ extension ViewEnvironmentPropagating { | |
public var environment: ViewEnvironment { | ||
var environment = environmentAncestor?.environment ?? .empty | ||
|
||
for storedCustomization in customizations { | ||
storedCustomization.customization(&environment) | ||
} | ||
|
||
if let observing = self as? ViewEnvironmentObserving { | ||
observing.customize(environment: &environment) | ||
} | ||
|
@@ -167,7 +171,7 @@ extension ViewEnvironmentPropagating { | |
/// | ||
@_spi(ViewEnvironmentWiring) | ||
public func addEnvironmentNeedsUpdateObserver( | ||
_ onNeedsUpdate: @escaping (ViewEnvironment) -> Void | ||
_ onNeedsUpdate: @escaping ViewEnvironmentUpdateObservation | ||
) -> ViewEnvironmentUpdateObservationLifetime { | ||
let object = ViewEnvironmentUpdateObservationKey() | ||
needsUpdateObservers[object] = onNeedsUpdate | ||
|
@@ -176,6 +180,31 @@ extension ViewEnvironmentPropagating { | |
} | ||
} | ||
|
||
/// Adds a `ViewEnvironment` customization to this node. | ||
/// | ||
/// These customizations will occur before the node's `customize(environment:)` in cases where | ||
/// this node conforms to `ViewEnvironmentObserving`, and will occur the order in which they | ||
/// were added. | ||
/// | ||
/// The customization will only be active for as long as the returned lifetime is retained or | ||
/// until `remove()` is called on it. | ||
/// | ||
@_spi(ViewEnvironmentWiring) | ||
public func addEnvironmentCustomization( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just to better my own understanding – should this trigger the 'setNeedsEnvironmentUpdate' (or whatever its name is) logic, or does that responsibility lie at a different level? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah—I'm fairly torn on whether or not this should automatically call The current shape also makes it a bit easier to ensure events happen at more appropriate times. For example, if you were to be adding a customization to a new child VC in a container VC, you might have something like this: addChild(childVC)
customizationLifetime = childVC
.addEnvironmentCustomization(makeCustomization(for: column))
if let view = viewIfLoaded {
view.add(childVC.view)
}
childVC.didMove(toParent: self)
childVC.setNeedsEnvironmentUpdate() This order of operations means:
I'm very open to automatically calling |
||
_ customization: @escaping ViewEnvironmentCustomization | ||
) -> ViewEnvironmentCustomizationLifetime { | ||
let storedCustomization = StoredViewEnvironmentCustomization(customization: customization) | ||
customizations.append(storedCustomization) | ||
return .init { [weak self] in | ||
guard let self, | ||
let index = self.customizations.firstIndex(where: { $0 === storedCustomization }) | ||
else { | ||
return | ||
} | ||
self.customizations.remove(at: index) | ||
} | ||
} | ||
|
||
/// The `ViewEnvironment` propagation ancestor. | ||
/// | ||
/// This describes the ancestor that the `ViewEnvironment` is inherited from. | ||
|
@@ -351,17 +380,21 @@ public final class ViewEnvironmentUpdateObservationLifetime { | |
/// This is called in `deinit`. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this still true? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, yeah I should add some nuance to this comment. The closure provided for
|
||
/// | ||
public func remove() { | ||
guard let onRemove else { | ||
preconditionFailure("Environment customization was already removed") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this seems like an assert warning about unexpected behavior may be more appropriate. this is idempotent, right (other than the precondition)? will something break horribly if it's called more than once? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Asserting is fine with me. I think the idea here is that we consider it a programmer error to try to remove a customization more than once, even if it is effectively idempotent. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh whoops, I added this to the wrong lifetime 🤦
|
||
} | ||
self.onRemove = nil | ||
onRemove() | ||
} | ||
|
||
private let onRemove: () -> Void | ||
private var onRemove: (() -> Void)? | ||
|
||
init(onRemove: @escaping () -> Void) { | ||
self.onRemove = onRemove | ||
} | ||
|
||
deinit { | ||
remove() | ||
onRemove?() | ||
} | ||
} | ||
|
||
|
@@ -370,6 +403,7 @@ private enum ViewEnvironmentPropagatingNSObjectAssociatedKeys { | |
static var needsUpdateObservers = NSObject() | ||
static var ancestorOverride = NSObject() | ||
static var descendantsOverride = NSObject() | ||
static var customizations = NSObject() | ||
} | ||
|
||
extension ViewEnvironmentPropagating { | ||
|
@@ -432,3 +466,62 @@ extension ViewEnvironmentPropagating { | |
} | ||
|
||
private class ViewEnvironmentUpdateObservationKey: NSObject {} | ||
|
||
/// A closure that customizes the `ViewEnvironment` as it flows through a propagation node. | ||
/// | ||
public typealias ViewEnvironmentCustomization = (inout ViewEnvironment) -> Void | ||
|
||
/// Describes the lifetime of a `ViewEnvironment` customization. | ||
/// | ||
/// This customization will be removed when `remove()` is called or the lifetime token is | ||
/// de-initialized. | ||
/// | ||
/// ## SeeAlso ## | ||
/// - `addEnvironmentCustomization(_:)` | ||
/// | ||
public final class ViewEnvironmentCustomizationLifetime { | ||
/// Removes the observation. | ||
/// | ||
/// This is called in `deinit`. | ||
/// | ||
public func remove() { | ||
onRemove() | ||
} | ||
|
||
private let onRemove: () -> Void | ||
n8chur marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
init(onRemove: @escaping () -> Void) { | ||
self.onRemove = onRemove | ||
} | ||
|
||
deinit { | ||
remove() | ||
} | ||
} | ||
|
||
extension ViewEnvironmentPropagating { | ||
fileprivate var customizations: [StoredViewEnvironmentCustomization] { | ||
get { | ||
objc_getAssociatedObject( | ||
self, | ||
&AssociatedKeys.customizations | ||
) as? [StoredViewEnvironmentCustomization] ?? [] | ||
} | ||
set { | ||
objc_setAssociatedObject( | ||
self, | ||
&AssociatedKeys.customizations, | ||
newValue, | ||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC | ||
) | ||
} | ||
} | ||
} | ||
|
||
private final class StoredViewEnvironmentCustomization { | ||
var customization: ViewEnvironmentCustomization | ||
|
||
init(customization: @escaping ViewEnvironmentCustomization) { | ||
self.customization = customization | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
more of a general question about the changeset – is this alternate API mainly for convenience? curious what the motivating examples are (if they exist).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's mainly for convenience. The motivating example is support for what we call "pane-based size class", where a split container VC is able to override the horizontal size class for a particular pane based on the available horizontal width of that pane.
Without this change we'd either need to insert a node between the split container VC and it's children, or introduce a wrapper VC around those children to perform the environment mutation.