Swift Combine: Using Timer Publisher in an Observable Object

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.

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
}
}
}

Combine Timer - setting timer

You can significantly reduce complexity in your update function. There's no need to create a new publisher with Just to get the date. Plus, you can already get that in your initial sink:

class Example : ObservableObject {
private var cancellable: AnyCancellable? = nil

@Published var somedate: Date = Date()

init() {
cancellable = Timer
.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink(receiveValue: { value in
self.update(date: value)
})
}

func update(date: Date) {
self.somedate = date
print(self.somedate)
}
}

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
}
}
}
}

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))
})
}

How can I avoid this SwiftUI + Combine Timer Publisher reference cycle / memory leak?

Views should be thought of as describing the structure of the view, and how it reacts to data. They ought to be small, single-purpose, easy-to-init structures. They shouldn't hold instances with their own life-cycles (like keeping publisher subscriptions) - those belong to the view model.

class ViewModel: ObservableObject {
var pub: AnyPublisher<Void, Never> {
Timer.publish(every: 2.0, on: .main, in: .default).autoconnect()
.prefix(1)
.map { _ in }
.eraseToAnyPublisher()
}
}

And use .onReceive to react to published events in the View:

struct ContentView: View {
@State var showRedView = true

@ObservedObject vm = ViewModel()

var body: some View {
ZStack {
if showRedView {
Color.red
.transition(.opacity)
}
Text("Hello, world!")
.padding()
}
.onReceive(self.vm.pub, perform: {
withAnimation {
self.showRedView = false
}
})
}
}

So, it seems that with the above arrangement, the TimerPublisher with prefix publisher chain is causing the leak. It's also not the right publisher to use for your use case.

The following achieves the same result, without the leak:

class ViewModel: ObservableObject {
var pub: AnyPublisher<Void, Never> {
Just(())
.delay(for: .seconds(2.0), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

How can I pass BindingTimer in SwiftUI?

With this timer declaration you are in the Combine world. Combine is the reactive framework from Apple.

First you would need to import it:

import Combine

I have commented the code but Combine is a far field and it probably would be best to read the documentation about it, read some tutorials and try some things out.

documentation

struct ContentView: View {
// The typ here is Publishers.Autoconnect<Timer.TimerPublisher>
// But we can erase it and the result will be a Publisher that emits a date and never throws an error: AnyPublisher<Date,Never>
@State private var timer = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common)
.autoconnect()
.eraseToAnyPublisher()
@State private var timeRemaining = 10


var body: some View {
NavigationView {
VStack {
Text("\(timeRemaining)")
.onReceive(timer) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
}
}

NavigationLink {
// pass the publisher on
SecondView(timer: timer)
} label: {
Text("Change View")
}
}
}
}
}

struct SecondView: View {
//You don´t need binding here as this view never manipulates this publisher
var timer: AnyPublisher<Date,Never>
@State private var timeRemaining = 5

var body: some View {
Text("Hello")
.onReceive(timer) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
print(timeRemaining)
}
}
}
}

struct SecondView_Previews: PreviewProvider {
// Creating a static private var should work here !not tested!
@State static private var timer = Timer.publish(every: 1, tolerance: 0.5, on: .main, in: .common)
.autoconnect()
.eraseToAnyPublisher()
static var previews: some View {
SecondView(timer: timer)
}
}


Related Topics



Leave a reply



Submit