¡No aparezcas! Transiciones interrumpibles en iOS

¿También te molestan las ventanas emergentes en las aplicaciones? En este artículo mostraré cómo ocultar interactivamente y mostrar ventanas emergentes, hacer que la animación sea interrumpible y no enfurecer a mis clientes.



En un artículo anterior, analicé cómo puedes animar la visualización de un nuevo controlador.


Nos decidimos por el hecho de que viewController puede mostrar y esconderse animadamente:



Ahora le enseñaremos a responder al gesto de ocultamiento.


Transición Interactiva


Agrega un gesto cercano


Para enseñar al controlador a cerrar de forma interactiva, debe agregar un gesto y procesarlo. Todo el trabajo estará en la clase TransitionDriver :


 class TransitionDriver: UIPercentDrivenInteractiveTransition { func link(to controller: UIViewController) { presentedController = controller panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handle(recognizer:))) presentedController?.view.addGestureRecognizer(panRecognizer!) } private var presentedController: UIViewController? private var panRecognizer: UIPanGestureRecognizer? } 

Puede adjuntar un controlador en la ubicación de DimmPresentationController, dentro de PanelTransition:


 private let driver = TransitionDriver() func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { driver.link(to: presented) let presentationController = DimmPresentationController(presentedViewController: presented, presenting: presenting) return presentationController } 

Al mismo tiempo, debe indicar que la piel se ha vuelto manejable (ya lo hicimos en el último artículo):


 // PanelTransition.swift func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return driver } 

Manejar el gesto


Comencemos con el gesto de cierre: si arrastra el panel hacia abajo, comenzará la animación de cierre y el movimiento del dedo afectará el grado de cierre.
UIPercentDrivenInteractiveTransition permite capturar la animación de transición y controlarla manualmente. Tiene métodos de update , finish y cancel . Es conveniente realizar el procesamiento de gestos en su subclase.


