
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() {
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() {
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í.