
哈勃!
我将讨论从初始屏幕到应用程序其他屏幕的动画过渡的实现。 这项任务是全球品牌重塑的一部分,如果不更改初始屏幕和产品外观就无法做到。
对于参与大型项目的许多开发人员而言,解决与创建精美动画有关的问题已成为Bug,复杂功能和热修复领域的新鲜空气。 这样的任务相对容易实现,其结果令人赏心悦目,看起来非常令人印象深刻! 但是有时候标准方法不适用,那么您需要提出各种解决方法。
乍一看,在更新初始屏幕的任务中,动画的创建似乎是最困难的,其余的是“常规工作”。 经典情况:首先显示一个屏幕,然后通过自定义过渡打开下一个屏幕-一切都很简单!
作为动画的一部分,您需要在初始屏幕上打一个洞,在其中显示下一个屏幕的内容,也就是说,我们绝对需要知道在初始下显示哪个
view
。 启动Yula后,磁带将打开,因此将其附加到相应控制器的
view
是合乎逻辑的。
但是,如果您使用带有通向用户配置文件的推送通知来运行应用程序,该怎么办? 还是通过浏览器打开产品卡? 然后,下一个屏幕根本不应该是磁带(这远非所有可能的情况)。 尽管所有的过渡都是在打开主屏幕之后进行的,但是动画与特定的
view
,但是哪个控制器呢?
为了避免
拐弯抹角地处理各种情况,将在
UIWindow
级别显示初始屏幕。 这种方法的优点是我们绝对不在乎启动后会发生什么:在应用程序的主窗口中,磁带可以加载,弹出或以动画方式过渡到某些屏幕。 接下来,我将详细讨论所选方法的实现,该方法包括以下步骤:
启动画面准备
首先,您需要准备一个静态启动屏幕-即在应用程序启动时立即显示的屏幕。
您可以通过两种方式执行此操作 :为每个设备提供不同分辨率的图像,或在
LaunchScreen.storyboard
组成此屏幕。 第二个选项是更快,更方便,并且由Apple本身推荐,因此我们将使用它:
一切都很简单:带有渐变背景的
imageView
和带有徽标的
imageView
。
如您所知,该屏幕无法设置动画,因此您需要创建另一个在视觉上相同的屏幕,以使它们之间的过渡不可见。 在
Main.storyboard
添加一个
ViewController
:
与前一个屏幕的区别在于,还有另一个
imageView
替换了随机文本(当然,它最初会被隐藏)。 现在为此控制器创建一个类:
final class SplashViewController: UIViewController { @IBOutlet weak var logoImageView: UIImageView! @IBOutlet weak var textImageView: UIImageView! var textImage: UIImage? override func viewDidLoad() { super.viewDidLoad() textImageView.image = textImage } }
除了
IBOutlet
的动画元素外,该类还具有
textImage
属性-随机选择的图片将传递给它。 现在,让我们回到
Main.storyboard
并将
SplashViewController
类指向相应的控制器。 同时,将带有
imageView
屏幕截图的imageView放在初始的
ViewController
以便在启动画面下没有空白屏幕。
现在我们需要一个主持人,负责显示和隐藏斜线屏幕的逻辑。 我们为其编写协议,并立即创建一个类:
protocol SplashPresenterDescription: class { func present() func dismiss(completion: @escaping () -> Void) } final class SplashPresenter: SplashPresenterDescription { func present() {
同一对象将为启动屏幕选择文本。 文本显示为图片,因此您需要在
Assets.xcassets
添加适当的资源。 资源的名称是相同的,除了数字-它是随机生成的:
private lazy var textImage: UIImage? = { let textsCount = 17 let imageNumber = Int.random(in: 1...textsCount) let imageName = "i-splash-text-\(imageNumber)" return UIImage(named: imageName) }()
我将
textImage
一个普通属性(即
lazy
)并不是偶然的,稍后您将了解原因。
在开始的时候,我保证启动屏幕将显示在单独的
UIWindow
,为此您需要:
- 创建一个
UIWindow
;
- 创建一个
SplashViewController
并使其成为rootViewController
;
- 将
windowLevel
设置windowLevel
大于windowLevel
(默认值),以便此窗口出现在主窗口的顶部。
在
SplashPresenter
添加:
private lazy var foregroundSplashWindow: UIWindow = { let splashViewController = self.splashViewController(with: textImage) let splashWindow = self.splashWindow(windowLevel: .normal + 1, rootViewController: splashViewController) return splashWindow }() private func splashWindow(windowLevel: UIWindow.Level, rootViewController: SplashViewController?) -> UIWindow { let splashWindow = UIWindow(frame: UIScreen.main.bounds) splashWindow.windowLevel = windowLevel splashWindow.rootViewController = rootViewController return splashWindow } private func splashViewController(with textImage: UIImage?) -> SplashViewController? { let storyboard = UIStoryboard(name: "Main", bundle: nil) let viewController = storyboard.instantiateViewController(withIdentifier: "SplashViewController") let splashViewController = viewController as? SplashViewController splashViewController?.textImage = textImage return splashViewController }
您可能会发现奇怪的是,
splashViewController
和
splashWindow
的创建是在单独的函数中
splashWindow
,但是稍后会派上用场。
我们尚未开始编写动画逻辑,并且
SplashPresenter
已经有很多代码。 因此,我建议创建一个将直接处理动画的实体(加上此职责划分):
protocol SplashAnimatorDescription: class { func animateAppearance() func animateDisappearance(completion: @escaping () -> Void) } final class SplashAnimator: SplashAnimatorDescription { private unowned let foregroundSplashWindow: UIWindow private unowned let foregroundSplashViewController: SplashViewController init(foregroundSplashWindow: UIWindow) { self.foregroundSplashWindow = foregroundSplashWindow guard let foregroundSplashViewController = foregroundSplashWindow.rootViewController as? SplashViewController else { fatalError("Splash window doesn't have splash root view controller!") } self.foregroundSplashViewController = foregroundSplashViewController } func animateAppearance() {
rootViewController
传递给构造函数,为方便起见,从其中“提取”了
rootViewController
,该
rootViewController
也存储在诸如
rootViewController
类的属性中。
添加到
SplashPresenter
:
private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow)
并修复他的
present
和
dismiss
:
func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) }
一切,其中最无聊的部分,终于可以开始动画了!
外观动画
让我们从启动屏幕的外观动画开始,它很简单:
- 徽标被
logoImageView
( logoImageView
)。
- 文本出现在推子上并上升一点(
textImageView
)。
让我提醒您,默认情况下,
UIWindow
创建为不可见,有两种方法可以解决此问题:
- 调用
makeKeyAndVisible
方法;
- set属性
isHidden = false
。
第二种方法对我们来说很合适,因为我们不希望
foregroundSplashWindow
keyWindow
成为
keyWindow
。
考虑到这
SplashAnimator
在
animateAppearance()
实现了
animateAppearance()
方法:
func animateAppearance() { foregroundSplashWindow.isHidden = false foregroundSplashViewController.textImageView.transform = CGAffineTransform(translationX: 0, y: 20) UIView.animate(withDuration: 0.3, animations: { self.foregroundSplashViewController.logoImageView.transform = CGAffineTransform(scaleX: 88 / 72, y: 88 / 72) self.foregroundSplashViewController.textImageView.transform = .identity }) foregroundSplashViewController.textImageView.alpha = 0 UIView.animate(withDuration: 0.15, animations: { self.foregroundSplashViewController.textImageView.alpha = 1 }) }
我不认识你,但我想尽快启动该项目,看看发生了什么! 仅打开
AppDelegate
,在其中添加
splashPresenter
属性
splashPresenter
并在其上调用
present
方法,仍然保留。 同时,在2秒钟后,我们将调用
dismiss
这样我们就不必返回此文件:
private var splashPresenter: SplashPresenter? = SplashPresenter() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { splashPresenter?.present() let delay: TimeInterval = 2 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { self.splashPresenter?.dismiss { [weak self] in self?.splashPresenter = nil } } return true }
隐藏启动画面后,对象本身将从内存中删除。
万岁,您可以跑步!
隐藏动画
不幸的是(或幸运的是),10行代码无法应付隐藏的动画。 必须制作一个通孔,该通孔仍会旋转并增加! 如果您认为“可以用口罩来完成”,那么您绝对正确!
我们将
layer
主应用程序窗口的
layer
添加一个遮罩(我们不想绑定到特定的控制器)。 让我们立即进行操作,同时隐藏
foregroundSplashWindow
,因为将在其下进行进一步的操作。
func animateDisappearance(completion: @escaping () -> Void) { guard let window = UIApplication.shared.delegate?.window, let mainWindow = window else { fatalError("Application doesn't have a window!") } foregroundSplashWindow.alpha = 0 let mask = CALayer() mask.frame = foregroundSplashViewController.logoImageView.frame mask.contents = SplashViewController.logoImageBig.cgImage mainWindow.layer.mask = mask }
重要的是在此处注意,我通过
alpha
属性隐藏了
foregroundSplashWindow
的
isHidden
,而不是
isHidden
(否则屏幕将闪烁)。 另一个有趣的点:由于此遮罩将在动画过程中增加,因此您需要为其使用更高分辨率的徽标(例如1024x1024)。 所以我添加到
SplashViewController
:
static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")!
检查发生了什么事?
我知道,现在看起来并不令人印象深刻,但是一切都在前进,我们继续前进! 特别细心的人会注意到,动画期间徽标不会立即变为透明,而是会变为透明状态。 为此,请在
mainWindow
中所有
subviews
顶部
subviews
添加带有徽标的
imageView
,该徽标将被淡入淡出隐藏。
let maskBackgroundView = UIImageView(image: SplashViewController.logoImageBig) maskBackgroundView.frame = mask.frame mainWindow.addSubview(maskBackgroundView) mainWindow.bringSubviewToFront(maskBackgroundView)
因此,我们有一个徽标形式的孔,在徽标本身下方的孔中。
现在回到美丽的渐变背景和文字的地方。 任何想法如何做到这一点?
我有:在
mainWindow
下放置另一个
UIWindow
(即,使用较小的
windowLevel
,将其称为
backgroundSplashWindow
),然后将看到它而不是黑色背景。 而且,当然,
rootViewController'
将具有
SplashViewContoller
,只有您需要隐藏
logoImageView
。 为此,请在
SplashViewController
创建一个属性:
var logoIsHidden: Bool = false
然后在
viewDidLoad()
方法中添加:
logoImageView.isHidden = logoIsHidden
SplashPresenter
最后确定
SplashPresenter
:在
splashViewController (with textImage: UIImage?)
添加另一个
logoIsHidden: Bool
参数,该参数将传递给
SplashViewController
:
splashViewController?.logoIsHidden = logoIsHidden
因此,在创建
foregroundSplashWindow
位置,必须将
false
传递给此参数,对于
backgroundSplashWindow
true
传递给:
private lazy var backgroundSplashWindow: UIWindow = { let splashViewController = self.splashViewController(with: textImage, logoIsHidden: true) let splashWindow = self.splashWindow(windowLevel: .normal - 1, rootViewController: splashViewController) return splashWindow }()
您还需要通过
SplashAnimator
的构造
SplashAnimator
(类似于
foregroundSplashWindow
SplashAnimator
)将该对象抛出并在其中添加属性:
private unowned let backgroundSplashWindow: UIWindow private unowned let backgroundSplashViewController: SplashViewController
这样一来,我们会看到黑色的背景,而不是黑色的背景,就在隐藏
foregroundSplashWindow
之前,您需要显示
backgroundSplashWindow
:
backgroundSplashWindow.isHidden = false
确保该计划成功:
现在最有趣的部分是隐藏动画! 由于您需要设置
CALayer
动画而不是
UIView
动画,因此我们将向
CoreAnimation
寻求帮助。 让我们从旋转开始:
private func addRotationAnimation(to layer: CALayer, duration: TimeInterval, delay: CFTimeInterval = 0) { let animation = CABasicAnimation() let tangent = layer.position.y / layer.position.x let angle = -1 * atan(tangent) animation.beginTime = CACurrentMediaTime() + delay animation.duration = duration animation.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) animation.fromValue = 0 animation.toValue = angle animation.isRemovedOnCompletion = false animation.fillMode = CAMediaTimingFillMode.forwards layer.add(animation, forKey: "transform") }
如您所见,旋转角度是根据屏幕大小计算的,因此所有设备上的Yula都旋转到左上角。
徽标缩放动画:
private func addScalingAnimation(to layer: CALayer, duration: TimeInterval, delay: CFTimeInterval = 0) { let animation = CAKeyframeAnimation(keyPath: "bounds") let width = layer.frame.size.width let height = layer.frame.size.height let coefficient: CGFloat = 18 / 667 let finalScale = UIScreen.main.bounds.height * coeficient let scales = [1, 0.85, finalScale] animation.beginTime = CACurrentMediaTime() + delay animation.duration = duration animation.keyTimes = [0, 0.2, 1] animation.values = scales.map { NSValue(cgRect: CGRect(x: 0, y: 0, width: width * $0, height: height * $0)) } animation.timingFunctions = [CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut), CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)] animation.isRemovedOnCompletion = false animation.fillMode = CAMediaTimingFillMode.forwards layer.add(animation, forKey: "scaling") }
值得注意的是
finalScale
:最终比例也是根据屏幕尺寸(与高度成比例)计算的。 也就是说,屏幕高度为667点(iPhone 6)时,Yula应该增加18倍。
但首先,它会略有减少(根据
scales
和
keyTimes
的第二个元素)。 也就是说,在时间
0.2 * duration
(
duration
是缩放动画的总持续时间)下,Yula的缩放比例将为0.85。
我们已经到了终点线! 在
animateDisappearance
方法中
animateDisappearance
运行所有动画:
1)缩放主窗口(
mainWindow
)。
2)徽标的旋转,缩放,消失(
maskBackgroundView
)。
3)旋转,缩放“孔”(
mask
)。
4)文本(
textImageView
)消失。
CATransaction.setCompletionBlock { mainWindow.layer.mask = nil completion() } CATransaction.begin() mainWindow.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) UIView.animate(withDuration: 0.6, animations: { mainWindow.transform = .identity }) [mask, maskBackgroundView.layer].forEach { layer in addScalingAnimation(to: layer, duration: 0.6) addRotationAnimation(to: layer, duration: 0.6) } UIView.animate(withDuration: 0.1, delay: 0.1, options: [], animations: { maskBackgroundView.alpha = 0 }) { _ in maskBackgroundView.removeFromSuperview() } UIView.animate(withDuration: 0.3) { self.backgroundSplashViewController.textImageView.alpha = 0 } CATransaction.commit()
我使用
CATransaction
来完成动画。 在这种情况下,它比
animationGroup
更方便,因为并非所有动画都是通过
CAAnimation
完成的。
结论
因此,在输出中,我们得到了一个不依赖于应用程序启动上下文的组件(无论是diplink,push通知,正常启动还是其他)。 动画仍然可以正常工作!
您可以在此处下载项目。