Modifying user input in Swift - swift

I'm trying to learn Swift and have gone through several tutorials, however, I seem to be going in circles and need some direction on how to solve this problem.
My goal is to create a decrementing timer, given a user-input (in seconds), in this case I've chosen a Stepper to -/+ the value. Then begin decrementing the timer on a button press, in this case "Decrement". The counter is displayed on the label.
This problem is super easy if I hard code the starting value, but what purpose would that serve for a UI Test. So, this was the "challenging" task I was able to think of to help understand how SwiftUI works.
The problem I'm encountering is the variable passed by the user is immutable. I have tried making copies of it or assigning it to other variables to manipulate but seem to be going in circles. A nudge in the right direction or a potential solution would go a long way.
struct ContentView: View {
#State private var timeInput: Int = 0
var timer = Timer()
var timeInputCopy: Int {
timeInput
}
var body: some View {
Stepper("Input", value: $timeInput, in: 0...150)
Button("Decrement", action: decrementFunction)
Label(String(timeInputCopy), image: "")
.labelStyle(TitleOnlyLabelStyle())
}
func decrementFunction() {
timer.invalidate()
timer = Timer.schedulerTimer(timeInterval: 1,
target: self,
selector: #selector(ContentView.timerClass),
userInfo: nil,
repeats: true)
}
func timerClass() {
timeInputCopy -= timeInputCopy
if (timeInputCopy == 0) {
timer.invalidate()
}
}
> Cannot assign to property: 'self' is immutable
> Mark method 'mutating' to make 'self' mutable
Attempting to auto-fix as Xcode recommends does not lead to a productive solution. I feel I am missing a core principle here.

As mentioned in my comments above:
timeInputCopy doesn't have a point -- it's not really a copy, it's just a computed property that returns timeInput
You won't have much luck with that form of Timer in SwiftUI with a selector. Instead, look at the Timer publisher.
Here's one solution:
import Combine
import SwiftUI
class TimerManager : ObservableObject {
#Published var timeRemaining = 0
private var cancellable : AnyCancellable?
func startTimer(initial: Int) {
timeRemaining = initial
cancellable = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { _ in
self.timeRemaining -= 1
if self.timeRemaining == 0 {
self.cancellable?.cancel()
}
}
}
}
struct ContentView: View {
#StateObject private var timerManager = TimerManager()
#State private var stepperValue = 60
var body: some View {
Stepper("Input \(stepperValue)", value: $stepperValue, in: 0...150)
Button("Start") {
timerManager.startTimer(initial: stepperValue)
}
Label("\(timerManager.timeRemaining)", image: "")
.labelStyle(TitleOnlyLabelStyle())
}
}
This could all be done in the View, but using the ObservableObject gives a nice separation of managing the state of the timer vs the state of the UI.

Related

Stop a timer on an if statement within a view

I'am quite new to Swift and I despair of a problem.
I created a class with two functions to start and stop a timer.
There's a struct that starts the timer (start function) with onAppear.
Within the struct there's a button to stop the time.
import SwiftUI
class StopWatch: ObservableObject {
#Published var secondsElapsed = 0.0
var timer = Timer()
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
self.secondsElapsed += 0.1 }
}
func stopTimer() {
timer.invalidate()
}
}
struct Test: View {
#ObservedObject var stopWatch = StopWatch()
var body: some View {
VStack{
Button("Stop") {
stopWatch.stopTimer()
}
Text(String(format: "%.1f", self.stopWatch.secondsElapsed))
}.onAppear(perform: {stopWatch.startTimer()})
}
}
The timer starts if the view starts. So far so good :-).
But I want to get rid of the stop button and replace it with an if statement like:
if stopWatch.secondsElapsed > 5.0 {
stopWatch.stopTimer() }
I want to stop the timer without a user action by this if statement and the current timer value should show up.
I tried for hours, but I didn't get it. For that case I get the error message "Type '()' cannot conform to 'View'", but I tried a lot of other things and got a lot of other errors.
Can anyone help me?
If you want the timer to stop on 5 seconds you should probably do this in the closure from the timer. You can do this:
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
self.secondsElapsed += 0.1
if self.secondselapsed == 5.0 {
timer.invalidate()
}
}
}
You get this error ("Type '()' cannot conform to 'View'") because you try to execute code within the body of a SwiftUI view without returning a view. If you really want to do this in the body for some reason you could do it like this: (I would not recommend doing this)
if stopWatch.secondsElapsed > 5.0 {
Text("Five seconds elapsed")
.onAppear {
stopWatch.stop()
}
}

