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