原文:ios-animation-tutorial-custom-view-controller-presentation-transitions
作者:Marin Todorov
译者:kmyhy
当你显现相机、通讯录、或某种自定义模式窗口时,你每次都会调用同1个 UIKit 方法 present(_:animated:completion:)。这个方法将当前屏幕“让给”另外一个 view controller。
默许的显现动画简单地用新视图推开当前视图。下图演示了“新建联系人” view controller 在联系人列表视图上层向上滑出:
在本教程中,你将用自己的自定义显现动画替换默许动画,并完本钱教程中的项目。
下载本文的开始项目 Beginner Cook。打开 Main.storyboard :
第1个 view controller(即 ViewController)包括了 app 的标题和主要介绍和底部的1个 scroll view,用于显示1个有用的香草列表。
当用户点击了列表中的图片,main view controller 会显现1个 HerbDetailsViewController;这个 view controller 有1个背景、1个标题、1个描写和几个按钮用于注明图片的所有者。
在 ViewController.swift 和 HerbDetailsViewController.swift 已有部份代码了,足以保持 app 运行。运行程序,app 是这个模样:
点击某个香草图片,细节页面以标准的弹出动画方式显现。对1般的 app 来讲这也足够了,但对你的 app 则需要做得更好!
你的任务是创建自定义显现动画让你的 app 更加残暴夺目!你需要将目前内置的动画替换成:用所点击的香草的图片展开至全屏!
撸起手袖,系紧围裙,准备动手开始定制显现控制器!
UIKit 允许你通过拜托模型定制化 view controller 的显现进程;你可让 main view controller(或可以用另外一个类专门来干这个)采取 UIViewControllerTransitioningDelegate 协议。
每当你显现1个新的 view controller 时,UIKit 会询问它的拜托是不是需要使用自定义动画。自定义动画的第1步是这样的:
UIKit 会调用 animationController(forPresented:presenting:source:) 方法,看是不是有1个 UIViewControllerAnimatedTransitioning 对象返回。如果这个方法返回空,UIKit 使用默许动画,否则,UIKit 使用返回的对象作为这次转换的动画控制器。
UIKit 首先询问动画控制器(简称为 animator),动画需要几秒钟?然后调用它的animateTransition(using:) 方法。这时候你的自定义动画开始生效了。
在 animateTransition(using:) 方法中,你可以同时访问到正在显示的 view controller 和行将显现的新 view controller。你可以淡入、缩放、旋转并为所欲为地操作已有的视图和新的视图。
你已大致了解了之定义显现控制器是如何工作的了,现在,开始来创建我们自己的吧!
由于拜托的责任是管理动画控制器 animator 对象,而由 animator 来履行真实的动画,因此在编写拜托代码之前的第1件事情就是创建1个 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。
在这个方法最后1行,即调用 present(…) 方法以后加入以下代码:
// ...
present(herbDetails, animated: true, completion: nil)
herbDetails.transitioningDelegate = self // 加入这行
现在 UIKit 会在每次显现 details view controller 的时候都索要1个 animator 对象。但你还没有实现任何 UIViewControllerTransitioningDelegate 方法,所以 UIKit 还是会使用默许的动画。
接下来应当实例化1个 animator 对象并在 UIKit 询问的时候返给它。
在 ViewController 中添加1个属性:
let transition = PopAnimator()
这个是1个 PopAnimator 对象,用于驱动你的 view controller 动画。你只需要1个 PopAnimator 对象,由于你可以在每次显现 view controller 时都使用同1个 animator 对象,由于每次的动画都是同1个。
在 ViewController 的扩大中加入第1个拜托方法:
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return transition
}
这个方法提供几个参数,你可以根据它们来决定是返回1个自定义的动画还是不。在本文中,你总是返回同1个 PopAnimator 实例,由于你只有1个显现动画。
你已添加了1个用于显现 view controller 的拜托方法,那末用于解散的呢?
这是另外一个拜托方法:
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return nil
}
这个方法和前1个方法基本是干同1件事情:判断要解散的是哪一个 view controller,觉此来决定是不是返回 nil,返回 nil 表示采取默许的解散动画,或返回1个自定义的 animator。这里你返回的是 nil,由于你还没有实现解散动画。
你已具有了1个自定义的 animator 来负责自定义动画,但它是如何工作的呢?
运行程序,点击任何1张香草图片:
甚么也没产生。为啥?你有1个用于驱动动画的自定义 animator,但是……等等,在 animator 类中还没有编写代码!你会在下1节完成这个任务。
打开 PopAnimator.swift; 这里我们将加入两个 view controller 之间进行转换的代码。
首先,加入几个属性:
let duration = 1.0
var presenting = true
var originFrame = CGRect.zero
duration 变量会用到几个地方,比如告知 UIKit 动画时长,和创建动画时。
我们还定义了1个 presenting 变量,用于告知 animator 类,当前是在显现还是解散进程。我们需要记住这个变量,由于我们将以正面顺序履行显现,而以相反顺序履行解散。
最后,我们用 originFrame 变量保存原来用户所点到的图片的 frame 形状——我们会将图片由这个 frame 以动画方式放大到全屏,反过来则履行相反动作。当你获得当前所选图片并将它的 frame 传递给 animator 实例时,需要注意这个 originFrame。
现在,你可以回到 UIViewControllerAnimatedTransitioning 方法来了。
在 transitionDuration() 方法中,用下句替换:
return duration
重用 duration 属性,能让你很容易可以调试 transition 动画。你可以简单修改这个值,使动画变快变慢。
现在为 animateTransition 注入魔力。这个方法有1个 UIViewControllerContextTransitioning 参数,通过它你能访问和转换相干的 view controller 和参数。
在开始编写代码之前,1个重要的问题就是理解 animation context 的实际上是甚么。
当两个 view controller 之间开始转换时,原来的 view 被添加到 transition container 转换容器,新的 view controller 的 view 被创建出来,但依然不可见,以下图所示:
因此你的任务是在 animateTransition() 方法中将新的 view 添加到转换容器,“以动画方式”显示它,如果有必要的话,将原本的 view 以动画方式移除。
默许,当转换动画完成时,原有 view 从转换容器中移除。
在你能够“烹制”出更多的食品之前,你需要创建1个简单的动画,看看它是如何实现的,然后再实现更酷的、同时也是更复杂的转换。
开始,我们用1个简单的淡出动画来实现自定义动画。在 animateTransition 方法中加入:
let containerView = transitionContext.containerView
let toView = transitionContext.view(forKey: .to)!
首先,获得容器 view,你的动画将在这个 view 中产生。然后获得新的 view 并赋给 toView 变量。
转换上下文有两个非常方便的方法,允许你访问动画的参与者
这里,你要同时用到 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 方法中应当干些甚么——你将在里面加入1些更好的东西!
新的动画需要重新调剂1下代码结构,因此将 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,因此在第1个行代码,你会将 toView 加在最上层,这样你的动画会被隐藏在下层看不见,所以你需要将 herbView 放到上层。
然后,开始动画。这里使用了1个 spring 动画,这会带来1种弹簧效果。
在 animations 块中,我们修改 herbView 的 transform 属性和位置。在显现时,你将底部的小尺寸动画到全屏,因此目标 transform 就是 identity transform。在解散时,你将它的大小缩小到原始图片大小。
这里,我们已准备好了将新 view 的位置对齐被点到的图片,在原来的 frame 和终究的 frame 之间进行动画,最后调用 completeTransition() 方法将控制转给 UIKit。让我们来看看代码的实际效果!
运行程序,点击第1个香草图片,看看你的动画效果:
是的,它还不是10分完善。但当你修改了这些瑕疵,你的动画就会同你想象的1模1样!
当前你的动画是从左上角开始;由于 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 对象,你将解散1个 view controller,这样动画代码会以正确的方式进行。
运行程序,点击1张香草图片然后点击屏幕任何地方解散它:
转换动画看起来没甚么问题,但请注意,你选择的香草从 scroll view 中消失了!当你解散细节页面时,你需要让所点击的图片重新显示。
打开 PopAnimator.swift 添加1个新的闭包属性:
var dismissCompletion: (()->Void)?
这将允许解散动画完成时履行你传入的代码。
然后,找到 animateTransition() 方法在完成块中,在调用 completeTransition() 之前加入:
if !self.presenting {
self.dismissCompletion?()
}
当解散完成,调用 dismissCompletion —— 这里恰好可以显示原来的图片。
打开 ViewController.swift 在 viewDidLoad() 中加入:
transition.dismissCompletion = {
self.selectedImage!.isHidden = false
}
这里当转换动画完成重新显示了原来的图片,以替换详情页面。
运行程序,体验转换动画,包括显现和解散。现在,香草不会在平白无故消失了!
注意: 这部份内容是可选的。如果你对装备方向改变不感兴趣的话,请跳到挑战部份。
你可以将装备方向改变看成是1种显现,从1个 view controller 转换到它自己,仅仅是 size 不同。
iOS 8 中出现的 viewWillTransition(to size:coordinator:)方法,允许你以1种简单直白的方式处理装备方向的变化。你不再需要为横屏竖屏分别设计不同的布局,相反,你只需改变 view controller 的视图 size。
打开 ViewController.swift ,实现 viewWillTransition(to:with:) 方法:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
}
第1个参数 size 通知你 view controller 当前正在转换到哪一个 size。第2个参数 coordinator 是1个 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 履行默许旋屏动画的同时。
你的动画块会收到1个 transitionging 上下文,这和你在显现 view controller 时使用的上下文是1样的。这里,你没有 from 和 to 视图控制器了,由于它们是同1个,但你可以取得比如动画时长等属性。
在动画块中,我们判断目标 size 的宽度是不是大于高度,如果是,下降背景图的 alpha 值为 0.25。这将使横屏下的背景变淡。如果是竖屏模式,alpha 值设为 0.55。
运行程序,旋转装备(如果是摹拟器,按 command+左箭头),查看实际效果。
你将看到当旋转到横屏时背景变暗。这使得长文本更容易浏览。
如果你点击图片,你会注意到动画有点乱。由于屏幕旋转为横屏后,图片依然是竖向的大小。在原始图片和拉伸至全屏的图象之间的转换其实不流畅。
不要担心——你有1个新方法 viewWillTransition(to:with:) 能够解决这个问题。
ViewController 有1个成员方法叫 positionListItems(),它负责香草图片的大小和位置。这个方法在 app 1启动时,被 viewDidLoad()方法所调用。
在 animate(alongsideTransition:) 方法的动画块中,在设置 alpha 值以后加入以下代码:
self.positionListItems()
这将在装备旋转后改变香草图片的 size 和位置。当屏幕完成旋转后,香草图片也会被重新改变大小:
由于这些图片都已有了1个横屏布局,因此你的转换动画就可以正常运行了。试试看!
从这里下载终究完成的项目。
这里,你可以对这个转换进行大量的改进。例如,这些点子:
希望你喜欢本教程,如果有任何问题和建议,请在下面留言!