
Hai Habr!
Saya akan berbicara tentang implementasi transisi animasi dari layar splash ke layar aplikasi lainnya. Tugas muncul sebagai bagian dari rebranding global, yang tidak dapat dilakukan tanpa mengubah layar splash dan penampilan produk.
Bagi banyak pengembang yang terlibat dalam proyek besar, menyelesaikan masalah yang terkait dengan pembuatan animasi yang indah menjadi angin segar di dunia bug, fitur kompleks, dan perbaikan panas. Tugas-tugas semacam itu relatif mudah diimplementasikan, dan hasilnya enak dipandang dan terlihat sangat mengesankan! Tetapi ada saat-saat ketika pendekatan standar tidak berlaku, dan kemudian Anda perlu menemukan semua jenis solusi.
Sekilas, dalam tugas memperbarui layar splash, penciptaan animasi tampaknya menjadi yang paling sulit, dan sisanya adalah "pekerjaan rutin". Situasi klasik: pertama kita menunjukkan satu layar, dan kemudian dengan transisi khusus kita membuka yang berikutnya - semuanya sederhana!
Sebagai bagian dari animasi, Anda perlu membuat lubang pada layar splash di mana isi layar berikutnya ditampilkan, yaitu, kita pasti perlu tahu 
view mana 
view ditampilkan di bawah splash. Setelah memulai Yula, rekaman itu terbuka, jadi akan logis untuk melampirkan ke 
view pengontrol yang sesuai.
Tetapi bagaimana jika Anda menjalankan aplikasi dengan pemberitahuan push yang mengarah ke profil pengguna? Atau buka kartu produk dari browser? Maka layar berikutnya seharusnya tidak menjadi kaset sama sekali (ini jauh dari semua kasus yang mungkin). Dan meskipun semua transisi dilakukan setelah membuka layar utama, animasinya terikat pada tampilan tertentu, tetapi pengontrol yang mana?
Untuk menghindari 
kruk banyak blok 
if-else untuk menangani setiap situasi, layar splash akan ditampilkan di tingkat 
UIWindow . Keuntungan dari pendekatan ini adalah bahwa kami benar-benar tidak peduli dengan apa yang terjadi di bawah percikan: di jendela utama aplikasi, kaset dapat memuat, pop-up atau membuat transisi animasi ke beberapa layar. Selanjutnya, saya akan berbicara secara rinci tentang penerapan metode yang kami pilih, yang terdiri dari langkah-langkah berikut:
- Mempersiapkan layar splash.
 
- Animasi penampilan.
 
- Sembunyikan animasi.
 
Persiapan layar splash
Pertama, Anda perlu menyiapkan splash screen statis - yaitu, layar yang muncul segera saat aplikasi dimulai. 
Anda dapat melakukan ini dengan dua cara : memberikan gambar dengan resolusi berbeda untuk setiap perangkat, atau membuat layar ini di 
LaunchScreen.storyboard . Opsi kedua lebih cepat, lebih nyaman dan direkomendasikan oleh Apple sendiri, jadi kami akan menggunakannya:
Semuanya sederhana: 
imageView dengan latar belakang gradien dan 
imageView dengan logo.
Seperti yang Anda ketahui, layar ini tidak dapat dianimasikan, jadi Anda perlu membuat yang lain, identik secara visual, sehingga transisi di antara keduanya tidak terlihat. Di 
Main.storyboard tambahkan 
ViewController :
Perbedaan dari layar sebelumnya adalah bahwa ada 
imageView lain di mana teks acak diganti (tentu saja, itu akan disembunyikan pada awalnya). Sekarang buat kelas untuk pengontrol ini:
 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 } } 
Selain 
IBOutlet untuk elemen yang ingin kita menghidupkan, kelas ini juga memiliki properti 
textImage - gambar yang dipilih secara acak akan diberikan padanya. Sekarang mari kita kembali ke 
Main.storyboard dan 
SplashViewController kelas 
SplashViewController ke controller yang sesuai. Pada saat yang sama, letakkan 
imageView dengan tangkapan layar Yula di 
ViewController awal sehingga tidak ada layar kosong di bawah splash.
Sekarang kita membutuhkan presenter yang akan bertanggung jawab untuk logika menunjukkan dan menyembunyikan layar slash. Kami menulis protokol untuk itu dan segera membuat kelas:
 protocol SplashPresenterDescription: class { func present() func dismiss(completion: @escaping () -> Void) } final class SplashPresenter: SplashPresenterDescription { func present() {  
Objek yang sama akan memilih teks untuk layar splash. Teks ditampilkan sebagai gambar, jadi Anda perlu menambahkan sumber daya yang sesuai di 
Assets.xcassets . Nama-nama sumber daya adalah sama, kecuali untuk jumlahnya - itu akan dihasilkan secara acak:
  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) }() 
