Créer des cartes comme Tinder sur Swift

image

Tinder - nous savons tous qu'il s'agit d'une application de rencontres où vous pouvez simplement rejeter ou accepter quelqu'un en balayant vers la gauche ou la droite. Cette idée de lecteur de carte est maintenant utilisée dans des tonnes d'applications. Cette façon d'afficher les données est pour vous si vous en avez assez d'utiliser les vues de table et de collection. Il existe de nombreux manuels sur ce sujet, mais ce projet m'a pris beaucoup de temps.

Vous pouvez voir le projet complet sur mon github .

Tout d'abord, je voudrais rendre hommage à la publication de Phill Farrugia sur ce sujet, puis à la série YouTube du studio Big Mountain sur un sujet similaire. Alors, comment faisons-nous cette interface? J'ai obtenu de l'aide pour publier Phil sur ce sujet. Essentiellement, l'idée est de créer des UIViews et de les insérer en tant que sous-vues dans la vue du conteneur. Ensuite, en utilisant l'index, nous donnerons à chaque UIView une insertion horizontale et verticale et changerons légèrement sa largeur. De plus, lorsque nous faisons glisser un doigt sur une carte, tous les cadres des vues sont réorganisés conformément à la nouvelle valeur d'index.

Nous allons commencer par créer une vue conteneur dans un simple ViewController.

class ViewController: UIViewController { //MARK: - Properties var viewModelData = [CardsDataModel(bgColor: UIColor(red:0.96, green:0.81, blue:0.46, alpha:1.0), text: "Hamburger", image: "hamburger"), CardsDataModel(bgColor: UIColor(red:0.29, green:0.64, blue:0.96, alpha:1.0), text: "Puppy", image: "puppy"), CardsDataModel(bgColor: UIColor(red:0.29, green:0.63, blue:0.49, alpha:1.0), text: "Poop", image: "poop"), CardsDataModel(bgColor: UIColor(red:0.69, green:0.52, blue:0.38, alpha:1.0), text: "Panda", image: "panda"), CardsDataModel(bgColor: UIColor(red:0.90, green:0.99, blue:0.97, alpha:1.0), text: "Subway", image: "subway"), CardsDataModel(bgColor: UIColor(red:0.83, green:0.82, blue:0.69, alpha:1.0), text: "Robot", image: "robot")] var stackContainer : StackContainerView! //MARK: - Init override func loadView() { view = UIView() view.backgroundColor = UIColor(red:0.93, green:0.93, blue:0.93, alpha:1.0) stackContainer = StackContainerView() view.addSubview(stackContainer) configureStackContainer() stackContainer.translatesAutoresizingMaskIntoConstraints = false configureNavigationBarButtonItem() } override func viewDidLoad() { super.viewDidLoad() title = "Expense Tracker" stackContainer.dataSource = self } //MARK: - Configurations func configureStackContainer() { stackContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true stackContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -60).isActive = true stackContainer.widthAnchor.constraint(equalToConstant: 300).isActive = true stackContainer.heightAnchor.constraint(equalToConstant: 400).isActive = true } 

Comme vous pouvez le voir, j'ai créé ma propre classe appelée SwipeContainerView et viens de configurer stackViewContainer à l'aide de contraintes automatiques. Rien à craindre. La taille de SwipeContainerView sera de 300 x 400, et elle sera centrée sur l'axe X et à seulement 60 pixels au-dessus du milieu de l'axe Y.

Maintenant que nous avons configuré stackContainer, nous allons passer à la sous-classe de StackContainerView et y charger toutes sortes de cartes. Avant cela, nous allons créer un protocole qui aura trois méthodes:

 protocol SwipeCardsDataSource { func numberOfCardsToShow() -> Int func card(at index: Int) -> SwipeCardView func emptyView() -> UIView? } 

Considérez ce protocole comme une TableViewDataSource. La conformité de notre classe ViewController avec ce protocole permettra de transférer des informations sur nos données à la classe SwipeCardContainer. Il a trois méthodes:

  1. numberOfCardsToShow () -> Int : Renvoie le nombre de cartes que nous devons montrer. Ce n'est qu'un compteur de tableau de données.
  2. card(at index: Int) -> SwipeCardView : retourne SwipeCardView (nous allons créer cette classe en un instant)
  3. EmptyView -> Nous ne ferons rien avec, mais dès que toutes les cartes seront supprimées, l'appel de cette méthode déléguée renverra une vue vide avec un message (je n'implémenterai pas cela dans cette leçon spécifique, essayez-le vous-même)

