原文:ios-animation-tutorial-custom-view-controller-presentation-transitions
作者:Marin Todorov
译者:kmyhy
当你呈现相机、通讯录、或者某种自定义模式窗口时,你每次都会调用同一个 UIKit 方法 present(_:animated:completion:)。这个方法将当前屏幕“让给”另一个 view controller。
默认的呈现动画简单地用新视图推开当前视图。下图演示了“新建联系人” view controller 在联系人列表视图上层向上滑出:
在本教程中,你将用自己的自定义呈现动画替换默认动画,并完成本教程中的项目。
开始
下载本文的开始项目 Beginner Cook。打开 Main.storyboard :
第一个 view controller(即 ViewController)包含了 app 的标题和主要介绍以及底部的一个 scroll view,用于显示一个有用的香草列表。
当用户点击了列表中的图片,main view controller 会呈现一个 HerbDetailsViewController;这个 view controller 有一个背景、一个标题、一个描述和几个按钮用于注明图片的所有者。
在 ViewController.swift 和 HerbDetailsViewController.swift 已经有部分代码了,足以维持 app 运行。运行程序,app 是这个样子:
点击某个香草图片,细节页面以标准的弹出动画方式呈现。对于一般的 app 来说这也足够了,但对于你的 app 则需要做得更好!
你的任务是创建自定义呈现动画让你的 app 更加绚烂夺目!你需要将目前内置的动画替换成:用所点击的香草的图片展开至全屏!
撸起手袖,系紧围裙,准备动手开始定制呈现控制器!
自定义动画的幕后工作
UIKit 允许你通过委托模型定制化 view controller 的呈现过程;你可以让 main view controller(或者可以用另一个类专门来干这个)采用 UIViewControllerTransitioningDelegate 协议。
每当你呈现一个新的 view controller 时,UIKit 会询问它的委托是否需要使用自定义动画。自定义动画的第一步是这样的:
UIKit 会调用 animationController(forPresented:presenting:source:) 方法,看是否有一个 UIViewControllerAnimatedTransitioning 对象返回。如果这个方法返回空,UIKit 使用默认动画,否则,UIKit 使用返回的对象作为这次转换的动画控制器。
UIKit 首先询问动画控制器(简称为 animator),动画需要几秒钟?然后调用它的animateTransition(using:) 方法。这时你的自定义动画开始生效了。
在 animateTransition(using:) 方法中,你可以同时访问到正在显示的 view controller 和即将呈现的新 view controller。你可以淡入、缩放、旋转并随心所欲地操作已有的视图和新的视图。
你已经大致了解了之定义呈现控制器是如何工作的了,现在,开始来创建我们自己的吧!
实现 Transition 委托
因为委托的责任是管理动画控制器 animator 对象,而由 animator 来执行真正的动画,因此在编写委托代码之前的第一件事情就是创建一个 animator 类。
打开 Xcode 菜单 File\New\File… 选择模板: iOS\Source\Cocoa Touch Class。
类名设置为 PopAnimator,语言选择 Swift,继承于 NSObject。
打开 PopAnimator.swift 修改类定义,实现 UIViewControllerAnimatedTransitioning 协议:
class PopAnimator: NSObject, UIViewControllerAnimatedTransitioning {
}
Xcode 会抱怨没有实现必须的委托方法,等下我们会解决这个。
在类中添加如下方法:
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0
}
动画时长返回 0 只是临时的;后面会修改这个为真正的时长。
继续新增如下方法:
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
}
这个方法将放入动画代码,暂时是空实现,以消除 Xcode 的报错。
有了基本的 animator 类之后,你可以在 view controller 中实现委托方法了。
打开 ViewController.swift 新增如下扩展:
extension ViewController: UIViewControllerTransitioningDelegate {
}
这声明对 transitioning delegate 协议的实现。等会我们再来添加这些方法。
找到 didTapImageView(_:) 方法。在方法底部,你看到了呈现详情 view controller 的代码。herbDetails 是新 view controller 的实例;你需要将它的 transitioning 委托设置为 main controller。
在这个方法最后一行,即调用 present(…) 方法之后加入以下代码:
// ...
present(herbDetails, animated: true, completion: nil)
herbDetails.transitioningDelegate = self // 加入这行
现在 UIKit 会在每次呈现 details view controller 的时候都索要一个 animator 对象。但你还没有实现任何 UIViewControllerTransitioningDelegate 方法,所以 UIKit 还是会使用默认的动画。
接下来应该实例化一个 animator 对象并在 UIKit 询问的时候返给它。
在 ViewController 中添加一个属性:
let transition = PopAnimator()
这个是一个 PopAnimator 对象,用于驱动你的 view controller 动画。你只需要一个 PopAnimator 对象,因为你可以在每次呈现 view controller 时都使用同一个 animator 对象,因为每次的动画都是同一个。
在 ViewController 的扩展中加入第一个委托方法:
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return transition
}
这个方法提供几个参数,你可以根据它们来决定是返回一个自定义的动画还是不。在本文中,你总是返回同一个 PopAnimator 实例,因为你只有一个呈现动画。
你已经添加了一个用于呈现 view controller 的委托方法,那么用于解散的呢?
这是另一个委托方法:
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return nil
}
这个方法和前一个方法基本是干同一件事情:判断要解散的是哪个 view controller,觉此来决定是否返回 nil,返回 nil 表示采用默认的解散动画,或者返回一个自定义的 animator。这里你返回的是 nil,因为你还没有实现解散动画。
你已经拥有了一个自定义的 animator 来负责自定义动画,但它是如何工作的呢?
运行程序,点击任何一张香草图片:
什么也没发生。为啥?你有一个用于驱动动画的自定义 animator,但是……等等,在 animator 类中还没有编写代码!你会在下一节完成这个任务。
创建 Transition Animator
打开 PopAnimator.swift; 这里我们将加入两个 view controller 之间进行转换的代码。
首先,加入几个属性:
let duration = 1.0
var presenting = true
var originFrame = CGRect.zero
duration 变量会用到几个地方,比如告诉 UIKit 动画时长,以及创建动画时。
我们还定义了一个 presenting 变量,用于告诉 animator 类,当前是在呈现还是解散过程。我们需要记住这个变量,因为我们将以正面顺序执行呈现,而以相反顺序执行解散。
最后,我们用 originFrame 变量保存原来用户所点到的图片的 frame 形状——我们会将图片由这个 frame 以动画方式放大到全屏,反过来则执行相反动作。当你获取当前所选图片并将它的 frame 传递给 animator 实例时,需要注意这个 originFrame。
现在,你可以回到 UIViewControllerAnimatedTransitioning 方法来了。
在 transitionDuration() 方法中,用下句替换:
return duration
重用 duration 属性,能让你很容易可以调试 transition 动画。你可以简单修改这个值,使动画变快变慢。
设置转换上下文
现在为 animateTransition 注入魔力。这个方法有一个 UIViewControllerContextTransitioning 参数,通过它你能访问和转换相关的 view controller 和参数。
在开始编写代码之前,一个重要的问题就是理解 animation context 的实际上是什么。
当两个 view controller 之间开始转换时,原来的 view 被添加到 transition container 转换容器,新的 view controller 的 view 被创建出来,但仍然不可见,如下图所示:
因此你的任务是在 animateTransition() 方法中将新的 view 添加到转换容器,“以动画方式”显示它,如果有必要的话,将原有的 view 以动画方式移除。
默认,当转换动画完成时,原有 view 从转换容器中移除。
在你能够“烹制”出更多的食品之前,你需要创建一个简单的动画,看看它是如何实现的,然后再实现更酷的、同时也是更复杂的转换。
添加淡入动画
开始,我们用一个简单的淡出动画来实现自定义动画。在 animateTransition 方法中加入:
let containerView = transitionContext.containerView
let toView = transitionContext.view(forKey: .to)!
首先,获取容器 view,你的动画将在这个 view 中发生。然后获取新的 view 并赋给 toView 变量。
转换上下文有两个非常方便的方法,允许你访问动画的参与者
- view(forKey:): 你可以访问到 “原有的” and “新的” view,通过指定 key 参数为 UITransitionContextViewKey.from 或 UITransitionContextViewKey.to。
- viewController(forKey:): 你可以通过指定 key 参数为 UITransitionContextViewControllerKey.from 或者 UITransitionContextViewControllerKey.to 来访问 “原有的” 和 “新的” view controller。
这里,你要同时用到 container view 和要呈现的 view 。然后你将要呈现的 view 添加为 container view 的 subview 并以某种方式动画。
在 animateTransition() 中添加:
containerView.addSubview(toView)
toView.alpha = 0.0
UIView.animate(withDuration: duration,
animations: {
toView.alpha = 1.0
},
completion: { _ in
transitionContext.completeTransition(true)
}
)
注意,你需要在动画完成块中调用转换上下文的 completeTransition() 方法,这是为了通知 UIKit 你的转换动画已经完成,UIKit 可以完成这次 view controller 转换了。
运行程序,点击某张香草图片,你会看到香草的介绍以淡入的方式出现在主 view controller 中:
这个转换勉强过得去,你已经大概弄清了在 animateTransition 方法中应该干些什么——你将在里面加入一些更好的东西!
加入一个 Pop 动画
新的动画需要重新调整一下代码结构,因此将 animateTransition() 中的代码替换为:
let containerView = transitionContext.containerView
let toView = transitionContext.view(forKey: .to)!
let herbView = presenting ? toView :
transitionContext.view(forKey: .from)!
containerView 是你的动画即将发生的地方,toView 是需要呈现的新视图。如果你正在呈现,herbView 就是 toView,否则它应该从上下文中获取。对于解散和呈现,herbView 都会是你将执行动画的 view。
当你呈现细节页面时,它会拉伸到整个屏幕大小。解散时,它又会缩小到原始 frame 大小。
在 animateTransition() 添加:
let initialFrame = presenting ? originFrame : herbView.frame
let finalFrame = presenting ? herbView.frame : originFrame
let xScaleFactor = presenting ?
initialFrame.width / finalFrame.width :
finalFrame.width / initialFrame.width
let yScaleFactor = presenting ?
initialFrame.height / finalFrame.height :
finalFrame.height / initialFrame.height
在上述代码中,我们需要根据条件获得原先的 frame 和最终动画结束时的 frame,然后计算两个 view 之间的横纵比例。
现在我们需要关注新 view 的位置,因为它需要显示在所点击的 image 上方,看起来就像是被点的图像拉伸到了全屏大小。
在 animateTransition() 中添加:
let scaleTransform = CGAffineTransform(scaleX: xScaleFactor,
y: yScaleFactor)
if presenting {
herbView.transform = scaleTransform
herbView.center = CGPoint(
x: initialFrame.midX,
y: initialFrame.midY)
herbView.clipsToBounds = true
}
当呈现新 view 时,我们设置了它的 scale 和位置以便和原图 frame 的位置大小匹配。
现在在 animateTransition() 中加入最后的代码:
containerView.addSubview(toView)
containerView.bringSubview(toFront: herbView)
UIView.animate(withDuration: duration, delay:0.0,
usingSpringWithDamping: 0.4, initialSpringVelocity: 0.0,
animations: {
herbView.transform = self.presenting ?
CGAffineTransform.identity : scaleTransform
herbView.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY)
},
completion:{_ in
transitionContext.completeTransition(true)
}
)
首先将 toView 添加到 container。然后,让 herbView 放在 subview 的最上层,因为你只会对这个 view 进行动画。记住,在解散时,toView 是原始 view,因此在第一个行代码,你会将 toView 加在最上层,这样你的动画会被隐藏在下层看不见,所以你需要将 herbView 放到上层。
然后,开始动画。这里使用了一个 spring 动画,这会带来一种弹簧效果。
在 animations 块中,我们修改 herbView 的 transform 属性和位置。在呈现时,你将底部的小尺寸动画到全屏,因此目标 transform 就是 identity transform。在解散时,你将它的大小缩小到原始图片大小。
这里,我们已经准备好了将新 view 的位置对齐被点到的图片,在原来的 frame 和最终的 frame 之间进行动画,最后调用 completeTransition() 方法将控制转给 UIKit。让我们来看看代码的实际效果!
运行程序,点击第一个香草图片,看看你的动画效果:
是的,它还不是十分完美。但当你修改了这些瑕疵,你的动画就会同你想象的一模一样!
当前你的动画是从左上角开始;因为 originFrame 默认的 origin 是(0,0)——你并没有修改过这个值。
打开 ViewController.swift 在 animationController(forPresented:) 头部加入:
transition.originFrame =
selectedImage!.superview!.convert(selectedImage!.frame, to: nil)
transition.presenting = true
selectedImage!.isHidden = true
将 transition 的 originFrame 设置为 selectedImage 即你刚刚点击的图片的 frame。然后将 presenting 设置为 true,在动画期间隐藏所选图片。
运行程序,点击列表中的不同香草,你会看到:
添加解散动画
剩下来的事情就是解散详情页面。实际上大部分工作都已经在 animator 中做完了——转换动画中的代码中开始、结束 frame 都已经设置正确,你最后的工作就是在呈现和解散时适时地播放动画。开心吧?
打开 ViewController.swift 修改 animationController(forDismissed:) 方法为:
transition.presenting = false
return transition
这将告诉 animator 对象,你将解散一个 view controller,这样动画代码会以正确的方式进行。
运行程序,点击一张香草图片然后点击屏幕任何地方解散它:
转换动画看起来没什么问题,但请注意,你选择的香草从 scroll view 中消失了!当你解散细节页面时,你需要让所点击的图片重新显示。
打开 PopAnimator.swift 添加一个新的闭包属性:
var dismissCompletion: (()->Void)?
这将允许解散动画完成时执行你传入的代码。
然后,找到 animateTransition() 方法在完成块中,在调用 completeTransition() 之前加入:
if !self.presenting {
self.dismissCompletion?()
}
当解散完成,调用 dismissCompletion —— 这里刚好可以显示原来的图片。
打开 ViewController.swift 在 viewDidLoad() 中加入:
transition.dismissCompletion = {
self.selectedImage!.isHidden = false
}
这里当转换动画完成重新显示了原来的图片,以替代详情页面。
运行程序,体验转换动画,包括呈现和解散。现在,香草不会在无缘无故消失了!
设备方向的转换
注意: 这部分内容是可选的。如果你对设备方向改变不感兴趣的话,请跳到挑战部分。
你可以将设备方向改变看成是一种呈现,从一个 view controller 转换到它自己,仅仅是 size 不同。
iOS 8 中出现的 viewWillTransition(to size:coordinator:)方法,允许你以一种简单直白的方式处理设备方向的变化。你不再需要为横屏竖屏分别设计不同的布局,相反,你只需改变 view controller 的视图 size。
打开 ViewController.swift ,实现 viewWillTransition(to:with:) 方法:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
}
第一个参数 size 通知你 view controller 当前正在转换到哪个 size。第二个参数 coordinator 是一个 transition coordinator 对象,通过它可以访问该转换的许多属性。
当横屏的时候,你需要做的仅仅是降低 app 背景图片的 alpha 值,提高文字的可读性。
在 viewWillTransitionToSize 加入:
coordinator.animate(
alongsideTransition: {context in
self.bgImage.alpha = (size.width>size.height) ? 0.25 : 0.55
},
completion: nil
)
animate(alongsideTransition:) 允许你在旋屏过程中同时执行你指定的动画,也就是在 UIKit 执行默认旋屏动画的同时。
你的动画块会收到一个 transitionging 上下文,这和你在呈现 view controller 时使用的上下文是一样的。这里,你没有 from 和 to 视图控制器了,因为它们是同一个,但你可以获得比如动画时长等属性。
在动画块中,我们判断目标 size 的宽度是否大于高度,如果是,降低背景图的 alpha 值为 0.25。这将使横屏下的背景变淡。如果是竖屏模式,alpha 值设为 0.55。
运行程序,旋转设备(如果是模拟器,按 command+左箭头),查看实际效果。
你将看到当旋转到横屏时背景变暗。这使得长文本更容易阅读。
如果你点击图片,你会注意到动画有点乱。因为屏幕旋转为横屏后,图片仍然是竖向的大小。在原始图片和拉伸至全屏的图像之间的转换并不流畅。
不要担心——你有一个新方法 viewWillTransition(to:with:) 能够解决这个问题。
ViewController 有一个成员方法叫 positionListItems(),它负责香草图片的大小和位置。这个方法在 app 一启动时,被 viewDidLoad()方法所调用。
在 animate(alongsideTransition:) 方法的动画块中,在设置 alpha 值之后加入以下代码:
self.positionListItems()
这将在设备旋转后改变香草图片的 size 和位置。当屏幕完成旋转后,香草图片也会被重新改变大小:
因为这些图片都已经有了一个横屏布局,因此你的转换动画就能正常运行了。试试看!
结束语
从这里下载最终完成的项目。
这里,你可以对这个转换进行大量的改进。例如,这些点子:
- 在转换期间隐藏被点击的图片,以便它们看起来真的想“长大”到整个屏幕。
- 让每个香草的描述文本以淡入淡出的方式动画,这样转换动画会更加平滑。
- 针对横屏对转换进行测试和调整。
如果你想学习更多内容,请参考我们的iOS Animations by Tutorials。这本书已完全迟滞 Swift 3 和 iOS10 。你会学习如何使用 spring 动画、转换、关键帧动画、CALayer 动画、自动布局约束动画、view controller 转换动画等等!
希望你喜欢本教程,如果有任何问题和建议,请在下面留言!