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
}
}