Alignez le contrôleur de vue avec ce protocole:

 extension ViewController : SwipeCardsDataSource { func numberOfCardsToShow() -> Int { return viewModelData.count } func card(at index: Int) -> SwipeCardView { let card = SwipeCardView() card.dataSource = viewModelData[index] return card } func emptyView() -> UIView? { return nil } } 

La première méthode renvoie le nombre d'éléments dans le tableau de données. Dans la deuxième méthode, créez une nouvelle instance SwipeCardView () et envoyez les données du tableau pour cet index, puis renvoyez l'instance SwipeCardView.

SwipeCardView est une sous-classe de UIView qui a UIImage, UILabel et un identificateur de mouvement. Plus d'informations à ce sujet plus tard. Nous utiliserons ce protocole pour communiquer avec la présentation du conteneur.

 stackContainer.dataSource = self 

Lorsque le code ci-dessus se déclenche, la fonction reloadData est appelée, qui appelle ensuite ces fonctions de source de données.

 Class StackViewContainer: UIView { . . var dataSource: SwipeCardsDataSource? { didSet { reloadData() } } .... 

Fonction ReloadData:

 func reloadData() { guard let datasource = dataSource else { return } setNeedsLayout() layoutIfNeeded() numberOfCardsToShow = datasource.numberOfCardsToShow() remainingcards = numberOfCardsToShow for i in 0..<min(numberOfCardsToShow,cardsToBeVisible) { addCardView(cardView: datasource.card(at: i), atIndex: i ) } } 

Dans la fonction reloadData, nous obtenons d'abord le nombre de cartes et le stockons dans la variable numberOfCardsToShow. Ensuite, nous attribuons cela à une autre variable nommée restantCartes. Dans la boucle for, nous créons une carte qui est une instance de SwipeCardView en utilisant la valeur d'index.

 for i in 0..<min(numberOfCardsToShow,cardsToBeVisible) { addCardView(cardView: datasource.card(at: i), atIndex: i ) } 

En fait, nous voulons que moins de 3 cartes apparaissent à la fois. Par conséquent, nous utilisons la fonction min. CardsToBeVisible est une constante égale à 3. Si numberOfToShow est supérieur à 3, alors seulement trois cartes seront affichées. Nous créons ces cartes à partir du protocole:

 func card(at index: Int) -> SwipeCardView 

La fonction addCardView () est simplement utilisée pour insérer des cartes en tant que sous-vues.

  private func addCardView(cardView: SwipeCardView, atIndex index: Int) { cardView.delegate = self addCardFrame(index: index, cardView: cardView) cardViews.append(cardView) insertSubview(cardView, at: 0) remainingcards -= 1 } 

Dans cette fonction, nous ajoutons cardView à la hiérarchie des vues, et en ajoutant des cartes comme sous-vue, nous réduisons les cartes restantes de 1. Dès que nous ajoutons cardView en tant que sous-vue, nous définissons le cadre de ces cartes. Pour ce faire, nous utilisons une autre fonction addCardFrame ():

  func addCardFrame(index: Int, cardView: SwipeCardView) { var cardViewFrame = bounds let horizontalInset = (CGFloat(index) * self.horizontalInset) let verticalInset = CGFloat(index) * self.verticalInset cardViewFrame.size.width -= 2 * horizontalInset cardViewFrame.origin.x += horizontalInset cardViewFrame.origin.y += verticalInset cardView.frame = cardViewFrame } 

Cette logique addCardFrame () est tirée directement de la publication de Phil. Ici, nous définissons le cadre de la carte en fonction de son index. La première carte avec l'index 0 aura un cadre, tout comme un conteneur. Ensuite, nous modifions l'origine du cadre et la largeur de la carte en fonction de l'insert. Ainsi, nous ajoutons la carte un peu à droite de la carte ci-dessus, réduisons sa largeur et tirons également nécessairement les cartes vers le bas pour créer l'impression que les cartes sont empilées les unes sur les autres.

Une fois cela fait, vous verrez que les cartes sont empilées les unes sur les autres. Assez bien!

image

Cependant, nous devons maintenant ajouter un geste de balayage à la vue de la carte. Tournons maintenant notre attention vers la classe SwipeCardView.

SwipeCardView


La classe swipeCardView est une sous-classe régulière d'UIView. Cependant, pour des raisons connues uniquement des ingénieurs d'Apple, il est incroyablement difficile d'ajouter des ombres à une UIView avec un coin arrondi. Pour ajouter des ombres aux vues de carte, je crée deux UIViews. L'un d'eux est shadowView, puis swipeView. En substance, shadowView a une ombre et c'est tout. SwipeView a des coins arrondis. Sur swipeView, j'ai ajouté UIImageView, un UILabel pour présenter les données et les images.

  var swipeView : UIView! var shadowView : UIView! 

Définition de shadowView et de swipeView:

  func configureShadowView() { shadowView = UIView() shadowView.backgroundColor = .clear shadowView.layer.shadowColor = UIColor.black.cgColor shadowView.layer.shadowOffset = CGSize(width: 0, height: 0) shadowView.layer.shadowOpacity = 0.8 shadowView.layer.shadowRadius = 4.0 addSubview(shadowView) shadowView.translatesAutoresizingMaskIntoConstraints = false shadowView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true shadowView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true shadowView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true shadowView.topAnchor.constraint(equalTo: topAnchor).isActive = true } func configureSwipeView() { swipeView = UIView() swipeView.layer.cornerRadius = 15 swipeView.clipsToBounds = true shadowView.addSubview(swipeView) swipeView.translatesAutoresizingMaskIntoConstraints = false swipeView.leftAnchor.constraint(equalTo: shadowView.leftAnchor).isActive = true swipeView.rightAnchor.constraint(equalTo: shadowView.rightAnchor).isActive = true swipeView.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor).isActive = true swipeView.topAnchor.constraint(equalTo: shadowView.topAnchor).isActive = true } 

