
Tinder: todos sabemos que esta es una aplicación de citas en la que puedes simplemente rechazar o aceptar a alguien deslizando el dedo hacia la izquierda o hacia la derecha. Esta idea de lector de tarjetas ahora se usa en toneladas de aplicaciones. Esta forma de mostrar datos es para usted si está cansado de usar vistas de tabla y colección. Hay muchos libros de texto sobre este tema, pero este proyecto me llevó mucho tiempo.
Puedes ver el proyecto completo en mi
github .
En primer lugar, me gustaría rendir homenaje a la
publicación de Phill Farrugia sobre este tema, y luego a la serie de
YouTube en el estudio Big Mountain sobre un tema similar. Entonces, ¿cómo hacemos esta interfaz? Recibí ayuda para publicar Phil sobre este tema. Básicamente, la idea es crear UIViews e insertarlas como subvistas en la vista del contenedor. Luego, usando el índice, le daremos a cada UIView una inserción horizontal y vertical y cambiaremos ligeramente su ancho. Además, cuando arrastramos un dedo por un mapa, todos los cuadros de las vistas se reorganizarán de acuerdo con el nuevo valor del índice.
Comenzaremos creando una vista de contenedor en un ViewController simple.
class ViewController: UIViewController {
Como puede ver, creé mi propia clase llamada SwipeContainerView y acabo de configurar stackViewContainer usando restricciones automáticas. Nada de qué preocuparse. El tamaño del SwipeContainerView será de 300x400, y estará centrado en el eje X y solo 60 píxeles por encima del centro del eje Y.
Ahora que hemos configurado stackContainer, iremos a la subclase de StackContainerView y cargaremos todo tipo de mapas en él. Antes de eso, crearemos un protocolo que tendrá tres métodos:
protocol SwipeCardsDataSource { func numberOfCardsToShow() -> Int func card(at index: Int) -> SwipeCardView func emptyView() -> UIView? }
Piense en este protocolo como un TableViewDataSource. El cumplimiento de nuestra clase ViewController con este protocolo permitirá transferir información sobre nuestros datos a la clase SwipeCardContainer. Tiene tres métodos:
numberOfCardsToShow () -> Int
: Devuelve el número de tarjetas que necesitamos mostrar. Es solo un contador de matriz de datos.card(at index: Int) -> SwipeCardView
: devuelve SwipeCardView (crearemos esta clase en un momento)EmptyView
-> No haremos nada con él, pero tan pronto como se eliminen todas las tarjetas, llamar a este método delegado devolverá una vista vacía con algún mensaje (no implementaré esto en esta lección específica, pruébalo tú mismo)
Alinee el controlador de vista con este protocolo:
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 } }
El primer método devolverá el número de elementos en la matriz de datos. En el segundo método, cree una nueva instancia de SwipeCardView () y envíe los datos de la matriz para este índice, y luego devuelva la instancia de SwipeCardView.
SwipeCardView es una subclase de UIView que tiene UIImage, UILabel y un reconocedor de gestos. Más sobre esto más tarde. Utilizaremos este protocolo para comunicarnos con la presentación del contenedor.
stackContainer.dataSource = self
Cuando se activa el código anterior, se llama a la función reloadData, que luego llama a estas funciones de origen de datos.
Class StackViewContainer: UIView { . . var dataSource: SwipeCardsDataSource? { didSet { reloadData() } } ....
Función 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 ) } }
En la función reloadData, primero obtenemos el número de tarjetas y lo almacenamos en la variable numberOfCardsToShow. Luego asignamos esto a otra variable llamada restoCards. En el bucle for, creamos un mapa que es una instancia de SwipeCardView usando el valor del índice.
for i in 0..<min(numberOfCardsToShow,cardsToBeVisible) { addCardView(cardView: datasource.card(at: i), atIndex: i ) }
De hecho, queremos que aparezcan menos de 3 cartas a la vez. Por lo tanto, usamos la función min. CardsToBeVisible es una constante igual a 3. Si numberOfToShow es mayor que 3, solo se mostrarán tres tarjetas. Creamos estas tarjetas desde el protocolo:
func card(at index: Int) -> SwipeCardView
La función addCardView () simplemente se usa para insertar mapas como subvistas.
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 }
En esta función, agregamos cardView a la jerarquía de vistas, y al agregar tarjetas como subvista, reducimos las tarjetas restantes en 1. Tan pronto como agreguemos cardView como subvista, establecemos el marco de estas tarjetas. Para hacer esto, usamos otra función 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 }
Esta lógica addCardFrame () se toma directamente de la publicación de Phil. Aquí establecemos el marco del mapa de acuerdo con su índice. La primera tarjeta con índice 0 tendrá un marco, como un contenedor. Luego cambiamos el origen del marco y el ancho del mapa de acuerdo con la inserción. Por lo tanto, agregamos la tarjeta un poco a la derecha de la tarjeta de arriba, reducimos su ancho y también necesariamente tiramos las tarjetas hacia abajo para crear la sensación de que las tarjetas están apiladas una encima de la otra.
Una vez hecho esto, verá que las cartas se apilan unas encima de otras. Bastante bueno!

