Adding Multiple Countdown Timers to UICollectionView - ios

My goal is to have a unique (hh:mm:ss) countdown timer for each populated uicollectionviewcell within a uicollectionview.
Below is the (abbreviated) code I've been able to compile, but am stuck on getting it to really work in any capacity...any guidance would be appreciated.
class VC: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
var timer = Timer()
var secondsRemaining: Int?
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath as IndexPath) as! ExampleCollectionViewCell
let expirationDate = Calendar.current.date(byAdding: .hour, value: 24, to: startDate)
secondsRemaining = expirationDate?.seconds(from: startDate)
timer = Timer(timeInterval: 1, target: self, selector: #selector(countdown), userInfo: nil, repeats: true)
return cell
}
#objc func countdown() {
secondsRemaining = secondsRemaining! - 1
if secondsRemaining == 0 {
print("CELL TIME HAS EXPIRED!")
timer.invalidate()
} else {
let hours = secondsRemaining! / 3600
let minutes = (secondsRemaining! % 3600) / 60
let seconds = (secondsRemaining! % 3600) % 60
let countdownLabelString = String(format: "%02d:%02d:%02d", hours, minutes, seconds)
//i need to take this countdownLabelString and set it to the
//countdownLabel.text that is an element of each cell. i tried
//doing all of this in the cell subclass, but no luck...
}
}
}

Collection view cells are not made to update constantly. If you want them to update, you have to tell them to update.
If you want each cell to show a different amount of time remaining, you need a data model that saves a separate expirationDate for each indexPath you display. If your collection view is a simple one-dimensional list of items, like a table view, then you could just use a 1-dimensional array. If your collection view shows a grid of cells you might need a 2 dimensional array of model objects.
Each time you add an item to your data model, include an expirationDate value. (The Date when that item expires.)
Then have your timer call the collection view's reloadData method each time it fires.
In your collectionView(collectionView:cellForItemAt:) method, use the indexPath to figure out which item in your data model you are displaying. Look up the expirationDate for that indexPath, calculate the time remaining from now until your expiration date, and display that information in the cell. (Use the Calendar method dateComponents(_:from:to:) to calculate the number of hours, minutes, and seconds between now and your expiration date.)
EDIT
DO NOT decrement a counter every time your timer fires. Do math on the difference between your expiration date and the current date using dateComponents(_:from:to:)
EDIT #2:
A sample collection view controller that does something like what you want is below:
import UIKit
private let reuseIdentifier = "aCell"
class DatesCollectionVC: UICollectionViewController {
weak var timer: Timer?
var dates: [Date] = []
lazy var componentsFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
return formatter
}()
override func viewDidLoad() {
super.viewDidLoad()
for _ in 1...100 {
let interval = Double(arc4random_uniform( 10000)) + 120
dates.append(Date().addingTimeInterval(interval))
}
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
self.collectionView?.reloadData()
}
}
// MARK: - UICollectionViewDataSource
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dates.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? MyCell else {
return UICollectionViewCell()
}
let now = Date()
let expireDate = dates[indexPath.row]
guard expireDate > now else {
cell.intervalLabel.text = "00"
return cell
}
let desiredComponents: Set<Calendar.Component> = [.hour, .minute, .second]
let components = Calendar.current.dateComponents(desiredComponents, from: now, to: expireDate)
cell.intervalLabel.text = self.componentsFormatter.string(from: components)
return cell
}
}
The code above looks like this:

Related

how to change button icon only if time in json response is updated?

I have to change a button icon only if the time in response changes. So I created a timer function which hits api every 30 seconds and reloads table. Here is the code:
class HomeViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
var refreshed = false
override func viewDidLoad() {
super.viewDidLoad()
table.dataSource = self
table.delegate = self
self.getPosts()
timer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { (timer) in
self.refreshed = true
self.getPosts()
}
}
//TableView Datasource
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let data = posts[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "HomeTableViewCell", for: indexPath) as! HomeTableViewCell
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" // Pass any format here you want according to your date response
let convDate = dateFormatter.date(from: data.postUpdatedAt ?? "")
if refreshed == false {
cell.timeBeforeRefreshInterval = convDate
}
else {
cell.timeAfterRefreshInterval = convDate
if cell.timeAfterRefreshInterval == cell.timeBeforeRefreshInterval {
cell.button.setImage(UIImage(named: "1"), for: .normal)
}
else {
cell.button.setImage(UIImage(named: "2"), for: .normal)
cell.timeBeforeRefreshInterval = cell.timeAfterRefreshInterval
refreshed = false
}
}
return cell
}
}
class HomeTableViewCell: UITableViewCell {
var timeBeforeRefreshInterval : Date?
var timeAfterRefreshInterval : Date?
}
First I created variable to store date response in HomeViewController but it will always store the response of last cell created so I added those variables in HomeTableVIewCell. However its still not working. Is there any better logic to compare times of post and update your cell according to that? All I wanna do is compare time, if someone posts something in group, time gets updated and I need to change button icon to show someone has posted something new.

