SwiftUI - Optional Timer, reset and recreate - ios

Normally, I would use an optional variable to hold my Timer reference, as it's nice to be able to invalidate and set it to nil before recreating.
I'm trying to use SwiftUI and want to make sure I'm correctly doing so...
I declare as:
#State var timer:Publishers.Autoconnect<Timer.TimerPublisher>? = nil
Later I:
self.timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
To drive a UI text control I use:
.onReceive(timer) { time in
print("The time is now \(time)")
}
What is the right way with this Combine typed Timer to invalidate and recreate?
I've read one should call:
self.timer.upstream.connect().cancel()
However, do I also need to invalidate or simply then nil out?

There is no need to throw away the TimerPublisher itself. Timer.publish creates a Timer.TimerPublisher instance, which like all other publishers, only starts emitting values when you create a subscription to it - and it stops emitting as soon as the subscription is closed.
So instead of recreating the TimerPublisher, you just need to recreate the subscription to it - when the need arises.
So assign the Timer.publish on declaration, but don't autoconnect() it. Whenever you want to start the timer, call connect on it and save the Cancellable in an instance property. Then whenever you want to stop the timer, call cancel on the Cancellable and set it to nil.
You can find below a fully working view with a preview that starts the timer after 5 seconds, updates the view every second and stops streaming after 30 seconds.
This can be improved further by storing the publisher and the subscription on a view model and just injecting that into the view.
struct TimerView: View {
#State private var text: String = "Not started"
private var timerSubscription: Cancellable?
private let timer = Timer.publish(every: 1, on: .main, in: .common)
var body: some View {
Text(text)
.onReceive(timer) {
self.text = "The time is now \($0)"
}
}
mutating func startTimer() {
timerSubscription = timer.connect()
}
mutating func stopTimer() {
timerSubscription?.cancel()
timerSubscription = nil
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
var timerView = TimerView()
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
timerView.startTimer()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
timerView.stopTimer()
}
return timerView
}
}
With a view model, you don't even need to expose a TimerPublisher (or any Publisher) to the view, but can simply update an #Published property and display that in the body of your view. This enables you to declare timer as autoconnect, which means you don't manually need to call cancel on it, you can simply nil out the subscription reference to stop the timer.
class TimerViewModel: ObservableObject {
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private var timerSubscription: Cancellable?
#Published var time: Date = Date()
func startTimer() {
timerSubscription = timer.assign(to: \.time, on: self)
}
func stopTimer() {
timerSubscription = nil
}
}
struct TimerView: View {
#ObservedObject var viewModel: TimerViewModel
var body: some View {
Text(viewModel.time.description)
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = TimerViewModel()
let timerView = TimerView(viewModel: viewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
viewModel.startTimer()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
viewModel.stopTimer()
}
return timerView
}
}

Related

Multiple Timers counting down are not working properly | SwiftUI

I have an auction app running. I need several timers counting down in the UI, these timers have different end Dates, and the end seconds can be updated when SignalR receives a new value.
I have implemented running timers in my current solution, but sometimes and suddenly, they start having delays between counting down a second.
The timers are inside these components called LotCard within the ForEach
ForEach($lotService.getLotListDto) { $item in
LotCard(lotCardViewModel: $item.lotCardViewModel,
lotDto: item,
fnStartLotConnection: { value in lotService.initSingleLotCard(uID: value)})
}
This is the necessary code within these components:
//MARK: Timer
#State var timeRemaining = 9999
let timerLotCard = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
HStack(spacing: 3){
Image(systemName: "stopwatch")
if(lotCardViewModel.showTimer){
Text("\(timeRemaining.toTimeString())")
.font(.subheadline)
.onReceive(timerLotCard){ _ in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
if(self.timeRemaining <= 0){
self.timerLotCard.upstream.connect().cancel()
}
}else{
self.timerLotCard.upstream.connect().cancel()
}
}
}
}
I guess it's a problem with threads and using many timers simultaneously with the same Instance but I am not an experienced developer using SwiftUI/Swift.
This is how my interface looks like:
Thanks for your help.
I came up with this approach thanks to the comments in my question.
I hope it works and suits your problems.
First, I created a Published Timer, meaning every component will run the same Timer.
import Foundation
import Combine
class UIService : ObservableObject {
static let shared = UIService()
//MARK: Timer to be used for any interested party
#Published var generalTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
}
Finally, I am using that Timer wherever I want to use it. In this case, I use it in every LotCard component.
struct LotCard: View {
//MARK: Observable Class
#EnvironmentObject var UISettings: UIService
//MARK: Time for the counting down
#State var timeRemaining = 9999
var body: some View {
HStack{
Text(timeRemaining.toTimeString())
.onReceive(UISettings.generalTimer){ _ in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
}
}
}
}

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

