Erstellen des allgegenwärtigen Begrüßungsbildschirms unter iOS



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

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 UIWindow erstellen;
  • Erstellen Sie einen SplashViewController und machen Sie ihn zu rootViewController `ohm;
  • windowLevel .normal so ein, dass windowLevel größer als .normal (der Standardwert) ist, damit dieses Fenster über dem .normal angezeigt 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() { //     } func animateDisappearance(completion: @escaping () -> Void) { //     } 

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.

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


All Articles