
Hallo Habr!
Ich werde über die Implementierung des Animationsübergangs vom Begrüßungsbildschirm zu anderen Bildschirmen der Anwendung sprechen. Die Aufgabe entstand im Rahmen eines globalen Rebrandings, bei dem der Begrüßungsbildschirm und das Erscheinungsbild des Produkts nicht geändert werden konnten.
Für viele Entwickler, die an großen Projekten beteiligt sind, wird das Lösen der Probleme beim Erstellen schöner Animationen zu einem Hauch frischer Luft in der Welt der Fehler, komplexen Funktionen und Hotfixes. Solche Aufgaben sind relativ einfach zu implementieren, und das Ergebnis ist angenehm für das Auge und sieht sehr beeindruckend aus! Es gibt jedoch Zeiten, in denen Standardansätze nicht anwendbar sind und Sie dann alle Arten von Problemumgehungen finden müssen.
Auf den ersten Blick scheint die Erstellung von Animationen bei der Aktualisierung des Begrüßungsbildschirms am schwierigsten zu sein, und der Rest ist „Routinearbeit“. Die klassische Situation: Zuerst zeigen wir einen Bildschirm und dann öffnen wir mit einem benutzerdefinierten Übergang den nächsten - alles ist einfach!
Als Teil der Animation müssen Sie ein Loch in den Begrüßungsbildschirm bohren, in dem der Inhalt des nächsten Bildschirms angezeigt wird. Das heißt, wir müssen auf jeden Fall wissen, welche 
view unter dem Begrüßungsbildschirm angezeigt wird. Nach dem Starten von Yula wird das Band geöffnet, sodass es logisch wäre, eine Verbindung zur 
view entsprechenden Controllers herzustellen.
Was aber, wenn Sie die Anwendung mit einer Push-Benachrichtigung ausführen, die zum Benutzerprofil führt? Oder eine Produktkarte über einen Browser öffnen? Dann sollte der nächste Bildschirm überhaupt kein Band sein (dies ist weit von allen möglichen Fällen entfernt). Und obwohl alle Übergänge nach dem Öffnen des Hauptbildschirms vorgenommen werden, ist die Animation an eine bestimmte 
view gebunden, aber welcher Controller?
Um 
Krücken vieler 
if-else Blöcke für jede Situation zu vermeiden, wird der 
UIWindow auf der 
UIWindow Ebene 
UIWindow . Der Vorteil dieses Ansatzes ist, dass es uns absolut egal ist, was unter dem Splash passiert: Im Hauptfenster der Anwendung kann ein Band geladen, eingeblendet oder ein animierter Übergang zu einem Bildschirm vorgenommen werden. Als nächstes werde ich detailliert auf die Implementierung unserer gewählten Methode eingehen, die aus den folgenden Schritten besteht:
- Begrüßungsbildschirm vorbereiten.
 
- Animation des Erscheinungsbildes.
 
- Animation ausblenden.
 
Vorbereitung des Begrüßungsbildschirms
Zuerst müssen Sie einen statischen Begrüßungsbildschirm vorbereiten, dh einen Bildschirm, der sofort beim Start der Anwendung angezeigt wird. 
Sie haben zwei Möglichkeiten : 
LaunchScreen.storyboard Bilder mit unterschiedlichen Auflösungen für jedes Gerät 
LaunchScreen.storyboard oder 
LaunchScreen.storyboard diesen Bildschirm in 
LaunchScreen.storyboard . Die zweite Option ist schneller, bequemer und wird von Apple selbst empfohlen. Wir werden sie daher verwenden:
Alles ist einfach: 
imageView mit Farbverlaufshintergrund und 
imageView mit Logo.
Wie Sie wissen, kann dieser Bildschirm nicht animiert werden. Sie müssen daher einen anderen, visuell identischen Bildschirm erstellen, damit der Übergang zwischen ihnen unsichtbar ist. 
Main.storyboard Sie in 
Main.storyboard einen 
ViewController :
Der Unterschied zum vorherigen Bildschirm besteht darin, dass es eine andere 
imageView in die zufälliger Text eingesetzt wird (natürlich wird er zunächst ausgeblendet). Erstellen Sie nun eine Klasse für diesen Controller:
 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 } } 
