Firebase Firestore Tutorial for iOS

Firebase is a powerful tool for app developers. It allows developers to focus in on app development without having to manage a lot of infrastructure like data storage on the cloud and user authentication, management and security.

Firestore stores data in collections, which are similar to database tables. You can have several collections in your database, in contrast to Realtime database which stored all the data in one big JSON tree, which made it difficult for complex data structuring. Each collection can have several documents. Documents have an identifier and contain many key-value pairs; the values of which can be strings, number, boolean, arrays of these, or sub-collections.

Using Firestore you can make complex queries and get filtered data by multiple where clauses. Firestore can also perform batch write operations.

Let's prepare account and create a project.

Open the Firebase Website and sign up for a free account. Then, log into your new account and go to your Firebase Dashboard.

Next, click Add Project. In the dialogs that appear, set a project name.

Finally, click Create Project. Then, in the screen that appears, choose Add Firebase to your iOS app.

Another dialog appears. Choose the following settings:

  • iOS Bundle ID: Input your app’s Bundle ID. You choose that when creating the Xcode project. It’s something like: me.proft.MyApp.
  • App Nickname: Anything, something like "My App"

Finally, click Register App. In the next screen that appears, click the big Download GoogleService-Info.plist button. You’ll now download a .plist file, so save it in a convenient location (like ~/Downloads).

Add the .plist file to your Xcode project. Drag-and-drop the file from Finder into Xcode, and add it to the Project Navigator. When a dialog appears, make sure to tick the checkbox for Copy items if needed, and tick the checkbox next to Target.

Finally, click Continue.

Install Firebase SDK

The easiest way to add third-party libraries to your project is with CocoaPods. CocoaPods is a package manager, and it helps you to manage your libraries, download new versions, and integrate them with Xcode.

Open the Podfile file, and add the following lines:

pod 'Firebase/Core'
pod 'Firebase/Firestore'
pod 'ObjectMapper', '~> 3.4'

Configure Firebase in AppDelegate. Start by making sure the Firebase module is imported.

import Firebase

Use the configure method in FirebaseApp inside the application:didFinishLaunchingWithOptions function to configure underlying Firebase services from your .plist file.

func application(_ application: UIApplication, 
    didFinishLaunchingWithOptions launchOptions: 
        [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    FirebaseApp.configure()
    return true
}

Connect Firebase Database. Open up your app in Firebase and click the Database option on the left hand panel. Next you’ll be presented with an option to use Cloud Firestore or Realtime database. For this article, we’re going to use Cloud Firestore. Once you’ve selected Cloud Firestore, you’ll be presented with Security rules dialog. We’re going to select to Start in test mode. This will allow us to quickly develop without worrying about setting up permissions. Click Enable, Firebase will then set up your Cloud Firestore.

Model.

import Foundation
import ObjectMapper
import Firebase

class Movie: Mappable, CustomStringConvertible {
    var title: String?
    var year: Int?

    public var description: String { return "Movie: title=\(title)" }

    init(title: String, year: Int) {
        self.title = title
        self.year = year
    }

    func mapping(map: Map) {
        title <- map["title"]
        year <- map["year"]
    }

    required init?(map: Map) { }
}

extension BaseMappable {
    init?(document: [String: Any]) {
        self.init(JSON: document)
    }
}

Save object.

Add a new document with a generated ID

import Firebase
...

let db = Firestore.firestore()

var ref: DocumentReference? = nil
ref = db.collection("items").addDocument(data: [
    "name": "item 1",
]) { err in
    if let err = err {
        print("Error adding document: \(err)")
    } else {
        print("Document added with ID: \(ref!.documentID)")
    }
}

Add a new document using ObjectMapper

let db = Firestore.firestore()
var ref: DocumentReference? = nil
let item = Movie(title: "The Movie", year: 2019)

ref = db.collection("movies").addDocument(data: item.toJSON()) { err in
    if let err = err {
        print("Error adding document: \(err)")
    } else {
        print("Document added with ID: \(ref!.documentID)")
    }
}

Add a new document with a specified ID

db.collection("items").document("1").setData([
    "name": "item 2",
])

Retrieving Data.

Read all documents from a collection

import Firebase
...

let db = Firestore.firestore()
db.collection("items").getDocuments() { (querySnapshot, err) in
    if let err = err {
        print("Error getting documents: \(err)")
    } else {
        for document in querySnapshot!.documents {
            print("\(document.documentID) => \(document.data())")
        }
    }
}

Read a particular document from a collection, by ID

db.collection("items").document("1")
    .getDocument { (document, error) in
        if let document = document, document.exists {
            let dataDescription = document.data().map(String.init(describing:)) ?? "nil"
            print("Document data: \(dataDescription)")
        } else {
            print("Document does not exist")
        }
    }

Using closure with status

// somewhere in ViewModel

func loadList(completion: @escaping (Bool, [Movie]) -> ()){
    var tasks = [Movie]()
    db.collection("movies").getDocuments() { (querySnapshot, err) in
        if let err = err {
            print("Error getting documents: \(err)")
            completion(false, tasks)
        } else {
            for document in querySnapshot!.documents {
                print("\(document.documentID) => \(document.data())")
            }
            completion(true, tasks)
        }
    }
}

// somewhere in Controller

func loadMovies() {
    viewModel.loadList(completion: { (status, items) in
        print(status)
    })
}

Add listener to specific reference

class ViewController: UITableViewController {
    @IBOutlet weak var addButton: UIBarButtonItem!

    public var movies: [Movie] = []
    private var listener : ListenerRegistration!

    fileprivate func baseQuery() -> Query {
        return Firestore.firestore().collection("movies").limit(to: 50)
    }

    fileprivate var query: Query? {
        didSet {
            if let listener = listener {
                listener.remove()
            }
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.query = baseQuery()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        self.listener.remove()
    }

    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)
        self.listener = query?.addSnapshotListener { (documents, error) in
            guard let snapshot = documents else {
                print("Error fetching documents results: \(error!)")
                return
            }

            let results = snapshot.documents.map { (document) -> Movie in
                if let movie = Movie(dictionary: document.data()) {
                    return movie
                } else {
                    fatalError("Unable to initialize type \(Movie.self) with dictionary \(document.data())")
                }
            }

            self.movies = results
            self.tableView.reloadData()             
        }
    }

    // TableView stuff
}

