One Timer Per Table View Cell

One timer per table view cell

This is how I would do it:

  • Create a struct to represent each row containing a start time and whether that row is currently running
  • Schedule a repeating timer for 0.5 seconds to account for the jitter in Timer
  • Each time the timer fires, update the visible rows that have "running" timers

import UIKit

struct TimerModel {
private var startTime: Date?
private var offset: TimeInterval = 0

var elapsed : TimeInterval {
get {
return self.elapsed(since:Date())
}
}

var isRunning = false {
didSet {
if isRunning {
self.startTime = Date()
} else {
if self.startTime != nil{
self.offset = self.elapsed
self.startTime = nil
}
}
}
}

func elapsed(since: Date) -> TimeInterval {
var elapsed = offset
if let startTime = self.startTime {
elapsed += -startTime.timeIntervalSince(since)
}
return elapsed
}
}

class ViewController: UIViewController {

@IBOutlet weak var tableview: UITableView!

var timer: Timer?
var timersActive = 0

let formatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.zeroFormattingBehavior = .pad
formatter.allowedUnits = [.hour, .minute, .second]
return formatter
}()

var timers = [TimerModel](repeating:TimerModel(), count:30)


override func viewDidLoad() {
super.viewDidLoad()

// Do any additional setup after loading the view, typically from a nib.
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}

func elapsedTimeSince(_ startTime: Date) -> String {
let elapsed = -startTime.timeIntervalSinceNow

return self.formatter.string(from: elapsed) ?? "0:00:00"
}

func startTimer() {
self.timersActive += 1
guard self.timer == nil else {
return
}

self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { [weak self] (timer) in
if let me = self {
for indexPath in me.tableview.indexPathsForVisibleRows ?? [] {
let timer = me.timers[indexPath.row]
if timer.isRunning {
if let cell = me.tableview.cellForRow(at: indexPath) {
cell.textLabel?.text = me.formatter.string(from: timer.elapsed) ?? "0:00:00"
}
}
}
}
})
}

func stopTimer() {
self.timersActive -= 1
if self.timersActive == 0 {
self.timer?.invalidate()
self.timer = nil
}
}

}

extension ViewController: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.timers.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let timer = self.timers[indexPath.row]

cell.imageView?.image = timer.isRunning ? #imageLiteral(resourceName: "GreenDot") : #imageLiteral(resourceName: "RedDot")
cell.textLabel?.text = self.formatter.string(from: timer.elapsed) ?? "0:00:00"

return cell
}
}

extension ViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
self.timers[indexPath.row].isRunning = !self.timers[indexPath.row].isRunning
self.tableview.reloadRows(at: [indexPath], with: .none)
if self.timers[indexPath.row].isRunning {
self.startTimer()
} else {
self.stopTimer()
}
}
}

Countdown timer in table view cell shows different values after scrolling

This is quite a lot of code but from what you are describing your issue is in reusing cells.

You would do well to separate the timers out of the cells and put them inside your objects. It is where they belong (or in some manager like view controller). Imagine having something like the following:

class MyObject {
var timeLeft: TimeInterval = 0.0 {
didSet {
if timeLeft > 0.0 && timer == nil {
timer = Timer.scheduled...
} else if timeLeft <= 0.0, let timer = timer {
timer.invalidate()
self.timer = nil
}
delegate?.myObject(self, updatedTimeLeft: timeLeft)
}
}

weak var delegate: MyObjectDelegate?
private var timer: Timer?

}

Now all you need is is a cell for row at index path to assign your object: cell.myObject = myObjects[indexPath.row].

And your cell would do something like:

var myObject: MyObject? {
didSet {
if oldValue.delegate == self {
oldValue.delegate = nil // detach from previous item
}
myObject.delegate = self
refreshUI()
}

}

func myObject(_ sender: MyObject, updatedTimeLeft timeLeft: TimeInterval) {
refreshUI()
}

I believe the rest should be pretty much straight forward...

Time Countdown on each cell of UITableView in iOS

I’d rather suggest you to create a custom cell and in that cell class, create a timer based on your value from server. This way every cell would have its own timer. That’s what you probably want.
In your current implementation, your timer handler is unaware of cell or its row number.

class MyCustomCell: UITableViewCell {
@IBOutlet weak private var myLabel: UILabel!

private var timer: Timer?
private var timeCounter: Double = 0

var expiryTimeInterval: TimeInterval? {
didSet {
startTimer()
}
}

private func startTimer() {
if let interval = expiryTimeInterval {
timeCounter = interval
if #available(iOS 10.0, *) {
timer = Timer(timeInterval: 1.0,
repeats: true,
block: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.onComplete()
})
} else {
timer = Timer(timeInterval: 1.0,
target: self,
selector: #selector(onComplete),
userInfo: nil,
repeats: true)
}
}
}

