
Oi Habr!
Vou falar sobre a implementação da transição de animação da tela inicial para outras telas do aplicativo. A tarefa surgiu como parte de uma rebranding global, o que não poderia acontecer sem alterar a tela inicial e a aparência do produto.
Para muitos desenvolvedores envolvidos em grandes projetos, resolver os problemas associados à criação de belas animações se torna uma lufada de ar fresco no mundo de bugs, recursos complexos e hot fixes. Essas tarefas são relativamente fáceis de implementar, e o resultado é agradável aos olhos e parece muito impressionante! Mas há momentos em que abordagens padrão não são aplicáveis e, em seguida, você precisa apresentar todos os tipos de soluções alternativas.
À primeira vista, na tarefa de atualizar a tela inicial, a criação de animação parece ser a mais difícil, e o resto é "trabalho de rotina". A situação clássica: primeiro, mostramos uma tela e, em seguida, com uma transição personalizada, abrimos a próxima - tudo é simples!
Como parte da animação, você precisa fazer um furo na tela inicial na qual o conteúdo da próxima tela é exibido, ou seja, precisamos definitivamente saber qual 
view exibida sob a abertura. Depois de iniciar o Yula, a fita é aberta, portanto, seria lógico anexar à 
view controlador correspondente.
Mas e se você executar o aplicativo com uma notificação por push que leva ao perfil do usuário? Ou abrir um cartão de produto em um navegador? A próxima tela não deverá ser uma fita (isso está longe de todos os casos possíveis). E embora todas as transições sejam feitas após a abertura da tela principal, a animação está vinculada a uma 
view específica, mas qual controlador?
Para evitar 
muletas de muitos blocos 
if-else para lidar com cada situação, a tela inicial será mostrada no nível 
UIWindow . A vantagem dessa abordagem é que não nos importamos absolutamente com o que acontece sob o efeito: na janela principal do aplicativo, uma fita pode carregar, abrir ou fazer uma transição animada para alguma tela. A seguir, falarei em detalhes sobre a implementação do método escolhido, que consiste nas seguintes etapas:
- Preparando uma tela inicial.
 
- Animação da aparência.
 
- Ocultar animação.
 
Preparação da tela inicial
Primeiro, você precisa preparar uma tela inicial estática - ou seja, uma tela que apareça imediatamente quando o aplicativo for iniciado. 
Você pode fazer isso de duas maneiras : forneça imagens de diferentes resoluções para cada dispositivo ou crie essa tela no 
LaunchScreen.storyboard . A segunda opção é mais rápida, mais conveniente e recomendada pela própria Apple, então vamos usá-la:
Tudo é simples: 
imageView com um fundo gradiente e 
imageView com um logotipo.
Como você sabe, essa tela não pode ser animada, então você precisa criar outra, visualmente idêntica, para que a transição entre elas seja invisível. No 
Main.storyboard adicione um 
ViewController :
A diferença da tela anterior é que existe outro 
imageView no qual o texto aleatório é substituído (é claro, ele será oculto inicialmente). Agora crie uma classe para este controlador:
 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 } } 
Além das 
IBOutlet para os elementos que queremos animar, essa classe também possui uma propriedade 
textImage - uma imagem selecionada aleatoriamente será passada para ela. Agora vamos voltar para 
Main.storyboard e apontar a classe 
SplashViewController para o controlador correspondente. Ao mesmo tempo, coloque um 
imageView com uma captura de tela do Yula no 
ViewController inicial para que não exista uma tela em branco sob o splash.
Agora precisamos de um apresentador que seja responsável pela lógica de mostrar e ocultar a tela da barra. Nós escrevemos o protocolo para ele e imediatamente criamos uma classe:
 protocol SplashPresenterDescription: class { func present() func dismiss(completion: @escaping () -> Void) } final class SplashPresenter: SplashPresenterDescription { func present() {  
O mesmo objeto selecionará texto para uma tela inicial. O texto é exibido como uma figura, portanto, você precisa adicionar os recursos apropriados em 
Assets.xcassets . Os nomes dos recursos são os mesmos, exceto o número - ele será gerado aleatoriamente:
  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) }() 
Não foi por acaso que fiz do 
textImage uma propriedade comum, ou seja, 
lazy , depois você entenderá o porquê.
No começo, prometi que a tela inicial seria exibida em uma 
UIWindow separada, para isso você precisa:
- crie um UIWindow;
 