Sin embargo, ahora necesitamos agregar un gesto de deslizamiento a la vista del mapa. Pasemos ahora nuestra atención a la clase SwipeCardView.
SwipeCardView
La clase swipeCardView es una subclase regular de UIView. Sin embargo, por razones conocidas solo por los ingenieros de Apple, es increíblemente difícil agregar sombras a una UIView con una esquina redondeada. Para agregar sombras a las vistas del mapa, creo dos UIViews. Uno de ellos es shadowView, y luego a él swipeView. En esencia, shadowView tiene una sombra y eso es todo. SwipeView tiene esquinas redondeadas. En swipeView, agregué UIImageView, un UILabel para mostrar datos e imágenes.
var swipeView : UIView! var shadowView : UIView!
Configuración de shadowView y 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 }
Luego agregué un reconocedor de gestos a este tipo de tarjeta y la función del selector se llama al momento del reconocimiento. Esta función de selector tiene mucha lógica para desplazarse, inclinarse, etc. A ver:
@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 } }
Las primeras cuatro líneas en el código anterior:
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)
Primero tenemos la idea por la cual se realizó el gesto. A continuación, utilizamos el método de transferencia para averiguar cuántas veces el usuario golpeó la tarjeta. La tercera línea esencialmente obtiene el punto medio del contenedor principal. La última línea donde instalamos card.center. Cuando el usuario desliza un dedo sobre la tarjeta, el centro de la tarjeta aumenta con el valor traducido de xy el valor traducido de y. Para obtener este comportamiento de ajuste, cambiamos significativamente el punto central del mapa a partir de coordenadas fijas. Cuando finaliza la traducción de los gestos, la devolvemos a card.center.
En el caso 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 }
Verificamos si card.center.x es mayor que 400 o si card.center.x es menor que -65. Si es así, descartamos estas cartas, cambiando el centro.
Si desliza hacia la derecha:
card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75)
Si desliza hacia la izquierda:
card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75)
Si el usuario finaliza el gesto en el medio entre 400 y -65, restableceremos el centro del mapa. También llamamos al método delegado cuando finaliza el deslizamiento. Más sobre esto más tarde.
Para obtener esta inclinación cuando desliza el mapa; Seré brutalmente honesto. Usé un poco de geometría y usé diferentes valores de la perpendicular y la base, y luego usé la función de bronceado para obtener el ángulo de rotación. Nuevamente, esto fue solo prueba y error. Usar point.xy el ancho del contenedor como dos perímetros parecía funcionar bien. Siéntase libre de experimentar con estos valores.
case .changed: let rotation = tan(point.x / (self.frame.width * 2.0)) card.transform = CGAffineTransform(rotationAngle: rotation)
Ahora hablemos de la función delegar. Utilizaremos la función de delegado para comunicarnos entre SwipeCardView y ContainerView.
protocol SwipeCardsDelegate { func swipeDidEnd(on view: SwipeCardView) }
Esta función tendrá en cuenta el tipo en el que ocurrió el deslizamiento, y tomaremos varios pasos para eliminarlo de las subvistas y luego rehacer todos los marcos para las tarjetas debajo de él. Así es como:
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() }) } } }
Primero elimine esta vista de la supervista. Una vez hecho esto, verifique si queda alguna tarjeta. Si lo hay, crearemos un nuevo índice para la tarjeta que se creará. Crearemos newIndex restando el número total de tarjetas para mostrar con el resto de las tarjetas. Luego agregaremos el mapa como una subvista. Sin embargo, esta nueva tarjeta será la más baja, por lo que el 2 que enviamos garantizará esencialmente que el marco agregado coincida con el índice 2 o el más bajo.
Para animar los marcos de las tarjetas restantes, utilizaremos el índice de subvista. Para hacer esto, crearemos una matriz visibleCards, que contendrá todas las subvistas del contenedor como una matriz.
var visibleCards: [SwipeCardView] { return subviews as? [SwipeCardView] ?? [] }
Sin embargo, el problema es que la matriz visibleCards tendrá un índice de subvista invertido. Por lo tanto, la primera carta será la tercera, la segunda permanecerá en el segundo lugar y la tercera estará en la primera posición. Para evitar que esto suceda, ejecutaremos la matriz visibleCards en el orden inverso para obtener el índice de subvista real, y no cómo están ubicados en la matriz 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() }) }
Así que ahora actualizaremos los marcos del resto de cardViews.
Eso es todo Esta es una forma ideal de presentar una pequeña cantidad de datos.