SetNeedsDisplay-like properties

It would be really useful to have a native swift way to make properties/functions which behave similarly to the setNeedsDisplay/drawRect & setNeedsLayout/layoutSubviews pairs. That is, you have a public property or function which registers the intent to call the other one, but doesn't call it immediately.

This lets you register the intent multiple times as changes are made, but only call the actual function once for the batch of changes. Super useful for anything that needs to do an expensive re-calculation as changes are made!

It is really tricky to get the necessary dance correct for custom implementations with this behavior, but it seems like it would be relatively easy to automate. The only part that I haven't figured out is how to do it using only native swift constructs (since Swift doesn't have a concept of a run-loop itself). Thoughts?

I'd like us to keep this functionality in mind as we design Swift's concurrency primitives...

This current can be done with a property wrapper:

@propertyWrapper class SetterResponsive<T, U> {
    var wrappedValue: (U, T)!
    private var method: (U) -> () -> Void
    private var runLoopObserver: CFRunLoopObserver!
    private var runLoop: CFRunLoop
    
    init(_ method: @escaping (U) -> () -> Void) {
        self.method = method
        runLoop = CFRunLoopGetCurrent()
        runLoopObserver = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, CFRunLoopActivity.beforeSources.rawValue, true, 0) { [unowned self] _, _ in
            guard let value = self.wrappedValue else { return }
            self.method(value.0)()
        }
        CFRunLoopAddObserver(runLoop, runLoopObserver, .defaultMode)
    }
    
    deinit { CFRunLoopRemoveObserver(runLoop, runLoopObserver, .defaultMode) }
}

It could use the CFRunLoopObserver pattern and listen for a run loop entry, executing the provided method on the next callback execution. For, example:

class Deferred {
    @SetterResponsive(_layoutIfNeededComingFromRunLoopSource) private var needsLayoutUpdate: (Deferred, Bool)!
    
    init() { needsLayoutUpdate = (self, false) }
    
    func layoutIfNeeded() { print("Laying out!") }
    
    private func _layoutIfNeededComingFromRunLoopSource() {
        guard needsLayoutUpdate.1 else { return }
        needsLayoutUpdate.1 = false
        layoutIfNeeded()
    }
    
    func setNeedsLayout() {
        needsLayoutUpdate.1 = true
    }
}

would call _layoutIfNeededComingFromRunLoopSource the next RunLoop cycle after setNeedsLayout is called. One major thing to note, the wrapper would need to be defined in Foundation, as RunLoop is only available from Foundation.

I agree this isn't exactly convenient though, and requires a bit of boilerplate (one target-boolean pair per method/method combination)

Personally, I'd like to see an @functionWrapper language feature that allowed us to define custom execution based on a direct method call, cutting out the need to store the boolean & having a private inner method. Something like:

@functionWrapper class RunLoopMethodDispatch<T> {
    weak var wrappedTarget: T? // Required by `@functionWrapper`
    private var method: (T) -> () -> Void
    private var runLoopObserver: CFRunLoopObserver?
    private var runLoop: CFRunLoop?
    private var currentWhen: When
    
    enum When {
        case immediately
        case nextRunLoopCycle
    }
    
    init(_ method: @escaping (T) -> () -> Void, when: When) {
        self.method = method
        currentWhen = when
        if when == .nextRunLoopCycle {
            runLoop = CFRunLoopGetCurrent()
            runLoopObserver = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, CFRunLoopActivity.beforeSources.rawValue, true, 0) { [unowned self] _, _ in
                guard let target = self.wrappedTarget else { return }
                self.method(target)()
            }
            CFRunLoopAddObserver(runLoop, runLoopObserver, .defaultMode)
        }
    }
    
    deinit { CFRunLoopRemoveObserver(runLoop, runLoopObserver, .defaultMode) }
    
    func wrappedMethodInvoked() { // Required by `@functionWrapper`
        guard currentWhen == .immediately else { return } // We'll dispatch on a run loop cycle, NOT when the method is invoked
        if let target = wrappedTarget {
            method(target)()
        }
    }
}

where wrappedMethodInvoked would be called whenever the wrapped method was called:

class Deferred {
    ...

    @RunLoopMethodDispatch(layoutIfNeeded, when: .nextRunLoopCycle)
    func setNeedsLayout() {
        // Do something in here... maybe pre-setup?
    }
}
3 Likes
Terms of Service

Privacy Policy

Cookie Policy