
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
SplashViewController
e torne-o rootViewController
`ohm;
- defina
windowLevel
maior 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.