Création de l'écran de démarrage omniprésent sur iOS



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() { //     } func dismiss(completion: @escaping () -> Void) { //     } } 

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 SplashViewController et en faire rootViewController `ohm;
  • définissez windowLevel pour windowLevel plus 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() { //     } func animateDisappearance(completion: @escaping () -> Void) { //     } 

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.

Source: https://habr.com/ru/post/fr456394/


All Articles