Guidance needed on using CADisplayLink with tableViewCells - ios

I am creating an app that contains lists which will be valid for a certain amount of time. For that I am creating a countdown bar animation on each cell. Each cell has it's own custom duration after which the cell displays "Time's Up". The cell even shows the time in seconds as it ticks down to 0 and then displays "Time's Up" message.
Right above this, there is a countdown bar animation. I am controlling all these animations from the viewController and not doing these in the customView cell as cell reusability makes the timers go haywire.
I am using two timers:
Timer() : that invokes every 1 second. This is used for the simple seconds countdown. I am using an array of type struct that houses two variables durationTime & width. The width is set to 400 by default. The durationTime is set by the user.
#objc func handleCountdown() {
let arrayLength = duration.count
var i: Int = 0
var timeCount: Int = 0
for _ in 0..<arrayLength {
let value = duration[i].durationTime - 1 //Decreasing the timer every second
let indexPath = IndexPath(row: i, section: 0)
if (value > 0)
{
duration[i].durationTime = value
if let cell = listTableView.cellForRow(at: indexPath) as! listTableViewCell? {
cell.testlabel.text = "\(value)"
}
//listTableView.reloadRows(at: [indexPath], with: .automatic)
i = i + 1
} else {
if let cell = listTableView.cellForRow(at: indexPath) as! listTableViewCell? {
cell.testlabel.text = "Time's Up"
}
i = i + 1
timeCount = timeCount + 1
}
}
if (timeCount == (arrayLength - 1)) {
self.timer?.invalidate()
}
}
The above code is invoked every second. It decrements the value of the time, and then displays it in the tableview cell. The above code works fine as it should.
CADisplayLink: this timer is used to run the progress bar animation. In this case however, I am calculating the elapsed time and dividing it by the durationTime of each element and calculating a percentage. This percentage is used to update the value of width of each progress bar. (Keep in mind that durationBar is simply duplicate of duration array. In this array however, the durationTime is not being decremented -> Just for testing purposes) Here is the code:
#objc func handleProgressAnimation() {
let currenttime = Date()
let elapsed = currenttime.timeIntervalSince(animationStartDate)
let totalWidth: Double = 400.0
print(elapsed)
let arrayLength = duration.count
var i: Int = 0
for _ in 0..<arrayLength {
let indexPath = IndexPath(row: i, section: 0)
let percentage = (elapsed / Double(durationBar[i].durationTime))
let newWidth = Double(totalWidth - (totalWidth * percentage))
durationBar[i].width = newWidth
if let cell = listTableView.cellForRow(at: indexPath) as! listTableViewCell? {
cell.timeRemainingView.frame.size.width = CGFloat(durationBar[indexPath.row].width)
}
i = i + 1
}
}
Question: After some time, some progress bars just disappear from the cells even though the time has not been completed. It occurs most often once I have done some scrolling on the tableview.
This is willDisplayCell & cellForRowIndexPath:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellid, for: indexPath) as! listTableViewCell
return cell
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let cell:listTableViewCell = cell as! listTableViewCell
cell.testlabel.text = "\(duration[indexPath.row].durationTime)"
cell.timeRemainingView.frame.size.width = CGFloat(duration[indexPath.row].width)
}
As you can in pictures below, after sometime, some progress bars vanished even though there is still some time left:
What is the issue here, I am controlling all the animations and timers from the viewController and not the cell in order to prevent cell reuseability to become a problem. Help is needed!

The thing is that you update cell.timeRemainingView.frame.size.width with
durationBar[indexPath.row].width for handleProgressAnimation and
duration[indexPath.row].width for willDisplayCell.
And also I would switch to tableView.indexPathsForVisibleRows for updating UI only for visible cells so it wouldn't call .cellForRow for every cell in if-let.

Related

PrepareForReuse() to reset UIView width not working

