Skip to content

Commit bec8cc8

Browse files
authored
[feat]: add imperative environment customization support (#231)
1 parent eb3f60b commit bec8cc8

File tree

2 files changed

+197
-11
lines changed

2 files changed

+197
-11
lines changed

Diff for: ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift

+105-4
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ extension ViewEnvironmentPropagating {
119119
public var environment: ViewEnvironment {
120120
var environment = environmentAncestor?.environment ?? .empty
121121

122+
for storedCustomization in customizations {
123+
storedCustomization.customization(&environment)
124+
}
125+
122126
if let observing = self as? ViewEnvironmentObserving {
123127
observing.customize(environment: &environment)
124128
}
@@ -167,7 +171,7 @@ extension ViewEnvironmentPropagating {
167171
///
168172
@_spi(ViewEnvironmentWiring)
169173
public func addEnvironmentNeedsUpdateObserver(
170-
_ onNeedsUpdate: @escaping (ViewEnvironment) -> Void
174+
_ onNeedsUpdate: @escaping ViewEnvironmentUpdateObservation
171175
) -> ViewEnvironmentUpdateObservationLifetime {
172176
let object = ViewEnvironmentUpdateObservationKey()
173177
needsUpdateObservers[object] = onNeedsUpdate
@@ -176,6 +180,31 @@ extension ViewEnvironmentPropagating {
176180
}
177181
}
178182

183+
/// Adds a `ViewEnvironment` customization to this node.
184+
///
185+
/// These customizations will occur before the node's `customize(environment:)` in cases where
186+
/// this node conforms to `ViewEnvironmentObserving`, and will occur the order in which they
187+
/// were added.
188+
///
189+
/// The customization will only be active for as long as the returned lifetime is retained or
190+
/// until `remove()` is called on it.
191+
///
192+
@_spi(ViewEnvironmentWiring)
193+
public func addEnvironmentCustomization(
194+
_ customization: @escaping ViewEnvironmentCustomization
195+
) -> ViewEnvironmentCustomizationLifetime {
196+
let storedCustomization = StoredViewEnvironmentCustomization(customization: customization)
197+
customizations.append(storedCustomization)
198+
return .init { [weak self] in
199+
guard let self,
200+
let index = self.customizations.firstIndex(where: { $0 === storedCustomization })
201+
else {
202+
return
203+
}
204+
self.customizations.remove(at: index)
205+
}
206+
}
207+
179208
/// The `ViewEnvironment` propagation ancestor.
180209
///
181210
/// This describes the ancestor that the `ViewEnvironment` is inherited from.
@@ -348,20 +377,26 @@ public typealias ViewEnvironmentUpdateObservation = (ViewEnvironment) -> Void
348377
public final class ViewEnvironmentUpdateObservationLifetime {
349378
/// Removes the observation.
350379
///
351-
/// This is called in `deinit`.
380+
/// The observation is removed when the lifetime is de-initialized if this function was not
381+
/// called before then.
352382
///
353383
public func remove() {
384+
guard let onRemove else {
385+
assertionFailure("Environment update observation was already removed")
386+
return
387+
}
388+
self.onRemove = nil
354389
onRemove()
355390
}
356391

357-
private let onRemove: () -> Void
392+
private var onRemove: (() -> Void)?
358393

359394
init(onRemove: @escaping () -> Void) {
360395
self.onRemove = onRemove
361396
}
362397

363398
deinit {
364-
remove()
399+
onRemove?()
365400
}
366401
}
367402

@@ -370,6 +405,7 @@ private enum ViewEnvironmentPropagatingNSObjectAssociatedKeys {
370405
static var needsUpdateObservers = NSObject()
371406
static var ancestorOverride = NSObject()
372407
static var descendantsOverride = NSObject()
408+
static var customizations = NSObject()
373409
}
374410

375411
extension ViewEnvironmentPropagating {
@@ -432,3 +468,68 @@ extension ViewEnvironmentPropagating {
432468
}
433469

434470
private class ViewEnvironmentUpdateObservationKey: NSObject {}
471+
472+
/// A closure that customizes the `ViewEnvironment` as it flows through a propagation node.
473+
///
474+
public typealias ViewEnvironmentCustomization = (inout ViewEnvironment) -> Void
475+
476+
/// Describes the lifetime of a `ViewEnvironment` customization.
477+
///
478+
/// This customization will be removed when `remove()` is called or the lifetime token is
479+
/// de-initialized.
480+
///
481+
/// ## SeeAlso ##
482+
/// - `addEnvironmentCustomization(_:)`
483+
///
484+
public final class ViewEnvironmentCustomizationLifetime {
485+
/// Removes the customization.
486+
///
487+
/// The customization is removed when the lifetime is de-initialized if this function was not
488+
/// called before then.
489+
///
490+
public func remove() {
491+
guard let onRemove else {
492+
assertionFailure("Environment customization was already removed")
493+
return
494+
}
495+
self.onRemove = nil
496+
onRemove()
497+
}
498+
499+
private var onRemove: (() -> Void)?
500+
501+
init(onRemove: @escaping () -> Void) {
502+
self.onRemove = onRemove
503+
}
504+
505+
deinit {
506+
onRemove?()
507+
}
508+
}
509+
510+
extension ViewEnvironmentPropagating {
511+
fileprivate var customizations: [StoredViewEnvironmentCustomization] {
512+
get {
513+
objc_getAssociatedObject(
514+
self,
515+
&AssociatedKeys.customizations
516+
) as? [StoredViewEnvironmentCustomization] ?? []
517+
}
518+
set {
519+
objc_setAssociatedObject(
520+
self,
521+
&AssociatedKeys.customizations,
522+
newValue,
523+
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
524+
)
525+
}
526+
}
527+
}
528+
529+
private final class StoredViewEnvironmentCustomization {
530+
var customization: ViewEnvironmentCustomization
531+
532+
init(customization: @escaping ViewEnvironmentCustomization) {
533+
self.customization = customization
534+
}
535+
}

Diff for: ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift

+92-7
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ final class ViewEnvironmentObservingTests: XCTestCase {
191191

192192
var leafEnvironmentDidChangeCallCount = 0
193193
let leafNode = ViewEnvironmentPropagationNode(
194-
environmentAncestor: { [weak viewController] in
194+
environmentAncestor: { [weak viewController] in
195195
viewController
196196
},
197197
environmentDidChange: { _ in
@@ -226,22 +226,22 @@ final class ViewEnvironmentObservingTests: XCTestCase {
226226

227227
func test_descendant_customFlow() {
228228
let descendant = TestViewEnvironmentObservingViewController()
229-
229+
230230
let viewController = TestViewEnvironmentObservingViewController()
231231
viewController.environmentDescendantsOverride = { [descendant] }
232-
232+
233233
viewController.applyEnvironmentIfNeeded()
234234
descendant.applyEnvironmentIfNeeded()
235235
XCTAssertFalse(viewController.needsEnvironmentUpdate)
236236
XCTAssertFalse(descendant.needsEnvironmentUpdate)
237-
237+
238238
// With no ancestor configured the descendant should not respond to needing update
239239
viewController.setNeedsEnvironmentUpdate()
240240
XCTAssertTrue(viewController.needsEnvironmentUpdate)
241241
XCTAssertFalse(descendant.needsEnvironmentUpdate)
242-
242+
243243
// With an ancestor defined the VC should respond to needing update
244-
244+
245245
descendant.environmentAncestorOverride = { [weak viewController] in
246246
viewController
247247
}
@@ -318,12 +318,97 @@ final class ViewEnvironmentObservingTests: XCTestCase {
318318
XCTAssertEqual(observedEnvironments.count, 2)
319319
XCTAssertEqual(expectedTestContext, observedEnvironments.last?.testContext)
320320

321-
_ = observation // Suppress warning about variable never being read
321+
withExtendedLifetime(observation) {}
322322
observation = nil
323323

324324
container.setNeedsEnvironmentUpdate()
325325
XCTAssertEqual(observedEnvironments.count, 2)
326326
}
327+
328+
// MARK: - Customizations
329+
330+
func test_customization() throws {
331+
let viewController = TestViewEnvironmentObservingViewController()
332+
333+
// Customizations should be respected as long as the lifetime exists.
334+
do {
335+
var customizationLifetime: ViewEnvironmentCustomizationLifetime? = viewController
336+
.addEnvironmentCustomization {
337+
$0.testContext.number = 200
338+
}
339+
340+
XCTAssertEqual(viewController.environment.testContext.number, 200)
341+
342+
withExtendedLifetime(customizationLifetime) {}
343+
customizationLifetime = nil
344+
345+
// Customization should be removed when lifetime is deallocated
346+
XCTAssertEqual(
347+
viewController.environment.testContext.number,
348+
TestContextKey.defaultValue.number
349+
)
350+
}
351+
352+
// Customizations should be respected until `remove()` is called on the lifetime.
353+
do {
354+
var customizationLifetime: ViewEnvironmentCustomizationLifetime? = viewController
355+
.addEnvironmentCustomization {
356+
$0.testContext.number = 200
357+
}
358+
359+
XCTAssertEqual(viewController.environment.testContext.number, 200)
360+
361+
customizationLifetime?.remove()
362+
363+
// Customization should be removed when lifetime is deallocated
364+
XCTAssertEqual(
365+
viewController.environment.testContext.number,
366+
TestContextKey.defaultValue.number
367+
)
368+
369+
withExtendedLifetime(customizationLifetime) {}
370+
customizationLifetime = nil
371+
}
372+
373+
// Customizations should occur in the order they are added
374+
do {
375+
var customization1Lifetime: ViewEnvironmentCustomizationLifetime? = viewController
376+
.addEnvironmentCustomization {
377+
$0.testContext.number = 100
378+
}
379+
380+
var customization2Lifetime: ViewEnvironmentCustomizationLifetime? = viewController
381+
.addEnvironmentCustomization {
382+
$0.testContext.number = 200
383+
}
384+
385+
XCTAssertEqual(viewController.environment.testContext.number, 200)
386+
387+
withExtendedLifetime(customization1Lifetime) {}
388+
customization1Lifetime = nil
389+
withExtendedLifetime(customization2Lifetime) {}
390+
customization2Lifetime = nil
391+
}
392+
393+
// Customizations should favor the nodes customizations if present
394+
do {
395+
viewController.customizeEnvironment = {
396+
$0.testContext.number = 300
397+
}
398+
399+
var customizationLifetime: ViewEnvironmentCustomizationLifetime? = viewController
400+
.addEnvironmentCustomization {
401+
$0.testContext.number = 200
402+
$0.testContext.string = "200"
403+
}
404+
405+
XCTAssertEqual(viewController.environment.testContext.number, 300)
406+
XCTAssertEqual(viewController.environment.testContext.string, "200")
407+
408+
withExtendedLifetime(customizationLifetime) {}
409+
customizationLifetime = nil
410+
}
411+
}
327412
}
328413

329414
// MARK: - Helpers

0 commit comments

Comments
 (0)