How to Set Up Multiple Combine Timer Publishers

Create a Timer Publisher using Swift Combine

Here you have an example of a Combine timer. I am using a global, but of course you should use whatever is applicable to your scenario (environmentObject, State, etc).

import SwiftUI
import Combine

class MyTimer {
let currentTimePublisher = Timer.TimerPublisher(interval: 1.0, runLoop: .main, mode: .default)
let cancellable: AnyCancellable?

init() {
self.cancellable = currentTimePublisher.connect() as? AnyCancellable
}

deinit {
self.cancellable?.cancel()
}
}

let timer = MyTimer()

struct Clock : View {
@State private var currentTime: Date = Date()

var body: some View {
VStack {
Text("\(currentTime)")
}
.onReceive(timer.currentTimePublisher) { newCurrentTime in
self.currentTime = newCurrentTime
}
}
}

Swift Combine - combining publishers without waiting for all publishers to emit first element

You can use prepend(…) to prepend values to the beginning of a publisher.

Here's a version of your code that will prepend nil to both publishers.

let timer = Timer.publish(every: 10, on: .current, in: .common).autoconnect()
let anotherPub: AnyPublisher<Int, Never> = Just(10).delay(for: 5, scheduler: RunLoop.main).eraseToAnyPublisher()

Publishers.CombineLatest(
timer.map(Optional.init).prepend(nil),
anotherPub.map(Optional.init).prepend(nil)
)
.filter { $0 != nil && $1 != nil } // Filter the event when both are nil values
.sink(receiveValue: { (timer, val) in
print("Hello! \(timer) \(val)")
})

Timer publisher with initial fire now

Alternatively to creating another publisher, you can simply prepend to your timer publisher:

Timer
.publish(every: 10, on: .main, in: .common)
.autoconnect()
.prepend(Date())
.map { _ in ... }
.sink(receiveValue: { [weak self] in
...
})
.store(in: &subscriptions)

The above code has the same effect of publishing a value right away, and let the timer publish the other values.

Swift Combine: Using timer publisher in an observable object

This is a bit different to your original but nothing important is changed I hope.

import Combine
import SwiftUI

class TimerViewModel: ObservableObject {
private var assignCancellable: AnyCancellable? = nil

@Published var tick: String = "0:0:0"

init() {
assignCancellable = Timer.publish(every: 1.0, on: .main, in: .default)
.autoconnect()
.map { String(describing: $0) }
.assign(to: \TimerViewModel.tick, on: self)
}
}

struct ContentView: View {
@State private var currentTime: String = "Initial"
@ObservedObject var viewModel = TimerViewModel()

var body: some View {
VStack {
Text(currentTime)
Text(viewModel.tick) // why doesn't this work?
}
.onReceive(Timer.publish(every: 0.9, on: .main, in: .default).autoconnect(),
perform: {
self.currentTime = String(describing: $0)
}
)
}
}

I made viewModel an ObservedObject just to simplify the code.

The Timer.publish method along with autoconnect make Timer easier to use. I have found that using the same publisher with multiple subscribers causes problems as the first cancel kills the publisher.

I removed the deinit() as the cancel seems to be implicit for subscribers.

There was an interference between updates from onReceive and viewModel but changing the onReceive to 0.9 fixed that.

Finally I have discovered that the print() method in Combine is very useful for watching pipelines.

How do you run a Swift Combine Publisher for a certain amount of time?

This operator will do the trick.

import PlaygroundSupport
import Foundation
import Combine

let page = PlaygroundPage.current
page.needsIndefiniteExecution = true

extension Publisher {
func stopAfter<S>(_ interval: S.SchedulerTimeType.Stride, tolerance: S.SchedulerTimeType.Stride? = nil, scheduler: S, options: S.SchedulerOptions? = nil) -> AnyPublisher<Output, Failure> where S: Scheduler {
prefix(untilOutputFrom: Just(()).delay(for: interval, tolerance: tolerance, scheduler: scheduler, options: nil))
.eraseToAnyPublisher()
}
}

let source = Timer.publish(every: 1, tolerance: nil, on: RunLoop.main, in: .default, options: nil)
.autoconnect()
.eraseToAnyPublisher()

let cancellable = source
.stopAfter(10, scheduler: DispatchQueue.main)
.sink(receiveValue: { print($0) })