J'ai ensuite ajouté un identificateur de gestes à ce type de carte et la fonction de sélection est appelée reconnaissance. Cette fonction de sélection a beaucoup de logique pour le défilement, l'inclinaison, etc. Voyons voir:

  @objc func handlePanGesture(sender: UIPanGestureRecognizer){ let card = sender.view as! SwipeCardView let point = sender.translation(in: self) let centerOfParentContainer = CGPoint(x: self.frame.width / 2, y: self.frame.height / 2) card.center = CGPoint(x: centerOfParentContainer.x + point.x, y: centerOfParentContainer.y + point.y) switch sender.state { case .ended: if (card.center.x) > 400 { delegate?.swipeDidEnd(on: card) UIView.animate(withDuration: 0.2) { card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75) card.alpha = 0 self.layoutIfNeeded() } return }else if card.center.x < -65 { delegate?.swipeDidEnd(on: card) UIView.animate(withDuration: 0.2) { card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75) card.alpha = 0 self.layoutIfNeeded() } return } UIView.animate(withDuration: 0.2) { card.transform = .identity card.center = CGPoint(x: self.frame.width / 2, y: self.frame.height / 2) self.layoutIfNeeded() } case .changed: let rotation = tan(point.x / (self.frame.width * 2.0)) card.transform = CGAffineTransform(rotationAngle: rotation) default: break } } 

Les quatre premières lignes du code ci-dessus:

 let card = sender.view as! SwipeCardView let point = sender.translation(in: self) let centerOfParentContainer = CGPoint(x: self.frame.width / 2, y: self.frame.height / 2) card.center = CGPoint(x: centerOfParentContainer.x + point.x, y: centerOfParentContainer.y + point.y) 

Nous avons d'abord l'idée par laquelle le geste a été tenu. Ensuite, nous utilisons la méthode de transfert pour savoir combien de fois l'utilisateur a frappé la carte. La troisième ligne obtient essentiellement le milieu du conteneur parent. La dernière ligne où nous installons card.center. Lorsque l'utilisateur glisse un doigt sur la carte, le centre de la carte est augmenté de la valeur traduite de x et de la valeur traduite de y. Pour obtenir ce comportement d'accrochage, nous changeons considérablement le point central de la carte à partir de coordonnées fixes. Une fois la traduction des gestes terminée, nous la renvoyons à card.center.