I have the following SwiftUI view which contains a subview that fades away after five seconds. The fade is triggered by receiving the result of a Combine TimePublisher, but changing the value of showRedView in the sink publisher's sink block is causing a memory leak.
import Combine
import SwiftUI
struct ContentView: View {
#State var showRedView = true
#State var subscriptions: Set<AnyCancellable> = []
var body: some View {
ZStack {
if showRedView {
Color.red
.transition(.opacity)
}
Text("Hello, world!")
.padding()
}
.onAppear {
fadeRedView()
}
}
func fadeRedView() {
Timer.publish(every: 5.0, on: .main, in: .default)
.autoconnect()
.prefix(1)
.sink { _ in
withAnimation {
showRedView = false
}
}
.store(in: &subscriptions)
}
}
I thought this was somehow managed behind the scenes with the AnyCancellable collection. I'm relatively new to SwiftUI and Combine, so sure I'm either messing something up here or not thinking about it correctly. What's the best way to avoid this leak?
Edit: Adding some pictures showing the 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()
}
}
My guess is that you're leaking because you store an AnyCancellable in subscriptions and you never remove it.
The sink operator creates the AnyCancellable. Unless you store it somewhere, the subscription will be cancelled prematurely. But if we use the Subscribers.Sink subscriber directly, instead of using the sink operator, there will be no AnyCancellable for us to manage.
func fadeRedView() {
Timer.publish(every: 5.0, on: .main, in: .default)
.autoconnect()
.prefix(1)
.subscribe(Subscribers.Sink(
receiveCompletion: { _ in },
receiveValue: { _ in
withAnimation {
showRedView = false
}
}
))
}
But this is still overkill. You don't need Combine for this. You can schedule the event directly:
func fadeRedView() {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
withAnimation {
showRedView = false
}
}
}

iOS Swift Combine: cancel a Set<AnyCancellable>

