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
Updating a @Published Variable Based on Changes in an Observed Variable
How to Duplicate a Sprite in Sprite Kit and Have Them Behave Differently
Binding 2 Properties (Observe) Using Keypath
How to Create Objects from Swiftyjson
Why Do I Get "Static Member '...' Cannot Be Used on Instance of Type '...'" Error
Create a Swift Dictionary Subclass
More Concise Way to Nest Enums for Access by Switch Statements in Swift
Require Associatedtype to Be Representable in a @Convention(C) Block
Nested Tabview - Remove Inner Tab Bar iOS 13, Swift Ui
Uiswipegesturerecognizer Doesn't Recognize Swipe Gesture Initiated Outside the View
Swift 3 Init Method That Accepts JSON with Optional Parameters
Implementing "User Stopped Speaking" Notification for 'Sfspeechrecognizer'
Swift Build Error_If_Any_Output_Files_Are_Specified_They_All_Must_Be
Swift Convert Decimal Coordinate into Degrees, Minutes, Seconds, Direction
Struggling with Notificationcenter/Combine in Swiftui/Avplayer