I am stuck at this for a while now, and while I think I know what is the issue I am still not able to solve it. I am working with tableView with each cell having countdown bar animation. Each cell has a custom time duration set by the user. Then a bar animation slides as the time elapses.
I am using CADisplayLink to get this animation running, this code is written in controller file and I am just changing the width of a UIView as the time elapses:
#objc func handleProgressAnimation() {
let currenttime = Date()
let elapsed = currenttime.timeIntervalSince(animationStartDate)
let totalWidth: Double = 400.0
print(elapsed)
let arrayLength = durationBar.count
var i: Int = 0
for _ in 0..<arrayLength {
let indexPath = IndexPath(row: i, section: 0)
let percentage = (elapsed / Double(durationBar[i].durationTime))
let newWidth = Double(totalWidth - (totalWidth * percentage))
durationBar[i].width = newWidth
if (percentage < 1)
{
if let cell = listTableView.cellForRow(at: indexPath) as! listTableViewCell? {
cell.timeRemainingView.frame.size.width = CGFloat(durationBar[indexPath.row].width)
}
}
else {
if let cell = listTableView.cellForRow(at: indexPath) as! listTableViewCell? {
cell.timeRemainingView.frame.size.width = 400
cell.timeRemainingView.isHidden = true
}
}
i = i + 1
}
}
When all the bars are decreasing, everything goes fine but as one of the cells time is completed then the UIView width becomes zero and then as the user scrolls that cell is reused and this vanishes the progress bar from other cells where there is still time.
I am using prepareforsegue() method to reset the width of the bar to 400(default value) but that doesn't seem to work.
This is cellforRow code:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellid, for: indexPath) as! listTableViewCell
cell.testlabel.text = "\(duration[indexPath.row].durationTime)"
cell.timeRemainingView.frame.size.width = CGFloat(durationBar[indexPath.row].width)
return cell
}
And this is PrepareForReuse()
override func prepareForReuse() {
super.prepareForReuse()
self.timeRemainingView.frame.size.width = 400
self.timeRemainingView.isHidden = false
}
Here is a screenshot. You can see in some cells the time is up, but in others there is still time remaining but the progress bar has disappeared.
You have to set the frame again. width is a get-only property.

Timer will reset and stop running during scrolling UITableView

timer.gif
↑↑↑↑↑↑↑↑↑ I show the problem in this GIF ↑↑↑↑↑
There is a timer and stratButton in the tableView cell, when I click the Button, the timeLabel will start running.
Now the problem is when I scroll the running timer out of the screen and scroll it back, the timer will reset and stop.
Hope someone can help me! I check many solution but they didn't work for me! I am almost cry.
my cellForRow:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", for: indexPath) as! TimerTableViewCell
cell.timer?.invalidate()
let item = myTimerList[indexPath.row]
cell.timerName.text = item.timerName
cell.secondLeftLabel.text = "\(item.timerSecond)"
return cell
}
I created a small project contained my code for you to modify : https://app.box.com/s/axluqkjg0f7zjyigdmx1lau0c9m3ka47
You need to preserve the state of each cell
class Service {
static let shared = Service()
var myTimerList = [TimerClass]()
}
//
here i added another 2 vars , why timerName is left despite you have to init them the same because current will hold the changing value
class TimerClass {
let timerSecond : Int
let timerName : String
var current: Int
var isPlaying = false
init(second:Int, name:String) {
timerSecond = second
timerName = name
current = second
}
}
//
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "myCell", for: indexPath) as! TimerTableViewCell
let item = Service.shared.myTimerList[indexPath.row]
cell.tag = indexPath.row // to access the array inside the cell
if item.isPlaying {
cell.play() // add play method to the cell it has same button play action
}
else {
cell.timer?.invalidate()
}
cell.timerName.text = item.timerName
cell.secondLeftLabel.text = "\(item.current)"
return cell
}
//
inside the cell when the button is clicked , change isPlaying property to true and to false when stopped like this
// here self is the cell itself
Service.shared.myTimerList[self.tag].isPlaying = true
also when the timer ticks change
Service.shared.myTimerList[self.tag].current = // value
You are reusing UITableViewCell. It means once cell is out of screen, it will be reused for the cell getting into the screen. Whenever it happens, it calls the delegate method - means cell.timer?.invalidate() is invoked again and again.
Remove cell.timer?.invalidate() from cellForRowAt method, because every time you scroll the tableView this method is called to display new cells. In your case when you have started a timer for a cell and that cell is not in the view, next time you scroll to bring that cell again to the view cell.timer?.invalidate() causes it to stop.

