Привет!
Я расскажу о реализации анимации перехода со сплэш скрина на другие экраны приложения. Задача возникла в рамках глобального ребрендинга, который не мог обойтись без изменения заставки и внешнего вида продукта.
Для многих разработчиков, участвующих в крупных проектах, решение задач, связанных с созданием красивой анимации, становится глотком свежего воздуха в мире багов, сложных фичей и хот-фиксов. Такие задачи относительно просты в реализации, а результат радует глаз и выглядит очень впечатляюще! Но бывают случаи, когда стандартные подходы не применимы, и тогда нужно придумывать всевозможные обходные решения.
На первый взгляд, в задаче обновления сплэш скрина самым сложным кажется создание анимации, а остальное — «рутинная работа». Классическая ситуация: сначала показываем один экран, а потом с кастомным переходом открываем следующий — всё просто!
В рамках анимации нужно сделать на сплэш скрине отверстие, в котором отображается содержимое следующего экрана, то есть мы точно должны знать, какой view
показывается под сплэшом. После запуска Юлы открывается лента, поэтому было бы логично привязаться ко view
соответствующего контроллера.
Но что если запустить приложение с push-уведомления, которое ведет на профиль пользователя? Или из браузера открыть карточку товара? Тогда следующим экраном должна быть вовсе не лента (это далеко не все возможные случаи). И хотя все переходы совершаются после открытия главного экрана, анимация привязывается к конкретному view
, но какого именно контроллера?
Во избежание костылей множества if-else
блоков для обработки каждой ситуации, cплэш скрин будет показываться на уровне UIWindow
. Преимущество такого подхода в том, что нам абсолютно не важно, что происходит под сплэшом: в главном окне приложения может загружаться лента, выезжать попап или совершаться анимированный переход на какой-нибудь экран. Далее я подробно расскажу о реализации выбранного нами способа, которая состоит из следующих этапов:
- Подготовка сплэш скрина.
- Анимация появления.
- Анимация скрытия.
Подготовка сплэш скрина
Для начала нужно подготовить статический сплэш скрин — то есть экран, который отображается сразу при запуске приложения. Сделать это можно двумя способами: предоставить картинки разного разрешения для каждого девайса, либо сверстать этот экран в LaunchScreen.storyboard
. Второй вариант быстрее, удобнее и рекомендован самой компанией Apple, поэтому им мы и воспользуемся:
Тут всё просто: imageView
с градиентным фоном и imageView
с логотипом.
Как известно, этот экран анимировать нельзя, поэтому нужно создать еще один, визуально идентичный, чтобы переход между ними был незаметен. В Main.storyboard
добавим ViewController
:
Отличие от предыдущего экрана в том, что тут есть еще один imageView
, в который подставится случайный текст (разумеется, изначально он будет скрыт). Теперь создадим класс для этого контроллера:
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
. Заодно в начальный 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 строчек кода не справятся. Нужно сделать сквозное отверстие, которое будет еще вращаться и увеличиваться! Если вы подумали, что «это можно сделать маской», то вы совершенно правы!
Маску мы будем добавлять на 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
я скрыл через свойство 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
, а для backgroundSplashWindow
— true
:
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 раз.
Но сначала она немного уменьшается (в соответствии со вторыми элементами в массивах scales
и keyTimes
). То есть в момент времени 0.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-уведомление, обычный старт или что-то другое). Анимация сработает корректно в любом случае!
Автор: Artur Sardaryan