Создание карточек как Tinder на Swift

в 7:49, , рубрики: iOS, swift, разработка мобильных приложений, разработка под iOS

image

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. В нем есть три метода:

  1. numberOfCardsToShow () -> Int: Возвращает количество карт, которое нам нужно показать. Это просто счетчик массива данных.
  2. card(at index: Int) -> SwipeCardView: возвращает SwipeCardView (мы создадим этот класс в один момент)
  3. 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 будет иметь фрейм, прямо как у контейнера. Затем мы меняем происхождение кадра и ширину карты в соответствии со вставкой. Таким образом, мы добавляем карту немного справа от карты выше, уменьшаем ее ширину, а также обязательно тянем карты вниз, чтобы создать ощущение того, что карты сложены друг на друга.

Как только это будет сделано, вы увидите, что карты сложены друг на друга. Довольно хорошо!

image

Однако теперь нам нужно добавить жест смахивания к виду карты. Давайте теперь обратим наше внимание на класс 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.

Вот и всё. Это идеальный способ представить небольшое количество данных.

Автор: AnDVa

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js