Membuat Layar Splash di mana-mana di iOS



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

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 SplashViewController dan buat itu rootViewController `ohm;
  • atur windowLevel lebih 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() { //     } func animateDisappearance(completion: @escaping () -> Void) { //     } 

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.

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


All Articles