Array of Timers

I am trying to create an application that would run several timers in a table view and I got stuck on the initialisation of timers. I try to adapt this cool tutorial https://www.raywenderlich.com/113835-ios-timer-tutorial#toc-anchor-002 to my needs. Right now I only want to be able to fire an appropriate timer by clicking a table view cell that would, in its turn, call updateTime() in TimerTableViewCell. I seem not to be able to instantiate several timers at the same time. When one timer is running, clicking other cells won’t result in anything. Is anybody able to let me know where I am making a mistake?

TableViewController with list of timers looks like this right now:

import UIKit
class TimerListTableViewController: UITableViewController {
// MARK: - Properties
var timers: [MyTimer] = []
var running: [String] = []
var ticker: Timer?

override func viewDidLoad() {
    super.viewDidLoad()
    
    // load fake timer array on app start
    loadTimerArray()
}

// fake timer data array
func loadTimerArray() {
    let timer1 = MyTimer(title: "Cucumbers", minutes: 30, seconds: 15)
    let timer2 = MyTimer(title: "Eggs", minutes: 8, seconds: 0)
    let timer3 = MyTimer(title: "Spagetti", minutes: 3, seconds: 0)
    timers += [timer1, timer2, timer3]
}

// MARK: - TableView data source
// there will always be one section in table
override func numberOfSections(in tableView: UITableView) -> Int {
    return 1
}

// number of row equals number of timers in timers array
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return timers.count
}

// cell/row set up
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    let cell = tableView.dequeueReusableCell(withIdentifier: "TimerCell", for: indexPath)
    
    if let cell = cell as? TimerTableViewCell {
      cell.timer = timers[indexPath.row]
    }
    
    return cell
}

// MARK: - TableViewDelegate
// when rows tapped
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // deselect row after tap
    tableView.deselectRow(at: indexPath, animated: true)
    
    let context = ["timerID":indexPath.row]
    createTimer(with: context)
}

}

// MARK: - Timer
extension TimerListTableViewController {
@objc func updateTimer(_ timerWithContext: Timer) {
    let context = timerWithContext.userInfo as! [String:Int]
    let cellIndexPath = IndexPath(row: context["timerID"]!, section: 0)
    if let cell = tableView.cellForRow(at: cellIndexPath) as? TimerTableViewCell {
        cell.updateTime()
        print(cellIndexPath)
    }
}

func createTimer(with context: [String:Int]) {
    let context = context
    
    // in case theres no timer instance
    if ticker == nil {
        print("*** timer with \(context)")
        // set timer to repeatedly call updateTimer
        let ticker = Timer(timeInterval: 1.0, target: self, selector: #selector(updateTimer), userInfo: context, repeats: true)
        
        // using commmon runloop to preven timers from freezing when scrolling
        RunLoop.current.add(ticker, forMode: .common)
        
        // add timer tolerance reduce energy impact
        ticker.tolerance = 0.1
        self.ticker = ticker
    }
}

// called to cancel timer
func cancelTimer() {
    ticker?.invalidate()
    ticker = nil
}
}

I’m not an expert but just a guess that viewdidload is called after numberofrowsinsection. Maybe test specifying 3 rows instead of timers.count. Not sure if you need to refresh the table view.

Rather than using multiple actual timers and runloops, I think I’d be tempted to make a simple pseudo-timer than knew only what the current time was at the moment it started, then did some simple arithmetic to work out how long it had been running and whether it had expired.
You’d only need one actual Timer to drive the updating.

1 Like

Yeah, it took me a while to understand what a bunch of nonsense have I wrote up there. The way I ended up tackling the task is I have one instance of Timer() starting up on TableViewCell tap firing up a function that goes through the array of timers of MyTimer() class and calling updates on them, in case their running state is true. That particular function and its implementation is basically the most important lesson of the tutorial that I needed to learn. TableViewCell tap does toggle the running state of MyTimer() as well, making it possible to start and stop updates on a timer. All that is left to do to finish this up is to implement adding new timer view and functionality. I hope this all makes more sense than my previous post.

at the end of createTimer test putting self.tableView.reloadData()

Thats what I was trying to do a few years back, failing miserably and leaving all attempts of learning iOS programming for a long while. You don’t need to reload TableView, apparently, to change the label text in table view cells and you only need one(!) instance of Timer.

If you are interested, I can try to explain more thoroughly what I ended up doing:

  1. I have an array of objects of a made up class of MyTimer. The class holds data for my countdown timers: hours, minutes, seconds, total running time in seconds and running status of the timer (a Boolean: timer is either running or not). Right now the array is filled with dummy data but I am implementing a view that will take user input and put new timer into the array. The whole thing is being displayed in a TimerListTableView.

  2. There is a startTimer() function, that checks if no instance of Timer is running and starts one up, scheduling it to fire up updateTimers() function every second. There is a stopTimer() function that stops and destroys Timer object when needed.

When user taps a cell in TimerListTableView I call startTimer() function and toggle the state of a timer with an index that corresponds to TableViews’s IndexPath.row, since timers are displayed in the same order they are in array.

