Хабрахабр

Делаем вездесущий Splash Screen на iOS

Привет Хабр!

Задача возникла в рамках глобального ребрендинга, который не мог обойтись без изменения заставки и внешнего вида продукта. Я расскажу о реализации анимации перехода со сплэш скрина на другие экраны приложения.

Такие задачи относительно просты в реализации, а результат радует глаз и выглядит очень впечатляюще! Для многих разработчиков, участвующих в крупных проектах, решение задач, связанных с созданием красивой анимации, становится глотком свежего воздуха в мире багов, сложных фичей и хот-фиксов. Классическая ситуация: сначала показываем один экран, а потом с кастомным переходом открываем следующий — всё просто! Но бывают случаи, когда стандартные подходы не применимы, и тогда нужно придумывать всевозможные обходные решения.
На первый взгляд, в задаче обновления сплэш скрина самым сложным кажется создание анимации, а остальное — «рутинная работа».

После запуска Юлы открывается лента, поэтому было бы логично привязаться ко view соответствующего контроллера. В рамках анимации нужно сделать на сплэш скрине отверстие, в котором отображается содержимое следующего экрана, то есть мы точно должны знать, какой view показывается под сплэшом.

Или из браузера открыть карточку товара? Но что если запустить приложение с push-уведомления, которое ведет на профиль пользователя? И хотя все переходы совершаются после открытия главного экрана, анимация привязывается к конкретному view, но какого именно контроллера? Тогда следующим экраном должна быть вовсе не лента (это далеко не все возможные случаи).

Преимущество такого подхода в том, что нам абсолютно не важно, что происходит под сплэшом: в главном окне приложения может загружаться лента, выезжать попап или совершаться анимированный переход на какой-нибудь экран. Во избежание костылей множества if-else блоков для обработки каждой ситуации, cплэш скрин будет показываться на уровне UIWindow. Далее я подробно расскажу о реализации выбранного нами способа, которая состоит из следующих этапов:

  • Подготовка сплэш скрина.
  • Анимация появления.
  • Анимация скрытия.

Подготовка сплэш скрина

Для начала нужно подготовить статический сплэш скрин — то есть экран, который отображается сразу при запуске приложения. Сделать это можно двумя способами: предоставить картинки разного разрешения для каждого девайса, либо сверстать этот экран в LaunchScreen.storyboard. Второй вариант быстрее, удобнее и рекомендован самой компанией Apple, поэтому им мы и воспользуемся:

Тут всё просто: imageView с градиентным фоном и imageView с логотипом.

В Main.storyboard добавим ViewController: Как известно, этот экран анимировать нельзя, поэтому нужно создать еще один, визуально идентичный, чтобы переход между ними был незаметен.

Отличие от предыдущего экрана в том, что тут есть еще один imageView, в который подставится случайный текст (разумеется, изначально он будет скрыт). Теперь создадим класс для этого контроллера:

final class SplashViewController: UIViewController }

Помимо IBOutlet'ов для элементов, которые мы хотим анимировать, в этом классе есть свойство textImage — в него будет передаваться случайно выбранная картинка. Теперь вернемся в Main.storyboard и укажем соответствующему контроллеру класс SplashViewController. Заодно в начальный ViewController положим imageView со скриншотом Юлы, чтобы под сплэшом не было пустого экрана.

Запишем протокол для него и сразу создадим класс: Теперь нам нужен презентер, который будет отвечать за логику показа и скрытия слэш скрина.

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) { // Пока оставим метод пустым }

В конструктор передается foregroundSplashWindow, а для удобства из него «извлекается» 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).
  • Фэйдом появляется текст и немного поднимается (textImageView).

Напомню, что по умолчанию UIWindow создается невидимым, и исправить это можно двумя способами:

  • вызвать у него метод makeKeyAndVisible;
  • установить свойство isHidden = false.

Нам подходит второй способ, так как мы не хотим, чтобы foregroundSplashWindow становился keyWindow.

С учетом этого, в SplashAnimator реализуем метод 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. Заодно через 2 секунды вызовем 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 строчек кода не справятся. Нужно сделать сквозное отверстие, которое будет еще вращаться и увеличиваться! Если вы подумали, что «это можно сделать маской», то вы совершенно правы!

Давайте сразу сделаем это, и заодно скроем foregroundSplashWindow, так как дальнейшие действия будут происходить под ним. Маску мы будем добавлять на layer главного окна приложения (ведь мы не хотим привязываться к конкретному контроллеру).

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 я скрыл через свойство alpha, а не isHidden (иначе моргнет экран). Еще один интересный момент: так как эта маска будет увеличиваться во время анимации, нужно использовать для нее логотип более высокого разрешения (например, 1024х1024). Поэтому я добавил в SplashViewController:

static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")!

Проверим, что получилось?

Знаю, сейчас это выглядит не очень впечатляюще, но всё впереди, идем дальше! Особо внимательные могли заметить, что во время анимации логотип становится прозрачным не мгновенно, а в течение некоторого времени. Для этого в mainWindow поверх всех subviews добавим 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, который будет передаваться дальше в SplashViewController:

splashViewController?.logoIsHidden = logoIsHidden

Соответственно, там, где создается foregroundSplashWindow, нужно передать в этот параметр false, а для backgroundSplashWindowtrue:

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. Начнем с вращения:

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") }

Как вы могли заметить, угол поворота рассчитывается исходя из размеров экрана, чтобы Юла на всех девайсах крутилась до верхнего левого угла.

Анимация масштабирования логотипа:

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) Юла должна увеличиться в 18 раз.

То есть в момент времени 0. Но сначала она немного уменьшается (в соответствии со вторыми элементами в массивах scales и keyTimes). 2 * duration (где duration — общая продолжительность анимации масштабирования) масштаб Юлы будет равен 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.

Заключение

Таким образом, на выходе у нас получился компонент, не зависящий от контекста запуска приложения (будь то диплинк, push-уведомление, обычный старт или что-то другое). Анимация сработает корректно в любом случае!

Скачать проект можно тут

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть