Écrire une interface utilisateur Snapchat sur Swift

Prologue


Dans l'un de mes projets, j'avais besoin de faire une interface comme ça dans Snepchat. Lorsqu'une carte contenant des informations sort de l'image de l'appareil photo, remplacez-la en douceur par une couleur unie, et tout aussi bien dans la direction opposée. Personnellement, j'ai été particulièrement fasciné par le passage de la fenêtre de la caméra à la carte latérale et, avec grand plaisir, je suis allé raconter les moyens de résoudre ce problème.


A gauche, un exemple de Snepchat, à droite, un exemple d'application que nous allons créer.



La première solution qui vient à l'esprit est probablement d'adapter UIScrollView , de disposer d'une manière ou d'une autre les vues sur celui-ci, d'utiliser la pagination, mais, franchement, le défilement est pensé pour résoudre des tâches complètement différentes, choisir des animations supplémentaires est laborieux et n'a pas la flexibilité nécessaire paramètres. Par conséquent, son utilisation pour résoudre ce problème est absolument injustifiée.


Le défilement entre la fenêtre de la caméra et l'onglet latéral est trompeur - ce n'est pas du tout un défilement, c'est une transition interactive entre les vues appartenant à différents contrôleurs. Les boutons dans sa partie inférieure sont des onglets ordinaires, en cliquant sur ce qui nous jette entre les contrôleurs.



De cette façon, Snatch utilise sa propre version d'un contrôleur de navigation tel que UITabBarController avec des transitions interactives personnalisées.


UIKit comprend deux options pour les contrôleurs de navigation qui vous permettent de personnaliser les transitions - ce sont UINavigationController et UITabBarController . Les deux ont navigationController(_:interactionControllerFor:) méthodes navigationController(_:interactionControllerFor:) et tabBarController(_:interactionControllerFor:) dans leurs délégués, respectivement, qui nous permettent d'utiliser notre propre animation interactive pour la transition.


tabBarController (_: interactionControllerFor :)


navigationController (_: interactionControllerFor :)


Mais je ne voudrais pas être limité par l'implémentation de UITabBarController ou UINavigationController , d'autant plus que nous ne pouvons pas contrôler leur logique interne. Par conséquent, j'ai décidé d'écrire mon contrôleur similaire, et maintenant je veux dire et montrer ce qui en est arrivé.


Énoncé du problème


Créez votre propre contrôleur de conteneur, dans lequel vous pouvez basculer entre les contrôleurs enfants à l'aide d'animations interactives pour les transitions, en utilisant le mécanisme standard dans UITabBarController et UINavigationController . Nous avons besoin de ce mécanisme standard pour utiliser des animations de transition prêtes à l'emploi du type UIViewControllerAnimatedTransitioning déjà écrit.


Préparation du projet


Habituellement, j'essaie de déplacer les modules dans des cadres distincts, pour cela, je crée un nouveau projet d'application, et j'y ajoute une cible Cocoa Touch Framework supplémentaire, puis je disperse les sources dans le projet pour les cibles correspondantes. De cette façon, j'obtiens un cadre séparé avec une application de test pour le débogage.


Créez une Single View App .



Product Name sera notre cible.



Cliquez sur + pour ajouter la cible.



Choisissez Cocoa Touch Framework .



Nous appelons notre framework le nom approprié, Xcode sélectionne automatiquement le projet pour notre cible et propose de lier le binaire directement dans l'application. Nous sommes d'accord.



Nous Main.storyboard pas besoin du Main.storyboard et du ViewController.swift par défaut, nous les ViewController.swift .



N'oubliez pas non plus de supprimer la valeur de l' Main Interface dans la cible de l'application sous l'onglet General .



Maintenant, nous allons à AppDelegate.swift et ne laissons que la méthode d' application du contenu suivant:


 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // Launch our master view controller let master = MasterViewController() window = UIWindow() window?.rootViewController = master window?.makeKeyAndVisible() return true } 

