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
Get All Documents at Once in a Completion Handler with Getdocuments in Firestore
How to Deallocate All References Elements from an Array
Iterate a Grid of Views Swiftui
Swiftui: Problem with Adding a Property to Data Struct
How to Store the Progress of Progressview into Arraylist as One Element
Getting Wrong Date from Dateformat
A Method Without Parameters Is Calling for an Argument
How to Decode Utf8-Literals Like "\Xc3\Xa6" in Swift 5
Get Images from Document Directory Not File Path Swift 3
Initializer for Conditional Binding Must Have Optional Type, Not '[String:Any]'
How to Pass Closure as a Parameter in Perform(Selector, Withobject)
Core Data Update in Swift While Selecting Any Row in List Table View Not Working
Retrieve an Image from Firebase to an Uiimage Swift5