Ne sautez pas! Transitions interruptibles dans iOS

Êtes-vous également énervé par les pop-ups dans les applications? Dans cet article, je montrerai comment masquer et afficher les pop-ups de manière interactive, rendre l'animation interruptible et ne pas exaspérer mes clients.



Dans un article précédent, j'ai vu comment animer l'affichage d'un nouveau contrôleur.


Nous avons décidé que viewController peut afficher et masquer de manière animée:



Nous allons maintenant lui apprendre à répondre au geste de dissimulation.


Transition interactive


Ajouter un geste de fermeture


Pour apprendre au contrôleur à se fermer de manière interactive, vous devez ajouter un geste et le traiter. Tout le travail sera dans la classe 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? } 

Vous pouvez attacher un gestionnaire à l'emplacement du DimmPresentationController, à l'intérieur 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 } 

Dans le même temps, vous devez indiquer que la peau est devenue gérable (nous l'avons déjà fait dans le dernier article):


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

Gérez le geste


Commençons par le geste de fermeture: si vous faites glisser le panneau vers le bas, l'animation de fermeture commencera et le mouvement du doigt affectera le degré de fermeture.
UIPercentDrivenInteractiveTransition permet de capturer l'animation de transition et de la contrôler manuellement. Il a des méthodes de update , de finish et d' cancel . Il est pratique d'effectuer un traitement gestuel dans sa sous-classe.


Traitement gestuel


 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
Commencez la mise à l'écart de la manière la plus courante. Nous avons enregistré le lien vers le contrôleur dans la méthode link(to:)


.changed
Comptez l'incrément et passez-le à la méthode de update à update . La valeur acceptée peut varier de 0 à 1, nous contrôlerons donc le degré d'achèvement de l'animation à partir de la méthode interactionControllerForDismissal(using:) . Les calculs ont été effectués dans le prolongement du geste, pour que le code devienne plus propre.


Calculs de gestes
 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 } } 

Les calculs sont basés sur maxTranslation , nous le calculons comme la hauteur du contrôleur affiché:


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

.end
Nous regardons l'intégralité du geste. Règle d'achèvement: si plus de la moitié s'est déplacée, fermez. Dans ce cas, le décalage doit être pris en compte non seulement par la coordonnée actuelle, mais également par la velocity . Nous comprenons donc l'intention de l'utilisateur: il pourrait ne pas finir jusqu'au milieu, mais glisser beaucoup vers le bas. Ou vice versa: abattez, mais glissez vers le haut pour revenir.


Calculs de l'emplacement projeté
 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 - se produira si vous verrouillez l'écran du téléphone ou s'ils appellent. Vous pouvez le gérer comme un bloc .ended ou annuler une action.
.failed - se produira si le geste est annulé par un autre geste. Ainsi, par exemple, un geste de glissement peut annuler un geste de toucher.
.possible - l'état initial du geste, ne nécessite généralement pas beaucoup de travail.


Maintenant, le panneau peut également être fermé avec un coup, mais le bouton de fermeture est dismiss . Cela s'est produit car il existe une propriété wantsInteractiveStart dans TransitionDriver , par défaut, elle est true . Ceci est normal pour un balayage, mais il bloque le dismiss habituel.


Décomposons le comportement en fonction de l'état du geste. Si le geste a commencé, alors c'est une clôture interactive, et s'il n'a pas commencé, alors l'habituel:


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

L'utilisateur peut maintenant contrôler le masquage:



Interrompre la transition


Supposons que nous commencions à fermer notre carte, mais que nous ayons changé d'avis et que nous voulions revenir. C'est simple: dans un état .began , .began appelons pause() pour arrêter.


Mais vous devez séparer deux scénarios:


  • quand nous commençons à nous cacher du geste;
  • lorsque nous interrompons l'actuel.