Can a timer be scheduled to start at a real time?

Making a simple timer is easy. We know how to build something that fires at a certain interval. Functions and other tasks can be delayed with Dispatch Queue. Has anyone ever tried to start a function with timed precision?
I've found some documentation that shows that within the Dispatch Framework there are some structs that deal with microsecond(DispatchWallTime) and nanosecond(DispatchTime) precision. Given the assumption that a timer isn't "real-time", how can we go about firing a function precisely at a later date?
Here's a simple timer, the goal is to make the start function start at a later time but precisely at the start of a minute or a specific second.
struct ContentView: View {
#StateObject var timeManager = TimeManager()
#State var date = Date.now.formatted(.dateTime.hour().minute().second())
var body: some View {
VStack {
Text("Current Time: \(date)")
Text("Elapsed Time: \(timeManager.elapsedTime)").monospacedDigit()
HStack {
Button(action: {
timeManager.start()
}) {
Image(systemName: "play.fill")
.tint(.green)
}
Button(action: {
timeManager.stop()
}) {
Image(systemName: "stop.fill")
.tint(.red)
}
}
.font(.largeTitle)
}
}
}
class TimeManager: ObservableObject {
#Published var elapsedTime = 0
var timer = Timer()
// Make this function start at a later date/time with precision.
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
self.elapsedTime += 1
}
}
func stop() {
timer.invalidate()
}
}
I found an answer that gets to the point. It's from Paul Hudson, If anyone would like to answer this question with more depth to it or variations I'll mark it as the answer.
let date = Date().addingTimeInterval(5)
let timer = Timer(fireAt: date, interval: 0, target: self, selector: #selector(runCode), userInfo: nil, repeats: false)
RunLoop.main.add(timer, forMode: .common)
The reason I like this answer is twofold, one you can use a date. Then you can bind this to a date picker or any arbitrary date or time you choose. Two, I like it because it's built right into Swift. Please add your answer if you have a better solution. Thanks everyone!

Observe singleton timer value changes with Publisher in Combine

One of the requirements of my application is the ability to start multiple timers, for reporting purposes.
I've tried to store the timers and seconds passed in an #EnvironmentObject with #Published variables, but every time the object refreshes, any view that observes the #EnvironmentObject refreshes too.
Example
class TimerManager: ObservableObject {
#Published var secondsPassed: [String: Int]
var timers: [String:AnyCancellable]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.timers[itemId] = Timer
.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink(receiveValue: { _ in
self.secondsPassed[itemId]! += 1
})
}
func isTimerValid(itemId: String) -> Bool {
return self.timers[itemId].isTimerValid
}
// other code...
}
So for example, if in any other view I need to know if a particular timer is active by calling a function isTimerValid, I need to include this #EnvironmentObject in that view, and it won't stop refreshing it because the timer changes secondsPassed which is Published, causing lags and useless redrawings.
So one thing I did was to cache the itemId of the active timers somewhere else, in a static struct that I update every time I start or stop a timer.
It seemed a bit hacky, so lately I've been thinking to move all this into a Singleton, like this for example
class SingletonTimerManager {
static let singletonTimerManager = SingletonTimerManager()
var secondsPassed: [String: Int]
var timers: [String:AnyCancellable]
func startTimer(itemId: String) {
self.secondsPassed[itemId] = 0
self.timers[itemId] = Timer
.publish(every: 1, on: .main, in: .default)
.autoconnect()
.sink(receiveValue: { _ in
self.secondsPassed[itemId]! += 1
})
}
// other code...
}
and only let some Views observe the changes to secondsPassed. On the plus side, I can maybe move the timer on the background thread.
I've been struggling how to do this properly.
These are my Views (albeit a very simple extract)
struct ContentView: View {
// set outside the ContentView
var selectedItemId: String
// timerValue: set by a publisher?
var body: some View {
VStack {
ItemView(seconds: Binding.constant(timerValue))
}
}
}
struct ItemView: View {
#Binding var seconds: Int
var body: some View {
Text("\(self.seconds)")
}
}
I need to somehow observe the SingletonChronoManager.secondsPassed[selectedItemId] so the ItemView updates in real time.
By putting the timer publisher results into Environment, you are propagating change notifications to all views within the tree that define that environment object, which I'm sure will cause un-needed redraws and performance issues (and as you've seen).
A better mechanism is strongly limiting the views (or subviews) that need to display the constantly updating time, and pass in a reference to a timer publisher directly to them rather than layering it into the environment. Putting the timer itself into a singleton is one option but not critical to this, and won't effect the cascading redraws you're seeing.
How to use a timer with SwiftUI has a "shoving a timer into the view itself", which may work for what you're trying to do, but slightly better is the video here: https://www.hackingwithswift.com/books/ios-swiftui/triggering-events-repeatedly-using-a-timer
In Paul's example, he's stuffing the timer into the view itself - wouldn't be my choice, but for a simple real-time clock view it's not bad. You could just as easily pass in the timer publisher from an external object - like your singleton for example.
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
}
}
}
}