Zusätzlich zu den 
IBOutlet für die Elemente, die animiert werden sollen, verfügt diese Klasse auch über eine 
textImage Eigenschaft - ein zufällig ausgewähltes Bild wird an sie übergeben. 
Main.storyboard wir nun zu 
Main.storyboard und weisen den entsprechenden Controller auf die 
SplashViewController Klasse hin. 
imageView gleichzeitig eine 
imageView mit einem Screenshot von Yula in den anfänglichen 
ViewController sodass unter dem 
ViewController kein leerer Bildschirm angezeigt wird.
Jetzt brauchen wir einen Moderator, der für die Logik des Ein- und Ausblendens des Schrägstrichs verantwortlich ist. Wir schreiben das Protokoll dafür und erstellen sofort eine Klasse:
 protocol SplashPresenterDescription: class { func present() func dismiss(completion: @escaping () -> Void) } final class SplashPresenter: SplashPresenterDescription { func present() {  
Das gleiche Objekt wählt Text für einen Begrüßungsbildschirm aus. Der Text wird als Bild angezeigt, daher müssen Sie die entsprechenden Ressourcen in 
Assets.xcassets . Die Namen der Ressourcen sind bis auf die Nummer identisch - sie werden zufällig generiert:
  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) }() 
Es war kein Zufall, dass ich 
textImage nicht zu einer gewöhnlichen Eigenschaft gemacht habe, nämlich 
lazy . Später werden Sie verstehen, warum.
Ganz am Anfang habe ich versprochen, dass der 
UIWindow in einem separaten 
UIWindow angezeigt wird. Dazu benötigen Sie:
- ein UIWindowerstellen;
 
