Aswath
Aswath

Reputation: 1480

How can I force animations in UITableViewCells to stop while scrolling through the table view?

The problem I face itself is simple to describe.

I have a table view in my application in which animations are started asynchronously. And for once index path, the animations happen only once. If I scroll the cell out of visible area, I assume that the animation for that index path are finished. i.e. Next time the index path comes back to the visible area, I set it to the final values without animation. If I do not scroll, the animations work as expected.

However, when I scroll the table view, because the cell is reused and the animations intended to be performed on the cells currently not visible are still being performed on the now visible cell. I have put together a sample code that describes my problem.

import UIKit

class ViewController: UIViewController {
    
    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
    }
}


extension ViewController: UITableViewDataSource, UITableViewDelegate {
    
    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        100
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TestCell", for: indexPath)
        cell.textLabel?.layer.opacity = 1
        UIView.animate(withDuration: 2, delay: 0, options: .curveEaseIn) { [weak cell] in
            cell?.textLabel?.layer.opacity = 0
        } completion: { [weak cell] _ in
            cell?.textLabel?.layer.opacity = 0
            cell?.textLabel?.text = "\(indexPath.row)"
            UIView.animate(withDuration: 2, delay: 0, options: .curveEaseIn) { [weak cell] in
                cell?.textLabel?.layer.opacity = 1
            } completion: { [weak cell] _ in
                cell?.textLabel?.layer.opacity = 1
                cell?.textLabel?.text = "\(indexPath.row)"
            }
        }
        animated[indexPath.row].toggle()
        return cell
    }
}

To stop the animation if the cell goes out of bounds of the table view, I tried to save a reference of the animation in the cell and remove right after a cell is dequeued. But it doesn't help my case.

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
    }
    
    var animated: [Bool] = Array(repeating: false, count: 100)
}

class TestCell: UITableViewCell {
    
    var animations: [UIViewPropertyAnimator] = []
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {
    
    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        100
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TestCell", for: indexPath) as! TestCell
        
        if !animated[indexPath.row] {
            
            //Stop animations if any
            cell.animations.forEach { animator in
                animator.stopAnimation(true)
            }
            cell.animations.removeAll()

            //
            let animator = UIViewPropertyAnimator(duration: 5, curve: .easeIn) { [weak cell] in
                cell?.textLabel?.layer.opacity = 0
            }
            
            animator.addCompletion { [weak cell] completed in
                cell?.textLabel?.layer.opacity = 0
                cell?.textLabel?.text = "\(indexPath.row)"
                
                if completed == .end {
                    
                    let animator1 = UIViewPropertyAnimator(duration: 5, curve: .easeIn) { [weak cell] in
                        cell?.textLabel?.layer.opacity = 1
                    }
                    
                    animator1.addCompletion { [weak cell] _ in
                        cell?.textLabel?.layer.opacity = 1
                        cell?.textLabel?.text = "\(indexPath.row)"
                    }
                    
                    animator1.startAnimation(afterDelay: 0)
                    cell?.animations.append(animator1)
                }
            }
            
            animator.startAnimation(afterDelay: 0)
            cell.animations.append(animator)
            
            animated[indexPath.row].toggle()
        } else {
            cell.textLabel?.layer.opacity = 1
            cell.textLabel?.text = "\(indexPath.row)"
        }
        return cell
    }
}

I could not find anything similar on SO. Any help is appreciated.

Upvotes: 0

Views: 1164

Answers (2)

DonMag
DonMag

Reputation: 77442

It's a bit of an odd animation sequence, but I'm guessing this is more for testing / figuring it out for your actual use.

This might help...

Implement prepareForReuse() in your cell to stop and remove any executing animations, and set the label's opacity to 1.0.

In cellForRowAt, set the slot in your animated array to false instead of toggling it, since you only want the animation to run the first time a row appears.

Also in cellForRowAt, make sure you "reset" the cell's label to its initial value.

Give this a try and see if it gets you closer to your goal:

class TestCell: UITableViewCell {
    
    var animations: [UIViewPropertyAnimator] = []
    
    override func prepareForReuse() {
        super.prepareForReuse()
        
        animations.forEach { animator in
            animator.stopAnimation(true)
        }
        animations.removeAll()

        textLabel?.layer.opacity = 1.0
    }
}

class ViewController: UIViewController {
    
    @IBOutlet weak var tableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
    }
    
    var animated: [Bool] = Array(repeating: false, count: 100)
}
extension ViewController: UITableViewDataSource, UITableViewDelegate {
    
    func numberOfSections(in tableView: UITableView) -> Int {
        1
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        100
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TestCell", for: indexPath) as! TestCell
        
        if !animated[indexPath.row] {
            
            animated[indexPath.row] = true
            
            // cells are reused, so "reset" the initial label text
            cell.textLabel?.text = "Needs to animate..."

            //
            let animator = UIViewPropertyAnimator(duration: 5, curve: .easeIn) { [weak cell] in
                cell?.textLabel?.layer.opacity = 0
            }
            
            animator.addCompletion { [weak cell] completed in
                cell?.textLabel?.layer.opacity = 0
                cell?.textLabel?.text = "\(indexPath.row)"
                
                if completed == .end {
                    
                    let animator1 = UIViewPropertyAnimator(duration: 5, curve: .easeIn) { [weak cell] in
                        cell?.textLabel?.layer.opacity = 1
                    }
                    
                    animator1.addCompletion { [weak cell] _ in
                        cell?.textLabel?.layer.opacity = 1
                        cell?.textLabel?.text = "\(indexPath.row)"
                    }
                    
                    animator1.startAnimation(afterDelay: 0)
                    cell?.animations.append(animator1)
                }
            }
            
            animator.startAnimation(afterDelay: 0)
            cell.animations.append(animator)
            
        } else {
            cell.textLabel?.layer.opacity = 1
            cell.textLabel?.text = "\(indexPath.row)"
        }
        return cell
    }
    
}

Edit

Note on animator completion blocks...

First, you used completed as your completion block parameter -- you might be misleading yourself, as it's not a Bool value... it's a UIViewAnimatingPosition value.

From Apple's docs:

The ending position of the animations. Use this value to determine whether the animations stopped at the beginning, end, or somewhere in the middle.

Completion blocks are executed after the animations finish normally. If you call the stopAnimation(_:) method, the completion blocks are not called if you specify true for the method’s parameter. If you specify false for the parameter, the animator executes the completion blocks normally after you call the its finishAnimation(at:) method.

So, if your

animator.stopAnimation(true)

is called before the first animation finishes, its completion block will not be called.

If it's called after the first animation finishes, but before the second animation finishes, the second animation's completion block will not be called.

Upvotes: 1

Ptit Xav
Ptit Xav

Reputation: 3219

Set/stop animation in cell class :

class TestCell: UITableViewCell {

    var animation : UIViewPropertyAnimator?

    // call this in cellForRow in tableView controller 
    func startAnimation() {
        … create animation and save it in self.animation
    }

    func stopAnimation () {
        … stop animation 
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        StopAnimation();
    }
}

Upvotes: 1

Related Questions