在iOS上制作无处不在的启动画面



哈勃!

我将讨论从初始屏幕到应用程序其他屏幕的动画过渡的实现。 这项任务是全球品牌重塑的一部分,如果不更改初始屏幕和产品外观就无法做到。

对于参与大型项目的许多开发人员而言,解决与创建精美动画有关的问题已成为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() { //     } func dismiss(completion: @escaping () -> Void) { //     } } 

同一对象将为启动屏幕选择文本。 文本显示为图片,因此您需要在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 } 

您可能会发现奇怪的是, splashViewControllersplashWindow的创建是在单独的函数中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() { //     } func animateDisappearance(completion: @escaping () -> Void) { //     } 

rootViewController传递给构造函数,为方便起见,从其中“提取”了rootViewController ,该rootViewController也存储在诸如rootViewController类的属性中。

添加到SplashPresenter

  private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow) 

并修复他的presentdismiss

  func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) } 

一切,其中最无聊的部分,终于可以开始动画了!

外观动画


让我们从启动屏幕的外观动画开始,它很简单:

  • 徽标被logoImageViewlogoImageView )。
  • 文本出现在推子上并上升一点( textImageView )。

让我提醒您,默认情况下, UIWindow创建为不可见,有两种方法可以解决此问题:

  • 调用makeKeyAndVisible方法;
  • set属性isHidden = false

第二种方法对我们来说很合适,因为我们不希望foregroundSplashWindow keyWindow成为keyWindow

考虑到这SplashAnimatoranimateAppearance()实现了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属性隐藏了foregroundSplashWindowisHidden ,而不是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倍。

但首先,它会略有减少(根据scaleskeyTimes的第二个元素)。 也就是说,在时间0.2 * durationduration是缩放动画的总持续时间)下,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通知,正常启动还是其他)。 动画仍然可以正常工作!

您可以在此处下载项目。

Source: https://habr.com/ru/post/zh-CN456394/


All Articles