Dans le cas de state.ended:

 if (card.center.x) > 400 { delegate?.swipeDidEnd(on: card) UIView.animate(withDuration: 0.2) { card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75) card.alpha = 0 self.layoutIfNeeded() } return }else if card.center.x < -65 { delegate?.swipeDidEnd(on: card) UIView.animate(withDuration: 0.2) { card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75) card.alpha = 0 self.layoutIfNeeded() } return } 

Nous vérifions si card.center.x est supérieur à 400 ou si card.center.x est inférieur à -65. Si c'est le cas, nous défaussons ces cartes, en changeant le centre.

Si vous glissez vers la droite:

 card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75) 

Si balayez vers la gauche:

 card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75) 

Si l'utilisateur termine le geste au milieu entre 400 et -65, nous réinitialiserons le centre de la carte. Nous appelons également la méthode déléguée à la fin du balayage. Plus d'informations à ce sujet plus tard.

Pour obtenir cette inclinaison lorsque vous faites glisser la carte; Je serai brutalement honnête. J'ai utilisé un peu de géométrie et utilisé différentes valeurs de la perpendiculaire et de la base, puis j'ai utilisé la fonction bronzage pour obtenir l'angle de rotation. Encore une fois, ce n'était que des essais et des erreurs. L'utilisation de point.x et de la largeur du conteneur comme deux périmètres semblait bien fonctionner. N'hésitez pas à expérimenter ces valeurs.

 case .changed: let rotation = tan(point.x / (self.frame.width * 2.0)) card.transform = CGAffineTransform(rotationAngle: rotation) 

Parlons maintenant de la fonction déléguée. Nous utiliserons la fonction déléguée pour communiquer entre SwipeCardView et ContainerView.

 protocol SwipeCardsDelegate { func swipeDidEnd(on view: SwipeCardView) } 

Cette fonction prendra en compte le type dans lequel le balayage s'est produit, et nous prendrons plusieurs mesures pour le supprimer des sous-vues, puis refaire tous les cadres pour les cartes en dessous. Voici comment:

  func swipeDidEnd(on view: SwipeCardView) { guard let datasource = dataSource else { return } view.removeFromSuperview() if remainingcards > 0 { let newIndex = datasource.numberOfCardsToShow() - remainingcards addCardView(cardView: datasource.card(at: newIndex), atIndex: 2) for (cardIndex, cardView) in visibleCards.reversed().enumerated() { UIView.animate(withDuration: 0.2, animations: { cardView.center = self.center self.addCardFrame(index: cardIndex, cardView: cardView) self.layoutIfNeeded() }) } }else { for (cardIndex, cardView) in visibleCards.reversed().enumerated() { UIView.animate(withDuration: 0.2, animations: { cardView.center = self.center self.addCardFrame(index: cardIndex, cardView: cardView) self.layoutIfNeeded() }) } } } 

Supprimez d'abord cette vue de la super vue. Une fois cela fait, vérifiez s'il reste une carte. Si tel est le cas, nous créerons un nouvel index pour la carte à créer. Nous allons créer newIndex en soustrayant le nombre total de cartes à afficher avec le reste des cartes. Ensuite, nous ajouterons la carte en tant que sous-vue. Cependant, cette nouvelle carte sera la plus basse, donc les 2 que nous enverrons garantiront essentiellement que la trame que vous ajoutez correspond à l'index 2 ou au plus bas.

Pour animer les cadres des cartes restantes, nous utiliserons l'index de sous-vue. Pour ce faire, nous allons créer un tableau visibleCards, qui contiendra toutes les sous-vues du conteneur sous forme de tableau.

 var visibleCards: [SwipeCardView] { return subviews as? [SwipeCardView] ?? [] } 

Le problème, cependant, est que le tableau visibleCards aura un index de sous-vue inversé. Ainsi, la première carte sera troisième, la seconde restera en deuxième position et la troisième sera en première position. Pour éviter que cela ne se produise, nous exécuterons le tableau visibleCards dans l'ordre inverse pour obtenir l'index de sous-vue réel, et non la façon dont ils se trouvent dans le tableau visibleCards.

  for (cardIndex, cardView) in visibleCards.reversed().enumerated() { UIView.animate(withDuration: 0.2, animations: { cardView.center = self.center self.addCardFrame(index: cardIndex, cardView: cardView) self.layoutIfNeeded() }) } 

Alors maintenant, nous mettrons à jour les cadres du reste de cardViews.

C’est tout. C'est un moyen idéal pour présenter une petite quantité de données.

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


All Articles