
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
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() {
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.