
Salut Habr!
Je vais parler de la mise en œuvre de la transition d'animation de l'écran de démarrage vers d'autres écrans de l'application. La tâche s'est présentée dans le cadre d'un rebranding global, qui ne pouvait se passer de modifier l'écran de démarrage et l'apparence du produit.
Pour de nombreux développeurs impliqués dans de grands projets, résoudre les problèmes associés à la création de belles animations devient une bouffée d'air frais dans le monde des bogues, des fonctionnalités complexes et des correctifs. De telles tâches sont relativement faciles à mettre en œuvre, et le résultat est agréable à l'œil et semble très impressionnant! Mais il y a des moments où les approches standard ne sont pas applicables, et ensuite vous devez trouver toutes sortes de solutions de contournement.
À première vue, dans la tâche de mise à jour de l'écran de démarrage, la création d'animation semble être la plus difficile, et le reste est du «travail de routine». La situation classique: nous montrons d'abord un écran, puis avec une transition personnalisée, nous ouvrons le suivant - tout est simple!
Dans le cadre de l'animation, vous devez faire un trou sur l'écran de démarrage dans lequel le contenu de l'écran suivant est affiché, c'est-à-dire que nous devons certainement savoir quelle 
view affichée sous le démarrage. Après avoir démarré Yula, la bande s'ouvre, il serait donc logique de l'attacher à la 
view contrôleur correspondant.
Mais que faire si vous exécutez l'application avec une notification push qui mène au profil utilisateur? Ou ouvrir une fiche produit à partir d'un navigateur? Ensuite, l'écran suivant ne devrait pas du tout être une bande (c'est loin de tous les cas possibles). Et bien que toutes les transitions soient effectuées après l'ouverture de l'écran principal, l'animation est liée à une 
view spécifique, mais quel contrôleur?
Afin d'éviter les 
béquilles de nombreux blocs 
if-else pour gérer chaque situation, l'écran de démarrage sera affiché au niveau 
UIWindow . L'avantage de cette approche est que nous ne nous soucions absolument pas de ce qui se passe sous le splash: dans la fenêtre principale de l'application, une bande peut se charger, s'afficher ou effectuer une transition animée vers un écran. Ensuite, je parlerai en détail de la mise en œuvre de notre méthode choisie, qui comprend les étapes suivantes:
- Préparation d'un écran de démarrage.
 
- Animation de l'apparence.
 
- Masquer l'animation.
 
Préparation de l'écran de démarrage
Vous devez d'abord préparer un écran de démarrage statique, c'est-à-dire un écran qui apparaît immédiatement au démarrage de l'application. 
Vous pouvez le faire de deux manières : fournir des images de résolutions différentes pour chaque appareil, ou créer cet écran dans 
LaunchScreen.storyboard . La deuxième option est plus rapide, plus pratique et recommandée par Apple elle-même, nous allons donc l'utiliser:
Tout est simple: 
imageView avec un fond dégradé et 
imageView avec un logo.
Comme vous le savez, cet écran ne peut pas être animé, vous devez donc en créer un autre, visuellement identique, afin que la transition entre eux soit invisible. Dans 
Main.storyboard ajoutez un 
ViewController :
La différence avec l'écran précédent est qu'il existe une autre 
imageView dans laquelle du texte aléatoire est substitué (bien sûr, il sera initialement masqué). Créez maintenant une classe pour ce contrôleur:
 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 } } 
En plus des 
IBOutlet pour les éléments que nous voulons animer, cette classe a également une propriété 
textImage - une image sélectionnée au hasard lui sera transmise. Revenons maintenant à 
Main.storyboard et 
SplashViewController classe 
SplashViewController au contrôleur correspondant. Dans le même temps, placez une 
imageView avec une capture d'écran de Yula dans le 
ViewController initial afin qu'il n'y ait pas d'écran vide sous le splash.
Maintenant, nous avons besoin d'un présentateur qui sera responsable de la logique d'affichage et de masquage de la barre oblique. Nous écrivons le protocole pour cela et créons immédiatement une classe:
 protocol SplashPresenterDescription: class { func present() func dismiss(completion: @escaping () -> Void) } final class SplashPresenter: SplashPresenterDescription { func present() {  
Le même objet sélectionnera du texte pour un écran de démarrage. Le texte est affiché sous forme d'image, vous devez donc ajouter les ressources appropriées dans 
Assets.xcassets . Les noms des ressources sont les mêmes, à l'exception du nombre - ils seront générés aléatoirement:
  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) }() 