If I have stored a cancellable set into a ViewController:
private var bag = Set<AnyCancellable>()
Which contains multiple subscription.
1 - Should I cancel subscription in deinit? or it does the job automatically?
2 - If so, how can I cancel all the stored subscriptions?
bag.removeAll() is enough?
or should I iterate through the set and cancel all subscription one by one?
for sub in bag {
sub.cancel()
}
Apple says that the subscription is alive until the stored AnyCancellable is in memory. So I guess that deallocating the cancellables with bag.removeAll() should be enough, isn't it?
On deinit your ViewController will be removed from memory. All of its instance variables will be deallocated.
The docs for Combine > Publisher > assign(to:on:) say:
An AnyCancellable instance. Call cancel() on this instance when you no
longer want the publisher to automatically assign the property.
Deinitializing this instance will also cancel automatic assignment.
1 - Should I cancel subscription in deinit? or it does the job automatically?
You don't need to, it does the job automatically. When your ViewController gets deallocated, the instance variable bag will also be deallocated. As there is no more reference to your AnyCancellable's, the assignment will end.
2 - If so, how can I cancel all the stored subscriptions?
Not so. But often you might have some subscriptions that you want to start and stop on, say, viewWillAppear/viewDidDissapear, for example. In this case your ViewController is still in memory.
So, in viewDidDissappear, you can do bag.removeAll() as you suspected. This will remove the references and stop the assigning.
Here is some code you can run to see .removeAll() in action:
var bag = Set<AnyCancellable>()
func testRemoveAll() {
Timer.publish(every: 1, on: .main, in: .common).autoconnect()
.sink { print("===== timer: \($0)") }
.store(in: &bag)
Timer.publish(every: 10, on: .main, in: .common).autoconnect()
.sink { _ in self.bag.removeAll() }
.store(in: &bag)
}
The first timer will fire every one second and print out a line. The second timer will fire after 10 seconds and then call bag.removeAll(). Then both timer publishers will be stopped.
https://developer.apple.com/documentation/combine/publisher/3235801-assign
if you happened to subscribe to a publisher from your View controller, likely you will capture self in sink, which will make a reference to it, and won't let ARC remove your view controller later if the subscriber didn't finish, so it's, advisable to weakly capture self
so instead of:
["title"]
.publisher
.sink { (publishedValue) in
self.title.text = publishedValue
}
.store(in: &cancellable)
you should use a [weak self]:
["title"]
.publisher
.sink { [weak self] (publishedValue) in
self?.title.text = publishedValue
}
.store(in: &cancellable)
thus, when View controller is removed, you won't have any retain cycle or memory leaks.
Try creating a pipeline and not storing the cancellable in some state variable. You’ll find that the pipeline stops as soon as it encounters an async operation. That’s because the Cancellable was cleaned up by ARC and it was thus automatically cancelled. So you don’t need to call cancel on a pipeline if you release all references to it.
From the documentation:
An AnyCancellable instance automatically calls cancel() when deinitialized.
I test this code
let cancellable = Set<AnyCancellable>()
Timer.publish(every: 1, on: .main, in: .common).autoconnect()
.sink { print("===== timer: \($0)") }
.store(in: &cancellable)
cancellable.removeAll() // just remove from Set. not cancellable.cancel()
so I use this extension.
import Combine
typealias CancelBag = Set<AnyCancellable>
extension CancelBag {
mutating func cancelAll() {
forEach { $0.cancel() }
removeAll()
}
}
Create a Cancellable+Extensions.swift
import Combine
typealias DisposeBag = Set<AnyCancellable>
extension DisposeBag {
mutating func dispose() {
forEach { $0.cancel() }
removeAll()
}
}
In your implementation class, in my case CurrentWeatherViewModel.swift simply add disposables.dispose() to remove Set of AnyCancellable
import Combine
import Foundation
final class CurrentWeatherViewModel: ObservableObject {
#Published private(set) var dataSource: CurrentWeatherDTO?
let city: String
private let weatherFetcher: WeatherFetchable
private var disposables = Set<AnyCancellable>()
init(city: String, weatherFetcher: WeatherFetchable = WeatherNetworking()) {
self.weatherFetcher = weatherFetcher
self.city = city
}
func refresh() {
disposables.dispose()
weatherFetcher
.currentWeatherForecast(forCity: city)
.map(CurrentWeatherDTO.init)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
self.dataSource = nil
case .finished:
break
}
}, receiveValue: { [weak self] weather in
guard let self = self else { return }
self.dataSource = weather
})
.store(in: &disposables)
}
}

Swift DispatchSourceTimer makes unrelated views reload every second

