Learn step by step how to create a SwiftUI Firebase Booklist, SwiftUI and Combine, This is the MVVM architecture
https://github.com/firebase/firebase-ios-sdk.git
https://github.com/SDWebImage/SDWebImageSwiftUI.git
ContentView.swift
// // ContentView.swift // DevSwiftUI // // Created by Cairocoders // import SwiftUI import Firebase import SDWebImageSwiftUI struct ContentView: View { @StateObject var viewModel = BooksViewModel() @State var presentAddBookSheet = false private func bookRowView(book: Book) -> some View { NavigationLink(destination: BookDetailsView(book: book)) { HStack { AnimatedImage(url: URL(string: book.image)!).resizable().frame(width: 70, height: 110) .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 10)) .shadow(color: .gray, radius: 10, x: 5, y: 5) .padding() VStack(alignment: .leading) { Text(book.title).font(.headline) Text("by " + book.author).font(.subheadline).foregroundColor(.gray) Spacer().frame(height: 15) Text("$" + String(book.price)).font(.title) } } } } var body: some View { NavigationView { List { ForEach (viewModel.books) { book in bookRowView(book: book) } } .navigationBarTitle("Book List") .onAppear() { print("BooksListView appears. Subscribing to data updates.") self.viewModel.subscribe() } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }Book.swift
// // Book.swift // DevSwiftUI // // Created by Cairocoders // import Foundation import FirebaseFirestoreSwift import Combine struct Book: Identifiable, Codable { @DocumentID var id: String? var title: String var author: String var numberOfPages: Int var image: String var price: Double var description: String var isAvailable: Bool enum CodingKeys: String, CodingKey { case id case title case author case numberOfPages = "pages" case image case price case description case isAvailable } } struct Order: Identifiable, Codable { @DocumentID var id: String? var username: String var title: String var price: Double var image: String }BookViewModel.swift
// // BookViewModel.swift // DevSwiftUI // // Created by Cairocoders // import Foundation import Combine import FirebaseFirestore class BookViewModel: ObservableObject { @Published var book: Book @Published var modified = false private var cancellables = Set<AnyCancellable>() init(book: Book = Book(title: "", author: "", numberOfPages: 0, image: "", price: 0, description: "", isAvailable: false)) { self.book = book self.$book .dropFirst() .sink { [weak self] book in self?.modified = true } .store(in: &self.cancellables) } }BooksViewModel.swift
// // BooksViewModel.swift // DevSwiftUI // // Created by Cairocoders // import Foundation import Combine import FirebaseFirestore class BooksViewModel: ObservableObject { @Published var books = [Book]() private var db = Firestore.firestore() private var listenerRegistration: ListenerRegistration? deinit { unsubscribe() } func unsubscribe() { if listenerRegistration != nil { listenerRegistration?.remove() listenerRegistration = nil } } func subscribe() { if listenerRegistration == nil { listenerRegistration = db.collection("books").addSnapshotListener { (querySnapshot, error) in guard let documents = querySnapshot?.documents else { print("No documents") return } self.books = documents.compactMap { queryDocumentSnapshot in try? queryDocumentSnapshot.data(as: Book.self) } } } } func removeBooks(atOffsets indexSet: IndexSet) { let books = indexSet.lazy.map { self.books[$0] } books.forEach { book in if let documentId = book.id { db.collection("books").document(documentId).delete { error in if let error = error { print("Unable to remove document: \(error.localizedDescription)") } } } } } }BookDetailsView.swift
// // BookDetailsView.swift // DevSwiftUI // // Created by Cairocoders // import SwiftUI import SDWebImageSwiftUI struct BookDetailsView: View { @ObservedObject var viewModel = BookViewModel() @ObservedObject var viewModelOrder = OrderViewModel() @State private var showModal = false @State private var showAlert = false @State private var showModalbookreading = false var book: Book var body: some View { VStack { AnimatedImage(url: URL(string: book.image)!) .resizable() .frame(width: 180, height: 280) .clipShape(RoundedRectangle(cornerRadius: 10)) .shadow(color: .gray, radius: 10, x: 5, y: 5) Spacer() .frame(height: 30) Text(book.author) .foregroundColor(.gray) Text(book.title) .font(.system(size: 24, weight: .semibold)) .padding([.leading, .trailing], 20) Spacer() .frame(height: 20) Text(book.description) .lineLimit(4) .padding([.leading, .trailing], 20) .lineSpacing(6) .foregroundColor(.gray) Spacer() .frame(height: 20) Divider() .padding(.bottom, 30) .padding([.leading, .trailing], 20) if book.isAvailable { // Read button Button(action: { self.showModalbookreading = true }) { HStack { Text("Read") .fontWeight(.semibold) } .frame(width: 200) .padding() .foregroundColor(.white) .background(Color.green) .cornerRadius(40) } .sheet(isPresented: self.$showModalbookreading) { BookReadingView(titleBook: book.title) } }else{ // Add button Button(action: { self.showAlert = true self.handleOrderTapped() }) { HStack { Text("Buy for " + String(book.price) + "$") .fontWeight(.semibold) } .frame(width: 200) .padding() .foregroundColor(.white) .background(Color.black) .cornerRadius(40) } .alert(isPresented: $showAlert) { Alert(title: Text("Book added to cart"), message: Text("You're ready to proceed to checkout and complete your order."), dismissButton: .default(Text("Done"))) } } } // NavBar item - Checkout button .navigationBarItems(trailing: Button(action: { self.showModal = true }) { CartButtonView(numberOfItems: total()) }.sheet(isPresented: self.$showModal, onDismiss: { }) { CartView(showModal: self.$showModal) }) .navigationBarTitle(Text(""), displayMode: .inline) .onAppear() { print("BookDetailsView.onAppear() for \(self.book.title)") } .onDisappear() { print("BookDetailsView.onDisappear()") } } func handleOrderTapped() { self.viewModelOrder.handleOrderTapped(username: "Cairocoders", title: book.title, price: book.price, image: book.image) } func total() -> Int { let totalprice = 3 return totalprice } } struct BookDetailsView_Previews: PreviewProvider { static var previews: some View { let book = Book(title: "Coder", author: "Cairocoders", numberOfPages: 23, image: "photo1", price: 12.5, description: "Book Description", isAvailable: false) return NavigationView { BookDetailsView(book: book) } } }CartButtonView.swift
// // CartButtonView.swift // DevSwiftUI // // Created by Cairocoders // import SwiftUI struct CartButtonView: View { var numberOfItems: Int var body: some View { VStack { Image(systemName: "cart") .resizable() .scaledToFit() .frame(width: 32, height: 32, alignment: .center) .foregroundColor(.green) .overlay(ImageOverlay(numberOfItems: numberOfItems), alignment: .center) Spacer() } } struct ImageOverlay: View { var numberOfItems: Int var body: some View { ZStack { Text(String(numberOfItems)) .font(.system(size: 22)) .fontWeight(.bold) .foregroundColor(.black) .padding(5) } } } } struct CartButtonView_Previews: PreviewProvider { static var previews: some View { CartButtonView(numberOfItems: 3) } }CartView.swift
// // CartView.swift // DevSwiftUI // // Created by Cairocoders // import SwiftUI struct CartView: View { @Binding var showModal: Bool @State private var showingAlert = false @StateObject var viewModel = OrdersViewModel() var body: some View { ScrollView(.vertical) { VStack { // Dismiss button HStack() { Image(systemName: "multiply") //Close .resizable() .frame(width: 25, height: 25) .padding(20) Spacer() } // Title VStack { Text("Your bag") .font(.system(size: 34)) .fontWeight(.bold) Text(String(viewModel.orders.count) + " items") .font(.system(size: 18)) .foregroundColor(.gray) } // Item list VStack(alignment: .leading) { ForEach (viewModel.orders) { items in CartRow(title: items.title , image: items.image, price: Int(items.price)) } } .onAppear() { print("Order appears.") self.viewModel.subscribe() } Spacer().frame(height: 20) // Summary HStack { VStack { Image(systemName: "shippingbox") .resizable() .frame(width: 32, height: 32) .padding(.bottom, -8) Text("FREE") .font(.system(size: 16)) .fontWeight(.bold) .padding(.bottom, 5) }.frame(width: 64, height: 64) .background(Color.gray.opacity(0.4)) .cornerRadius(15) Spacer().frame(width: 40) VStack(alignment: .leading) { Text("Total:") .font(.system(size: 18)) .foregroundColor(.gray) Text("$" + "25") .font(.system(size: 34)) .fontWeight(.bold) } Spacer().frame(width: 80) } // Checkout button Divider().padding() Button(action: { self.showingAlert = true }) { HStack { Text("Checkout") .font(.system(size: 18)) .fontWeight(.bold) } .frame(width: 200) .padding() .foregroundColor(.white) .background(Color.yellow) .cornerRadius(40) } .alert(isPresented: $showingAlert) { Alert(title: Text("Order confirmed"), message: Text("Thank you for your purchase."), dismissButton: .default(Text("Done")) { self.showModal.toggle() }) } } } } }CartRow.swift
// // CartRow.swift // DevSwiftUI // // Created by Cairocoders // import SwiftUI import SDWebImageSwiftUI struct CartRow: View { var id = "" var title = "" var image = "" var price = 0 @State var presentActionSheet = false var body: some View { HStack { AnimatedImage(url: URL(string: image)!) .resizable() .frame(width: 80, height: 100) .clipShape(RoundedRectangle(cornerRadius: 10)) .shadow(color: .gray, radius: 10, x: 5, y: 5) .padding() VStack(alignment: .leading) { Text(title) Spacer().frame(height: 15) Text("$" + String(price)).font(.system(size: 18)).bold() }.padding([.top, .bottom]) .frame(width: 150) Text("x" + String("1")) .fontWeight(.semibold) .padding(10) .overlay( RoundedRectangle(cornerRadius: 20) .stroke(Color.gray, lineWidth: 1) ) .padding(.leading, 20) } .actionSheet(isPresented: $presentActionSheet) { ActionSheet(title: Text("Are you sure?"), buttons: [ .destructive(Text("Delete book"), action: { }), .cancel() ]) } } } struct CartRow_Previews: PreviewProvider { static var previews: some View { CartRow(title: "Cairocoders", image: "hello", price: 55) } }OrderModel.swift
// // OrderModel.swift // DevSwiftUI // // Created by Cairocoders // import Foundation import Combine import FirebaseFirestore class OrderViewModel: ObservableObject { @Published var order: Order @Published var modified = false private var cancellables = Set<AnyCancellable>() init(order: Order = Order(username: "", title: "", price: 0, image: "")) { self.order = order self.$order .dropFirst() .sink { [weak self] order in self?.modified = true } .store(in: &self.cancellables) } private var db = Firestore.firestore() func handleOrderTapped(username: String, title: String, price: Double, image: String) { db.collection("order").addDocument(data: ["username": username, "title":title, "price":price, "image": image]) { (err) in if err != nil { print((err?.localizedDescription) ?? "Error") return } } } }BookReadingView.swift
// // BookReadingView.swift // DevSwiftUI // // Created by Cairocoders // import SwiftUI struct BookReadingView: View { var titleBook: String var body: some View { VStack { Text(titleBook) .font(.largeTitle) Divider() .padding(.top, 1) .padding([.leading, .trailing], 20) .padding(.bottom, 20) Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent gravida elit vitae quam consequat ullamcorper. Vestibulum turpis est, congue ut posuere et, sollicitudin ac neque. Fusce nec tellus arcu. Nunc consequat lacus et dui vestibulum maximus. In lobortis nibh a facilisis aliquam. Proin eget ornare urna. Aliquam at malesuada massa, vitae accumsan eros. Vestibulum nec dui scelerisque enim rhoncus suscipit. Fusce a magna odio. Vestibulum egestas in tellus a porttitor. Nunc pellentesque, augue eu dignissim ullamcorper, magna eros semper nulla, at rutrum lorem est ut lectus. Nunc id libero lectus. Nunc egestas velit eu quam finibus, et convallis ipsum volutpat. Praesent sodales ultricies nunc, vel molestie quam ornare in. Pellentesque nec commodo est, in commodo nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.") .padding(30) } } } struct BookReadingView_Previews: PreviewProvider { static var previews: some View { BookReadingView(titleBook:"The two towers") } }OrderViewModel.swift
// // OrderViewModel.swift // DevSwiftUI // // Created by Cairocoders // import Foundation import Combine import FirebaseFirestore class OrdersViewModel: ObservableObject { @Published var orders = [Order]() private var db = Firestore.firestore() private var listenerRegistration: ListenerRegistration? deinit { unsubscribe() } func unsubscribe() { if listenerRegistration != nil { listenerRegistration?.remove() listenerRegistration = nil } } func subscribe() { if listenerRegistration == nil { listenerRegistration = db.collection("order").addSnapshotListener { (querySnapshot, error) in guard let documents = querySnapshot?.documents else { print("No documents") return } self.orders = documents.compactMap { queryDocumentSnapshot in try? queryDocumentSnapshot.data(as: Order.self) } } } } func removeOrders(atOffsets indexSet: IndexSet) { let orders = indexSet.lazy.map { self.orders[$0] } orders.forEach { order in if let documentId = order.id { db.collection("order").document(documentId).delete { error in if let error = error { print("Unable to remove document: \(error.localizedDescription)") } } } } } }DevSwiftUIApp.swift
// // DevSwiftUIApp.swift // DevSwiftUI // // Created by Cairocoders // import SwiftUI import Firebase @main struct DevSwiftUIApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { WindowGroup { ContentView() } } } class AppDelegate: NSObject,UIApplicationDelegate{ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { FirebaseApp.configure() return true } }