@objc func onComplete() {
guard timeCounter >= 0 else {
timer?.invalidate()
timer = nil
return
}
myLabel.text = String(format: "%d", timeCounter)
timeCounter -= 1
}
}

Usage

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell") as! MyCustomCell

cell.expiryTimeInterval = 10

return cell
}

Go to you cell nib or storyboard and change the class name for TimeCell to MyCustomCell

UITableViewCell contents change when scrolling because running a timer in one of the cell

So your problem is that you are creating the timer in the cellForRowAt:. The timer also works for the cell that was created by reusing this cell. So you have to implement this timer logic in the StatsuSessionTableViewCell. Also implement prepareForReuse method in the cell and invalidate the timer there. This is the safest way that I can suggest.

How to increase the size of the tableview cell gradually upon holding it

I don't like to explain things in details using words, so instead of a long answer, I will just include a relatively short code (available also as gist) that contains comments explaining the basics. I haven't really paid a lot of attention to architecture and clean code, I just focused on getting the task done. Take the code as such - try to make it better in your codebase.

Anyway, the code should be pretty clear and self-explanatory, but I would like to just sketch a big picture for you before you dive into it. In the solution, I keep the heights in an array of CGFloat (variable cellHeights) and modify a height for a given row by changing the corresponding height in an array. That array serves as a basis for heightForRowAt implementation.

When long press begins, I start a timer, that every 0.1 seconds updates the height for the selected row by modifying the height in the cellHeights array and telling the tableView to redraw itself. That happens until the limit is reached for that given row and then I simply cancel (invalidate) the timer.

If the long press ends before the limit height is reached, I just explicitly cancel the timer, so that the cell stops enlarging when the user releases the press.

And that's it. I suggest you take the EnlargingCellsOnLongPressController gist (or the one below, it is the same code), try it on your own device, read the code along with the comments, and I believe you should be able to implement it in your own situation.

import UIKit

class EnlargingCellsOnLongPressController: UITableViewController {
// this will hold the indexPath of the cell we are currently enlarging
var enlargingIndexPath: IndexPath?
// dummy data model
var modelItems: [String] = []

// we will need to keep the heights for each particular cell (since any can be resized)
var cellHeights: [CGFloat] = []

// some height limit, I will set it for myself to 200
let limitHeight = CGFloat(200)

// the enlarging itself will be done by a timer
weak var timer: Timer?

override func viewDidLoad() {
super.viewDidLoad()
// nothing special here
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
tableView.estimatedRowHeight = 100
tableView.allowsSelection = false

// creating some dummy data, 30 items, and for each a cell height that will start at 100
for index in 0..<30 {
modelItems.append("Item \(index)")
// by default we will start with 100
cellHeights.append(CGFloat(100))
}

// please, use swift 4 and the new #selector syntax
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPress(longPressGestureRecognizer:)))
longPressRecognizer.minimumPressDuration = 1
self.view.addGestureRecognizer(longPressRecognizer)
}

// following three methods should be clear
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return modelItems.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = modelItems[indexPath.row]
return cell
}

// for height for row we will return a specific height from cellHeights array
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return cellHeights[indexPath.row]
}

@objc func longPress(longPressGestureRecognizer: UILongPressGestureRecognizer) {
if longPressGestureRecognizer.state == UIGestureRecognizerState.began {
let touchPoint = longPressGestureRecognizer.location(in: self.tableView)
if let indexPath = tableView.indexPathForRow(at: touchPoint) {
//when the press starts on a cell, we will keep the indexPath for the cell
self.enlargingIndexPath = indexPath
// and turn on enlarging
self.startEnlarging()
}
} else if longPressGestureRecognizer.state == .ended {
// when the press is ended, we can stop enlarging
stopEnlarging()
}
}

func startEnlarging() {
// interval 0.1 second seems smooth enough (redraw seems to be animated anyway)
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] (timer) in
guard let strongSelf = self, let enlargingIndexPath = self?.enlargingIndexPath else { return }

let oldHeight = strongSelf.cellHeights[enlargingIndexPath.row]
// since the timer repeats every 0.1 second, this will enlarge the cell 20 points per second till limit
// in one cycle I will enlarge the cell by two points
let newHeight = oldHeight + 2
if newHeight < strongSelf.limitHeight {
// if the newHeight did not reach limit,
// update height and redraw tableView
strongSelf.cellHeights[enlargingIndexPath.row] = newHeight
strongSelf.tableView.beginUpdates()
strongSelf.tableView.setNeedsLayout()
strongSelf.tableView.endUpdates()
} else {
// reached maximum size, just cancel the timer
strongSelf.stopEnlarging()
}
})
}

func stopEnlarging() {
// this just cancels the timer
timer?.invalidate()
}
}

UPDATE

For the sake of completeness, I have create a gist using autolayout and UITableViewAutomaticDimension, if you ever decide to use that instead of heightForRowAt. But the principle is the same.



Related Topics



Leave a reply



Submit