  1. updateTimers() function is where the magic happens: here I iterate through the array of timers and check whether the status of the timer is running or not. If timer is running, I figure out its index in the array and call a function in a corresponding cell. That function subtracts total running time by 1, formats time string and displays it in a label. After that I check whether there are any running timers in the timer array and in case there are none I call stopTimer() function.

Thats it. Only one Timer instance is necessary and you do most of the work in a function that the timer is calling. That was the lesson I took from the tutorial.

class TimerListTableViewController: UITableViewController {
// MARK: - Properties
var timers: [MyTimer] = []
var ticker: Timer?

@IBOutlet weak var newTimerButton: UIBarButtonItem!

override func viewDidLoad() {
    super.viewDidLoad()
    
    // load timer array on app start
    loadTimerArray()
}

// fake timer data array
func loadTimerArray() {
    let timer3 = MyTimer(title: "Cucumbers", hours: 0, minutes: 0, seconds: 10)
    let timer1 = MyTimer(title: "Eggs", hours: 0, minutes: 8, seconds: 0)
    let timer2 = MyTimer(title: "Spagetti", hours: 0, minutes: 10, seconds: 0)
    let timer4 = MyTimer(title: "Beouf bourguignon", hours: 10, minutes: 10, seconds: 0)
    timers += [timer1, timer2, timer3, timer4]
}

// MARK: - TableView data source
// there will always be one section in table
override func numberOfSections(in tableView: UITableView) -> Int {
    return 1
}

// number of row equals number of timers in timers array
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return timers.count
}

// cell/row set up
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    let cell = tableView.dequeueReusableCell(withIdentifier: "TimerCell", for: indexPath)
    let timer = timers[indexPath.row]
    
    if let cell = cell as? TimerTableViewCell {
        cell.timer = timer
    }
    
    return cell
}

// MARK: - TableViewDelegate
// when rows tapped
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // deselect row after tap
    tableView.deselectRow(at: indexPath, animated: true)
    // toggle timer state
    timers[indexPath.row].isRunning.toggle()
    startTimer()
    
}

// MARK: - Timer Methods
func startTimer() {
    if ticker == nil {
        ticker = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(updateTimers), userInfo: nil, repeats: true)
    }
}

// method to be called by ticker every second of countdown
@objc func updateTimers() {
    for timer in timers {
        if timer.isRunning {
            // get index of running timer
            if let timerIndex = timers.firstIndex(where: {$0 === timer}) {
                // create IndexPath from index of running timer
                let indexPath = IndexPath(row: timerIndex, section: 0)
                // start updating cell at index of running timer
                let cell = tableView.cellForRow(at: indexPath) as! TimerTableViewCell
                cell.updateTime()
            }
        }
    }
    // array of running timers
    let runningTimers = timers.filter{$0.isRunning}
    // invalidate ticker if no timers running
    if runningTimers.count == 0 {
        stopTimer()
    }
}

func stopTimer() {
    // destroy Timer obj
    if ticker != nil {
        ticker?.invalidate()
        ticker = nil
        print("*** Ticker invalidated")
    }

}

}

class TimerTableViewCell: UITableViewCell {

var initialTime = 0

@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var timeLabel: UILabel!

var timer: MyTimer? {
    didSet {
        titleLabel.text = timer!.title
        timeLabel.text = displayTime(of: timer!)
        initialTime = timer!.runTime
    }
}

func updateTime() {
    if let timer = timer {
        if timer.runTime != 0 {
            timer.runTime -= 1
            
        } else {
            timer.isRunning = false
            timer.runTime = initialTime
        }
        
        timeLabel.text = displayTime(of: timer)
    }
}

// return formatted time string
func displayTime(of timer: MyTimer) -> String {
    let timer = timer
    let hours = timer.runTime / 3600
    let minutes = (timer.runTime / 60) % 60
    let seconds = timer.runTime % 60
    
    return (hours < 10 ? "0" : "") + String(hours) + ":" + (minutes < 10 ? "0" : "") + String(minutes) + ":" + (seconds < 10 ? "0" : "") + String(seconds)
}

}

Hi @glepp,
Glad that you found a solution to this, however a couple of pointers from what I have experienced in my career,

  1. Timers can become slow especially when dealing with more than a couple
  2. If you have an array of timers, and a loop to process them, that adds the O(n) complexity and adds to that the processing time of the previous timer trigger.
  3. You might want to consider a different approach to see how you can manage multiple timers

AS I see in the sample code, you want egg timers, you could still have one timer that looks at the duration and triggers the code for that duration instead.

cheers,

Hello, Jayant. Thanks for participating and your advice. Could you please point me in the direction of the approach of thinking about this problem? What type of a solution it might be?

Right now I actually do trigger a function that handles a total running time of every individual timer in the array that has isRunning set to true. I wonder what is there to be done in order to reduce complexity?

Hi @glepp,
This will work for apps that do not need a granular per second timer, this is more for the use case scenario you mentioned, that wait for a period of time and have a trigger to do something and then end.

Create a central receiver like NotificationCenter, then you can register the various tasks with this. Which could be in the form of duration, name and a callback. The timer can simply increment a counter and check if the Counter + startCounter equals a certain duration and if it does then you can trigger that callback

this way you can add several tasks and manage a single timer stack. This is a simple way to manage it.

I thought the main problem was the info just not displaying on screen, is this the case?

nah, that was just a messy post I made out of frustration. I made a lot of silly mistakes in my code, didn’t fully comprehend the lesson of the tutorial and couldn’t articulate the question well.

This topic was automatically closed after 166 days. New replies are no longer allowed.