
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.