Prologo
En uno de mis proyectos, necesitaba hacer una interfaz como esa en Snepchat. Cuando una tarjeta con información sale encima de la imagen de la cámara, reemplazándola suavemente por un color sólido, y también en la dirección opuesta. Personalmente, me fascinó especialmente la transición de la ventana de la cámara a la tarjeta lateral, y con gran placer fui a contar formas de resolver este problema.
A la izquierda hay un ejemplo de Snepchat, a la derecha hay un ejemplo de una aplicación que crearemos.


Probablemente la primera solución que se le ocurra es adaptar el UIScrollView
, organizar de alguna manera las vistas en él, usar la paginación, pero, francamente, el desplazamiento está pensado para resolver problemas completamente diferentes, recoger animaciones adicionales requiere mucho tiempo y no tiene la flexibilidad necesaria ajustes Por lo tanto, usarlo para resolver este problema es absolutamente injustificado.
El desplazamiento entre la ventana de la cámara y la pestaña lateral es engañoso: no es un desplazamiento en absoluto, es una transición interactiva entre las vistas que pertenecen a diferentes controladores. Los botones en su parte inferior son pestañas normales, haciendo clic en lo que nos arroja entre los controladores.

De esta manera, Snatch usa su propia versión de un controlador de navegación como UITabBarController
con transiciones interactivas personalizadas.
UIKit
incluye dos opciones para los controladores de navegación que le permiten personalizar las transiciones: estos son UINavigationController
y UITabBarController
. Ambos tienen navigationController(_:interactionControllerFor:)
métodos navigationController(_:interactionControllerFor:)
y tabBarController(_:interactionControllerFor:)
en sus delegados, respectivamente, que nos permiten usar nuestra propia animación interactiva para la transición.
tabBarController (_: InteractionControllerFor :)
navigationController (_: InteractionControllerFor :)
Pero no me gustaría estar limitado por la implementación de UITabBarController
o UINavigationController
, especialmente porque no podemos controlar su lógica interna. Por lo tanto, decidí escribir mi controlador similar, y ahora quiero contar y mostrar lo que salió de él.
Declaración del problema.
Cree su propio controlador de contenedor, en el que puede cambiar entre controladores secundarios utilizando animaciones interactivas para las transiciones, utilizando el mecanismo estándar en UITabBarController
y UINavigationController
. Necesitamos este mecanismo estándar para usar animaciones de transición listas para usar del tipo UIViewControllerAnimatedTransitioning
ya escrito.
Preparación del proyecto
Por lo general, trato de mover los módulos a marcos separados, para esto creo un nuevo proyecto de aplicación y agrego un objetivo adicional de Cocoa Touch Framework
allí, y luego disperso las fuentes en el proyecto para los objetivos correspondientes. De esta manera obtengo un marco separado con una aplicación de prueba para la depuración.
Crea una Single View App
.

Product Name
será nuestro objetivo.

Haga clic en +
para agregar el objetivo.

Elija Cocoa Touch Framework
.

Llamamos a nuestro marco el nombre apropiado, Xcode selecciona automáticamente el proyecto para nuestro objetivo y ofrece vincular el binario directamente a la aplicación. Estamos de acuerdo

No necesitaremos el Main.storyboard
y ViewController.swift
predeterminados, los eliminaremos.

Además, no olvide eliminar el valor de la Main Interface
en el objetivo de la aplicación en la pestaña General
.

Ahora vamos a AppDelegate.swift
y dejamos solo el método de application
de los siguientes contenidos:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
Aquí configuramos nuestro controlador en el lugar principal para que aparezca después del lanzador.
Ahora cree este mismo MasterViewController
. Se relacionará con la aplicación, por lo que es importante elegir el destino correcto al crear el archivo.

