
مرحباً هبر!
سأتحدث عن تنفيذ انتقال الرسوم المتحركة من شاشة البداية إلى الشاشات الأخرى للتطبيق. نشأت المهمة كجزء من إعادة صياغة العلامة التجارية العالمية ، والتي لا يمكن القيام بها دون تغيير شاشة البداية وظهور المنتج.
بالنسبة للعديد من المطورين المشاركين في المشاريع الكبيرة ، يصبح حل المشكلات المرتبطة بإنشاء رسوم متحركة جميلة نفسًا منعشًا في عالم الأخطاء والميزات المعقدة والإصلاحات الساخنة. هذه المهام سهلة التنفيذ نسبيا ، والنتيجة هي إرضاء العين وتبدو رائعة جدا! ولكن هناك أوقات لا تكون فيها الأساليب القياسية قابلة للتطبيق ، ومن ثم تحتاج إلى الخروج بكل أنواع الحلول.
للوهلة الأولى ، في مهمة تحديث شاشة البداية ، يبدو أن إنشاء الرسوم المتحركة هو الأكثر صعوبة ، والباقي هو "العمل الروتيني". الوضع الكلاسيكي: أولاً ، نعرض شاشة واحدة ، ثم مع الانتقال المخصص نفتح الشاشة التالية - كل شيء بسيط!
كجزء من الرسوم المتحركة ، تحتاج إلى إجراء ثقب على شاشة البداية التي يتم فيها عرض محتويات الشاشة التالية ، أي أننا بالتأكيد بحاجة إلى معرفة
view
التي
view
عرضها تحت البداية. بعد بدء تشغيل Yula ، يفتح الشريط ، لذلك سيكون من المنطقي أن نعلق على
view
وحدة التحكم المقابلة.
ولكن ماذا لو قمت بتشغيل التطبيق مع إشعار دفع يؤدي إلى ملف تعريف المستخدم؟ أو فتح بطاقة المنتج من المتصفح؟ ثم يجب ألا تكون الشاشة التالية شريطًا على الإطلاق (هذا أبعد ما يكون عن جميع الحالات الممكنة). وعلى الرغم من أن جميع التحولات تتم بعد فتح الشاشة الرئيسية ، فإن الرسوم المتحركة مرتبطة
view
معين ، ولكن أي جهاز تحكم؟
من أجل تجنب
عكازات العديد
if-else
الكتل
if-else
للتعامل مع كل موقف ، سيتم عرض شاشة البداية على مستوى
UIWindow
. تكمن ميزة هذا الأسلوب في أننا لا نهتم تمامًا بما يحدث في البداية: في الإطار الرئيسي للتطبيق ، يمكن لشريط أن يقوم بالتحميل أو النوافذ المنبثقة أو الانتقال بشكل متحرك إلى بعض الشاشة. بعد ذلك ، سأتحدث بالتفصيل عن تنفيذ الطريقة التي اخترناها ، والتي تتكون من الخطوات التالية:
- إعداد شاشة البداية.
- الرسوم المتحركة للمظهر.
- إخفاء الرسوم المتحركة.
إعداد شاشة البداية
تحتاج أولاً إلى إعداد شاشة البداية الثابتة - أي شاشة تظهر فور بدء تشغيل التطبيق.
يمكنك القيام بذلك بطريقتين : تقديم صور بدقة مختلفة لكل جهاز ، أو تكوين هذه الشاشة في
LaunchScreen.storyboard
. الخيار الثاني أسرع وأكثر ملاءمة وأوصت به Apple نفسها ، لذلك سنستخدمه:
كل شيء بسيط:
imageView
مع خلفية متدرجة و
imageView
مع شعار.
كما تعلم ، لا يمكن تحريك هذه الشاشة ، لذا تحتاج إلى إنشاء شاشة أخرى متطابقة بصريًا ، بحيث يكون الانتقال بينها غير مرئي. في
Main.storyboard
إضافة
ViewController
:
الفرق من الشاشة السابقة هو أن هناك صورة أخرى يتم فيها استبدال نص عشوائي (بالطبع ، سيتم إخفاؤه في البداية). الآن قم بإنشاء فصل لوحدة التحكم هذه:
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 } }
بالإضافة إلى
IBOutlet
للعناصر التي نريد تحريكها ، تحتوي هذه الفئة أيضًا على خاصية
textImage
- سيتم تمرير صورة عشوائية إليها. الآن دعنا نعود إلى
Main.storyboard
SplashViewController
فئة
SplashViewController
إلى وحدة التحكم المقابلة. في الوقت نفسه ، ضع صورة
imageView
مع لقطة شاشة لـ Yula في
ViewController
الأولي بحيث لا توجد شاشة فارغة أسفل البداية.
نحتاج الآن إلى مقدم عرض يكون مسؤولاً عن منطق عرض وإخفاء شاشة الشرطة المائلة. نكتب البروتوكول لذلك وننشئ فئة على الفور:
protocol SplashPresenterDescription: class { func present() func dismiss(completion: @escaping () -> Void) } final class SplashPresenter: SplashPresenterDescription { func present() {
سيحدد نفس الكائن نصًا لشاشة البداية. يتم عرض النص كصورة ، لذلك تحتاج إلى إضافة الموارد المناسبة في
Assets.xcassets
. أسماء الموارد هي نفسها ، باستثناء الرقم - سيتم إنشاؤه بشكل عشوائي:
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) }()
لم يكن من قبيل المصادفة أنني جعلت
textImage
ليس خاصية عادية ، ولا سيما
lazy
، في وقت لاحق سوف تفهم لماذا.
في البداية ، وعدت أن يتم عرض شاشة البداية في
UIWindow
منفصلة ، لهذا تحتاج:
- إنشاء
UIWindow
؛
- إنشاء
SplashViewController
وجعله rootViewController
`أوم ؛
- قم بتعيين
windowLevel
أكبر من .normal
(القيمة الافتراضية) بحيث تظهر هذه النافذة في أعلى الإطار الرئيسي.
في
SplashPresenter
أضف:
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 }
قد تجد أنه من الغريب أن يتم إنشاء
splashViewController
و
splashWindow
في وظائف منفصلة ، ولكن في وقت لاحق ، سيكون هذا مفيدًا.
لم نبدأ في كتابة منطق الرسوم المتحركة بعد ، و
SplashPresenter
لديه بالفعل الكثير من التعليمات البرمجية. لذلك ، أقترح إنشاء كيان يتعامل مباشرةً مع الرسوم المتحركة (بالإضافة إلى تقسيم المسؤوليات):
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
إلى المُنشئ ،
rootViewController
يتم استخراج "
rootViewController
" منه ، والذي يتم تخزينه أيضًا في خصائص ، مثل
foregroundSplashViewController
.
إضافة إلى
SplashPresenter
:
private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow)
وإصلاح
present
dismiss
:
func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) }
كل شيء ، الجزء الأكثر مملة وراء ، يمكنك أخيرا بدء الرسوم المتحركة!
الرسوم المتحركة المظهر
لنبدأ بالرسوم المتحركة لمظهر شاشة البداية ، إنها بسيطة:
- يتم
logoImageView
الشعار ( logoImageView
).
- يظهر النص على ترويسة
textImageView
قليلاً ( textImageView
).
اسمحوا لي أن أذكرك أنه يتم إنشاء
UIWindow
افتراضيًا ، وهناك طريقتان لإصلاح ذلك:
- استدعاء الأسلوب
makeKeyAndVisible
؛
- تعيين الخاصية
isHidden = false
.
الطريقة الثانية مناسبة لنا ، حيث أننا لا نريد
foregroundSplashWindow
تصبح
keyWindow
.
مع وضع ذلك في
SplashAnimator
animateAppearance()
طريقة
animateAppearance()
في
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 }) }
لا أعرف عنك ، لكن أود إطلاق المشروع في أقرب وقت ممكن ومعرفة ما حدث! يبقى فقط لفتح
AppDelegate
، إضافة خاصية
splashPresenter
واستدعاء الطريقة
present
على ذلك. في الوقت نفسه ، بعد ثانيتين ، سوف ندعو "
dismiss
حتى لا نضطر إلى العودة إلى هذا الملف:
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 }
يتم حذف الكائن نفسه من الذاكرة بعد إخفاء البداية.
الصيحة ، يمكنك تشغيل!
يخفي الرسوم المتحركة
لسوء الحظ (أو لحسن الحظ) ، فإن 10 سطور من التعليمات البرمجية لن تتعامل مع الرسوم المتحركة للاختباء. من الضروري عمل فتحة من خلالها ، والتي ستستمر في التدوير وزيادة! إذا كنت تعتقد أنه "يمكن القيام بذلك باستخدام قناع" ، فأنت محق تمامًا!
سنضيف قناعًا
layer
نافذة التطبيق الرئيسية (لا نريد الربط بوحدة تحكم معينة). دعونا نفعل ذلك على الفور ، وفي الوقت نفسه ، نخفي
foregroundSplashWindow
، حيث ستحدث إجراءات أخرى تحته.
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 }
من المهم أن نلاحظ هنا أنني أخفيت
foregroundSplashWindow
isHidden
خلال خاصية
alpha
، وليس
isHidden
(وإلا
isHidden
الشاشة). نقطة أخرى مثيرة للاهتمام: نظرًا لأن هذا القناع سيزداد أثناء الرسوم المتحركة ، فأنت بحاجة إلى استخدام شعار دقة أعلى له (على سبيل المثال ، 1024x1024). لذلك أضفت إلى
SplashViewController
:
static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")!
تحقق ما حدث؟
أنا أعلم ، الآن لا يبدو الأمر رائعًا ، لكن كل شيء أمامنا ، نمضي قدمًا! يمكن أن ينتبه الانتباه بشكل خاص إلى أنه خلال الرسوم المتحركة لا يصبح الشعار شفافًا على الفور ، ولكن لبعض الوقت. للقيام بذلك ، في
mainWindow
على رأس كل
subviews
أضف صورة
imageView
مع شعار
imageView
الخبو.
let maskBackgroundView = UIImageView(image: SplashViewController.logoImageBig) maskBackgroundView.frame = mask.frame mainWindow.addSubview(maskBackgroundView) mainWindow.bringSubviewToFront(maskBackgroundView)
لذلك ، لدينا ثقب في شكل شعار ، وتحت الحفرة الشعار نفسه.
عاد الآن إلى مكان خلفية التدرج جميلة والنص. أي أفكار كيفية القيام بذلك؟
لدي: ضع
UIWindow
آخر تحت
mainWindow
(أي ، مع
windowLevel
صغيرة أصغر ، دعنا نسميها
backgroundSplashWindow
) ، ثم سنرى ذلك بدلاً من خلفية سوداء. وبطبيعة الحال ، فإن
rootViewController'
سيكون له
SplashViewContoller
، فقط تحتاج إلى إخفاء
logoImageView
. للقيام بذلك ، قم بإنشاء خاصية في
SplashViewController
:
var logoIsHidden: Bool = false
وفي طريقة
viewDidLoad()
، أضف:
logoImageView.isHidden = logoIsHidden
SplashPresenter
: في
splashViewController (with textImage: UIImage?)
أضف
logoIsHidden: Bool
معلمة
logoIsHidden: Bool
، والتي سيتم تمريرها إلى
SplashViewController
:
splashViewController?.logoIsHidden = logoIsHidden
وفقًا لذلك ، حيث يتم إنشاء
foregroundSplashWindow
، يجب تمرير
false
إلى هذه المعلمة ،
true
لـ
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 }()
تحتاج أيضًا إلى رمي هذا الكائن خلال المنشئ في
SplashAnimator
(على غرار
foregroundSplashWindow
) وإضافة الخصائص هناك:
private unowned let backgroundSplashWindow: UIWindow private unowned let backgroundSplashViewController: SplashViewController
لذلك ، بدلاً من الخلفية السوداء ، نرى نفس شاشة البداية ، مباشرة قبل إخفاء
foregroundSplashWindow
تحتاج إلى إظهار
backgroundSplashWindow
:
backgroundSplashWindow.isHidden = false
تأكد من نجاح الخطة:
الآن الجزء الأكثر إثارة للاهتمام هو إخفاء الرسوم المتحركة! نظرًا لأنك تحتاج إلى تنشيط
CALayer
، وليس
UIView
،
CoreAnimation
إلى
CoreAnimation
للحصول على المساعدة. لنبدأ بالتناوب:
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") }
كما ترى ، يتم حساب زاوية الدوران استنادًا إلى حجم الشاشة بحيث تدور Yula على جميع الأجهزة في الزاوية العلوية اليسرى.
تحجيم الشعار:
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") }
يجدر الانتباه إلى
finalScale
: يتم احتساب المقياس النهائي أيضًا اعتمادًا على حجم الشاشة (بما يتناسب مع الارتفاع). وهذا يعني ، مع ارتفاع الشاشة 667 نقطة (iPhone 6) ، يجب أن يزيد Yula 18 مرة.
لكن أولاً ، يتناقص قليلاً (وفقًا للعناصر الثانية في
scales
keyTimes
). أي أن
0.2 * duration
الزمنية
0.2 * duration
(حيث تكون
duration
هي المدة الإجمالية للرسم المتحرك) ، سيكون مقياس Yula هو 0.85.
نحن بالفعل في خط النهاية! في طريقة
animateDisappearance
بتشغيل جميع الرسوم المتحركة:
1) تحجيم النافذة الرئيسية (
mainWindow
).
2) الدوران ، التحجيم ، اختفاء الشعار (
maskBackgroundView
).
3) الدوران ، تحجيم "ثقب" (
mask
).
4) اختفاء النص (
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()
لقد استخدمت
CATransaction
لإكمال الرسوم المتحركة. في هذه الحالة ، يكون أكثر ملاءمة من
animationGroup
، حيث لا تتم جميع الرسوم المتحركة من خلال
CAAnimation
.
استنتاج
وبالتالي ، في المخرجات ، حصلنا على مكون لا يعتمد على سياق تشغيل التطبيق (سواء كان دبلومًا أو إشعارًا بالدفع أو بداية طبيعية أو أي شيء آخر). سوف الرسوم المتحركة تعمل بشكل صحيح على أي حال!
يمكنك تنزيل المشروع هنا.