Hacer la ubicua pantalla de bienvenida en iOS



Hola habr

Hablaré sobre la implementación de la transición de animación desde la pantalla de inicio a otras pantallas de la aplicación. La tarea surgió como parte de un cambio de marca global, que no podría prescindir de cambiar la pantalla de presentación y la apariencia del producto.

Para muchos desarrolladores involucrados en grandes proyectos, resolver los problemas asociados con la creación de animaciones hermosas se convierte en un soplo de aire fresco en el mundo de los errores, las características complejas y las soluciones rápidas. ¡Estas tareas son relativamente fáciles de implementar, y el resultado es agradable a la vista y se ve muy impresionante! Pero hay momentos en que los enfoques estándar no son aplicables, y luego debe encontrar todo tipo de soluciones.

A primera vista, en la tarea de actualizar la pantalla de presentación, la creación de animación parece ser la más difícil, y el resto es "trabajo de rutina". La situación clásica: primero mostramos una pantalla y luego, con una transición personalizada, abrimos la siguiente, ¡todo es simple!

Como parte de la animación, debe hacer un agujero en la pantalla de presentación en la que se muestran los contenidos de la siguiente pantalla, es decir, definitivamente necesitamos saber qué view muestra debajo de la presentación. Después de iniciar Yula, la cinta se abre, por lo que sería lógico adjuntarla a la view controlador correspondiente.

Pero, ¿qué sucede si ejecuta la aplicación con una notificación push que conduce al perfil de usuario? ¿O abrir una tarjeta de producto desde un navegador? Entonces la siguiente pantalla no debería ser una cinta (esto está lejos de todos los casos posibles). Y aunque todas las transiciones se realizan después de abrir la pantalla principal, la animación está vinculada a una view específica, pero ¿qué controlador?

Para evitar muletas de muchos bloques if-else para manejar cada situación, la pantalla de bienvenida se mostrará en el nivel UIWindow . La ventaja de este enfoque es que no nos importa lo que ocurra debajo de la pantalla: en la ventana principal de la aplicación, una cinta puede cargarse, abrirse o hacer una transición animada a alguna pantalla. A continuación, hablaré en detalle sobre la implementación de nuestro método elegido, que consta de los siguientes pasos:

  • Preparando una pantalla de bienvenida.
  • Animación de la apariencia.
  • Ocultar animación

Preparación de pantalla de bienvenida


Primero debe preparar una pantalla de inicio estática, es decir, una pantalla que aparece inmediatamente cuando se inicia la aplicación. Puede hacerlo de dos maneras : proporcione imágenes de diferentes resoluciones para cada dispositivo, o LaunchScreen.storyboard esta pantalla en LaunchScreen.storyboard . La segunda opción es más rápida, más conveniente y recomendada por Apple, por lo que la usaremos:


Todo es simple: imageView con un fondo degradado e imageView con un logotipo.

Como sabes, esta pantalla no puede ser animada, por lo que debes crear otra, visualmente idéntica, para que la transición entre ellas sea invisible. En Main.storyboard agregue un ViewController :


La diferencia con la pantalla anterior es que hay otra imageView en la que se sustituye el texto aleatorio (por supuesto, se imageView inicialmente). Ahora cree una clase 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 } } 

Además de los IBOutlet para los elementos que queremos animar, esta clase también tiene una propiedad textImage : se le pasará una imagen seleccionada al azar. Ahora volvamos a Main.storyboard y SplashViewController clase SplashViewController al controlador correspondiente. Al mismo tiempo, coloque una imageView con una captura de pantalla de Yula en el ViewController inicial para que no haya una pantalla en blanco debajo del splash.

