Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swift Combine: Using timer publisher in an observable object

Before this question gets marked as duplicate of this other question, I am trying to understand how the publisher works as it behaves in a way I do not expect.

Using the same example as the answer from the question previously stated:

// Let's define the view model with my view...
import Combine
import SwiftUI

class TimerViewModel: ObservableObject {
  private let cancellable: AnyCancellable?

  let intervalPublisher = Timer.TimerPublisher(
                            interval: 1.0, 
                            runLoop: .main, 
                            mode: .default)

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

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

struct Clock : View {
  @EnvironmentObject var viewModel: TimerViewModel
  @State private var currentTime: String = "Initial"


  var body: some View {
    VStack {
      Text(currentTime)
    }
    .onReceive(timer.intervalPublisher) { newTime in
      self.currentTime = String(describing: newTime)
    }
  }
}

At this stage, all I wanted to do is my view model to publish the value directly. I don't want to have to declare the view will be receiving these sorts of values.

Ideally, I want to turn my publisher into a published properly... I though that the following code would work:

// Let's define the view model with my view...
import Combine
import SwiftUI

class TimerViewModel: ObservableObject {
  private let cancellable: AnyCancellable?
  private let assignCancellable: AnyCancellable?

  let intervalPublisher = Timer.TimerPublisher(
                            interval: 1.0, 
                            runLoop: .main, 
                            mode: .default)
 @Published var tick: String = "0:0:0"

  init() {
    cancellable = intervalPublisher.connect() as? AnyCancellable

    assignCancellable = intervalPublisher
                              .map { new in String(describing: new) }
                              .assign(to: \TimerViewModel.tick, on: self)
  }

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

struct Clock : View {
  @EnvironmentObject var viewModel: TimerViewModel
  @State private var currentTime: String = "Initial"


  var body: some View {
    VStack {
      Text(currentTime)
      Text(viewModel.tick) // why doesn't this work?
    }
    .onReceive(timer.intervalPublisher) { newTime in
      self.currentTime = String(describing: newTime)
    }
  }
}

What am I doing wrong for my assign?

Why isn't triggering?

Edit: the environment object was set on the SceneDelegate once the Clock view was created. The code excluded is attached below:

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let view = Clock().environmentObject(TimerViewModel())

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: view)
            self.window = window
            window.makeKeyAndVisible()
        }
    }
like image 280
rodrigoelp Avatar asked Jan 25 '23 21:01

rodrigoelp


2 Answers

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.

like image 83
Michael Salmon Avatar answered Jan 28 '23 09:01

Michael Salmon


First try Text("Timer: \(date, style:.timer)") which gives you a counting timer automatically. Also, consider the advantage of Timer.publisher that it is a struct that can be stored in @State so you don't even need ObservableObject.

import Combine
import SwiftUI

struct Clock : View {
  @State private var timer = Timer.publish(every: 1, on: .main, in:.common).autoconnect()
  @State private var currentTime: String = "Initial"

  var body: some View {
    VStack {
      Text(currentTime)
    }
    .onReceive(timer) { newTime in
      self.currentTime = String(describing: newTime)
    }
  }
}

The advantage to having the Timer as @State is if the Clock is no longer displayed for some reason then the timer stops and is discarded too.

If however, you did decide to use ObservableObject then you could simply do:

class MyTimer : ObservableObject {
    var objectWillChange = Timer.publish(every: 1, on: .main, in:.common).autoconnect()
}

struct Clock2: View {
    @StateObject var timer = MyTimer() // causes body to run every second
    var body: some View {
        VStack {
            Text("Hello, World! \(Date())")
        }
    }
}

And here is another way that gives a more accurate change to the date string, however I don't like this sample because the date formatting needs to be done in a Text inside body so that the label updates if the region settings change.

class MyTimer : ObservableObject {
    var timer : Timer? = nil
    @Published var timeString = ""

    init() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
            self.timeString = timer.fireDate.description
        }
    }
}

struct ContentView: View {
    @StateObject var timer = MyTimer()

    var body: some View {
        VStack {
            Text("Hello, World! \(timer.timeString)")
        }
    }
}
like image 34
malhal Avatar answered Jan 28 '23 09:01

malhal