...

среда, 26 июня 2019 г.

Делаем вездесущий 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 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, а для 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 раз.

Но сначала она немного уменьшается (в соответствии со вторыми элементами в массивах 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-уведомление, обычный старт или что-то другое). Анимация сработает корректно в любом случае!

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

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий