Tinder like ui using SwiftUI

//
//  User.swift
//  CardUI
//
//  Created by paige on 2021/12/09.
//

import SwiftUI

struct User: Identifiable {

    var id = UUID().uuidString
    var name: String
    var place: String
    var profilePic: String

}

//
//  HomeViewModel.swift
//  CardUI
//
//  Created by paige on 2021/12/09.
//

import SwiftUI

class HomeViewModel: ObservableObject {

    // Store All the fetched Users here...
    // Since we're building UI so using sample Users here....
    @Published var fetchedUsers: [User] = []

    @Published var displayingUsers: [User]?

    init() {

        // fetching users...
        fetchedUsers = [
            User(name: "Natalia", place: "Vadalia NYC", profilePic: "img1"),
            User(name: "Natalia", place: "Vadalia NYC", profilePic: "img2"),
            User(name: "Natalia", place: "Vadalia NYC", profilePic: "img3"),
            User(name: "Natalia", place: "Vadalia NYC", profilePic: "img1"),
            User(name: "Natalia", place: "Vadalia NYC", profilePic: "img2"),
        ]

        // storing it in displaying users...
        // what is displaying users?
        // it will be updated/removed based on user interaction to reduce memory usage....
        // and the same time we need all the fetched uesrs data....
        displayingUsers = fetchedUsers

    }

    // retreiving index...
    func getIndex(user: User) -> Int {
        let index = displayingUsers?.firstIndex(where: {
            return $0.id == user.id
        }) ?? 0
        return index
    }

}

//
//  Home.swift
//  CardUI
//
//  Created by paige on 2021/12/09.
//

import SwiftUI

struct Home: View {

    @StateObject private var viewModel = HomeViewModel()

    var body: some View {

        VStack {

            Button {

            } label: {
                Image("menu")
                    .resizable()
                    .renderingMode(.template)
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 22, height: 22)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .overlay(
                Text("Discover")
                    .font(.title.bold())
            )
            .foregroundColor(.black)
            .padding()

            // Users Stack...
            ZStack {

                if let users = viewModel.displayingUsers {

                    if users.isEmpty {
                        Text("Come back later we can find more matches for you!")
                            .font(.caption)
                            .foregroundColor(.gray)
                    } else {

                        // Displaying Cards
                        // Cards are reversed since its ZStack...
                        // You can use reverse here...
                        // or you can use while fetching users...
                        ForEach(users.reversed()) { user in

                            // Card View...
                            StackCardView(user: user)
                                .environmentObject(viewModel)

                        }

                    }

                } else {
                    ProgressView()
                }

            }
            .padding(.top, 30)
            .padding()
            .padding(.vertical)
            .frame(maxWidth: .infinity, maxHeight: .infinity)

            // Action Buttons
            HStack(spacing: 15) {
                Button {

                } label: {
                    Image(systemName: "arrow.uturn.backward")
                        .font(.system(size: 15, weight: .bold))
                        .foregroundColor(.white)
                        .shadow(radius: 5)
                        .padding(13)
                        .background(.gray)
                        .clipShape(Circle())
                }
                Button {
                    doSwipe(rightSwipe: false)
                } label: {
                    Image(systemName: "xmark")
                        .font(.system(size: 15, weight: .black))
                        .foregroundColor(.white)
                        .shadow(radius: 5)
                        .padding(13)
                        .background(.blue)
                        .clipShape(Circle())
                }
                Button {

                } label: {
                    Image(systemName: "star.fill")
                        .font(.system(size: 15, weight: .bold))
                        .foregroundColor(.white)
                        .shadow(radius: 5)
                        .padding(13)
                        .background(.yellow)
                        .clipShape(Circle())
                }
                Button {
                    doSwipe(rightSwipe: true)
                } label: {
                    Image(systemName: "suit.heart.fill")
                        .font(.system(size: 15, weight: .black))
                        .foregroundColor(.white)
                        .shadow(radius: 5)
                        .padding(13)
                        .background(.pink)
                        .clipShape(Circle())
                }

            } //: HSTACK
            .padding(.bottom)
            .disabled(viewModel.displayingUsers?.isEmpty ?? false)
            .opacity(viewModel.displayingUsers?.isEmpty ?? false ? 0.6 : 1)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)

    }