Combine Timer.TimerPublisher not starting

You have to call autoconnect() on the Timer publisher to fire it.

private init() {
NSLog("TokenObserver.init()")
self.cancellable = self.publisher.autoconnect().sink(receiveCompletion: { completion in
NSLog("TokenObserver \(completion)")
}, receiveValue: { date in
NSLog("TokenObserver timestamp=" + ISO8601DateFormatter().string(from: date))
})
}

Observe singleton timer value changes with Publisher in Combine

I've ended up using the following solution, combining @heckj suggestion and this one from @Mykel.

What I did was separating the AnyCancellable from the TimerPublishers by saving them in specific dictionaries of SingletonTimerManager.

Then, every time an ItemView is declared, I instantiate an autoconnected @State TimerPublisher. Every Timer instance now runs in the .common RunLoop, with a 0.5 tolerance to better help the perfomance as suggested by Paul here: Triggering events repeatedly using a timer

During the .onAppear() call of the ItemView, if a publisher with the same itemId already exists in SingletonTimerManager, I just assign that publisher to the one of my view.

Then I handle it like in @Mykel solution, with start and stopping both ItemView's publisher and SingletonTimerManager publisher.

The secondsPassed are shown in a text stored inside @State var seconds, which gets updated with a onReceive() attached to the ItemView's publisher.

I know that I'm probably creating too many publishers with this solution and I can't pinpoint exactly what happens when copying a publisher variable into another, but overall perfomance is much better now.

Sample Code:

SingletonTimerManager

class SingletonTimerManager {
static let singletonTimerManager = SingletonTimerManager()

var secondsPassed: [String: Int]
var cancellables: [String:AnyCancellable]
var publishers: [String: TimerPublisher]

func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.publisher[itemId] = Timer
.publish(every: 1, tolerance: 0.5, on: .main, in: .common)
self.cancellables[itemId] = self.publisher[itemId]!.autoconnect().sink(receiveValue: {_ in self.secondsPassed[itemId] += 1})
}

func isTimerValid(_ itemId: String) -> Bool {
if(self.cancellables[itemId] != nil && self.publishers[itemId] != nil) {
return true
}
return false
}
}

ContentView

struct ContentView: View {

var itemIds: [String]

var body: some View {
VStack {
ForEach(self.itemIds, id: \.self) { itemId in
ItemView(itemId: itemId)
}
}
}
}

struct ItemView: View {
var itemId: String
@State var seconds: Int
@State var timerPublisher = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common).autoconnect()

var body: some View {
VStack {
Button("StartTimer") {
// Call startTimer in SingletonTimerManager....
self.timerPublisher = SingletonTimerManager.publishers[itemId]!
self.timerPublisher.connect()
}
Button("StopTimer") {
self.timerPublisher.connect().cancel()
// Call stopTimer in SingletonTimerManager....
}
Text("\(self.seconds)")
.onAppear {
// function that checks if the timer with this itemId is running
if(SingletonTimerManager.isTimerValid(itemId)) {
self.timerPublisher = SingletonTimerManager.publishers[itemId]!
self.timerPublisher.connect()
}
}.onReceive($timerPublisher) { _ in
self.seconds = SingletonTimerManager.secondsPassed[itemId] ?? 0
}
}
}
}

How can I wait until all Combine publishers finished their jobs in Swift?

A possible way is a view model. In this class merge the publishers and use the receiveCompletion: parameter

class ViewModel : ObservableObject {

@Published var isFinished = false
let pub1 = ["one", "two", "three", "four"].publisher
let pub2 = ["five", "six", "seven", "eight"].publisher

private var subscriptions = Set<AnyCancellable>()

init() {
pub1
.sink { print($0) }
.store(in: &subscriptions)
pub2
.sink { print($0) }
.store(in: &subscriptions)

pub1.merge(with: pub2)
.sink(receiveCompletion: { _ in
self.isFinished = true
}, receiveValue: { _ in })
.store(in: &subscriptions)
}
}

struct SwiftUIView: View {
@StateObject private var model = ViewModel()
var body: some View {
if model.isFinished {
Text("Hello, World!")
} else {
ProgressView()
}
}
}


Related Topics



Leave a reply



Submit