Timers aren't updating in UITableView iOS

I am using countdown times in tableView for each cell as shown in the image.
TableView with Countdown timer
If I use the following code for a simple label in the controller, all works fine.
var duration = 0.0
func clock(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self](Timer) in
guard let strongSelf = self else { return }
strongSelf.duration -= 0.01
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.hour,.minute,.second]
formatter.zeroFormattingBehavior = .pad
strongSelf.timerLabel.text = formatter.string(from: strongSelf.duration)
})
}
But if I write similar for tableViewCell, this only shows values, but not updating them.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier:"myTimer", for: indexPath) as? UITableViewCell
let label = cell?.contentView.viewWithTag(13) as? UILabel
milisecondsClock(index: indexPath.row, label: label!)
return cell!
}
func milisecondsClock(index: Int,label: UILabel){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self](Timer) in
guard let strongSelf = self else { return }
strongSelf.myList[index] -= 0.01
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.hour,.minute,.second]
formatter.zeroFormattingBehavior = .pad
label.text = formatter.string(from: strongSelf.myList[index])
})
}
How do I show the timer in tableview with real-time countdown?
Table cells are not updated because timers created at millisecondsClock have no owner object and are immideately deleted by ARC. You should store your timers in your view controller the same way you store myList. You can find more info about ARC at https://docs.swift.org/swift-book/LanguageGuide/AutomaticReferenceCounting.html
You also shouldn't pass UILabel into millisecondsClock method because cells are reused by UITableView. That means that the same cell and UILabel inside that cell may be used several times to render contant at different index paths. In your case that means that several timers may simultaneously update the same label. I think you should use func cellForRow(at indexPath: IndexPath) -> UITableViewCell? inside timer block to get the correct cell to update the time.

SubViews being duplicated across collectionView cells?

I have a collectionView that represents a span of time. Each cell is a month. In each cell there is a horizontal stack view where each view represents a day.
The full project is here if you wanna try it out: https://github.com/AlexMarshall12/iOS-timeline
In my ViewController, I generate a random array of dates. Each time a cell is called in cellForItemAt, a custom function finds the dates in the array which are in the cell month.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MonthCollectionViewCell", for: indexPath) as! MonthCollectionViewCell
cell.backgroundColor = UIColor.gray
let firstDate = dates.first
let index = indexPath.item
let monthDate = Calendar.current.date(byAdding: .month, value: index, to: firstDate as! Date)
let monthInt = Calendar.current.component(.month, from: monthDate!)
let yearInt = Calendar.current.component(.year, from: monthDate!)
cell.monthLabel.text = String(monthInt)+" "+String(yearInt)
cell.year = yearInt
cell.month = monthInt
let monthDates = dates(self.dates as! [Date], withinMonth: monthInt, withinYear: yearInt)
cell.colorViews(monthDates:monthDates)
return cell
}
func dates(_ dates: [Date], withinMonth month: Int, withinYear year: Int) -> [Date] {
let calendar = Calendar.current
let components: Set<Calendar.Component> = [.month,.year]
let filtered = dates.filter { (date) -> Bool in
let monthAndYear = calendar.dateComponents(components, from: date)
return (monthAndYear.month == month && monthAndYear.year == year)
}
return filtered
}
These are passed to the custom cell class and it finds the subviews which represent those days and colors them like so:
class MonthCollectionViewCell: UICollectionViewCell {
#IBOutlet weak var monthLabel: UILabel!
#IBOutlet weak var stackView: UIStackView!
var year: Int?
var month: Int?
override func awakeFromNib() {
super.awakeFromNib()
for _ in 0...30 { //for the 31 days in a month...
let tick = UIView()
self.stackView?.addArrangedSubview(tick)
}
}
func colorViews(monthDates: [Date]){
for date in monthDates {
let dayIndex = Calendar.current.component(.day, from: date)
let tick = stackView.arrangedSubviews[dayIndex]
tick.backgroundColor = UIColor.red
}
}
}
I believe this should give me the desired affect. For instance if there are 3 dates generated across 2 years, it would create 3 stripes across 2 years worth of month cells. However, I find that many more than 3 stripes are rendered, almost like its duplicating the subview across many cells.
Here is what it looks like now: https://imgur.com/a/ZNSmIS8. Note this was with 3 dates. 5 stripes appear. It seems that scrolling back and forth adds stripes as though cellForItemAt is being called on the wrong cell? Im not exactly sure.
Any advice on what I am doing wrong in this implementation would be awesome.
Edit: I have added this method to my custom cell class:
override func prepareForReuse() {
super.prepareForReuse()
for each_view in self.subviews {
print("Cleared!")
each_view.backgroundColor = UIColor.clear
}
}
The same issue is still occurring especially with a really long time range and/or fast scrolling
It doesn't look like you're actually resetting the content of the cells that are being returned to you by dequeueReusableCell.
dequeueReusableCell provides you with a cell that may have been already used in order to optimise for performance. The documentation for the method notes:
If an existing cell was available for reuse, this method calls the cell’s prepareForReuse() method instead.
If you check out the documentation for prepareForReuse(), you will find that you get a chance to reset the properties of the cell so that you can reuse the cell.
If you reset the cell, you will probably find that it starts behaving the way you expect it to.