    // removing cards when doing Swipe...
    func doSwipe(rightSwipe: Bool = false) {
        guard let first = viewModel.displayingUsers?.first else {
            return
        }

        // Using Notification to post and receiveing in Stack Cards...
        NotificationCenter.default.post(name: NSNotification.Name("ACTIONFROMBUTTON"), object: nil, userInfo: [
            "id": first.id,
            "rightSwipe": rightSwipe
        ])
    }

}

//
//  StackCardView.swift
//  CardUI
//
//  Created by paige on 2021/12/09.
//

import SwiftUI

struct StackCardView: View {

    @EnvironmentObject private var viewModel: HomeViewModel
    let user: User

    // Gesture Properties...
    @State var offset: CGFloat = 0
    @GestureState var isDragging = false

    @State var endSwipe: Bool = false

    var body: some View {

        GeometryReader { proxy in
            let size = proxy.size
            let index = CGFloat(viewModel.getIndex(user: user))
            // Showing Next two cards at top like a Stack...
            let topOffset = (index <= 2 ? index : 2) * 15

            ZStack {

                Image(user.profilePic)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                // Reducing width too...
                    .frame(width: size.width - topOffset, height: size.height)
                    .cornerRadius(15)
                    .offset(y: -topOffset)

            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)

        }
        .offset(x: offset)
        .rotationEffect(.init(degrees: getRotation(angle: 8)))
        // about trim.. https://seons-dev.tistory.com/142
        .contentShape(Rectangle().trim(from: 0, to: endSwipe ? 0 : 1)) // 뒤에 있는 카드를 못 선택하게 막는다.
        //        .contentShape(Rectangle())
        .gesture(
            DragGesture()
                .updating($isDragging, body: { value, out, _ in
                    out = true
                })
                .onChanged({ value in
                    let translation = value.translation.width
                    offset = (isDragging ? translation : .zero)
                })
                .onEnded({ value in
                    let width = UIScreen.main.bounds.width - 50
                    let translation = value.translation.width
                    let checkingStatus = (translation > 0 ? translation : -translation)
                    withAnimation {
                        if checkingStatus > (width / 2) {
                            // remove card....
                            offset = (translation > 0 ? width : -width) * 2
                            endSwipeActions()

                            if translation > 0 {
                                rightSwipe()
                            } else {
                                leftSwipe()
                            }
                        } else {
                            // reset
                            offset = .zero
                        }
                    }
                })
        )
        .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ACTIONFROMBUTTON"))) { data in
            guard let info = data.userInfo else { return }
            let id = info["id"] as? String ?? ""
            let rightSwipe = info["rightSwipe"] as? Bool ?? false
            let width = UIScreen.main.bounds.width - 50

            if user.id == id {

                // removing card...
                withAnimation {
                    offset = (rightSwipe ? width : -width) * 2
                    endSwipeActions()

                    if rightSwipe {
                        self.rightSwipe()
                    } else {
                        leftSwipe()
                    }
                }
            }

        }

    }

    // Rotation
    private func getRotation(angle: Double) -> Double {
        let rotation = (offset / (UIScreen.main.bounds.width - 50)) * angle
        return rotation
    }

    private func endSwipeActions() {
        withAnimation(.none) {
            endSwipe = true
            // after the card is moved away removing the card from array to preserve the memory...

            // The delay time based on your animation duration...
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                if let _ = viewModel.displayingUsers?.first {
                    _ = withAnimation {
                        viewModel.displayingUsers?.removeFirst()
                    }
                }
            }
        }
    }

    private func leftSwipe() {
        // DO ACTIONS HERE
        print("Left Swiped")
    }

    private func rightSwipe() {
        // DO ACTIONS HERE
        print("Right Swiped")
    }

}

GitHub

View Github