- Erstellen Sie einen SplashViewControllerund machen Sie ihn zurootViewController`ohm;
 
- windowLevel- .normalso ein, dass- windowLevelgrößer als- .normal(der Standardwert) ist, damit dieses Fenster über dem- .normalangezeigt wird.
 
SplashPresenter in 
SplashPresenter hinzu:
  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 } 
Es mag seltsam sein, dass die Erstellung von 
splashViewController und 
splashWindow in separaten Funktionen 
splashWindow ist, aber später wird dies nützlich sein.
Wir haben noch nicht begonnen, Animationslogik zu schreiben, und 
SplashPresenter bereits viel Code. Daher schlage ich vor, eine Entität zu erstellen, die sich direkt mit Animation befasst (plus dieser Aufteilung der Zuständigkeiten):
 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 wird an den Konstruktor übergeben, und der 
rootViewController wird der 
rootViewController "extrahiert", der auch in Eigenschaften wie 
rootViewController gespeichert wird.
Zu 
SplashPresenter :
  private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow) 
und seine 
present und fixen 
dismiss festlegen:
  func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) } 
Alles, der langweiligste Teil dahinter, Sie können endlich die Animation starten!
Aussehensanimation
Beginnen wir mit der Animation des Erscheinungsbilds des Begrüßungsbildschirms. Es ist ganz einfach:
- Das Logo wird logoImageView(logoImageView).
 
- Der Text erscheint auf dem Fader und steigt etwas an ( textImageView).
 
Ich 
UIWindow Sie daran erinnern, dass 
UIWindow standardmäßig 
UIWindow erstellt wird. Es gibt zwei Möglichkeiten, dies zu beheben:
- Rufen Sie die Methode makeKeyAndVisible.
 
- set Eigenschaft isHidden = false.
 
Die zweite Methode ist für uns geeignet, da wir nicht möchten 
foregroundSplashWindow dass 
keyWindow zu 
keyWindow .
In diesem 
SplashAnimator implementieren 
SplashAnimator die 
animateAppearance() -Methode in 
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 }) } 
Ich weiß nichts über dich, aber ich möchte das Projekt so schnell wie möglich starten und sehen, was passiert ist! Es bleibt nur 
AppDelegate zu öffnen, dort die Eigenschaft 
AppDelegate hinzuzufügen und die 
present Methode darauf aufzurufen. Gleichzeitig rufen wir nach 2 Sekunden "Entlassen" auf 
dismiss damit wir nicht zu dieser Datei zurückkehren müssen:
  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 } 
Das Objekt selbst wird nach dem Ausblenden des Splashs aus dem Speicher gelöscht.
Hurra, du kannst rennen!
Animation ausblenden
Leider (oder zum Glück) werden 10 Codezeilen mit der Animation des Versteckens nicht fertig. Es ist notwendig, ein Durchgangsloch zu machen, das sich noch dreht und vergrößert! Wenn Sie dachten, dass dies mit einer Maske möglich ist, dann haben Sie absolut Recht!
Wir fügen der 
layer Hauptanwendungsfensters eine Maske hinzu (wir möchten nicht an einen bestimmten Controller binden). Lassen Sie es uns sofort tun und gleichzeitig foregroundSplashWindow ausblenden, da weitere Aktionen darunter ausgeführt werden.
  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 } 
Es ist wichtig zu beachten, dass ich 
isHidden durch die 
alpha Eigenschaft versteckt habe und nicht 
isHidden (andernfalls 
isHidden der Bildschirm). Ein weiterer interessanter Punkt: Da diese Maske während der Animation zunimmt, müssen Sie ein Logo mit höherer Auflösung verwenden (z. B. 1024 x 1024). Also habe ich 
SplashViewController hinzugefügt:
  static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")! 
Überprüfen Sie, was passiert ist?
Ich weiß, jetzt sieht es nicht sehr beeindruckend aus, aber alles ist vor uns, wir gehen weiter! Besonders aufmerksam kann man feststellen, dass das Logo während der Animation nicht sofort, sondern für einige Zeit transparent wird. 
mainWindow Sie dazu in 
mainWindow zu allen 
subviews eine 
imageView mit einem Logo hinzu, das durch die Überblendung ausgeblendet wird.
  let maskBackgroundView = UIImageView(image: SplashViewController.logoImageBig) maskBackgroundView.frame = mask.frame mainWindow.addSubview(maskBackgroundView) mainWindow.bringSubviewToFront(maskBackgroundView) 
Wir haben also ein Loch in Form eines Logos und unter dem Loch das Logo selbst.
Nun zurück zum Ort eines schönen Verlaufshintergrunds und Textes. Irgendwelche Ideen, wie das geht?
Ich habe: ein anderes 
UIWindow unter 
mainWindow ( 
mainWindow mit einem kleineren 
windowLevel nennen wir es 
backgroundSplashWindow ), und dann sehen wir es anstelle eines schwarzen Hintergrunds. Und natürlich hat der 
rootViewController' einen 
SplashViewContoller , nur Sie müssen das 
logoImageView . Erstellen Sie dazu eine Eigenschaft in 
SplashViewController :
  var logoIsHidden: Bool = false 
viewDidLoad() in der 
viewDidLoad() -Methode 
viewDidLoad() hinzu:
  logoImageView.isHidden = logoIsHidden 
SplashPresenter : 
SplashPresenter in der 
splashViewController (with textImage: UIImage?) logoIsHidden: Bool weiteren 
logoIsHidden: Bool Parameter hinzu, der an den 
SplashViewController :
 splashViewController?.logoIsHidden = logoIsHidden 
Wenn 
foregroundSplashWindow erstellt wird, muss dementsprechend 
false an diesen Parameter und 
true für 
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 }() 
Sie müssen dieses Objekt auch durch den Konstruktor in 
SplashAnimator (ähnlich wie bei 
SplashAnimator ) und dort die Eigenschaften hinzufügen:
  private unowned let backgroundSplashWindow: UIWindow private unowned let backgroundSplashViewController: SplashViewController 
Damit anstelle eines schwarzen Hintergrunds der gleiche Begrüßungsbildschirm angezeigt wird, müssen Sie kurz vor dem Ausblenden von 
foregroundSplashWindow backgroundSplashWindow :
  backgroundSplashWindow.isHidden = false 
Stellen Sie sicher, dass der Plan erfolgreich war:
Der interessanteste Teil ist jetzt die Animation zum Ausblenden! Da Sie 
CALayer und nicht 
UIView animieren 
CALayer , 
UIView wir uns an 
CoreAnimation um Hilfe zu erhalten. Beginnen wir mit der 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") } 
Wie Sie sehen können, wird der Drehwinkel basierend auf der Größe des Bildschirms berechnet, sodass sich Yula auf allen Geräten in die obere linke Ecke dreht.
Logo-Skalierungsanimation:
  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") } 
Es lohnt sich, auf 
finalScale zu 
finalScale : Die endgültige Skala wird auch in Abhängigkeit von der Größe des Bildschirms (im Verhältnis zur Höhe) berechnet. Das heißt, mit einer Bildschirmhöhe von 667 Punkten (iPhone 6) sollte sich Yula um das 18-fache erhöhen.
Aber zuerst nimmt sie leicht ab (gemäß den zweiten Elementen in den 
keyTimes scales und 
keyTimes ). Das heißt, zum Zeitpunkt 
0.2 * duration (wobei 
duration die Gesamtdauer der Skalierungsanimation ist) beträgt Yulas Skalierung 0,85.
Wir sind schon am Ziel! 
animateDisappearance Methode 
animateDisappearance alle Animationen aus:
1) Skalierung des Hauptfensters ( 
mainWindow ).
2) Drehung, Skalierung, Verschwinden des Logos ( 
maskBackgroundView ).
3) Drehung, Skalierung des „Lochs“ ( 
mask ).
4) Das Verschwinden des Textes ( 
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() 
Ich habe 
CATransaction verwendet, um die Animation zu vervollständigen. In diesem Fall ist es bequemer als 
animationGroup , da nicht alle Animationen über 
CAAnimation .
Fazit
So haben wir am Ausgang eine Komponente erhalten, die nicht vom Kontext des Anwendungsstarts abhängt (ob es sich um einen Diplink, eine Push-Benachrichtigung, einen normalen Start oder etwas anderes handelt). Die Animation funktioniert trotzdem richtig!
Sie können das Projekt hier herunterladen.