Ici, nous avons placé notre contrôleur à la place principale afin qu'il apparaisse après le lanceur.


Créez maintenant ce même MasterViewController . Il sera lié à l'application, il est donc important de choisir la bonne cible lors de la création du fichier.



Nous hériterons de MasterViewController de SnapchatNavigationController , que nous implémenterons plus tard dans le cadre. N'oubliez pas de spécifier l' import notre framework. Je ne fournis pas le code complet du contrôleur ici, les omissions sont indiquées par des ellipses ... , j'ai placé l'application sur GitHub , là vous pouvez voir tous les détails. Dans ce contrôleur, nous ne sommes intéressés que par la méthode viewDidLoad() , qui initialise le contrôleur d'arrière-plan avec la caméra + un contrôleur transparent (fenêtre principale) + le contrôleur contenant la carte de départ.


 import MakingSnapchatNavigation class MasterViewController: SnapchatNavigationController { override func viewDidLoad() { super.viewDidLoad() //   let camera = CameraViewController() setBackground(vc: camera) //     var vcs: [UIViewController] = [] //    var stub = UIViewController() stub.view.backgroundColor = .clear vcs.append(stub) //  ,     stub = UIViewController() stub.view.backgroundColor = .clear //   let scroll = UIScrollView() stub.view.addSubview(scroll) //  ... //  ,      let content = GradientView() //  ... //    scroll.addSubview(content) vcs.append(stub) //     - setViewControllers(vcs: vcs) } } 

Que se passe-t-il ici? Nous créons un contrôleur avec une caméra et le setBackground à l'arrière-plan en utilisant la méthode setBackground de SnapchatNavigationController . Ce contrôleur contient une image étirée pour la vue entière de la caméra. Ensuite, nous créons un contrôleur transparent vide et l'ajoutons au tableau, il passe simplement l'image de la caméra à travers elle, nous pouvons y placer des contrôles, créer un autre contrôleur transparent, y ajouter un défilement, ajouter une vue avec du contenu à l'intérieur du défilement, ajouter un deuxième contrôleur à tableau et définissez ce tableau à l'aide de la méthode spéciale setViewControllers du parent SnapchatNavigationController .


N'oubliez pas d'ajouter une demande d'utilisation de la caméra dans Info.plist


 <key>NSCameraUsageDescription</key> <string>Need camera for background</string> 

Sur ce point, nous considérons l'application de test prête, et passons à la partie la plus intéressante - la mise en œuvre du framework.


Structure du contrôleur parent


Tout d'abord, créez un SnapchatNavigationController vide, il est important de choisir la bonne cible pour cela. Si tout a été fait correctement, l'application doit être construite. Ce statut du projet peut être déchargé par référence .


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

Ajoutez maintenant les composants internes qui composent le contrôleur. Je n’apporte pas tout le code ici, je me concentre uniquement sur les points importants.


Nous définissons les variables pour stocker le tableau des contrôleurs enfants. Maintenant, nous définissons de manière rigide leur quantité requise - 2 pièces. À l'avenir, il sera possible d'étendre la logique du contrôleur pour l'utiliser avec n'importe quel nombre de contrôleurs. Nous avons également défini une variable pour stocker le contrôleur actuel affiché.


 private let requiredChildrenAmount = 2 // MARK: - View controllers /// top child view controller private var topViewController: UIViewController? /// all children view controllers private var children: [UIViewController] = [] 

Créez les vues. Nous avons besoin d'une vue pour l'arrière-plan, une vue avec l'effet que nous voulons appliquer à l'arrière-plan lors du changement de contrôleur. Nous avons également un conteneur de vue pour le contrôleur enfant actuel et un indicateur de vue qui diront à l'utilisateur comment travailler avec la navigation.


 // 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() 

Dans le bloc suivant, nous définissons deux variables, swipeAnimator est responsable de l'animation, swipeInteractor est responsable de l'interaction (la capacité de contrôler la progression de l'animation), nous devons l'initialiser lors du démarrage du contrôleur, afin de forcer le déballage.


 // MARK: - Animation and transition private let swipeAnimator = AnimatedTransitioning() private var swipeInteractor: CustomSwipeInteractor! 

Nous avons également défini la transformation de l'indicateur. Nous décalons l'indicateur de la largeur du conteneur + double décalage à partir du bord + la largeur de l'indicateur lui-même de sorte que l'indicateur se trouve à l'extrémité opposée du conteneur. La largeur du conteneur sera connue lors de l'application, la variable est donc calculée en déplacement.


 // MARK: - Animation transforms private var swipeIndicatorViewTransform: CGAffineTransform { get { return CGAffineTransform(translationX: -contentViewContainer.bounds.size.width + (swipeIndicatorViewXShift * 2) + swipeIndicatorViewWidth, y: 0) } } 

Lors du chargement du contrôleur, nous nous attribuons à l'animation (nous implémenterons le protocole correspondant ci-dessous), initialisons l'interacteur en fonction de notre animation, dont il contrôlera la progression. Nous le nommons également délégué. Le délégué répondra au début du geste de l'utilisateur et lancera l'animation ou l'annulera en fonction de l'état du contrôleur. Ensuite, nous ajoutons toutes les vues à la principale et appelons setupViews() , qui définit les contraintes.


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

Ensuite, nous passons à la logique d'installation et de suppression des contrôleurs enfants dans un conteneur. Tout ici est simple comme dans la documentation Apple. Nous utilisons les méthodes prescrites pour ce type d'opération.


addChildViewController(vc) - ajoutez un contrôleur enfant au contrôleur actuel.


contentViewContainer.addSubview(vc.view) - ajoutez la vue du contrôleur à la hiérarchie des vues.


vc.view.frame = contentViewContainer.bounds - étire la vue à l'ensemble du conteneur. Étant donné que nous utilisons des cadres ici au lieu de la mise en page automatique, nous devons changer leur taille chaque fois que la taille du contrôleur change, nous allons omettre cette logique et supposer que le conteneur ne changera pas la taille de l'application pendant que l'application est en cours d'exécution.


vc.didMove(toParentViewController: self) - met fin à l'opération d'ajout d'un contrôleur enfant.


swipeInteractor.wireTo - nous swipeInteractor.wireTo le contrôleur actuel aux gestes de l'utilisateur. Plus tard, nous analyserons cette méthode.


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

Il y a deux autres méthodes dont je ne donnerai pas le code ici: setViewControllers et setBackground . Dans la méthode setViewControllers nous définissons simplement le tableau des contrôleurs enfants dans la variable correspondante de notre contrôleur et appelons addChild pour afficher l'un d'entre eux dans la vue. Dans la méthode setBackground nous faisons la même chose que dans addChild , uniquement pour le contrôleur d'arrière-plan.


Logique d'animation du contrôleur de conteneur


Total, la base de notre contrôleur parent est:


  • UIView divisé en deux types
    • Conteneurs
    • Ordinaire
  • Liste des enfants UIViewController
  • Un objet de contrôle d'animation de type swipeAnimator AnimatedTransitioning
  • Un objet qui contrôle le cours interactif d'une animation swipeInteractor de type CustomSwipeInteractor
  • Déléguer l'animation interactive
  • Implémentation du protocole d'animation

Nous allons maintenant analyser les deux derniers points, puis passer à l'implémentation de AnimatedTransitioning et CustomSwipeInteractor .


Déléguer l'animation interactive


Le délégué se compose d'une seule panGestureDidStart(rightToLeftSwipe: Bool) -> Bool , qui informe le contrôleur du début du geste et de sa direction. En réponse, il attend des informations pour savoir si l'animation peut être considérée comme démarrée.


En tant que délégué, nous vérifions l'ordre actuel des contrôleurs afin de comprendre si nous pouvons démarrer l'animation dans la direction donnée, et si tout va bien, nous démarrons la méthode de transition , avec les paramètres: le contrôleur à partir duquel nous nous déplaçons, le contrôleur vers lequel nous nous déplaçons, la direction du mouvement, l'indicateur d'interactivité (en cas de false , une animation de transition à durée fixe est déclenchée).


 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 } 

Examinons immédiatement le corps de la méthode de transition . Tout d'abord, nous créons le contexte d'animation pour l'animation CustomControllerContext . Nous analyserons également cette classe un peu plus tard, elle implémente le protocole UIViewControllerContextTransitioning . Dans le cas de UINavigationController et UITabBarController instance de la mise en œuvre de ce protocole est automatiquement créée par le système et sa logique nous est cachée, nous devons créer la nôtre.


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

Ensuite, nous appelons simplement une animation fixe ou interactive. Dans le futur, il sera possible d'en accrocher un fixe sur les onglets de navigation entre les contrôleurs, dans cet exemple nous ne le ferons pas.


 if interactive { // Animate with interaction swipeInteractor.startInteractiveTransition(ctx) } else { // Animate without interaction swipeAnimator.animateTransition(using: ctx) } 

Protocole d'animation


TransitionAnimation protocole d'animation TransitionAnimation compose de 4 méthodes:


addTo est une méthode conçue pour créer la structure correcte des vues enfant dans le conteneur, de sorte que la vue précédente chevauche la nouvelle selon l'idée de l'animation.


 /// Setup the views hirearchy for animation. func addTo(containerView: UIView, fromView: UIView, toView: UIView, fromLeft: Bool) 

prepare est la méthode appelée avant l'animation pour préparer la vue.


 /// Setup the views position prior to the animation start. func prepare(fromView from: UIView?, toView to: UIView?, fromLeft: Bool) 

animation - l'animation elle-même.


 /// The animation. func animation(fromView from: UIView?, toView to: UIView?, fromLeft: Bool) 

finalize - les actions nécessaires après la fin de l'animation.


 /// Cleanup the views position after the animation ended. func finalize(completed: Bool, fromView from: UIView?, toView to: UIView?, fromLeft: Bool) 

Nous ne considérerons pas l'implémentation utilisée, tout y est assez transparent, nous irons directement aux trois classes principales, grâce auxquelles l'animation se déroule.


class CustomControllerContext: NSObject, UIViewControllerContextTransitioning


Le contexte de l'animation. Pour décrire sa fonction, nous nous référons à l'aide du protocole UIViewControllerContextTransitioning :


Un objet de contexte encapsule des informations sur les vues et les contrôleurs de vue impliqués dans la transition. Il contient également des détails sur la façon d'exécuter la transition.

La chose la plus intéressante est l'interdiction de l'adaptation de ce protocole:


N'adoptez pas ce protocole dans vos propres classes et ne devez pas créer directement des objets qui adoptent ce protocole.

Mais nous en avons vraiment besoin pour exécuter le moteur d'animation standard, nous l'adaptons donc quand même. Il n'a presque aucune logique, il ne stocke que l'état. Par conséquent, je ne l'apporterai même pas ici. Vous pouvez le regarder sur GitHub .


Cela fonctionne très bien sur les animations à durée fixe. Mais lors de son utilisation pour des animations interactives, un problème se pose: UIPercentDrivenInteractiveTransition invoque une méthode non documentée sur le contexte. La seule bonne solution dans cette situation est d'adapter un autre protocole - UIViewControllerInteractiveTransitioning pour utiliser votre propre contexte.


class PercentDrivenInteractiveTransition: NSObject, UIViewControllerInteractiveTransitioning


Le voici - le cœur du projet, permettant aux animations interactives d'exister dans des contrôleurs de conteneurs personnalisés. Prenons-le dans l'ordre.


La classe est initialisée avec un paramètre de type UIViewControllerAnimatedTransitioning , c'est le protocole standard pour animer la transition entre les contrôleurs. De cette façon, nous pouvons utiliser l'une des animations déjà écrites avec notre classe.


 init(with animator: UIViewControllerAnimatedTransitioning) { self.animator = animator } 

L'interface publique est assez simple, quatre méthodes, dont la fonctionnalité devrait être évidente.


Il suffit de noter le moment où l'animation démarre, nous prenons la vue parent du conteneur et définissons la vitesse du calque sur 0, nous avons donc la possibilité de contrôler la progression de l'animation manuellement.


 // 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() } 

Nous passons maintenant au bloc logique privé de notre classe.


setPercentComplete définit le décalage temporel de la progression de l'animation pour la couche superview, calculant la valeur à partir du pourcentage d'achèvement et de la durée de l'animation.


 private func setPercentComplete(percentComplete: CGFloat) { setTimeOffset(timeOffset: TimeInterval(percentComplete) * duration) transitionContext?.updateInteractiveTransition(percentComplete) } private func setTimeOffset(timeOffset: TimeInterval) { transitionContext?.containerView.superview?.layer.timeOffset = timeOffset } 

completeTransition est appelée lorsque l'utilisateur a arrêté son geste. Ici, nous créons une instance de la classe CADisplayLink , qui nous permettra de terminer automatiquement et magnifiquement l'animation à partir du moment où l'utilisateur ne contrôle plus sa progression. Nous ajoutons notre displayLink à la run loop afin que le système appelle notre sélecteur chaque fois qu'il a besoin d'afficher un nouveau cadre sur l'écran de l'appareil.


 private func completeTransition() { displayLink = CADisplayLink(target: self, selector: #selector(tickAnimation)) displayLink!.add(to: .main, forMode: .commonModes) } 

Dans notre sélecteur, nous calculons et définissons le déplacement temporaire de la progression de l'animation, comme nous l'avons fait auparavant lors du geste de l'utilisateur, ou nous terminons l'animation lorsqu'elle atteint son point de début ou de fin.


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

En terminant l'animation, nous désactivons notre displayLink , retournons la vitesse du calque, et si l'animation n'a pas été annulée, c'est-à-dire qu'elle a atteint sa dernière image, nous calculons le temps à partir duquel l'animation du calque doit commencer. Vous pouvez en savoir plus à ce sujet dans le Core Animation Programming Guide, ou dans cette réponse à 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 dernière classe que nous n'avons pas encore examinée est l'implémentation du protocole UIViewControllerAnimatedTransitioning , dans lequel nous contrôlons l'ordre d'exécution des méthodes de protocole de notre animation addTo , prepare , animation , addTo . Tout ici est assez prosaïque, il convient de noter uniquement l'utilisation de UIViewPropertyAnimator pour effectuer une animation au lieu du UIView.animate(withDuration:animations:) plus typique UIView.animate(withDuration:animations:) . Ceci est fait de sorte qu'il a été possible de contrôler davantage la progression de l'animation et, si elle est annulée, de la remettre à sa position d' finishAnimation(at: .start) en appelant finishAnimation(at: .start) , ce qui évite un clignotement inutile de l'image finale de l'animation à l'écran.


Épilogue


Nous avons créé une démo fonctionnelle d'une interface similaire à celle de Snapchat. Dans ma version, j'ai configuré les constantes pour qu'il y ait des champs à droite et à gauche de la carte, en plus, j'ai laissé la caméra en arrière plan pour créer un effet derrière la carte. Ceci est fait uniquement pour démontrer les capacités de cette approche, comment elle affectera les performances de l'appareil et je n'ai pas vérifié la charge de sa batterie.


— , - , . , - .


GitHub .


, , , !



Sources d'information


:


  1. Custom Container View Controller Transitions, Joachim Bondo.


    Objective C. Swift.



  2. Interactive Custom Container View Controller Transitions, Alek Åström


    , Objective C, Swift.



  3. SwipeableTabBarController


    , UITabBarController . .



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


All Articles