Tinder — мы все знаем, что это приложение для знакомств, где вы можете просто отклонить или принять кого-то свайпом вправо или влево. Эта идея считывания карт теперь используется в тоннах приложений. Этот способ отображения данных для вас, если вы устали от использования табличных и коллекционных представлений. Есть множество учебников по этому вопросу, но этот проект занял у меня немало времени.
Вы можете посмотреть полный проект на моем Github.
Прежде всего, я хотел бы отдать должное посту Phill Farrugia по этому вопросу, а затем и серии YouTube в студии Big Mountain по аналогичной теме. Так как же нам сделать этот интерфейс? Я получил помощь в публикации Фила нпо этой теме. По сути, идея заключается в создании UIViews и вставке их в качестве subviews в представление контейнера. Затем, используя индекс, мы дадим каждому UIView некоторую горизонтальную и вертикальную вставку и немного изменим его ширину. Дальше, когда мы проведем пальцем по одной карте, все кадры представлений будут переставлены в соответствии с новым значением индекса.
Мы начнем с создания контейнерного представления в простом 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
}
Как вы можете заметить, я создал собственный класс с именем SwipeContainerView и просто сконфигурировал stackViewContainer, используя автоматические ограничения. Ничего страшного. Размер SwipeContainerView будет 300x400, и он будет центрирован по оси X и всего на 60 пикселей выше середины оси Y.
Теперь, когда мы настроили stackContainer, мы перейдем к подклассу StackContainerView и загрузим в него все виды карт. Перед этим мы создадим протокол, который будет иметь три метода:
protocol SwipeCardsDataSource {
func numberOfCardsToShow() -> Int
func card(at index: Int) -> SwipeCardView
func emptyView() -> UIView?
}
Думайте об этом протоколе как о TableViewDataSource. Соответствие нашего класса ViewController этому протоколу позволит передавать информацию о наших данных в класс SwipeCardContainer. В нем есть три метода:
numberOfCardsToShow () -> Int
: Возвращает количество карт, которое нам нужно показать. Это просто счетчик массива данных.card(at index: Int) -> SwipeCardView
: возвращает SwipeCardView (мы создадим этот класс в один момент)EmptyView
-> Ничего не будем делать с ним, но как только все карточки будут удалены, вызов этого метода делегата вернет пустое представление с каким-то сообщением (я не буду реализовывать это в этом конкретном уроке, попробуйте сами)
Согласуйте контроллер представления с этим протоколом:
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
}
}
В первом методе вернется количество элементов в массиве данных. Во втором методе создайте новый экземпляр SwipeCardView() и отправьте данные массива для этого индекса, а затем верните экземпляр SwipeCardView.
SwipeCardView — это подкласс UIView, в котором есть UIImage, UILabel и распознаватель жестов. Подробнее об этом позже. Мы будем использовать этот протокол для связи с представлением контейнера.
stackContainer.dataSource = self
Когда приведенный выше код срабатывает, то вызовется функция reloadData, которая затем вызовет эти функции источника данных.
Class StackViewContainer: UIView {
.
.
var dataSource: SwipeCardsDataSource? {
didSet {
reloadData()
}
}
....
Функция 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 )
}
}
В функции reloadData мы сначала получаем количество карточек и сохраняем его в переменной numberOfCardsToShow. Затем присваиваем это другой переменной с именем remainingCards. В цикле for мы создаем карту, которая является экземпляром SwipeCardView, используя значение индекса.
for i in 0..<min(numberOfCardsToShow,cardsToBeVisible) {
addCardView(cardView: datasource.card(at: i), atIndex: i )
}
По сути, мы хотим, чтобы одновременно появлялось менее 3 карт. Поэтому мы используем функцию min. CardsToBeVisible — это константа, равная 3. Если numberOfToShow больше 3, то будут отображаться только три карты. Мы создаем эти карты из протокола:
func card(at index: Int) -> SwipeCardView
Функция addCardView() просто используется для вставки карт в качестве subviews.
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
}
В этой функции мы добавляем cardView в иерархию представлений, и, добавляя карточки в качестве подпредставления, мы уменьшаем оставшиеся карточки на 1. Как только мы добавили cardView в качестве подпредставления, мы устанавливаем кадр этих карточек. Для этого мы используем другую функцию 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
}
Эта логика addCardFrame() взята непосредственно из поста Фила. Здесь мы устанавливаем кадр карты в соответствии с ее индексом. Первая карточка с индексом 0 будет иметь фрейм, прямо как у контейнера. Затем мы меняем происхождение кадра и ширину карты в соответствии со вставкой. Таким образом, мы добавляем карту немного справа от карты выше, уменьшаем ее ширину, а также обязательно тянем карты вниз, чтобы создать ощущение того, что карты сложены друг на друга.
Как только это будет сделано, вы увидите, что карты сложены друг на друга. Довольно хорошо!
Однако теперь нам нужно добавить жест смахивания к виду карты. Давайте теперь обратим наше внимание на класс SwipeCardView.
SwipeCardView
Класс swipeCardView — это обычный подкласс UIView. Однако по причинам, известным только инженерам Apple, невероятно сложно добавить тени в UIView с закругленным углом. Чтобы добавить тени к видам карт, я создаю два UIView. Одним из них является shadowView, а затем к нему swipeView. По сути, shadowView имеет тень и все. SwipeView имеет закругленные углы. На swipeView я добавил UIImageView, UILabel для демонстрации данных и изображений.
var swipeView : UIView!
var shadowView : UIView!
Настройка shadowView и 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
}
Затем я добавил распознаватель жестов к этому виду карты и при распознавании вызывается функция селектора. Эта селекторная функция имеет массу логики прокрутки, наклона и т.д. Давайте посмотрим:
@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
}
}
Первые четыре строки в приведенном выше коде:
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)
Сначала мы получаем представление, по которому был проведен жест. Далее мы используем метод перевода, чтобы узнать, сколько раз пользователь ударил по карточке. Третья строка по существу получает среднюю точку родительского контейнера. Последняя строка, где мы устанавливаем card.center. Когда пользователь проводит пальцем по карточке, центр карточки увеличивается на переведенное значение x и переведенное значение y. Чтобы получить такое поведение привязки, мы существенно меняем центральную точку карты с фиксированных координат. Когда перевод жестов заканчивается, мы возвращаем его обратно в card.center.
В случае 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
}
Мы проверяем, является ли значение card.center.x больше 400 или если card.center.x меньше -65. Если это так, то мы отказываемся от этих карт, меняя центр.
Если свайп вправо:
card.center = CGPoint(x: centerOfParentContainer.x + point.x + 200, y: centerOfParentContainer.y + point.y + 75)
Если свайп влево:
card.center = CGPoint(x: centerOfParentContainer.x + point.x - 200, y: centerOfParentContainer.y + point.y + 75)
Если пользователь заканчивает жест в середине между 400 и -65, тогда мы сбросим центр карты. Мы также вызываем метод делегата, когда свайп заканчивается. Подробнее об этом позже.
Для получения этого наклона, когда вы проводите по карте; Я буду жестоко честен. Я использовал немного геометрии и использовал разные значения перпендикуляра и основания, а затем использовал функцию tan, чтобы получить угол поворота. Опять же, это было просто методом проб и ошибок. Использование point.x и ширина контейнера в качестве двух периметров, казалось, работали хорошо. Не стесняйтесь экспериментировать с этими значениями.
case .changed:
let rotation = tan(point.x / (self.frame.width * 2.0))
card.transform = CGAffineTransform(rotationAngle: rotation)
Теперь поговорим о функции делегата. Мы будем использовать функцию делегата для связи между SwipeCardView и ContainerView.
protocol SwipeCardsDelegate {
func swipeDidEnd(on view: SwipeCardView)
}
Эта функция будет учитывать вид, в котором произошел свайп, и мы сделаем несколько шагов, чтобы удалить его из подпредставлений, а затем переделать все кадры для карт под ним. Вот как:
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()
})
}
}
}
Сначала удалите этот вид из супер просмотра. Как только это будет сделано, проверьте, не осталось ли еще какой-либо карты. Если есть, то мы создадим новый индекс для карты, которая будет создана. Мы создадим newIndex, вычтя общее количество карт, чтобы показать с остальными картами. Затем мы добавим карту в качестве подпредставления. Тем не менее, эта новая карта будет самой нижней, так что отправляемая нами 2 будет по существу гарантировать, что добавляемый кадр соответствует индексу 2 или самому нижнему.
Чтобы анимировать кадры остальных карточек, мы будем использовать индекс подпредставлений. Для этого мы создадим массив visibleCards, в котором будут все подпредставления контейнера в виде массива.
var visibleCards: [SwipeCardView] {
return subviews as? [SwipeCardView] ?? []
}
Проблема, однако, в том, что массив visibleCards будет иметь инвертированный индекс подпредставлений. Таким образом, первая карта будет третьей, вторая останется на втором месте, а третья будет на первой позиции. Чтобы этого не случилось, мы будем запускать массив visibleCards в обратном порядке, чтобы получить фактический индекс подпредставления, а не то, как они расположены в массиве 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()
})
}
Так что теперь мы будем обновлять кадры остальной части cardViews.
Вот и всё. Это идеальный способ представить небольшое количество данных.