Search code examples
swiftxcodeswiftuixcode11combine

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

Solution

  • 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.