Heredaremos MasterViewController
de SnapchatNavigationController
, que implementaremos más adelante en el marco. No olvides especificar la import
nuestro framework. No proporciono el código del controlador completo aquí, las omisiones se muestran con puntos suspensivos ...
, coloqué la aplicación en GitHub , allí puede ver todos los detalles. En este controlador, solo nos interesa el método viewDidLoad()
, que inicializa el controlador de fondo con la cámara + un controlador transparente (ventana principal) + el controlador que contiene la tarjeta de salida.
import MakingSnapchatNavigation class MasterViewController: SnapchatNavigationController { override func viewDidLoad() { super.viewDidLoad()
¿Qué está pasando aquí? Creamos un controlador con una cámara y lo configuramos en segundo plano utilizando el método setBackground
de SnapchatNavigationController
. Este controlador contiene una imagen estirada para toda la vista desde la cámara. Luego creamos un controlador transparente vacío y lo agregamos a la matriz, simplemente pasa la imagen de la cámara a través de él, podemos colocar controles sobre él, crear otro controlador transparente, agregarle un desplazamiento, agregar una vista con contenido dentro del desplazamiento, agregar un segundo controlador a matriz y establezca esta matriz utilizando el método especial setViewControllers
del SnapchatNavigationController
padre.
No olvide agregar una solicitud para usar la cámara en Info.plist
<key>NSCameraUsageDescription</key> <string>Need camera for background</string>
En este sentido, consideramos que la aplicación de prueba está lista y pasamos a la parte más interesante: la implementación del marco.
Estructura del controlador principal
Primero, cree un SnapchatNavigationController
vacío, es importante elegir el objetivo adecuado para él. Si todo se hizo correctamente, entonces la aplicación debería estar construida. Este estado del proyecto se puede descargar por referencia .
open class SnapchatNavigationController: UIViewController { override open func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } // MARK: - Public interface /// Sets view controllers. public func setViewControllers(vcs: [UIViewController]) { } /// Sets background view. public func setBackground(vc: UIViewController) { } }
Ahora agregue los componentes internos en los que consistirá el controlador. No traigo todo el código aquí, me concentro solo en puntos importantes.
Establecemos las variables para almacenar la matriz de controladores secundarios. Ahora establecemos rígidamente su cantidad requerida: 2 piezas. En el futuro, será posible expandir la lógica del controlador para usar con cualquier número de controladores. También establecemos una variable para almacenar el controlador actual que se muestra.
private let requiredChildrenAmount = 2 // MARK: - View controllers /// top child view controller private var topViewController: UIViewController? /// all children view controllers private var children: [UIViewController] = []
Crea las vistas. Necesitamos una vista para el fondo, una vista con el efecto que queremos aplicar al fondo al cambiar el controlador. También tenemos un contenedor de vista para el controlador secundario actual y un indicador de vista que le indicará al usuario cómo trabajar con la navegación.
// MARK: - Views private let backgroundViewContainer = UIView() private let backgroundBlurEffectView: UIVisualEffectView = { let backgroundBlurEffect = UIBlurEffect(style: UIBlurEffectStyle.light) let backgroundBlurEffectView = UIVisualEffectView(effect: backgroundBlurEffect) backgroundBlurEffectView.alpha = 0 return backgroundBlurEffectView }() /// content view for children private let contentViewContainer = UIView() private let swipeIndicatorView = UIView()
En el siguiente bloque, establecemos dos variables, swipeAnimator
es responsable de la animación, swipeInteractor
es responsable de la interacción (la capacidad de controlar el progreso de la animación), debemos inicializarla durante el arranque del controlador, por lo que forzamos el desenvolvimiento.
// MARK: - Animation and transition private let swipeAnimator = AnimatedTransitioning() private var swipeInteractor: CustomSwipeInteractor!
También establecemos la transformación para el indicador. Cambiamos el indicador por el ancho del contenedor + doble desplazamiento desde el borde + el ancho del propio indicador para que el indicador esté en el extremo opuesto del contenedor. El ancho del contenedor se conocerá durante la aplicación, por lo que la variable se calcula sobre la marcha.
// MARK: - Animation transforms private var swipeIndicatorViewTransform: CGAffineTransform { get { return CGAffineTransform(translationX: -contentViewContainer.bounds.size.width + (swipeIndicatorViewXShift * 2) + swipeIndicatorViewWidth, y: 0) } }
Mientras cargamos el controlador, nos asignamos a la animación (implementaremos el protocolo correspondiente a continuación), inicializamos el interactor en función de nuestra animación, cuyo progreso controlará. También lo nombramos como delegado. El delegado responderá al comienzo del gesto del usuario y comenzará la animación o cancelará dependiendo del estado del controlador. Luego agregamos todas las vistas a la principal y llamamos a setupViews()
, que establece las restricciones.
override open func viewDidLoad() { super.viewDidLoad() swipeAnimator.animation = self swipeInteractor = CustomSwipeInteractor(with: swipeAnimator) swipeInteractor.delegate = self view.addSubview(backgroundViewContainer) view.addSubview(backgroundBlurEffectView) view.addSubview(contentViewContainer) view.addSubview(swipeIndicatorView) setupViews() }
A continuación, pasamos a la lógica de instalar y quitar controladores secundarios en un contenedor. Todo aquí es simple como en la documentación de Apple. Utilizamos los métodos prescritos para este tipo de operación.
addChildViewController(vc)
: agrega un controlador secundario al actual.
contentViewContainer.addSubview(vc.view)
: agrega la vista del controlador a la jerarquía de vistas.
vc.view.frame = contentViewContainer.bounds
: vc.view.frame = contentViewContainer.bounds
la vista a todo el contenedor. Como usamos marcos aquí en lugar del diseño automático, necesitamos cambiar sus tamaños cada vez que cambia el tamaño del controlador, omitiremos esta lógica y asumiremos que el contenedor no cambiará el tamaño de la aplicación mientras se ejecuta.
vc.didMove(toParentViewController: self)
: pone fin a la operación de agregar un controlador secundario.
swipeInteractor.wireTo
: swipeInteractor.wireTo
el controlador actual a los gestos del usuario. Más adelante analizaremos este método.
// MARK: - Private methods private func addChild(vc: UIViewController) { addChildViewController(vc) contentViewContainer.addSubview(vc.view) vc.view.frame = contentViewContainer.bounds vc.didMove(toParentViewController: self) topViewController = vc let goingRight = children.index(of: topViewController!) == 0 swipeInteractor.wireTo(viewController: topViewController!, edge: goingRight ? .right : .left) } private func removeChild(vc: UIViewController) { vc.willMove(toParentViewController: nil) vc.view.removeFromSuperview() vc.removeFromParentViewController() topViewController = nil }
Hay dos métodos más cuyo código no daré aquí: setViewControllers
y setBackground
. En el método setViewControllers
simplemente configuramos la matriz de controladores secundarios en la variable correspondiente de nuestro controlador y llamamos a addChild
para mostrar uno de ellos en la vista. En el método setBackground
hacemos lo mismo que en addChild
, solo para el controlador de fondo.
Lógica de animación de controlador de contenedor
Total, la base de nuestro controlador principal es:
- UIView dividido en dos tipos
- Lista de niños UIViewController
- Un objeto de control de animación de
swipeAnimator
tipo AnimatedTransitioning
- Un objeto que controla el curso interactivo de una animación de
CustomSwipeInteractor
de tipo CustomSwipeInteractor
- Delegar animación interactiva
- Implementación del Protocolo de Animación
Ahora analizaremos los dos últimos puntos, luego pasaremos a la implementación de AnimatedTransitioning
y CustomSwipeInteractor
.
Delegar animación interactiva
El delegado consta de un solo panGestureDidStart(rightToLeftSwipe: Bool) -> Bool
método panGestureDidStart(rightToLeftSwipe: Bool) -> Bool
, que informa al controlador sobre el comienzo del gesto y su dirección. En respuesta, espera información sobre si la animación puede considerarse iniciada.
Como delegado, verificamos el orden actual de los controladores para comprender si podemos comenzar la animación en la dirección dada, y si todo está bien, comenzamos el método de transition
, con los parámetros: el controlador desde el cual nos estamos moviendo, el controlador al que nos estamos moviendo, la dirección del movimiento, la bandera de interactividad (en caso de false
, se inicia una animación de transición de tiempo fijo).
func panGestureDidStart(rightToLeftSwipe: Bool) -> Bool { guard let topViewController = topViewController, let fromIndex = children.index(of: topViewController) else { return false } let newIndex = rightToLeftSwipe ? 1 : 0 // - if newIndex > -1 && newIndex < children.count && newIndex != fromIndex { transition(from: children[fromIndex], to: children[newIndex], goingRight: rightToLeftSwipe, interactive: true) return true } return false }
Examinemos de inmediato el cuerpo del método de transition
. En primer lugar, creamos el contexto de animación para la animación CustomControllerContext
. También analizaremos esta clase un poco más tarde; implementa el protocolo UIViewControllerContextTransitioning
. En el caso de UINavigationController
y UITabBarController
el sistema crea automáticamente UITabBarController
instancia de la implementación de este protocolo y su lógica está oculta para nosotros, necesitamos crear la nuestra.
let ctx = CustomControllerContext(fromViewController: from, toViewController: to, containerView: contentViewContainer, goingRight: goingRight) ctx.isAnimated = true ctx.isInteractive = interactive ctx.completionBlock = { (didComplete: Bool) in if didComplete { self.removeChild(vc: from) self.addChild(vc: to) } };
Luego simplemente llamamos animación fija o interactiva. En el futuro, será posible colgar uno fijo en las pestañas de los botones de navegación entre controladores, en este ejemplo no haremos esto.
if interactive { // Animate with interaction swipeInteractor.startInteractiveTransition(ctx) } else { // Animate without interaction swipeAnimator.animateTransition(using: ctx) }
Protocolo de animación
TransitionAnimation
protocolo de animación TransitionAnimation
consta de 4 métodos:
addTo
es un método diseñado para crear la estructura correcta de vistas secundarias en el contenedor, de modo que la vista anterior se superponga con la nueva según la idea de la animación.
prepare
es el método llamado antes de la animación para preparar la vista.
/// Setup the views position prior to the animation start. func prepare(fromView from: UIView?, toView to: UIView?, fromLeft: Bool)
animation
: la animación en sí misma.
finalize
: las acciones necesarias después de la finalización de la animación.
No consideraremos la implementación utilizada, todo es bastante transparente allí, iremos directamente a las tres clases principales, gracias a las cuales tiene lugar la animación.
class CustomControllerContext: NSObject, UIViewControllerContextTransitioning
El contexto de la animación. Para describir su función, nos referimos a la ayuda del protocolo UIViewControllerContextTransitioning
:
Un objeto de contexto encapsula información sobre las vistas y los controladores de vista involucrados en la transición. También contiene detalles sobre cómo ejecutar la transición.
Lo más interesante es la prohibición de la adaptación de este protocolo:
No adopte este protocolo en sus propias clases, ni debe crear directamente objetos que adopten este protocolo.
Pero realmente lo necesitamos para ejecutar el motor de animación estándar, por lo que lo adaptamos de todos modos. Casi no tiene lógica; solo almacena el estado. Por lo tanto, ni siquiera lo traeré aquí. Puedes verlo en GitHub .
Funciona muy bien en animaciones de tiempo fijo. Pero cuando se usa para animaciones interactivas, surge un problema: UIPercentDrivenInteractiveTransition
invoca un método no documentado en el contexto. La única solución correcta en esta situación es adaptar otro protocolo: UIViewControllerInteractiveTransitioning
para usar su propio contexto.
class PercentDrivenInteractiveTransition: NSObject, UIViewControllerInteractiveTransitioning
Aquí está: el corazón del proyecto, permitiendo que existan animaciones interactivas en controladores de contenedores personalizados. Vamos a tomarlo en orden.
La clase se inicializa con un parámetro del tipo UIViewControllerAnimatedTransitioning
, este es el protocolo estándar para animar la transición entre controladores. De esta manera, podemos usar cualquiera de las animaciones ya escritas junto con nuestra clase.
init(with animator: UIViewControllerAnimatedTransitioning) { self.animator = animator }
La interfaz pública es bastante simple, cuatro métodos, cuya funcionalidad debería ser obvia.
Solo hay que tener en cuenta el momento en que comienza la animación, tomamos la vista principal del contenedor y establecemos la velocidad de la capa en 0, para que podamos controlar el progreso de la animación manualmente.
// MARK: - Public func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { self.transitionContext = transitionContext transitionContext.containerView.superview?.layer.speed = 0 animator.animateTransition(using: transitionContext) } func updateInteractiveTransition(percentComplete: CGFloat) { setPercentComplete(percentComplete: (CGFloat(fmaxf(fminf(Float(percentComplete), 1), 0)))) } func cancelInteractiveTransition() { transitionContext?.cancelInteractiveTransition() completeTransition() } func finishInteractiveTransition() { transitionContext?.finishInteractiveTransition() completeTransition() }
Ahora pasamos al bloque lógico privado de nuestra clase.
setPercentComplete
establece el desplazamiento de tiempo del progreso de la animación para la capa de supervista, calculando el valor a partir del porcentaje de finalización y duración de la animación.
private func setPercentComplete(percentComplete: CGFloat) { setTimeOffset(timeOffset: TimeInterval(percentComplete) * duration) transitionContext?.updateInteractiveTransition(percentComplete) } private func setTimeOffset(timeOffset: TimeInterval) { transitionContext?.containerView.superview?.layer.timeOffset = timeOffset }
Se llama a completeTransition
cuando el usuario ha detenido su gesto. Aquí creamos una instancia de la clase CADisplayLink
, que nos permitirá completar automáticamente la animación desde el punto en que el usuario ya no controla su progreso. displayLink
nuestro displayLink
al run loop
para que el sistema llame a nuestro selector cada vez que necesite mostrar un nuevo marco en la pantalla del dispositivo.
private func completeTransition() { displayLink = CADisplayLink(target: self, selector: #selector(tickAnimation)) displayLink!.add(to: .main, forMode: .commonModes) }
En nuestro selector, calculamos y establecemos el desplazamiento temporal del progreso de la animación, como lo hicimos antes durante el gesto del usuario, o completamos la animación cuando llega a su punto inicial o final.
@objc private func tickAnimation() { var timeOffset = self.timeOffset() let tick = (displayLink?.duration ?? 0) * TimeInterval(completionSpeed) timeOffset += (transitionContext?.transitionWasCancelled ?? false) ? -tick : tick; if (timeOffset < 0 || timeOffset > duration) { transitionFinished() } else { setTimeOffset(timeOffset: timeOffset) } } private func timeOffset() -> TimeInterval { return transitionContext?.containerView.superview?.layer.timeOffset ?? 0 }
Al finalizar la animación, displayLink
nuestro displayLink
, devolvemos la velocidad de la capa, y si la animación no se ha cancelado, es decir, ha alcanzado su fotograma final, calculamos el momento en que debe comenzar la animación de la capa. Puede obtener más información sobre esto en la Guía de programación de animación principal, o en esta respuesta a stackoverflow.
private func transitionFinished() { displayLink?.invalidate() guard let layer = transitionContext?.containerView.superview?.layer else { return } layer.speed = 1; let wasNotCanceled = !(transitionContext?.transitionWasCancelled ?? false) if (wasNotCanceled) { let pausedTime = layer.timeOffset layer.timeOffset = 0.0; let timeSincePause = layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime layer.beginTime = timeSincePause } animator.animationEnded?(wasNotCanceled) }
class AnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning
La última clase que aún no hemos examinado es la implementación del protocolo UIViewControllerAnimatedTransitioning
, en el que controlamos el orden de ejecución de los métodos de protocolo de nuestra animación addTo
, prepare
, animation
, finalize
. Todo aquí es bastante prosaico, vale la pena señalar solo el uso de UIViewPropertyAnimator
para realizar animaciones en lugar del más típico UIView.animate(withDuration:animations:)
. Esto se hace para que sea posible controlar aún más el progreso de la animación, y si se cancela, devuélvala a su posición finishAnimation(at: .start)
llamando a finishAnimation(at: .start)
, lo que evita el parpadeo innecesario del cuadro final de la animación en la pantalla.
Epílogo
Hemos creado una demostración funcional de una interfaz similar a la de Snapchat. En mi versión, configuré las constantes para que haya campos a la derecha y a la izquierda de la tarjeta, además, dejé la cámara en la vista de fondo para crear un efecto detrás de la tarjeta. Esto se hace únicamente para demostrar las capacidades de este enfoque, cómo afectará el rendimiento del dispositivo y no verifiqué la carga de la batería.
— , - , . , - .
GitHub .
, , , !

:
Custom Container View Controller Transitions, Joachim Bondo.
Objective C. Swift.
Enlace
Interactive Custom Container View Controller Transitions, Alek Åström
, Objective C, Swift.
Enlace
SwipeableTabBarController
, UITabBarController
. .
Enlace