Ahora necesitamos un presentador que sea responsable de la lógica de mostrar y ocultar la barra diagonal. Escribimos el protocolo para ello e inmediatamente creamos una clase:

 protocol SplashPresenterDescription: class { func present() func dismiss(completion: @escaping () -> Void) } final class SplashPresenter: SplashPresenterDescription { func present() { //     } func dismiss(completion: @escaping () -> Void) { //     } } 

El mismo objeto seleccionará texto para una pantalla de bienvenida. El texto se muestra como una imagen, por lo que debe agregar los recursos apropiados en Assets.xcassets . Los nombres de los recursos son los mismos, excepto por el número; se generará 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) }() 

No fue por casualidad que hice que textImage no fuera una propiedad ordinaria, es decir, lazy , más tarde entenderás por qué.

Al principio, prometí que la pantalla de bienvenida se mostrará en una UIWindow separada, para esto necesitas:

  • crear una UIWindow ;
  • cree un SplashViewController y SplashViewController rootViewController `ohm;
  • configure windowLevel para que windowLevel mayor que .normal (el valor predeterminado) para que esta ventana aparezca encima de la principal.

En SplashPresenter agregue:

  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 } 

Puede resultarle extraño que la creación de splashViewController y splashWindow se splashWindow en funciones separadas, pero más tarde esto será útil.

Todavía no hemos comenzado a escribir lógica de animación, y SplashPresenter ya tiene mucho código. Por lo tanto, propongo crear una entidad que se ocupe directamente de la animación (más esta división 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 se pasa al constructor y, por conveniencia, se "extrae" rootViewController , que también se almacena en propiedades, como foregroundSplashViewController .

Añadir a SplashPresenter :

  private lazy var animator: SplashAnimatorDescription = SplashAnimator(foregroundSplashWindow: foregroundSplashWindow) 

y arreglar su present y dismiss :

  func present() { animator.animateAppearance() } func dismiss(completion: @escaping () -> Void) { animator.animateDisappearance(completion: completion) } 

¡Todo, la parte más aburrida detrás, finalmente puedes comenzar la animación!

Animación de apariencia


Comencemos con la animación de la apariencia de la pantalla de inicio, es simple:

  • El logotipo se logoImageView ( logoImageView ).
  • El texto aparece en el fader y sube un poco ( textImageView ).

Permítame recordarle que, por defecto, UIWindow se crea invisible, y hay dos formas de solucionarlo:

  • llame al método makeKeyAndVisible ;
  • establecer propiedad isHidden = false .

El segundo método es adecuado para nosotros, ya que no queremos que foregroundSplashWindow convierta en keyWindow .

Con esto en SplashAnimator implementamos el método animateAppearance() en 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 }) } 

No sé sobre ti, ¡pero me gustaría lanzar el proyecto lo antes posible y ver qué pasó! Solo queda abrir AppDelegate , agregar la propiedad splashPresenter y llamar al método present en él. Al mismo tiempo, después de 2 segundos, llamaremos a dismiss para no tener que volver a este archivo:

  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 } 

El objeto en sí se elimina de la memoria después de ocultar la salpicadura.

¡Hurra, puedes correr!


Ocultar animación


Desafortunadamente (o afortunadamente), 10 líneas de código no harán frente a la animación de esconderse. ¡Es necesario hacer un orificio pasante, que todavía rotará y aumentará! Si pensabas que "esto se puede hacer con una máscara", ¡tienes toda la razón!

Agregaremos una máscara a la layer ventana principal de la aplicación (no queremos vincularnos a un controlador específico). Hagámoslo de inmediato y, al mismo tiempo, ocultemos foregroundSplashWindow , ya que se realizarán más acciones debajo de él.

  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 } 

Es importante tener en cuenta aquí que oculté foregroundSplashWindow través de la propiedad alpha y no isHidden (de lo contrario, la pantalla parpadeará). Otro punto interesante: dado que esta máscara aumentará durante la animación, debe usar un logotipo de mayor resolución (por ejemplo, 1024x1024). Entonces agregué a SplashViewController :

  static let logoImageBig: UIImage = UIImage(named: "splash-logo-big")! 

Mira lo que pasó?


Lo sé, ahora no parece muy impresionante, pero todo está por delante, ¡seguimos adelante! Particularmente atento podría notar que durante la animación el logotipo no se vuelve transparente de inmediato, sino por algún tiempo. Para hacer esto, en mainWindow en la parte superior de todas las subviews agregue una imageView con un logotipo que estará oculto por el desvanecimiento.

  let maskBackgroundView = UIImageView(image: SplashViewController.logoImageBig) maskBackgroundView.frame = mask.frame mainWindow.addSubview(maskBackgroundView) mainWindow.bringSubviewToFront(maskBackgroundView) 

Entonces, tenemos un agujero en forma de logotipo, y debajo del agujero el logotipo en sí.


Ahora volvamos al lugar de un hermoso fondo degradado y texto. ¿Alguna idea de cómo hacer esto?
Tengo: poner otra UIWindow debajo de mainWindow (es decir, con un windowLevel más windowLevel , llamémoslo backgroundSplashWindow ), y luego lo veremos en lugar de un fondo negro. Y, por supuesto, el rootViewController' tendrá un SplashViewContoller , solo que usted necesita ocultar el logoImageView . Para hacer esto, cree una propiedad en SplashViewController :

  var logoIsHidden: Bool = false 

y en el método viewDidLoad() , agregue:

  logoImageView.isHidden = logoIsHidden 

SplashPresenter : en el splashViewController (with textImage: UIImage?) Agregue otro parámetro logoIsHidden: Bool , que se pasará al SplashViewController :

 splashViewController?.logoIsHidden = logoIsHidden 

En consecuencia, donde se crea foregroundSplashWindow , se debe pasar false a este parámetro y 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 }() 

También debe lanzar este objeto a través del constructor en SplashAnimator (similar a foregroundSplashWindow ) y agregar las propiedades allí:

  private unowned let backgroundSplashWindow: UIWindow private unowned let backgroundSplashViewController: SplashViewController 

Para que, en lugar de un fondo negro, veamos la misma pantalla de presentación, justo antes de ocultar foregroundSplashWindow necesita mostrar backgroundSplashWindow :

  backgroundSplashWindow.isHidden = false 

Asegúrese de que el plan fue un éxito:


¡Ahora la parte más interesante es la animación hide! Como necesita animar CALayer , no UIView , recurriremos a CoreAnimation para obtener ayuda. Comencemos con la rotación:

  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 puede ver, el ángulo de rotación se calcula en función del tamaño de la pantalla, por lo que Yula en todos los dispositivos gira hacia la esquina superior izquierda.

Animación de escala de 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 la pena prestar atención a finalScale : la escala final también se calcula en función del tamaño de la pantalla (en proporción a la altura). Es decir, con una altura de pantalla de 667 puntos (iPhone 6), Yula debería aumentar 18 veces.

Pero primero, disminuye ligeramente (de acuerdo con los segundos elementos en las scales y keyTimes ). Es decir, en el tiempo 0.2 * duration (donde la duration es la duración total de la animación de escala), la escala de Yula será de 0.85.

¡Ya estamos en la línea de meta! En el método animateDisappearance ejecuta todas las animaciones:

1) Escalado de la ventana principal ( mainWindow ).
2) Rotación, escalado, desaparición del logotipo ( maskBackgroundView ).
3) Rotación, escala del "agujero" ( mask ).
4) La desaparición del 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() 

Utilicé CATransaction para completar la animación. En este caso, es más conveniente que animationGroup , ya que no todas las animaciones se realizan a través de CAAnimation .


Conclusión


Por lo tanto, en la salida obtuvimos un componente que no depende del contexto del lanzamiento de la aplicación (ya sea un diploma, una notificación de inserción, un inicio normal u otra cosa). ¡La animación funcionará correctamente de todos modos!

Puedes descargar el proyecto aquí.

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


All Articles