[28/10/2019: I have recreated the same test project with UIKit... unsurprisingly, the timer does not make any of the view controller views reload!!! So this looks like a SwiftUI bug. UIKIt version: https://github.com/DominikButz/DispatchSourceTimerUIKit.git]
[19/10/2019: Please check out https://github.com/DominikButz/DispatchTimerSwiftUIReloadBug.git for better understanding of the issue. When you launch this in the simulator, just tap on "Launch timer". Check the debug console, you will see that all views are reloaded every second.]
Original post:
I'm creating an iOS app in SwiftUI for which I need to use a timer. I'm posting the class below.
The timer works fine (except for not continuing when the app is in the background, but that is another story...). As you can see in the resumeTimer function, the timer is set to repeat every second.
There is a view in which a Text label gets updated every time the timer fires. This works fine. However, the problem is that other views that are loaded after the timer starts all get reloaded every time the timer fires, that means every second. e.g. an unrelated list (where the timer text label is not placed) gets reloaded every second, which makes it unusable.
I know this because when I pause or cancel the timer through the user interface, the debugger shows that the views don't get reloaded every second anymore. I placed a breakpoint (with the option "continue after evaluation") in the body of each of those views and the debugger shows that the views gets reloaded every time the timer fires.
The timer class below is created in the SceneDelegate and then passed to the Content view as environment object. I had it as ObservedObject before only in the view where the timer Text label is placed, but the result is the same. See below a view that gets reloaded when the timer fires... (gif screenshot).
Could this be a bug in SwiftUI? or might there be an error in my timer class? thanks for any hints in the right direction
Here is the content view
struct ContentView: View {
#EnvironmentObject var workoutModel: WorkoutModel
#EnvironmentObject var workoutWatch: WorkoutStopWatchTimer
#State var selectedMenuIndex = UserDefaults.standard.integer(forKey: UserDefaultKeys.selectedMenuIndex)
#State private var workoutViewPresenting = false
func setMenuIndex() {
UserDefaults.standard.set(selectedMenuIndex, forKey: UserDefaultKeys.selectedMenuIndex)
}
var body: some View {
TabView(selection:$selectedMenuIndex) {
WorkoutSelectionView(workoutViewPresenting: self.$workoutViewPresenting).onAppear(perform: self.setMenuIndex)
.tabItem {
Image(systemName: "1.square.fill")
Text("Start Workout")
}.tag(0)
Text("History").onAppear(perform: self.setMenuIndex) // I set a breakpoint here (with continue after evaluation checked)
.tabItem {
Image(systemName: "2.square.fill")
Text("History")
}.tag(1)
ExerciseListView().onAppear(perform: self.setMenuIndex) // I set a breakpoint here (with continue after evaluation checked)
.tabItem {
Image(systemName: "3.square.fill")
Text("Exercises")
}.tag(2)
Text("Profile").onAppear(perform: self.setMenuIndex)
.tabItem {
Image(systemName: "person.fill")
Text("Profile")
}.tag(3)
}.sheet(isPresented: self.$workoutViewPresenting) {
// the timer is passed to this view in which a Text label is updated every second to show the time
WorkoutView().environmentObject(self.workoutModel).environmentObject(self.workoutWatch)
}
}
}
debugger console: see the comments I put in the content view above to see where I placed the breakpoints. I did not open and close those views that many times, they are reloaded automatically every second when the timer is running.
...
3 loading History
3 loading Exercise List View
4 loading History
4 loading Exercise List View
5 loading History
5 loading Exercise List View
6 loading History
6 loading Exercise List View
7 loading History
7 loading Exercise List View
...
class WorkoutStopWatchTimer: ObservableObject {
private var sourceTimer: DispatchSourceTimer?
private let queue = DispatchQueue.init(label: "stopwatch.timer", qos: .background, attributes: [], autoreleaseFrequency: .never, target: nil)
private var counter: Int = 0 // seconds
var endDate: Date?
var duration: TimeInterval?
#Published var timeDisplayString = "0:00"
var paused = false
var isActive: Bool {
return self.sourceTimer != nil
}
func start() {
self.paused = false
guard let _ = self.sourceTimer else {
self.startTimer()
return
}
self.resumeTimer()
}
func finish() {
guard self.sourceTimer != nil else {return}
self.endDate = Date()
self.duration = TimeInterval(exactly: Double(self.counter))
self.sourceTimer?.setEventHandler {}
self.sourceTimer?.cancel()
if self.paused == true {
self.sourceTimer?.resume()
}
self.sourceTimer = nil
self.reset()
}
func pause() {
self.paused = true
self.sourceTimer?.suspend()
}
private func reset() {
self.timeDisplayString = "0:00"
self.counter = 0
}
private func startTimer() {
self.sourceTimer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags.strict,
queue: self.queue)
self.resumeTimer()
}
private func resumeTimer() {
self.sourceTimer?.setEventHandler { [weak self] in
// self.eventHandler = {
self?.updateTimer()
// }
}
self.sourceTimer?.schedule(deadline: .now(),
repeating: 1)
self.sourceTimer?.resume()
}
private func updateTimer() {
self.counter += 1
DispatchQueue.main.async {
self.timeDisplayString = WorkoutStopWatchTimer.convertCountToTimeString(counter: self.counter)
}
}
}

How should I trigger network call in SwiftUI app to refresh data on app open?

I'm writing a SwiftUI app, and I want it periodically refresh data from a server:
when the app is first opened
if the app enters the foreground and the data has not been updated in the past 5 minutes
Below is the code I have so far.
What is the best way to trigger this update code the first time the app is opened in a SwiftUI app? Is adding the observer in onAppear a good practice for triggering the update when the app enters the foreground? (This is the only view in the app)
class InfoStore {
var lastValueCheck: Date = .distantPast
}
struct ContentView : View {
var infoStore: InfoStore
private func updateValueFromServer() {
// request updated value from the server
// if the request is successful, store the new value
currentValue = 500
UserDefaults.cachedValue = 500
// hardcoded for this example
infoStore.lastValueCheck = Date()
}
private func updateValueIfOld() {
let fiveMinutesAgo: Date = Date(timeIntervalSinceNow: (-5 * 60))
if infoStore.lastValueCheck < fiveMinutesAgo {
updateValueFromServer()
}
}
#State var currentValue: Int = 100
var body: some View {
Text("\(currentValue)")
.font(.largeTitle)
.onAppear {
NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main) { (notification) in
self.updateValueIfOld()
}
}
}
}
extension UserDefaults {
private struct Keys {
static let cachedValue = "cachedValue"
}
static var cachedValue: Int {
get {
return standard.value(forKey: Keys.cachedValue) as? Int ?? 0
}
set {
standard.set(newValue, forKey: Keys.cachedValue)
}
}
}
1) About the first point (app first opened): probably the best way to get what you want is to extract the logic outside the View (as MVVM suggests) using DataBinding and ObservableObjects. I changed your code as less as possible in order to show you what I mean:
import SwiftUI
class ViewModel: ObservableObject {
#Published var currentValue = -1
private var lastValueCheck = Date.distantPast
init() {
updateValueFromServer()
}
func updateValueIfOld() {
let fiveMinutesAgo: Date = Date(timeIntervalSinceNow: (-5 * 60))
if lastValueCheck < fiveMinutesAgo {
updateValueFromServer()
}
}
private func updateValueFromServer() {
// request updated value from the server
// if the request is successful, store the new value
currentValue = 500
UserDefaults.cachedValue = 500
// hardcoded for this example
lastValueCheck = Date()
}
}
struct ContentView : View {
#ObservedObject var viewModel: ViewModel
var body: some View {
Text("\(viewModel.currentValue)")
.font(.largeTitle)
.onAppear {
NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main) { (notification) in
self.viewModel.updateValueIfOld()
}
}
}
}
extension UserDefaults {
private struct Keys {
static let cachedValue = "cachedValue"
}
static var cachedValue: Int {
get {
return standard.value(forKey: Keys.cachedValue) as? Int ?? 0
}
set {
standard.set(newValue, forKey: Keys.cachedValue)
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: ViewModel())
}
}
#endif
This way, as soon as the ViewModel is created the currentValue is updated. Also, every time currentValue is changed by a server call the UI is automatically recreated for you. Note that you have to modify the sceneDelegate this way:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: ContentView(viewModel: ViewModel()))
self.window = window
window.makeKeyAndVisible()
}
}
2) About the second point (app enters foreground): you should be careful here because you're registering the observer multiple times (every time the onAppear is fired). Depending on your needs you should decide to:
remove the observer onDisappear (this is very frequent)
add the observer just one time checking if you have already added it.
In any case it's a good practice to implement the:
deinit {
}
method and eventually remove the observer.

Resources