iOS

刚开始从 Android 转到 iOS 写应用时,发现 iOS 的界面之间切换真方便,什么都不用写都有不错的转场动画,除了简单的平移、底部弹出,甚至还有 3D 旋转和翻页效果,而 Android 那系统默认转场是屏幕闪一下。

尽管系统提供的方式已经满足大部分场景了,但也没法挡住审美疲劳啊,所以就需要自定义转场动画了。

Present&Dismiss Transition

先看个效果图:

gif

刚开始写 iOS 时看到这种会动会弹的效果一直都是懵的,心里觉得这一定非常复杂,等到真正沉下心去学时,发现只要搞清楚每个步骤,这一点都不难。

1、实现 UIViewControllerTransitioningDelegate

比如我的第一个页面叫 FirstViewController,单击底部任意一个按钮都会以 present 方式打开 SecondViewController。点击按钮触发的代码如下:

1
2
3
4
5
6
7
8
self.tappedButton = button
let sb = UIStoryboard(name: "Main", bundle: nil)
let vc = sb.instantiateViewController(withIdentifier: "SBSecond")
vc.view.backgroundColor = button.backgroundColor
//下面这行就表示我们要自定义转场了
vc.transitioningDelegate = self
self.present(vc, animated: true, completion: nil)

实现 UIViewControllerTransitioningDelegate 的两个方法

1
2
3
4
5
6
7
8
9
10
11
extension : UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return nil
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return nil
}
}

暂时给 present 和 dismiss 都返回 nil

2、编写具体的转场动画

先分析效果图的中的打开动画,SecondViewController 是从当前点击按钮的位置和大小,放大的同时改变中心点,达到了最终的全屏显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class FirstPresentTransition: NSObject, UIViewControllerAnimatedTransitioning {
//动画时长
let duration = 1.0
//点击按钮的 frame,因为 SecondViewController 的显示动画是由按钮的位置开始的
var fromFrame = CGRect.zero
//返回动画时长
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
//具体的转场代码
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
//present 是从 FirstViewController 到 SecondViewController
//toView 就是 SecondViewController 的 view
let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)!
//SecondViewController 完全显示后的 frame
let toFrame = toView.frame
//缩放的比例,按钮的宽高 / 最终的宽高
let scaleX = fromFrame.size.width / toFrame.size.width
let scaleY = fromFrame.size.height / toFrame.size.height
//其实最终的动画效果并不是真的把按钮放大了或者两者之间真的有衔接动画
//而是先把全屏的 SecondViewController 缩放到按钮一样的大小并位于同一位置
//然后根据计算的结果使用动画再"恢复"到全屏
toView.transform = toView.transform.scaledBy(x: scaleX, y: scaleY)
toView.center = CGPoint(x: fromFrame.midX, y: fromFrame.midY)
containerView.addSubview(toView)
//执行动画,就是将 SecondViewController "恢复"到全屏
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.3, initialSpringVelocity: 0, options: [], animations: {
toView.transform = CGAffineTransform.identity
toView.center = CGPoint(x: toFrame.midX, y: toFrame.midY)
}) { (_) in
//转场完成后需要调用
transitionContext.completeTransition(true)
}
}
}

再看 dismiss 动画。SecondViewController 由全屏缩小一半到屏幕中心,再向上平移到屏幕外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class SecondDismissTransition: NSObject, UIViewControllerAnimatedTransitioning {
let duration = 1.0
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
//dismiss 是从 SecondViewController 到 FirstViewController
//fromView 是 SecondViewController 的 view
//toView 是 FirstViewController 的 view
let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from)!
let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)!
//把 toView 添加到 fromView 下面,这样当 fromView 缩小时就能看到 toView
containerView.insertSubview(toView, belowSubview: fromView)
//前半秒,缩小一半
UIView.animate(withDuration: duration / 2, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 10, options: [], animations: {
fromView.transform = fromView.transform.scaledBy(x: 0.5, y: 0.5)
}) { (_) in
//后半秒,向上移出屏幕
UIView.animate(withDuration: self.duration / 2, animations: {
fromView.frame.origin.y -= containerView.frame.size.height
}, completion: { (_) in
transitionContext.completeTransition(true)
})
}
}
}

present 和 dismiss 的转场动画写完了,就该把开始 return nil 的地方替换掉了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension : UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
let transition = FirstPresentTransition()
//当前点击按钮的 frame 在这里赋值给 transition,才能计算出正确的缩放值和位移
transition.fromFrame = self.tappedButton.frame
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return SecondDismissTransition()
}
}

我添加的 3 个按钮都是固定位置的,并没有滚动条,所以可以直接使用。但如果是在 UIScrollView、UITableView 或 UICollectionView 中,那就需要转换一下 frame 了。

Push&Pop Transition

既然 Present 和 Dismiss 的转场可以自定义,那 UINavigationController 的 Push 和 Pop 肯定也是可以的,实现同样的效果,动画代码可以用同一份,只需设置不同的 delegate 即可。

给 FirstViewController 套一个 UINavigationController,按钮触发的代码修改为:

1
2
3
4
5
6
7
self.tappedButton = button
let sb = UIStoryboard(name: "Main", bundle: nil)
let vc = sb.instantiateViewController(withIdentifier: "SBSecond")
vc.view.backgroundColor = button.backgroundColor
//变化的只是下面两行
self.navigationController?.delegate = self
self.navigationController?.pushViewController(vc, animated: true)

实现 UINavigationControllerDelegate:

1
2
3
4
5
6
7
8
9
10
11
12
13
extension : UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if operation == .push {
let transition = FirstPresentTransition()
transition.fromFrame = self.tappedButton.frame
return transition
} else {
return SecondDismissTransition()
}
}
}

就这么简单

gif