Why will didDeselectItemAt of UICollectionView throw an error of unexpected found nil and crash the app Swift? [duplicate]

This question already has answers here:
How can I fix crash when tap to select row after scrolling the tableview?
(2 answers)
Closed 6 years ago.
I have used a collection view to display a collection of images. It is working, but when I make use of the function didDeselectItemAt it will crash if I click certain images.
Error that occurs:
fatal error: unexpectedly found nil while unwrapping an Optional value
Code setup:
numberOfItemsInSection:
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return images.count
}
cellForItemAt:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cellIdentifier = "ImageCell"
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as! ImageCollectionViewCell
let imageUrls = collection?.imageUrls
// Configure the cell
// Reset alpha of first item in collection
if indexPath.row == 0 {
cell.imageView.alpha = 1.0
if videoUrl != nil {
cell.backgroundColor = UIColor.red
cell.imageView.alpha = 0.5
}
}
cell.imageView.af_setImage(withURL: URL(string: (imageUrls?[indexPath.row])!)!)
return cell
}
didDeselectItemAt:
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
let cell = collectionView.cellForItem(at: indexPath) as! ImageCollectionViewCell
UIView.animate(withDuration: 0.3, animations: {
cell.imageView.alpha = 0.6
})
}
didSelectItemAt:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print("You've tapped me. NICE!! \(indexPath.row)")
let cell = collectionView.cellForItem(at: indexPath) as! ImageCollectionViewCell
cell.imageView.alpha = 1
let url = self.collection?.imageUrls?[indexPath.row]
self.selectedImageUrl = url
Alamofire.request(url!).responseImage(completionHandler: {
(response) in
if response.result.value != nil {
self.selectedImage.image = response.result.value
}
})
}
Above code is working, but will throw an error if I click certain images. The first cell of every collection view has a alpha of 100% - 1.0 and all other ones has an alpha of 0.6. What I noticed is that - except the first cell of the collection view - every 7th cell has also an alpha of 100% and if the collection view contains a videoUrl it will have a red background and an alpha of 50%..
Is this because of the reusable cells like the UITableViewController, where you use dequeueReusableCell and should you use that function too inside the didDeselectItemAt or is something else not correctly implemented?
So only the first cell in a collection view should have an alpha of 100, all other 60%. If the object has a videoUrl, the first element will have an alpha of 50% with a red background.
If there are any questions left, please let me know. Thanks in advance and have a great evening!
UPDATE:
The following print line will output below code when I click the first image of the collection view and after that the second one. This doesn't crash the app and it will still work
print("OUTPUT \(String(describing: collectionView.cellForItem(at: indexPath)))")
Above code snippet I've placed in both didSelectItemAt and didDeselectItemAt:
didSelectItemAt output:
OUTPUT Optional(<CollectionViewApp.ImageCollectionViewCell: 0x7ff61679c1a0; baseClass = UICollectionViewCell; frame = (150 0; 150 100); clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x6080000347c0>>)
didDeselectItemAt output:
OUTPUT Optional(>)
When I click the first or second image and then the last image in the collection view, the error will occur and will crash my app. The touch on the first image will give me the output:
OUTPUT Optional(<CollectionViewApp.ImageCollectionViewCell: 0x7fc3cb038540; baseClass = UICollectionViewCell; frame = (150 0; 150 100); clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x610000225980>>)
The last item - that will let my app crash - will throw the following output:
OUTPUT Optional(<CollectionViewApp.ImageCollectionViewCell: 0x7fc3cb03afb0; baseClass = UICollectionViewCell; frame = (300 0; 150 100); clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x6100002264c0>>).
So the crash can be triggered, when you click one of the first images in the collection view and after that one of the last images in the collection view, when the first images are out of canvas. I used a horizontal collection view.
So the crash is happening because when selecting an item, didDeselectItemAt can be called for an item that's not visible anymore.
The UICollectionView reuses UICollectionViewCells, this means that the collectionView.cellForItem(at: indexPath) method will return nil for cells that are not visible.
To fix this you can use the following code:
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
guard let cell = collectionView.cellForItem(at: indexPath) as! ImageCollectionViewCell else {
return //the cell is not visible
}
UIView.animate(withDuration: 0.3, animations: {
cell.imageView.alpha = 0.6
})
}
Some other suggestions I have for improving your code:
You have a lot of places where you're force unwrapping values.
Consider taking the following approach:
guard let url = self.collection?.imageUrls?[indexPath.row] else {
fatalError("url was nil")
}
self.selectedImageUrl = url
Alamofire.request(url).responseImage(completionHandler: {
(response) in
if response.result.value != nil {
self.selectedImage.image = response.result.value
}
})
By using the guard statement you force the app to crash with an appropriate error message, which will help you when debugging. It's a better practice compared to force unwrapping.
Also, when dequeing cells, you could go for something like this:
let cellIdentifier = "ImageCell"
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as? ImageCollectionViewCell else {
fatalError("Wrong cell type")
}
This can happen when the cell type is different