Pour ce faire, après l'arrêt, cochez percentComplete: si c'est 0, alors nous commençons à fermer la carte manuellement, plus nous devons appeler le dismiss . Si ce n'est pas 0, alors le masquage a déjà commencé, il suffit d'arrêter l'animation:


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

J'appuie sur le bouton et je glisse immédiatement vers le haut pour annuler le masquage:


Ne plus afficher le contrôleur


La situation inverse: la carte a commencé à apparaître, mais nous n'en avons pas besoin. Nous l'attrapons et l'envoyons par glisser vers le bas. Vous pouvez interrompre l'animation de l'affichage du contrôleur dans les mêmes étapes.


Renvoyez le pilote en tant que contrôleur d'affichage interactif:


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

Traitez le geste, mais avec des valeurs de biais inverse et d'exhaustivité:


 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 } } 

Pour séparer afficher et masquer, je suis entré enum avec la direction d'animation actuelle:


 enum TransitionDirection { case present, dismiss } 

La propriété est stockée dans TransitionDriver et affecte le gestionnaire de gestes à utiliser:


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

Cela affecte également wantsInteractiveStart . Nous ne prévoyons pas d'afficher le contrôleur avec un geste, nous renvoyons donc false pour .present :


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

Eh bien, il reste à changer la direction du geste lorsque le contrôleur a été entièrement montré. Le meilleur endroit est dans PresentationController :


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

Est-ce possible sans énumération?

Il semblerait que nous pouvons compter sur les propriétés du contrôleur isBeingPresented et isBeingDismissed . Mais ils ne montrent que le processus, et nous avons également besoin de directions possibles: au début de la fermeture interactive, les deux valeurs seront false , et nous devons déjà savoir que c'est la direction de la fermeture. Cela peut être résolu par des conditions supplémentaires pour vérifier la hiérarchie des contrôleurs, mais l'affectation explicite via enum semble être une solution plus simple.


Vous pouvez maintenant interrompre l'animation du spectacle. J'appuie sur le bouton et je glisse immédiatement vers le bas:



Montrer par geste


Si vous créez un menu hamburger pour une application, vous voudrez probablement l'afficher par un geste. Cela fonctionne comme la dissimulation interactive, mais dans un geste, au lieu de dismiss appelons present .
Commençons par la fin. Dans handlePresentation(recognizer:) montrez au contrôleur:


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

Voyons interactivement:


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

Pour que le code fonctionne, il n'y a pas suffisamment de liens vers presentingController et presentedController . Nous les passerons lors de la création du geste, ajoutez le 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!) } 

Vous pouvez transférer des contrôleurs lors de la création de PanelTransition :


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

Il reste à créer PanelTransition la PanelTransition :


  1. Créons un contrôleur child dans viewDidLoad , car nous pouvons avoir besoin d'un contrôleur à tout moment.
  2. Créez PanelTransition . Chez son constructeur, le geste est lié au contrôleur.
  3. Mettez le transitioningDelegate pour le contrôleur enfant.
  4. À des fins de formation, je glisse d'en bas, mais cela entre en conflit avec la fermeture de l'application sur l'iPhone X et le centre de contrôle. L'utilisation de preferredScreenEdgesDeferringSystemGestures désactivé le balayage du système par le bas.


     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 } } 

    Après la modification, il s'est avéré qu'il y avait un problème: après la première fermeture du panneau, il reste à jamais dans l'état TransitionDirection.dismiss . Définissez l'état correct après avoir masqué le contrôleur dans PresentationController :


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

    Le code d'affichage interactif peut être affiché dans un fil distinct . Cela ressemble à ceci:




Conclusion


En conséquence, nous pouvons montrer au contrôleur une animation interrompue et l'utilisateur a le contrôle de ce qui se passe à l'écran. C'est beaucoup plus agréable, car l'animation ne bloque plus l'interface, elle peut être annulée ou même accélérée.


Un exemple peut être vu sur github.


Abonnez-vous à la chaîne Dodo Pizza Mobile.

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


All Articles