- crie um SplashViewControllere torne-orootViewController`ohm;
 
- defina windowLevelmaior que.normal(o valor padrão) para que essa janela apareça na parte superior da janela principal.
 
No 
SplashPresenter adicione:
  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 } 
Você pode achar estranho que a criação do 
splashViewController e 
splashWindow seja 
splashWindow em funções separadas, mas mais tarde isso será útil.
Ainda não começamos a escrever a lógica da animação, e o 
SplashPresenter já possui muito código. Portanto, proponho criar uma entidade que lide diretamente com a animação (mais essa divisão de responsabilidades):
 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 é passado para o construtor e, por conveniência, o 
rootViewController é "extraído" dele, que também é armazenado em propriedades, como 
foregroundSplashViewController .
Adicionar ao 
SplashPresenter :
  private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow) 
e conserte seus 
dismiss present e 
dismiss :
  func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) } 
Tudo, a parte mais chata por trás, você pode finalmente começar a animação!
Animação de aparência
Vamos começar com a animação da aparência da tela inicial, é simples:
- O logotipo é logoImageView(logoImageView).
 
- O texto aparece no fader e aumenta um pouco ( textImageView).
 
Deixe-me lembrá-lo de que, por padrão, o 
UIWindow é criado invisível e há duas maneiras de corrigir isso:
- chame o método makeKeyAndVisible;
 
- definir propriedade isHidden = false.
 
O segundo método é adequado para nós, pois não queremos 
foregroundSplashWindow keyWindow foregroundSplashWindow torne 
keyWindow .
Com isso em 
SplashAnimator implementamos o método 
animateAppearance() no 
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 }) } 
Não conheço você, mas gostaria de iniciar o projeto o mais rápido possível e ver o que aconteceu! Resta apenas abrir o 
AppDelegate , adicionar a propriedade 
splashPresenter e chamar o método 
present nele. Ao mesmo tempo, após 2 segundos, chamaremos 
dismiss para que não precisemos retornar a este arquivo:
  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 } 
O próprio objeto é excluído da memória após ocultar o splash.
Viva, você pode correr!
Escondendo animação
Infelizmente (ou felizmente), 10 linhas de código não lidam com a animação de ocultar. É necessário fazer um furo passante, que ainda irá girar e aumentar! Se você pensou que "isso pode ser feito com uma máscara", então você está absolutamente certo!
Adicionaremos uma máscara à 
layer janela principal do aplicativo (não queremos vincular a um controlador específico). Vamos fazer isso imediatamente e, ao mesmo tempo, ocultar o 
foregroundSplashWindow , pois outras ações ocorrerão sob ele.
  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 } 
É importante observar aqui que eu ocultei o 
foregroundSplashWindow isHidden através da propriedade 
alpha e não 
isHidden (caso contrário, a tela piscará). Outro ponto interessante: como essa máscara aumentará durante a animação, você precisará usar um logotipo de maior resolução (por exemplo, 1024x1024). Então eu adicionei ao 
SplashViewController :
  static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")! 
Veja o que aconteceu?
Eu sei, agora não parece muito impressionante, mas está tudo à frente, seguimos em frente! Particularmente atento pode notar que, durante a animação, o logotipo não se torna transparente imediatamente, mas por algum tempo. Para fazer isso, na 
mainWindow além de todas as 
subviews adicione um 
imageView com um logotipo que será oculto pelo fade.
  let maskBackgroundView = UIImageView(image: SplashViewController.logoImageBig) maskBackgroundView.frame = mask.frame mainWindow.addSubview(maskBackgroundView) mainWindow.bringSubviewToFront(maskBackgroundView) 
Portanto, temos um buraco na forma de um logotipo e, sob o buraco, o próprio logotipo.
Agora, de volta ao lugar de um belo fundo e texto gradiente. Alguma idéia de como fazer isso?
Eu tenho: coloque outra 
UIWindow em 
mainWindow (ou seja, com uma menor 
windowLevel , vamos chamá-la 
backgroundSplashWindow ) e depois a veremos em vez de um fundo preto. E, é claro, o 
rootViewController' terá um 
SplashViewContoller , apenas você precisará ocultar o 
logoImageView . Para fazer isso, crie uma propriedade no 
SplashViewController :
  var logoIsHidden: Bool = false 
e no método 
viewDidLoad() , adicione:
  logoImageView.isHidden = logoIsHidden 
SplashPresenter finalizar 
SplashPresenter : no 
splashViewController (with textImage: UIImage?) Adicione outro parâmetro 
logoIsHidden: Bool , que será passado para o 
SplashViewController :
 splashViewController?.logoIsHidden = logoIsHidden 
Assim, onde 
foregroundSplashWindow é criado, 
false deve ser passado para esse parâmetro e 
true para 
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 }() 
Você também precisa lançar esse objeto através do construtor no 
SplashAnimator (semelhante ao 
foregroundSplashWindow SplashAnimator ) e adicionar as propriedades:
  private unowned let backgroundSplashWindow: UIWindow private unowned let backgroundSplashViewController: SplashViewController 
Para que, em vez de um plano de fundo preto, vejamos a mesma tela inicial, antes de ocultar o 
foregroundSplashWindow você precisa mostrar 
backgroundSplashWindow :
  backgroundSplashWindow.isHidden = false 
Verifique se o plano foi um sucesso:
Agora a parte mais interessante é a animação de esconder! Como você precisa animar o 
CALayer , não o 
UIView , usaremos o 
CoreAnimation para obter ajuda. Vamos começar com a rotação:
  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") } 
Como você pode ver, o ângulo de rotação é calculado com base no tamanho da tela, para que o Yula em todos os dispositivos gire no canto superior esquerdo.
Animação de escala do logotipo:
  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") } 
Vale a pena prestar atenção ao 
finalScale : a escala final também é calculada dependendo do tamanho da tela (proporcional à altura). Ou seja, com uma altura de tela de 667 pontos (iPhone 6), o Yula deve aumentar 18 vezes.
Mas, primeiro, diminui um pouco (de acordo com os segundos elementos nas 
keyTimes scales e 
keyTimes ). Ou seja, no tempo de 
0.2 * duration (onde 
duration é a duração total da animação em escala), a escala de Yula será de 0,85.
Já estamos na linha de chegada! No método 
animateDisappearance execute todas as animações:
1) Dimensionamento da janela principal ( 
mainWindow ).
2) Rotação, escala, desaparecimento do logotipo ( 
maskBackgroundView ).
3) Rotação, escala do “buraco” ( 
mask ).
4) O desaparecimento do texto ( 
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() 
Usei a 
CATransaction para concluir a animação. Nesse caso, é mais conveniente que o 
animationGroup , pois nem todas as animações são feitas pelo 
CAAnimation .
Conclusão
Portanto, na saída, obtivemos um componente que não depende do contexto de inicialização do aplicativo (se era um diplink, uma notificação por push, um início normal ou outra coisa). A animação funcionará corretamente de qualquer maneira!
Você pode baixar o projeto aqui.