How to trigger refresh in Supplementary view of UICollectionView

We are using UICollectionView to draw a calendar. The supplementary view is used for the date at the top of a column that represents a day in the calendar. Each calendar is a section in the UICollectionView. We want the current day highlighted. We have that working by
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
var view: UICollectionReusableView?
if kind == WeeklyCollectionElementKindDayColumnHeader {
let dayColumnHeader: WeeklyDayColumnHeader? = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: WeeklyDayColumnHeaderReuseIdentifier, for: indexPath) as? WeeklyDayColumnHeader
let day: Date? = collectionViewCalendarLayout?.dateForDayColumnHeader(at: indexPath)
let currentDay: Date? = currentTimeComponents(for: self.collectionView!, layout: collectionViewCalendarLayout!)
let startOfDay: Date? = Calendar.current.startOfDay(for: day!)
let startOfCurrentDay: Date? = Calendar.current.startOfDay(for: currentDay!)
dayColumnHeader?.day = day
dayColumnHeader?.isCurrentDay = startOfDay == startOfCurrentDay
view = dayColumnHeader
}
else if kind == WeeklyCollectionElementKindTimeRowHeader {
let timeRowHeader: WeeklyTimeRowHeader? = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: WeeklyTimeRowHeaderReuseIdentifier, for: indexPath) as? WeeklyTimeRowHeader
timeRowHeader?.time = collectionViewCalendarLayout?.dateForTimeRowHeader(at: indexPath)
view = timeRowHeader
}
return view!
}
which with this
class WeeklyDayColumnHeader: UICollectionReusableView {
var day: Date? {
didSet {
var dateFormatter: DateFormatter?
if dateFormatter == nil {
dateFormatter = DateFormatter()
dateFormatter?.dateFormat = "E d"
}
self.title!.text = dateFormatter?.string(from: day!)
setNeedsLayout()
}
}
var isCurrentDay: Bool = false {
didSet {
if isCurrentDay {
title?.textColor = UIColor.white
title?.font = UIFont.boldSystemFont(ofSize: CGFloat(16.0))
titleBackground?.backgroundColor = UIColor(red:0.99, green:0.22, blue:0.21, alpha:1.0)
}
else {
title?.font = UIFont.systemFont(ofSize: CGFloat(16.0))
title?.textColor = UIColor.black
titleBackground?.backgroundColor = UIColor.clear
}
}
}
set the background we want the first time I come into this view. But if I have this view open and the date changes, how do I update the header? Where should I move the logic that sets the date, and how do I "invalidate" or whatever I need to do?
Use the UIApplicationSignificantTimeChangeNotification in order to notify you of time changes.
Use the NotificationCenter to implement this. Something like this:
NotificationCenter.defaultCenter().addObserver(self, selector: "dayChanged:", name: UIApplicationSignificantTimeChangeNotification, object: nil)
Then you need a function to handle the change:
func dayChanged(notification: NSNotification){
// trigger the changes you need here
}

NSTimer update in real time?

I have an NSTimer and I want to update a label in real time which shows a timer's time. The following is my implementation of how I came about doing so:
override func viewDidAppear(animated: Bool) {
self.refreshTimer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: "refreshView:", userInfo: nil, repeats: true)
var currentRunLoop = NSRunLoop()
currentRunLoop.addTimer(refreshTimer, forMode: NSRunLoopCommonModes)
}
func refreshView(timer: NSTimer){
for cell in self.tableView.visibleCells(){
var indexPath = self.tableView.indexPathForCell(cell as! UITableViewCell)
self.tableView(self.tableView, cellForRowAtIndexPath: indexPath!)
}
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
return offerCellAtIndexPath(indexPath)
}
func offerCellAtIndexPath(indexPath: NSIndexPath) -> OfferCell{
//Dequeue a "reusable" cell
let cell = tableView.dequeueReusableCellWithIdentifier(offerCellIdentifier) as! OfferCell
setCellContents(cell, indexPath: indexPath)
return cell
}
func setCellContents(cell:OfferCell, indexPath: NSIndexPath!){
let item = self.offers[indexPath.row]
var expirDate: NSTimeInterval = item.dateExpired()!.doubleValue
var expirDateServer = NSDate(timeIntervalSince1970: expirDate)
//Get current time and subtract it by the predicted expiration date of the cell. Subtract them to get the countdown timer.
var timer = self.modelStore[indexPath.row] as! Timer
var timeUntilEnd = timer.endDate.timeIntervalSince1970 - NSDate().timeIntervalSince1970
if timeUntilEnd <= 0 {
cell.timeLeft.text = "Finished."
}
else{
//Display the time left
var seconds = timeUntilEnd % 60
var minutes = (timeUntilEnd / 60) % 60
var hours = timeUntilEnd / 3600
cell.timeLeft.text = NSString(format: "%dh %dm %ds", Int(hours), Int(minutes), Int(seconds)) as String
}
}
As seen by the code, I try to do the following:
dispatch_async(dispatch_get_main_queue(), { () -> Void in
cell.timeLeft.text = NSString(format: "%dh %dm %ds", Int(hours), Int(minutes), Int(seconds)) as String
})
Which I thought would help me update the cell in real time, however when I view the cells, they are not being updated in real time. When I print out the seconds, minutes, and hours, they are being updated every 1 second which is correct. However, the label is just not updating. Any ideas on how to do this?
EDIT: DuncanC's answer has helped a bunch. I want to also delete the timers when their timers go to 0. However, I am getting an error saying that there is an inconsistency when you delete and insert most likely extremely quickly without giving the table view any time to load. Here is my implmentation:
if timeUntilEnd <= 0 {
//Countdown is done for timer so delete it.
self.offers.removeAtIndex(indexPath!.row)
self.tableView.deleteRowsAtIndexPaths([indexPath!], withRowAnimation: UITableViewRowAnimation.Fade)
self.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: UITableViewRowAnimation.Fade)
cell.timeLeft.text = "Finished."
}
Your problem isn't with timers. Your problem is that you are handling table views totally wrong. In addition to the problems pointed out by Matt in his answer, your timer method refreshView makes no sense.
func refreshView(timer: NSTimer)
{
for cell in self.tableView.visibleCells()
{
var indexPath = self.tableView.indexPathForCell(cell as! UITableViewCell)
self.tableView(self.tableView, cellForRowAtIndexPath: indexPath!)
}
}
You are looping through the visible cells, asking for the indexPath of each cell, and then asking the table view to give you that cell again. What do you think this will accomplish? (The answer is "nothing useful".)
What you are supposed to do with table views, if you want a cell to update, is to change the data behind the cell (the model) and then tell the table view to update that cell/those cells. You can either tell the whole table view to reload by calling it's reloadData method, or use the 'reloadRowsAtIndexPaths:withRowAnimation:' method to reload only the cells who's data have changed.
After you tell a table view to reload certain indexPaths, it calls your cellForRowAtIndexPath method for the cells that need to be redisplayed. You should just respond to that call and build a cell containing the updated data.
This is madness:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
return offerCellAtIndexPath(indexPath)
}
func offerCellAtIndexPath(indexPath: NSIndexPath) -> OfferCell{
//Dequeue a "reusable" cell
let cell = tableView.dequeueReusableCellWithIdentifier(offerCellIdentifier) as! OfferCell
setCellContents(cell, indexPath: indexPath)
return cell
}
Your job in cellForRowAtIndexPath: is to return the cell - now. But setCellContents does not return any cell to offerCellAtIndexPath, so the cell being returned is merely the empty OfferCell from the first line. Moreover, setCellContents cannot return any cell, because it contains an async, which will not run until after it has returned.
You need to start by getting off this triple call architecture and returning the actual cell, now, in cellForRowAtIndexPath:. Then you can worry about the timed updates as a completely separate problem.
You could use a dispatch timer:
class func createDispatchTimer(
interval: UInt64,
leeway: UInt64,
queue: dispatch_queue_t,
block: dispatch_block_t) -> dispatch_source_t?
{
var timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue)
if timer != nil
{
dispatch_source_set_timer(timer, dispatch_walltime(nil, 0), interval, leeway)
dispatch_source_set_event_handler(timer, block)
return timer!
}
return nil
}

Resources