Ce n'est pas par hasard que j'ai fait de 
textImage une propriété non ordinaire, à savoir 
lazy , plus tard vous comprendrez pourquoi.
Au tout début, j'ai promis que l'écran de démarrage sera affiché dans une 
UIWindow distincte, pour cela, vous avez besoin:
- créer une UIWindow;
 
- créer un SplashViewControlleret en fairerootViewController`ohm;
 
- définissez windowLevelpourwindowLevelplus grand que.normal(la valeur par défaut) afin que cette fenêtre apparaisse au-dessus de la fenêtre principale.
 
Dans 
SplashPresenter ajoutez:
  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 } 
Vous pouvez trouver étrange que la création de 
splashViewController et 
splashWindow soit 
splashWindow dans des fonctions distinctes, mais plus tard cela vous sera utile.
Nous n'avons pas encore commencé à écrire la logique d'animation, et 
SplashPresenter déjà beaucoup de code. Par conséquent, je propose de créer une entité qui traitera directement de l'animation (plus cette répartition des responsabilités):
 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 est passé au constructeur, et pour plus de commodité, le 
rootViewController est "extrait", qui est également stocké dans des propriétés, comme 
foregroundSplashViewController .
Ajouter à 
SplashPresenter :
  private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow) 
et fixer ses 
dismiss present et 
dismiss :
  func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) } 
Tout, la partie la plus ennuyeuse derrière, vous pouvez enfin démarrer l'animation!
Animation d'apparence
Commençons par l'animation de l'apparence de l'écran de démarrage, c'est simple:
- Le logo est logoImageView(logoImageView).
 
- Le texte apparaît sur le fader et monte un peu ( textImageView).
 
Permettez-moi de vous rappeler que, par défaut, 
UIWindow est créé invisible, et il existe deux façons de résoudre ce problème:
- appeler la méthode makeKeyAndVisible;
 
- définir la propriété isHidden = false.
 
La deuxième méthode nous convient, car nous ne voulons pas que 
foregroundSplashWindow devienne 
keyWindow .
Dans cet 
SplashAnimator implémentons la méthode 
animateAppearance() dans 
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 }) } 
Je ne vous connais pas, mais je voudrais lancer le projet le plus tôt possible et voir ce qui s'est passé! Il ne reste plus qu'à ouvrir 
AppDelegate , à y ajouter la propriété 
splashPresenter et à 
splashPresenter appeler la méthode 
present . Dans le même temps, au bout de 2 secondes, nous appellerons 
dismiss afin de ne pas avoir à revenir dans ce fichier:
  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 } 
L'objet lui-même est supprimé de la mémoire après avoir masqué le splash.
Hourra, tu peux courir!
Masquer l'animation
Malheureusement (ou heureusement), 10 lignes de code ne pourront pas faire face à l'animation du masquage. Il est nécessaire de faire un trou traversant, qui va toujours tourner et augmenter! Si vous pensiez que «cela peut être fait avec un masque», alors vous avez absolument raison!
Nous allons ajouter un masque à la 
layer fenêtre principale de l'application (nous ne voulons pas nous lier à un contrôleur spécifique). Faisons-le tout de suite, et en même temps, cachons 
foregroundSplashWindow , car d'autres actions se produiront en dessous.
  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 } 
Il est important de noter ici que j'ai caché 
foregroundSplashWindow travers la propriété 
alpha , et non 
isHidden (sinon l'écran clignotera). Un autre point intéressant: puisque ce masque augmentera pendant l'animation, vous devez utiliser un logo de résolution plus élevée pour lui (par exemple, 1024x1024). J'ai donc ajouté à 
SplashViewController :
  static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")! 
Vérifiez ce qui s'est passé?
Je sais, maintenant ça n'a pas l'air très impressionnant, mais tout est en avance, on avance! Particulièrement attentif pourrait remarquer que pendant l'animation le logo ne devient pas transparent immédiatement, mais pendant un certain temps. Pour ce faire, dans 
mainWindow en plus de toutes les sous- 
subviews ajoutez une 
imageView avec un logo qui sera masqué par le fondu.
  let maskBackgroundView = UIImageView(image: SplashViewController.logoImageBig) maskBackgroundView.frame = mask.frame mainWindow.addSubview(maskBackgroundView) mainWindow.bringSubviewToFront(maskBackgroundView) 
Donc, nous avons un trou sous la forme d'un logo et sous le trou le logo lui-même.
Revenons maintenant à la place d'un beau fond dégradé et du texte. Des idées pour faire ça?
J'ai: mettez un autre 
UIWindow sous 
mainWindow (c'est-à-dire avec une 
windowLevel plus 
windowLevel , appelons-le 
backgroundSplashWindow ), puis nous le verrons au lieu d'un fond noir. Et, bien sûr, le 
rootViewController' aura un 
SplashViewContoller , vous seul devez masquer le 
logoImageView . Pour ce faire, créez une propriété dans 
SplashViewController :
  var logoIsHidden: Bool = false 
et dans la méthode 
viewDidLoad() , ajoutez:
  logoImageView.isHidden = logoIsHidden 
SplashPresenter : dans la 
splashViewController (with textImage: UIImage?) Ajoutez un autre paramètre 
logoIsHidden: Bool , qui sera transmis au 
SplashViewController :
 splashViewController?.logoIsHidden = logoIsHidden 
Par conséquent, lorsque 
foregroundSplashWindow est créé, 
false doit être transmis à ce paramètre et 
true pour 
backgroundSplashWindow :
  private lazy var backgroundSplashWindow: UIWindow = { let splashViewController = self.splashViewController(with: textImage, logoIsHidden: true) let splashWindow = self.splashWindow(windowLevel: .normal - 1, rootViewController: splashViewController) return splashWindow }() 
Vous devez également lancer cet objet via le constructeur dans 
SplashAnimator (similaire à 
foregroundSplashWindow ) et y ajouter les propriétés:
  private unowned let backgroundSplashWindow: UIWindow private unowned let backgroundSplashViewController: SplashViewController 
Ainsi, au lieu d'un fond noir, nous voyons le même écran de démarrage, juste avant de masquer 
foregroundSplashWindow vous devez afficher 
backgroundSplashWindow :
  backgroundSplashWindow.isHidden = false 
Assurez-vous que le plan a été un succès:
Maintenant, la partie la plus intéressante est l'animation de masquage! Puisque vous devez animer 
CALayer , pas 
UIView , nous nous tournerons vers 
CoreAnimation pour obtenir de l'aide. Commençons par la rotation:
  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") } 
Comme vous pouvez le voir, l'angle de rotation est calculé en fonction de la taille de l'écran, de sorte que Yula sur tous les appareils tourne dans le coin supérieur gauche.
Animation de mise à l'échelle du logo:
  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") } 
Il convient de prêter attention à 
finalScale : l'échelle finale est également calculée en fonction de la taille de l'écran (proportionnellement à la hauteur). Autrement dit, avec une hauteur d'écran de 667 points (iPhone 6), Yula devrait augmenter 18 fois.
Mais d'abord, il diminue légèrement (conformément aux deuxièmes éléments des 
scales et des 
keyTimes ). Autrement dit, au temps 
0.2 * duration (où la 
duration est la durée totale de l'animation de mise à l'échelle), l'échelle de Yula sera de 0,85.
Nous sommes déjà à la ligne d'arrivée! Dans la méthode 
animateDisappearance exécutez toutes les animations:
1) Mise à l'échelle de la fenêtre principale ( 
mainWindow ).
2) Rotation, mise à l'échelle, disparition du logo ( 
maskBackgroundView ).
3) Rotation, mise à l'échelle du «trou» ( 
mask ).
4) La disparition du texte ( 
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() 
J'ai utilisé 
CATransaction afin de terminer l'animation. Dans ce cas, c'est plus pratique que 
animationGroup , car toutes les animations ne sont pas effectuées via 
CAAnimation .
Conclusion
Ainsi, à la sortie, nous avons obtenu un composant qui ne dépend pas du contexte du lancement de l'application (que ce soit un diplink, une notification push, un démarrage normal ou autre). L'animation fonctionnera de toute façon correctement!
Vous pouvez télécharger le projet ici.