جعل شاشة البداية في كل مكان على دائرة الرقابة الداخلية



مرحباً هبر!

سأتحدث عن تنفيذ انتقال الرسوم المتحركة من شاشة البداية إلى الشاشات الأخرى للتطبيق. نشأت المهمة كجزء من إعادة صياغة العلامة التجارية العالمية ، والتي لا يمكن القيام بها دون تغيير شاشة البداية وظهور المنتج.

بالنسبة للعديد من المطورين المشاركين في المشاريع الكبيرة ، يصبح حل المشكلات المرتبطة بإنشاء رسوم متحركة جميلة نفسًا منعشًا في عالم الأخطاء والميزات المعقدة والإصلاحات الساخنة. هذه المهام سهلة التنفيذ نسبيا ، والنتيجة هي إرضاء العين وتبدو رائعة جدا! ولكن هناك أوقات لا تكون فيها الأساليب القياسية قابلة للتطبيق ، ومن ثم تحتاج إلى الخروج بكل أنواع الحلول.

للوهلة الأولى ، في مهمة تحديث شاشة البداية ، يبدو أن إنشاء الرسوم المتحركة هو الأكثر صعوبة ، والباقي هو "العمل الروتيني". الوضع الكلاسيكي: أولاً ، نعرض شاشة واحدة ، ثم مع الانتقال المخصص نفتح الشاشة التالية - كل شيء بسيط!

كجزء من الرسوم المتحركة ، تحتاج إلى إجراء ثقب على شاشة البداية التي يتم فيها عرض محتويات الشاشة التالية ، أي أننا بالتأكيد بحاجة إلى معرفة 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() { //     } func dismiss(completion: @escaping () -> Void) { //     } } 

سيحدد نفس الكائن نصًا لشاشة البداية. يتم عرض النص كصورة ، لذلك تحتاج إلى إضافة الموارد المناسبة في 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() { //     } func animateDisappearance(completion: @escaping () -> Void) { //     } 

يتم تمرير 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 .


استنتاج


وبالتالي ، في المخرجات ، حصلنا على مكون لا يعتمد على سياق تشغيل التطبيق (سواء كان دبلومًا أو إشعارًا بالدفع أو بداية طبيعية أو أي شيء آخر). سوف الرسوم المتحركة تعمل بشكل صحيح على أي حال!

يمكنك تنزيل المشروع هنا.

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


All Articles