Filtering Data

You can perform simple and compound queries on the Firestore database. The query is equivalent to an SQL where clause. For example, the below code will return all documents that have the given field value as true.

db.collection("movies").whereField("year", isEqualTo: 2019)
    .getDocuments() { (querySnapshot, err) in
        if let err = err {
                print("Error getting documents: \(err)")
        } else {
                for document in querySnapshot!.documents {
                        print("\(document.documentID) => \(document.data())")
                }
        }
}

Order and limit

You can specify the order and limit of your result.

itemsRef.order(by: "title").limit(to: 3)

Save and retrieve data using model inherited from Codable

Model.

struct Movie: Codable, CustomStringConvertible {
    var title: String
    var year: Int

    public var description: String { return "Movie: title=\(title)" }

    init(title: String, year: Int) {
        self.title = title
        self.year = year
    }
}

extension Encodable {
    func getDictionary() -> [String: Any]? {
        let encoder = JSONEncoder()

        guard let data = try? encoder.encode(self) else { return nil }
        return (try? JSONSerialization.jsonObject(with: data, 
            options: .allowFragments)).flatMap { $0 as? [String: Any]}
    }
}

extension Decodable {
    init?(dictionary value: [String:Any]) {
        guard JSONSerialization.isValidJSONObject(value) else { return nil }
        guard let jsonData = try? JSONSerialization.data(withJSONObject: value, options: []) else { return nil }

        guard let newValue = try? JSONDecoder().decode(Self.self, from: jsonData) else { return nil }
        self = newValue
    }
}

Saving.

let db = Firestore.firestore()
var ref: DocumentReference? = nil
let item = Movie(title: "The Movie", year: 2019)

if let json = item.getDictionary() {
    ref = db.collection("movies").addDocument(data: json) { err in
        if let err = err {
            print("Error adding document: \(err)")
        } else {
            print("Document added with ID: \(ref!.documentID)")
        }
    }
}

Retrieving.

let db = Firestore.firestore()

db.collection("movies").getDocuments() { (querySnapshot, err) in
    if let err = err {
        print("Error getting documents: \(err)")
    } else {
        for document in querySnapshot!.documents {
            if (document.exists) {
                let movie = Movie(dictionary: document.data())
                print("Movie => \(movie)")
            }
            print("\(document.documentID) => \(document.data())")
        }
    }
}

Useful links