Daniel Wood
Daniel Wood

Reputation: 4537

Why do animations not work when deselecting a UITableViewCell in a UITableView asynchronously?

Why do animations not work when deselecting a UITableViewCell in a UITableView asynchronously?

Below is a simple example. If you tap and release the first cell, it deselects with the animation as you would expect.

If you tap and release the second cell, you may not notice anything happen at all. You may even need to hold the cell to see the selection, and when you release it reverts back to the unselected state immediately.

This is obviously a contrived example but it shows the essence of the issue. If I wanted to delay the deselection until something had happened I would face the same issue.

Why does this happen and is there any way around it?

import UIKit

class ViewController: UITableViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    }
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 2
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = indexPath.row == 0 ? "Regular deselection" : "Async deselection"
        return cell
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if indexPath.row == 0 {
            tableView.deselectRow(at: indexPath, animated: true)
        } else {
            DispatchQueue.main.async {
                tableView.deselectRow(at: indexPath, animated: true)
            }
        }
    }
}

Upvotes: 0

Views: 262

Answers (1)

DonMag
DonMag

Reputation: 77442

This is a little curious...

There are a number of UI controls that have "internal processes" - that is, stuff goes on that we're not immediately aware of.

One example is a standard UIButton. During the touch-down / touch-up sequence, the button's Title Label does a cross-fade from .normal to .highlighted and back again.

So, my assumption is:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if indexPath.row == 0 {
        // 1
        tableView.deselectRow(at: indexPath, animated: true)
    } else {
        DispatchQueue.main.async {
            // 2
            tableView.deselectRow(at: indexPath, animated: true)
        }
    }
}

For the first case, UIKit probably "queues up" the highlight/unhighlight sequence.

Whereas in the second case, we are telling the table view to deselect the row on the next run-loop ... which will happen pretty much immediately. At that point, we're (in effect) interrupting the default sequence, and the table view un-highlights the row before it finishes (or, practically, before it starts) highlighting the row.

What's even more curious... if we leave a row selected, and then at a later point (such as a tap elsewhere) and then call tableView.deselectRow(at: indexPath, animated: true), the animation appears to be much shorter, to the point where it doesn't really even look animated.

Here's something to play around with -- maybe you'll find one approach suitable for your goal.

class DeselViewController: UITableViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
        
        let noAnimBtn = UIBarButtonItem(title: "No Anim", style: .plain, target: self, action: #selector(noAnim(_:)))
        let animBtn = UIBarButtonItem(title: "With Anim", style: .plain, target: self, action: #selector(withAnim(_:)))
        navigationItem.rightBarButtonItems = [animBtn, noAnimBtn]

    }

    @objc func noAnim(_ b: Any?) -> Void {
        if let p = tableView.indexPathForSelectedRow {
            tableView.deselectRow(at: p, animated: false)
        }
    }
    
    @objc func withAnim(_ b: Any?) -> Void {
        if let p = tableView.indexPathForSelectedRow {
            UIView.animate(withDuration: 0.3, animations: {
                self.tableView.deselectRow(at: p, animated: false)
            })
        }
    }
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 8
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        switch indexPath.row {
        case 0:
            cell.textLabel?.text = "Regular deselection"
        case 1:
            cell.textLabel?.text = "Async deselection"
        case 2:
            cell.textLabel?.text = "Async deselection with Anim Duration"
        case 3:
            cell.textLabel?.text = "Asnyc Delay deselection"
        case 4:
            cell.textLabel?.text = "Asnyc Delay Plus Anim Duration deselection"
        case 5:
            cell.textLabel?.text = "Asnyc Long Delay deselection"
        case 6:
            cell.textLabel?.text = "Asnyc Long Delay Plus Anim Duration deselection"
        default:
            cell.textLabel?.text = "Manual deselection"
        }
        return cell
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        switch indexPath.row {
        case 0:
            tableView.deselectRow(at: indexPath, animated: true)
        case 1:
            DispatchQueue.main.async {
                tableView.deselectRow(at: indexPath, animated: true)
            }
        case 2:
            DispatchQueue.main.async {
                UIView.animate(withDuration: 0.3, animations: {
                    tableView.deselectRow(at: indexPath, animated: true)
                })
            }
        case 3:
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
                tableView.deselectRow(at: indexPath, animated: true)
            })
        case 4:
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
                UIView.animate(withDuration: 0.3, animations: {
                    tableView.deselectRow(at: indexPath, animated: true)
                })
            })
        case 5:
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.75, execute: {
                tableView.deselectRow(at: indexPath, animated: true)
            })
        case 6:
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.75, execute: {
                UIView.animate(withDuration: 0.3, animations: {
                    tableView.deselectRow(at: indexPath, animated: true)
                })
            })
        default:
            ()
            // leave selected
        }
    }
}

Set that as the root view of a navigation controller, so it can put right-bar buttons for "Manual" deselection:

enter image description here

Upvotes: 1

Related Questions