Procesamiento de gestos


 private func handleDismiss(recognizer r: UIPanGestureRecognizer) { switch r.state { case .began: pause() //   percentComplete   0 let isRunning = percentComplete != 0 if !isRunning { presentingController?.present(presentedController!, animated: true) } case .changed: update(percentComplete + r.incrementToBottom(maxTranslation: maxTranslation)) case .ended, .cancelled: if r.isProjectedToDownHalf(maxTranslation: maxTranslation) { finish() } else { cancel() } case .failed: cancel() default: break } } 

.begin
Comience el despido de la manera más común. Guardamos el enlace al controlador en el método de link(to:)


.changed
Cuente el incremento y páselo al método de update . El valor aceptado puede variar de 0 a 1, por lo que controlaremos el grado de finalización de la animación desde la interactionControllerForDismissal(using:) método interactionControllerForDismissal(using:) . Los cálculos se llevaron a cabo en la extensión del gesto, para que el código se vuelva más limpio.


Cálculos gestuales
 private extension UIPanGestureRecognizer { func incrementToBottom(maxTranslation: CGFloat) -> CGFloat { let translation = self.translation(in: view).y setTranslation(.zero, in: nil) let percentIncrement = translation / maxTranslation return percentIncrement } } 

Los cálculos se basan en maxTranslation , lo calculamos como la altura del controlador que se muestra:


 var maxTranslation: CGFloat { return presentedController?.view.frame.height ?? 0 } 

.end
Nos fijamos en la integridad del gesto. Regla de finalización: si más de la mitad ha cambiado, cierre. En este caso, el desplazamiento debe ser considerado no solo por la coordenada actual, sino también por la velocity . Entonces entendemos la intención del usuario: puede que no termine en el medio, pero deslice mucho hacia abajo. O viceversa: derribar, pero deslizar hacia arriba para volver.


Cálculos de ubicación proyectada
 private extension UIPanGestureRecognizer { func isProjectedToDownHalf(maxTranslation: CGFloat) -> Bool { let endLocation = projectedLocation(decelerationRate: .fast) let isPresentationCompleted = endLocation.y > maxTranslation / 2 return isPresentationCompleted } func projectedLocation(decelerationRate: UIScrollView.DecelerationRate) -> CGPoint { let velocityOffset = velocity(in: view).projectedOffset(decelerationRate: .normal) let projectedLocation = location(in: view!) + velocityOffset return projectedLocation } } extension CGPoint { func projectedOffset(decelerationRate: UIScrollView.DecelerationRate) -> CGPoint { return CGPoint(x: x.projectedOffset(decelerationRate: decelerationRate), y: y.projectedOffset(decelerationRate: decelerationRate)) } } extension CGFloat { // Velocity value func projectedOffset(decelerationRate: UIScrollView.DecelerationRate) -> CGFloat { // Magic formula from WWDC let multiplier = 1 / (1 - decelerationRate.rawValue) / 1000 return self * multiplier } } extension CGPoint { static func +(left: CGPoint, right: CGPoint) -> CGPoint { return CGPoint(x: left.x + right.x, y: left.y + right.y) } } 

.cancelled : sucederá si bloquea la pantalla del teléfono o si llaman. Puede manejarlo como un bloque .ended o cancelar una acción.
.failed : sucederá si otro gesto cancela el gesto. Entonces, por ejemplo, un gesto de arrastre puede cancelar un gesto de toque.
.possible : el estado inicial del gesto, generalmente no requiere mucho trabajo.


Ahora el panel también se puede cerrar con un deslizamiento, pero el botón de dismiss se ha dismiss . Esto sucedió porque hay una propiedad wantsInteractiveStart en TransitionDriver , por defecto es true . Esto es normal para un deslizamiento, pero bloquea el dismiss habitual.


Analicemos el comportamiento en función del estado del gesto. Si el gesto comenzó, entonces este es un cierre interactivo, y si no comenzó, entonces el habitual:


 override var wantsInteractiveStart: Bool { get { let gestureIsActive = panRecognizer?.state == .began return gestureIsActive } set { } } 

Ahora el usuario puede controlar el hide:



Transición de interrupción


Supongamos que comenzamos a cerrar nuestra tarjeta, pero cambiamos de opinión y queremos volver. Es simple: en un estado .began , llamamos a pause() para detener.


Pero necesita separar dos escenarios:


  • cuando empezamos a escondernos del gesto;
  • cuando interrumpimos el actual.

Para hacer esto, después de detenerse, marque percentComplete: si es 0, entonces comenzamos a cerrar la tarjeta manualmente, además tenemos que llamar a dismiss . Si no es 0, entonces la ocultación ya ha comenzado, es suficiente para detener la animación:


 case .began: pause() // Pause allows to detect percentComplete if percentComplete == 0 { presentedController?.dismiss(animated: true) } 

Presiono el botón e inmediatamente deslizo hacia arriba para cancelar el ocultamiento:


Dejar de mostrar el controlador


La situación inversa: la tarjeta comenzó a aparecer, pero no la necesitamos. Lo atrapamos y lo enviamos deslizando hacia abajo. Puede interrumpir la animación de la pantalla del controlador en los mismos pasos.


Devuelva el controlador como un controlador de pantalla interactivo:


 func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return driver } 

Procese el gesto, pero con valores de sesgo inverso e integridad:


 private func handlePresentation(recognizer r: UIPanGestureRecognizer) { switch r.state { case .began: pause() case .changed: let increment = -r.incrementToBottom(maxTranslation: maxTranslation) update(percentComplete + increment) case .ended, .cancelled: if r.isProjectedToDownHalf(maxTranslation: maxTranslation) { cancel() } else { finish() } case .failed: cancel() default: break } } 

Para separar mostrar y ocultar, ingresé enum con la dirección de animación actual:


 enum TransitionDirection { case present, dismiss } 

La propiedad se almacena en TransitionDriver y afecta qué gestor de gestos se utilizará:


 var direction: TransitionDirection = .present @objc private func handle(recognizer r: UIPanGestureRecognizer) { switch direction { case .present: handlePresentation(recognizer: r) case .dismiss: handleDismiss(recognizer: r) } } 

También afecta a wantsInteractiveStart . No planeamos mostrar el controlador con un gesto, por lo que devolvemos false para .present :


 override var wantsInteractiveStart: Bool { get { switch direction { case .present: return false case .dismiss: let gestureIsActive = panRecognizer?.state == .began return gestureIsActive } } set { } } 

Bueno, queda por cambiar la dirección del gesto cuando el controlador se mostró completamente. El mejor lugar está en PresentationController :


 override func presentationTransitionDidEnd(_ completed: Bool) { super.presentationTransitionDidEnd(completed) if completed { driver.direction = .dismiss } } 

¿Es posible sin enumeración?

Parece que podemos confiar en las propiedades del controlador isBeingPresented y isBeingDismissed . Pero solo muestran el proceso, y también necesitamos posibles direcciones: al comienzo del cierre interactivo, ambos valores serán false , y ya necesitamos saber que esta es la dirección del cierre. Esto se puede resolver mediante condiciones adicionales para verificar la jerarquía de los controladores, pero la asignación explícita a través de enum parece ser una solución más simple.


Ahora puedes interrumpir la animación del espectáculo. Presiono el botón e inmediatamente deslizo hacia abajo:



Mostrar por gesto


Si está creando un menú de hamburguesas para una aplicación, lo más probable es que desee mostrarlo con un gesto. Esto funciona igual que la ocultación interactiva, pero en un gesto, en lugar de dismiss llamamos present .
Comencemos desde el final. En handlePresentation(recognizer:) muestra el controlador:


 case .began: pause() let isRunning = percentComplete != 0 if !isRunning { presentingController?.present(presentedController!, animated: true) } 

Vamos a mostrar interactivamente:


 override var wantsInteractiveStart: Bool { get { switch direction { case .present: let gestureIsActive = screenEdgePanRecognizer?.state == .began return gestureIsActive case .dismiss: … } 

Para que el código funcione, no hay suficientes enlaces para presentingController y presentedController . Los pasaremos al crear el gesto, agregaremos el UIScreenEdgePanGestureRecognizer :


 func linkPresentationGesture(to presentedController: UIViewController, presentingController: UIViewController) { self.presentedController = presentedController self.presentingController = presentingController //    panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handle(recognizer:))) presentedController.view.addGestureRecognizer(panRecognizer!) //    screenEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePresentation(recognizer:))) screenEdgePanRecognizer!.edges = .bottom presentingController.view.addGestureRecognizer(screenEdgePanRecognizer!) } 

Puede transferir controladores al crear PanelTransition :


 class PanelTransition: NSObject, UIViewControllerTransitioningDelegate { init(presented: UIViewController, presenting: UIViewController) { driver.linkPresentationGesture(to: presented, presentingController: presenting) } private let driver = TransitionDriver() } 

Queda por crear la PanelTransition :


  1. viewDidLoad un controlador child en viewDidLoad , ya que es posible que necesitemos un controlador en cualquier momento.
  2. Crear PanelTransition . En su constructor, el gesto está vinculado al controlador.
  3. Ponga el Delegado de transición para el controlador secundario.
  4. Para fines de capacitación, deslizo desde abajo, pero esto entra en conflicto con el cierre de la aplicación en el iPhone X y el centro de control. El uso de preferredScreenEdgesDeferringSystemGestures deshabilitó el deslizamiento del sistema desde abajo.


     class ParentViewController: UIViewController { private var child: ChildViewController! private var transition: PanelTransition! override func viewDidLoad() { super.viewDidLoad() child = ChildViewController() // 1 transition = PanelTransition(presented: child, presenting: self) // 2 // Setup the child child.modalPresentationStyle = .custom child.transitioningDelegate = transition // 3 } override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { return .bottom // 4 } } 

    Después del cambio, resultó que había un problema: después del primer cierre del panel, permanece para siempre en el estado de TransitionDirection.dismiss . Establezca el estado correcto después de ocultar el controlador en PresentationController :


     override func dismissalTransitionDidEnd(_ completed: Bool) { super.dismissalTransitionDidEnd(completed) if completed { driver.direction = .present } } 

    El código de visualización interactivo se puede ver en un hilo separado . Se ve así:




Conclusión


Como resultado, podemos mostrar el controlador con animación interrumpida, y el usuario tiene control sobre lo que está sucediendo en la pantalla. Esto es mucho mejor, porque la animación ya no bloquea la interfaz, puede cancelarse o incluso acelerarse.


Un ejemplo se puede ver en github.


Suscríbase al canal móvil de Dodo Pizza.

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


All Articles