iOS 原生 App 是怎么 deselectRow 的

2,790 阅读3分钟

这两天偶然发现系统设置里 tableView deselectRow 的时机和效果都很特别,正常情况下我们的 deselect 操作都会在 didSelect 代理方法里执行,抑或者是更加细致一点,在 viewDidAppear 里完成。

但 iOS 原生的 App 说不,我还可以做得更好,这是系统设置里的效果:

侧滑返回时,deselect 动画会随着滑动手势的进度而改变,搜了一下,国内似乎没有太多相关的文章,并且我手头常用的几款软件都做到没有类似的效果。

搜了一下之后,发现国外的记录也很少,只有三篇文章记录了这个交互,其中写的比较详细的是这篇 The Hit List Diary #17 – clearsSelectionOnViewWillAppear

转场动画的抽象 transitionCoordinator

这个交互其实是通过 UIViewControllertransitionCoordinator 属性实现的,它的类型是 UIViewControllerTransitionCoordinator

简单来说,它可以帮助我们在转场动画里加入一些自定义的动画,自定义动画的进度和生命周期会与转场动画保持一致,使用它可以达到更加自然和一致的转场效果,例如 push 动画里 navigationBar 背景颜色的变化,它提供了这几个方法供我们注册动画生命周期的回调:

protocol UIViewControllerTransitionCoordinator {
    func animate(
        alongsideTransition animation: ((UIViewControllerTransitionCoordinatorContext) -> Void)?, 
        completion: ((UIViewControllerTransitionCoordinatorContext) -> Void)? = nil
    ) -> Bool
    
    func animateAlongsideTransition(
        in view: UIView?,
        animation: ((UIViewControllerTransitionCoordinatorContext) -> Void)?, 
        completion: ((UIViewControllerTransitionCoordinatorContext) -> Void)? = nil
    ) -> Bool
    
    func notifyWhenInteractionChanges(
        _ handler: @escaping (UIViewControllerTransitionCoordinatorContext) -> Void
    )
}

推荐大家去看一下 UIViewControllerTransitionCoordinator 这个协议的文档,这里摘录一段我觉得比较有趣的描述:

Using the transition coordinator to handle view hierarchy animations is preferred over making those same changes in the viewWillAppear(_:) or similar methods of your view controllers. The blocks you register with the methods of this protocol are guaranteed to execute at the same time as the transition animations. More importantly, the transition coordinator provides important information about the state of the transition, such as whether it was cancelled, to your animation blocks through the UIViewControllerTransitionCoordinatorContext object.

比起 viewWillAppear 和其它相似的 ViewController 生命周期函数,我们更加推荐使用 transitionCoordinator 处理视图层级的动画。你注册的函数可以保证与转场动画同时执行。更重要的是,transitionCoordinator 通过 UIViewControllerTransitionCoordinatorContext 协议提供了转场动画的状态等重要信息,例如动画是否已被取消等。

我由于最近业务的原因,第一个想起的就是 navigationBar,像是 barTintColor 这种属性就可以使用 transitionCoordinator 做到更加自然的动画转场。

实现与封装

我看了别人的文章并且尝试其它集中方式之后,感觉 transitionCoordinator 获取的最佳时机应该是 viewWillAppear,实现的逻辑大概是这样:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    // 判断是否有被选中的 Row
    if let selectedIndexPath = tableView.indexPathForSelectedRow {
        // 判断是否有 transitionCoordinator
        if let coordinator = transitionCoordinator {
            // 有的情况下,通过 coordinator 注册 animation block
            coordinator.animate(
                alongsideTransition: { _ in
                    self.tableView.deselectRow(at: selectedIndexPath, animated: true)
                },
                completion: { context in
                    // 如果转场动画被取消了,则需要让 tableView 回到被选中的状态
                    guard context.isCancelled else { return }
                    self.tableView.selectRow(at: selectedIndexPath, animated: true, scrollPosition: .none)
                }
            )
        } else {
            // 没有的情况下直接 deselect 
            tableView.deselectRow(at: selectedIndexPath, animated: animated)
        }
    }
}

如果把 transitionCoordinator 单纯地看成是一个动画抽象(抛开转场),我们希望跟随动画完成的操作就是 deselect,那么就可以更进一步地把这个 deselect 的操作封装到 UITableView 的 extension 里:

extension UITableView {

    public func deselectRowIfNeeded(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) {
        guard let selectedIndexPath = selectRowAtIndexPath else { return }
    
        guard let coordinator = transitionCoordinator else {
            self.deselectRow(at: selectedIndexPath, animated: animated)
            return
        }

        coordinator.animate(
            alongsideTransition: { _ in
                self.deselectRow(at: selectedIndexPath, animated: true)
            },
            completion: { context in
                guard context.isCancelled else { return }
                self.selectRow(at: selectedIndexPath, animated: false, scrollPosition: .none)
            }
        )
    }
}

接着只要在 viewWillAppear 里调用即可:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    tableView.deselectRowIfNeeded(with: transitionCoordinator, animated: true)
}

如果大家在项目里封装了自己的 TableViewController 并且规范使用的话,那要加入这个效果就很简单了。

结语

这是完整的示例

参考链接:

觉得文章还不错的话可以关注一下我的博客