Prevent tableview cell recycle label value

I have a tableview with custom cell that contain 1 label and 1 button. The button is use for everytime I tap it, increase the number inside label, example if my label value is 0 then everytime I tap the button it is increase by 1
The problem is I have a lot of data, example I have 20 and everytime I set a value for the label when I scroll all the value is changed
this is my code:
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("testCell", forIndexPath: indexPath)
let pos = cell.viewWithTag(6) as! UIButton
pos.addTarget(self, action: #selector(testTableViewController.pos(_:)), forControlEvents: .TouchUpInside)
pos.tag = indexPath.row
return cell
}
func pos(sender: UIButton) {
let position: CGPoint = sender.convertPoint(CGPointZero, toView: self.tableView)
let indexPath = self.tableView.indexPathForRowAtPoint(position)
let cell: UITableViewCell = self.tableView.cellForRowAtIndexPath(indexPath!)! as UITableViewCell
let countNo = cell.viewWithTag(7) as! UILabel
var counter:Int = Int(countNo.text!)!
counter = counter + 1
countNo.text = String(counter)
}
please help me how to solve this problem. I have search a lot of web to find the answer, example like make dequeueReusableCellWithIdentifier nil but in Swift 2 dequeueReusableCellWithIdentifier never nil and a lot more with no one is work
I need some hint how to solve this problem
Here is your answer.
Initialise one array having number of objects same as your tableview's number of row. Initially insert value "0" or blank string (if do not want to display any number in label initially) for each object.
ex,
var arrValues:NSMutableArray = NSMutableArray()
for _ in 1...numberOfrow
{
arrValues.addObject("0")
}
Now in cellForRowAtIndexPath method , display text of label from this array
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("testCell", forIndexPath: indexPath)
let pos = cell.viewWithTag(6) as! UIButton
**let countNo = cell.viewWithTag(7) as! UILabel
countNo.text = String(arrValues.objectAtIndex(indexPath.row))**
pos.addTarget(self, action: #selector(testTableViewController.pos(_:)), forControlEvents: .TouchUpInside)
pos.tag = indexPath.row
return cell
}
On button click method replace object at particular index with incremented number. See your updated method as below.
func pos(sender: UIButton) {
let position: CGPoint = sender.convertPoint(CGPointZero, toView: self.tableView)
let indexPath = self.tableView.indexPathForRowAtPoint(position)
let cell: UITableViewCell = self.tableView.cellForRowAtIndexPath(indexPath!)! as UITableViewCell
let countNo = cell.viewWithTag(7) as! UILabel
var counter:Int = Int(countNo.text!)!
counter = counter + 1
countNo.text = String(counter)
**arrValues.replaceObjectAtIndex(indexPath.row, withObject: String(counter))**
}
So in cellForRowAtIndexPath , it will display updated number in label text

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