swiftuikitcombinerunloop

Is there a way to specify the run loop & mode to to receive elements from a publisher


I can specify the scheduler as RunLoop.main, but I could not find a native way to provide the associated RunLoop.Mode mode to receive elements from a publisher.

Why do I need this: I'm updating a tableView cell from my publisher but the UI does not update if the user is scrolling, it then updates as soon as the user interaction or scroll stops. This is a known behaviour for scrollViews but I want my content to be displayed as soon as possible, and being able to specify the run loop tracking mode would fix this.

Combine API: I do not think the receive(on:options:) method have any matching options to provide this. I think internally, if I call receive(on:RunLoop.main) then RunLoop.main.perform { } is called. This perform method can take the mode as parameter but this is not exposed to the Combine API.


Current Idea: To go around this I could do the perform action myself and not use the Combine API, so instead of doing this:

cancellable = stringFuture.receive(on: RunLoop.main) // I cannot specify the mode here
                          .sink { string in
    cell.textLabel.text = string
}

I could do this:

cancellable = stringFuture.sink { string in
    RunLoop.main.perform(inModes: [RunLoop.Mode.common]) { // I can specify it here
        cell.textLabel.text = string
    }
}

But this is not ideal.

Ideal Solution: I was wondering how could I wrap this into my own implementation of a publisher function to have something like this:

cancellable = stringFuture.receive(on: RunLoop.main, inMode: RunLoop.Mode.common)
                          .sink { string in
    cell.textLabel.text = string
}

Were the API of this function could be something like this:

extension Publisher {
    public func receive(on runLoop: RunLoop, inMode: RunLoop.Mode) -> AnyPublisher<Future.Output, Future.Failure> {

        // How to implement this?

    }
}

Solution

  • Actually what you've requested is custom Scheduler, because RunLoop is a Scheduler and running it in specific mode, instead of .default, is just additional configuration of that scheduler.

    I think that Apple will add such possibility in their RunLoop scheduler in some of next updates, but for now the following simple custom scheduler that wraps RunLoop works for me. Hope it would be helpful for you.

    Usage:

    .receive(on: MyScheduler(runLoop: RunLoop.main, modes: [RunLoop.Mode(rawValue: "myMode")]))
    

    or

    .delay(for: 10.0, scheduler: MyScheduler(runLoop: RunLoop.main, modes: [.common]))
    

    Scheduler code:

    struct MyScheduler: Scheduler {
        var runLoop: RunLoop
        var modes: [RunLoop.Mode] = [.default]
    
        func schedule(after date: RunLoop.SchedulerTimeType, interval: RunLoop.SchedulerTimeType.Stride,
                        tolerance: RunLoop.SchedulerTimeType.Stride, options: Never?,
                        _ action: @escaping () -> Void) -> Cancellable {
            let timer = Timer(fire: date.date, interval: interval.magnitude, repeats: true) { timer in
                action()
            }
            for mode in modes {
                runLoop.add(timer, forMode: mode)
            }
            return AnyCancellable {
                timer.invalidate()
            }
        }
    
        func schedule(after date: RunLoop.SchedulerTimeType, tolerance: RunLoop.SchedulerTimeType.Stride,
                        options: Never?, _ action: @escaping () -> Void) {
            let timer = Timer(fire: date.date, interval: 0, repeats: false) { timer in
                timer.invalidate()
                action()
            }
            for mode in modes {
                runLoop.add(timer, forMode: mode)
            }
        }
    
        func schedule(options: Never?, _ action: @escaping () -> Void) {
            runLoop.perform(inModes: modes, block: action)
        }
    
        var now: RunLoop.SchedulerTimeType { RunLoop.SchedulerTimeType(Date()) }
        var minimumTolerance: RunLoop.SchedulerTimeType.Stride { RunLoop.SchedulerTimeType.Stride(0.1) }
    
        typealias SchedulerTimeType = RunLoop.SchedulerTimeType
        typealias SchedulerOptions = Never
    }