Escaping closure captures mutating self in Swift Combine Subscriber [duplicate]

GOAL: I'm trying to make a general struct that can take an array of Ints and go through and set a timer for each one (and show a screen) in succession.
Problem: I get Escaping closure captures mutating 'self' parameter error as shown in the code.
import SwiftUI
struct ContentView: View {
#State private var timeLeft = 10
#State private var timers = Timers(timersIWant: [6, 8, 14])
// var timersIWantToShow: [Int] = [6, 8, 14]
var body: some View {
Button(action: {self.timers.startTimer(with: self.timeLeft)}) {
VStack {
Text("Hello, World! \(timeLeft)")
.foregroundColor(.white)
.background(Color.blue)
.font(.largeTitle)
}
}
}
struct Timers {
var countDownTimeStart: Int = 0
var currentTimer = 0
var timersIWant: [Int]
mutating func startTimer(with countDownTime: Int) {
var timeLeft = countDownTime
Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in //Escaping closure captures mutating 'self' parameter
if timeLeft > 0 {
timeLeft -= 1
} else {
timer.invalidate()
self.currentTimer += 1
if self.currentTimer < self.timersIWant.count {
self.startTimer(with: self.timersIWant[self.currentTimer])
} else {
timer.invalidate()
}
}
})
}
}
}
I'm not sure if this has to do with my recursvie function (maybe this is bad form?) and I'm guessing the escaping closure is the func startTimer and the offending the 'self' parameter is the countDownTime parameter, but I'm not really sure what is happening or why it's wrong.
Escaping closure captures mutating 'self' parameter
The escaping closure is the Button's action parameter, and the mutating function is your startTimer function.
Button(action: {self.timers.startTimer(with: self.timeLeft)}) {
A simple solution is to change Times to be a class instead of a struct.
Also notice that timeLeft is defined in two places. I don't think this is what you want.
As Gil notes, this needs to be a class because you are treating it as a reference type. When you modify currentTimer, you don't expect that to create a completely new Timers instance, which is what happens with a value type (struct). You expect it to modify the existing Timers instance. That's a reference type (class). But to make this work, there's quite a bit more you need. You need to tie the Timers to the View, or the View won't update.
IMO, the best way to approach this is let Timers track the current timeLeft and have the view observe it. I've also added an isRunning published value so that the view can reconfigure itself based on that.
struct TimerView: View {
// Observe timers so that when it publishes changes, the view is re-rendered
#ObservedObject var timers = Timers(intervals: [10, 6, 8, 14])
var body: some View {
Button(action: { self.timers.startTimer()} ) {
Text("Hello, World! \(timers.timeLeft)")
.foregroundColor(.white)
.background(timers.isRunning ? Color.red : Color.blue) // Style based on isRunning
.font(.largeTitle)
}
.disabled(timers.isRunning) // Auto-disable while running
}
}
// Timers is observable
class Timers: ObservableObject {
// And it publishes timeLeft and isRunning; when these change, update the observer
#Published var timeLeft: Int = 0
#Published var isRunning: Bool = false
// This is `let` to get rid of any confusion around what to do if it were changed.
let intervals: [Int]
// And a bit of bookkeeping so we can invalidate the timer when needed
private var timer: Timer?
init(intervals: [Int]) {
// Initialize timeLeft so that it shows the upcoming time before starting
self.timeLeft = intervals.first ?? 0
self.intervals = intervals
}
func startTimer() {
// Invalidate the old timer and stop running, in case we return early
timer?.invalidate()
isRunning = false
// Turn intervals into a slice to make popFirst() easy
// This value is local to this function, and is captured by the timer callback
var timerLengths = intervals[...]
guard let firstInterval = timerLengths.popFirst() else { return }
// This might feel redundant with init, but remember we may have been restarted
timeLeft = firstInterval
isRunning = true
// Keep track of the timer to invalidate it elsewhere.
// Make self weak so that the Timers can be discarded and it'll clean itself up the next
// time it fires.
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
// Decrement the timer, or pull the nextInterval from the slice, or stop
if self.timeLeft > 0 {
self.timeLeft -= 1
} else if let nextInterval = timerLengths.popFirst() {
self.timeLeft = nextInterval
} else {
timer.invalidate()
self.isRunning = false
}
}
}
}

