Criando a onipresente tela inicial no iOS



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() { //     } func dismiss(completion: @escaping () -> Void) { //     } } 

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() { //     } func animateDisappearance(completion: @escaping () -> Void) { //     } 

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.

Source: https://habr.com/ru/post/pt456394/


All Articles