Bukan kebetulan saya membuat 
textImage bukan properti biasa, yaitu 
lazy , nanti Anda akan mengerti kenapa.
Pada awalnya, saya berjanji bahwa splash screen akan ditampilkan di 
UIWindow terpisah, untuk ini Anda perlu:
- buat UIWindow;
 
- buat SplashViewControllerdan buat iturootViewController`ohm;
 
- atur windowLevellebih besar dari.normal(nilai default) sehingga jendela ini muncul di atas yang utama.
 
Di 
SplashPresenter tambahkan:
  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 } 
Anda mungkin merasa aneh bahwa pembuatan 
splashViewController dan 
splashWindow dalam fungsi yang terpisah, tetapi nantinya ini akan berguna.
Kami belum mulai menulis logika animasi, dan 
SplashPresenter sudah memiliki banyak kode. Karena itu, saya mengusulkan untuk membuat entitas yang akan berurusan langsung dengan animasi (ditambah pembagian tanggung jawab ini):
 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 dilewatkan ke konstruktor, dan untuk kenyamanan, 
rootViewController "diekstraksi" darinya, yang juga disimpan di properti, seperti 
foregroundSplashViewController .
Tambahkan ke 
SplashPresenter :
  private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow) 
dan perbaiki 
dismiss present dan 
dismiss :
  func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) } 
Semuanya, bagian paling membosankan di belakang, Anda akhirnya dapat memulai animasi!
Animasi penampilan
Mari kita mulai dengan animasi tampilan layar splash, sederhana:
- Logo logoImageView(logoImageView).
 
- Teks muncul di fader dan naik sedikit ( textImageView).
 
Biarkan saya mengingatkan Anda bahwa secara default 
UIWindow dibuat tidak terlihat, dan ada dua cara untuk memperbaikinya:
- panggil metode makeKeyAndVisible;
 
- set properti isHidden = false.
 
Metode kedua cocok untuk kita, karena kita tidak ingin 
foregroundSplashWindow menjadi 
keyWindow .
Dengan 
SplashAnimator menerapkan metode 
animateAppearance() di 
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 }) } 
Saya tidak tahu tentang Anda, tetapi saya ingin segera meluncurkan proyek ini dan melihat apa yang terjadi! Tetap hanya untuk membuka 
AppDelegate , tambahkan properti 
splashPresenter dan panggil metode 
present di atasnya. Pada saat yang sama, setelah 2 detik, kami akan memanggil 
dismiss sehingga kami tidak perlu kembali ke file ini:
  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 } 
Objek itu sendiri dihapus dari memori setelah menyembunyikan splash.
Hore, kamu bisa lari!
Menyembunyikan animasi
Sayangnya (atau untungnya), 10 baris kode tidak akan mengatasi animasi persembunyian. Perlu untuk membuat lubang, yang masih akan berputar dan meningkat! Jika Anda berpikir bahwa "ini bisa dilakukan dengan topeng", maka Anda benar sekali!
Kami akan menambahkan masker ke 
layer jendela aplikasi utama (kami tidak ingin mengikat ke pengontrol tertentu). Mari kita lakukan segera, dan pada saat yang sama menyembunyikan 
foregroundSplashWindow , karena tindakan lebih lanjut akan terjadi di bawahnya.
  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 } 
Penting untuk dicatat di sini bahwa saya menyembunyikan 
foregroundSplashWindow melalui properti 
alpha , dan bukan 
isHidden (jika tidak layar akan berkedip). Hal lain yang menarik: karena topeng ini akan meningkat selama animasi, Anda perlu menggunakan logo resolusi yang lebih tinggi untuk itu (misalnya, 1024x1024). Jadi saya menambahkan ke 
SplashViewController :
  static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")! 
Lihat apa yang terjadi?
Saya tahu, sekarang ini tidak terlihat sangat mengesankan, tetapi semuanya ada di depan, kita maju terus! Terutama perhatian dapat memperhatikan bahwa selama animasi logo tidak menjadi transparan segera, tetapi untuk beberapa waktu. Untuk melakukan ini, di 
mainWindow di atas semua 
subviews tambahkan 
imageView dengan logo yang akan disembunyikan oleh fade.
  let maskBackgroundView = UIImageView(image: SplashViewController.logoImageBig) maskBackgroundView.frame = mask.frame mainWindow.addSubview(maskBackgroundView) mainWindow.bringSubviewToFront(maskBackgroundView) 
Jadi, kami memiliki lubang dalam bentuk logo, dan di bawah lubang logo itu sendiri.
Sekarang kembali ke tempat latar belakang dan teks gradien yang indah. Ada ide bagaimana melakukan ini?
Saya telah: meletakkan 
UIWindow lain di bawah 
mainWindow (yaitu, dengan 
windowLevel lebih kecil, sebut saja 
backgroundSplashWindow ), dan kemudian kita akan melihatnya alih-alih latar belakang hitam. Dan, tentu saja, 
rootViewController' akan memiliki 
SplashViewContoller , hanya Anda yang perlu menyembunyikan 
logoImageView . Untuk melakukan ini, buat properti di 
SplashViewController :
  var logoIsHidden: Bool = false 
dan dalam metode 
viewDidLoad() , tambahkan:
  logoImageView.isHidden = logoIsHidden 
SplashPresenter selesaikan 
SplashPresenter : di metode 
splashViewController (with textImage: UIImage?) Tambahkan 
logoIsHidden: Bool parameter 
logoIsHidden: Bool , yang akan diteruskan ke 
SplashViewController :
 splashViewController?.logoIsHidden = logoIsHidden 
Dengan demikian, di mana 
foregroundSplashWindow dibuat, 
false harus diteruskan ke parameter ini, dan 
true untuk 
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 }() 
Anda juga perlu membuang objek ini melalui konstruktor di 
SplashAnimator (mirip dengan 
foregroundSplashWindow ) dan menambahkan properti di sana:
  private unowned let backgroundSplashWindow: UIWindow private unowned let backgroundSplashViewController: SplashViewController 
Jadi, alih-alih latar belakang hitam kita melihat layar splash yang sama, tepat sebelum menyembunyikan 
foregroundSplashWindow Anda perlu menunjukkan 
backgroundSplashWindow :
  backgroundSplashWindow.isHidden = false 
Pastikan bahwa rencana itu berhasil:
Sekarang bagian yang paling menarik adalah animasi hide! Karena Anda perlu menghidupkan 
CALayer , bukan 
UIView , kami akan 
CoreAnimation bantuan 
CoreAnimation . Mari kita mulai dengan rotasi:
  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") } 
Seperti yang Anda lihat, sudut rotasi dihitung berdasarkan ukuran layar, sehingga Yula di semua perangkat berputar ke sudut kiri atas.
Animasi penskalaan 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") } 
Perlu memperhatikan 
finalScale : skala final juga dihitung tergantung pada ukuran layar (sebanding dengan tinggi). Artinya, dengan ketinggian layar 667 poin (iPhone 6), Yula harus meningkat 18 kali.
Tapi pertama-tama, ia berkurang sedikit (sesuai dengan elemen kedua dalam 
scales dan 
keyTimes ). Yaitu, pada 
0.2 * duration (di mana 
duration adalah total durasi animasi penskalaan), skala Yula akan menjadi 0,85.
Kami sudah berada di garis finish! Dalam metode 
animateDisappearance jalankan semua animasi:
1) Scaling dari jendela utama ( 
mainWindow ).
2) Rotasi, penskalaan, hilangnya logo ( 
maskBackgroundView ).
3) Rotasi, penskalaan "lubang" ( 
mask ).
4) Hilangnya teks ( 
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() 
Saya menggunakan 
CATransaction untuk menyelesaikan animasi. Dalam hal ini, ini lebih nyaman daripada 
animationGroup , karena tidak semua animasi dilakukan melalui 
CAAnimation .
Kesimpulan
Jadi, pada output kami mendapat komponen yang tidak tergantung pada konteks peluncuran aplikasi (apakah itu diplink, notifikasi push, awal yang normal, atau yang lainnya). Lagipula Animasi akan bekerja dengan benar!
Anda dapat mengunduh proyek di sini.