Why can't I mutate a variable initially set to a certain parameter when the func was called?

GOAL: I'm trying to make a general struct that can take an array of Ints and go through and set a timer for each one (and show a screen) in succession.
Problem: I get Escaping closure captures mutating 'self' parameter error as shown in the code.
import SwiftUI
struct ContentView: View {
#State private var timeLeft = 10
#State private var timers = Timers(timersIWant: [6, 8, 14])
// var timersIWantToShow: [Int] = [6, 8, 14]
var body: some View {
Button(action: {self.timers.startTimer(with: self.timeLeft)}) {
VStack {
Text("Hello, World! \(timeLeft)")
.foregroundColor(.white)
.background(Color.blue)
.font(.largeTitle)
}
}
}
struct Timers {
var countDownTimeStart: Int = 0
var currentTimer = 0
var timersIWant: [Int]
mutating func startTimer(with countDownTime: Int) {
var timeLeft = countDownTime
Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { timer in //Escaping closure captures mutating 'self' parameter
if timeLeft > 0 {
timeLeft -= 1
} else {
timer.invalidate()
self.currentTimer += 1
if self.currentTimer < self.timersIWant.count {
self.startTimer(with: self.timersIWant[self.currentTimer])
} else {
timer.invalidate()
}
}
})
}
}
}
I'm not sure if this has to do with my recursvie function (maybe this is bad form?) and I'm guessing the escaping closure is the func startTimer and the offending the 'self' parameter is the countDownTime parameter, but I'm not really sure what is happening or why it's wrong.
Escaping closure captures mutating 'self' parameter
The escaping closure is the Button's action parameter, and the mutating function is your startTimer function.
Button(action: {self.timers.startTimer(with: self.timeLeft)}) {
A simple solution is to change Times to be a class instead of a struct.
Also notice that timeLeft is defined in two places. I don't think this is what you want.
As Gil notes, this needs to be a class because you are treating it as a reference type. When you modify currentTimer, you don't expect that to create a completely new Timers instance, which is what happens with a value type (struct). You expect it to modify the existing Timers instance. That's a reference type (class). But to make this work, there's quite a bit more you need. You need to tie the Timers to the View, or the View won't update.
IMO, the best way to approach this is let Timers track the current timeLeft and have the view observe it. I've also added an isRunning published value so that the view can reconfigure itself based on that.
struct TimerView: View {
// Observe timers so that when it publishes changes, the view is re-rendered
#ObservedObject var timers = Timers(intervals: [10, 6, 8, 14])
var body: some View {
Button(action: { self.timers.startTimer()} ) {
Text("Hello, World! \(timers.timeLeft)")
.foregroundColor(.white)
.background(timers.isRunning ? Color.red : Color.blue) // Style based on isRunning
.font(.largeTitle)
}
.disabled(timers.isRunning) // Auto-disable while running
}
}
// Timers is observable
class Timers: ObservableObject {
// And it publishes timeLeft and isRunning; when these change, update the observer
#Published var timeLeft: Int = 0
#Published var isRunning: Bool = false
// This is `let` to get rid of any confusion around what to do if it were changed.
let intervals: [Int]
// And a bit of bookkeeping so we can invalidate the timer when needed
private var timer: Timer?
init(intervals: [Int]) {
// Initialize timeLeft so that it shows the upcoming time before starting
self.timeLeft = intervals.first ?? 0
self.intervals = intervals
}
func startTimer() {
// Invalidate the old timer and stop running, in case we return early
timer?.invalidate()
isRunning = false
// Turn intervals into a slice to make popFirst() easy
// This value is local to this function, and is captured by the timer callback
var timerLengths = intervals[...]
guard let firstInterval = timerLengths.popFirst() else { return }
// This might feel redundant with init, but remember we may have been restarted
timeLeft = firstInterval
isRunning = true
// Keep track of the timer to invalidate it elsewhere.
// Make self weak so that the Timers can be discarded and it'll clean itself up the next
// time it fires.
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
guard let self = self else {
timer.invalidate()
return
}
// Decrement the timer, or pull the nextInterval from the slice, or stop
if self.timeLeft > 0 {
self.timeLeft -= 1
} else if let nextInterval = timerLengths.popFirst() {
self.timeLeft = nextInterval
} else {
timer.invalidate()
self.isRunning = false
}
}
}
}