Group Group Group Group Group Group Group Group Group

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

}