Getting started with Firebase on iOS: Part 2
This is the second part of the ‘Getting started with Firebase on iOS’ series. We are currently part way through creating an iOS app that mimics the functionality of the Slack app, LunchTrain. You can find the first part here.
In the first part, we covered authentication and basic Firestore setup. In this part, we will first start with some refactoring and testing of the existing codebase. Then we will implement the Train Details
screen, with the ability to see passengers, and also join a train yourself!
Refactoring
Currently, we have used a fairly basic MVVM architecture. This works well for this small scale project, though we have some points to improve.
Firstly, the table view in the train list access the view data, within the view model. This could be greatly improved by separating off the table view delegate methods into their own adapter class, allowing for as much business logic as possible to be outside of the View Controller.
Furthermore, given we are likely to use table views in other areas of the app, such as the passenger list, it is worth us using generics to design a adapter that can be reused.
This is the one we will use in the lunch train app. You can build on this to provide more of the delegate methods, depending on your requirements, but for now this implementation is enough for us.
In order to use this in the app, we need to adjust the view controller as below:
Again, the block within the didSelectItem
section doesn’t need to be implemented yet. We will be covering that in the next section. I will do other small refactors as we go 😊
Create a Train
Now we’re going to move onto adding new Trains from the app. For this, we will need to use some different methods for the Firestore, and implement a new UI.
We will start with the UI and structure first.
Create a NewTrain
folder, and add theNewTrainViewController.swift
and NewTrainViewModel.swift
files. We don’t need view data here as we can just reuse the Train
model.
You will note that I have added an option to handle an error when creating a new train. We will be looking at error cases and adding UI for these in a later post! For now, to the user, it will not crash but simply dismiss the screen:
Next, we want to hook this up to the button we have in the list view. This is fairly simple:
@objc func addPressed(sender:UITapGestureRecognizer) { performSegue(withIdentifier: "addTrain", sender: self)}override func prepare(for segue: UIStoryboardSegue, sender: Any?) { super.prepare(for: segue, sender: sender) guard let viewController = segue.destination as? NewTrainViewController else { return } viewController.viewModel = NewTrainViewModel()}
As you can see, we now have a dedicated screen for entering the train details, which automatically gets the user’s name and creates a new Train. If you open the Firebase console while creating this, you should see the collection automatically update, and the list will update with the new Train when you return to it.
Viewing Trains
Now we want the user to be able to select a train, and see the relevant details. For this again, create a new group, named TrainDetails
and add the files TrainDetailsViewController.swift
TrainDetailsViewModel.swift
and TrainDetailsViewData.swift
In the view data, we will have a reference to the Train
and also the DocumentReference
for the chosen train, in order to perform operations later.
Then in the view model for now, it will only contain the didChangeData
block, as we don’t need to do any network calls or data manipulation for now.
Again, feel free to customise your UI as you see fit. I have included a table view and a button ready for the passenger list and the ability to join a train.
So at this point, we have the ability to see the trains, and click into them to view more details. The join train button doesn’t currently work, so the passengers list isn’t functional at this point.
In order to implement this list, and the button, we want to add similar functionality to how we handled the list of trains, but just listen to a different collection of documents.
First off, we want to create a new data class to represent a user. This is really similar to the Train
model we made earlier.
For now, these users are going to be made per train. Later on we can look at improving this, so a user can see all the trains they have joined, and manage their trains more effectively.
Add the methods to the TrainDetailViewModel
as shown below, to observe the passengers
collection within the Train
model.
Next, just add the viewModel calls into the view lifecycle methods, to ensure no observers are left over when you leave the screen.
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.observeQuery()}override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) viewModel.stopObserving()}deinit { viewModel.stopObserving()}
Now you should be able to run the app, but nothing will happen! This is because there’s not actually any data in your passengers
collection yet, and there probably won’t be that value in the database either! So first we need to initialise each train with the list of passengers with just the owner inside.
Head over to your Firebase console, and delete all the trains you have in their, or add a passengers
field to each, otherwise we’re going to have issues later! Our guard statements keep us pretty safe, but in order to keep our database clean it’s a good idea to reset here, while we’re in active development.
If you choose to add to existing Trains, make sure you click Add Collection
rather than the Add Field
, as this then creates a new collection inside the train
document. Your console should then look similar to below:
Last, but definitely not least, we want to give our table view the data we get! Again, we’ll do this in the same way as in the list, with a table view adapter to handle the data. Handily, we made a generic adapter in our first part, so we can simply reuse this! Just make the below changes to the TrainDetailViewController
:
You’ll note that we have made a cell view this time, for now this is fine, as we only have the name field to show. We will customise this more in future parts 🎉
Now when you load up your app, if you’ve added a train with passengers, you should be able to click on this and see the list in the detail view.
If you haven’t yet, try adding some data into your trains, and see what happens. Does the app crash if the data isn’t the right type?
Joining a Train
The last main functionality we’re going to add in this part is the ability for a user to join a train. For this, we want to add a gesture recogniser to our button, and handle the tap. When this happens, we want to get the user’s name, and simply create a User, which can then be added to the passengers
collection. This should then automatically update the table view in the details.
In terms of UX, we probably want to add an activity indicator to replace the button whilst this is handled. And also the option to grey out the button if the user is already in the train. We’ll be adding the ability to leave a train later (again 🙃)!
First, add this code to our setup method:
let gesture = UITapGestureRecognizer(target: self, action: #selector(self.joinPressed))joinTrainButtonView.addGestureRecognizer(gesture)
Then we want to create the joinPressed
method:
@objc func joinPressed(sender: UITapGestureRecognizer) { //call a method on the view model!}
Now this is all tied up, we can implement the method on the viewModel. Add a public method joinTrain
as below:
fileprivate func baseQuery() -> CollectionReference? { return trainReference?.collection("passengers")}func joinTrain() { guard let query = query else { return } let user = Auth.auth().currentUser let name = user?.displayName ?? "" let passenger = User(name: name) let newPassengerReference = query.document() Firestore.firestore().runTransaction( { (transaction, errorPointer) -> Any in transaction.setData(passenger.dictionary, forDocument: newPassengerReference)}) { [weak self] (object, error) in guard let strongSelf = self else { return } if let error = error { print(error) strongSelf.joinTrainRequestCompleted?(false) return } strongSelf.joinTrainRequestCompleted?(true)
}}
You’ll note I’ve change the baseQuery method to return a CollectionReference
to allow use to more easily create new documents. You’ll need to change the baseQuery type to CollectionReference
too.
You’ll also notice that I’ve added a new callback, joinTrainRequestCompleted
. We can use this to handle stopping the activity indicator, and display an error if needed.
In your bindViewModel
method, add this:
Once you implement the new code for the detail view controller, you should get a similar result as the GIF on the left. Feel free to customise this as you wish.
You’ll notice that once we’ve joined a train, leave the view, and then return, the join train button is available again. In order to handle this, we need to cross check the list of passengers against the current user. Again, this will be simpler once we know which lunches a person is involved with, but for now this will work.
Add the following code to your view model. Here we are getting the current user’s name, and doing a simple check within the passengers list.
private func checkIfUserIsAPassenger() { viewData.isUserAPassenger = viewData.passengers.contains(User(name: getCurrentUserName()))}private func getCurrentUserName() -> String { let user = Auth.auth().currentUser return user?.displayName ?? "" }
Add a isUserAPassenger
boolean field defaulting to false
on the TripDetailViewData
struct. This allows us to simply check this value in the view controller, rather than having more complex callbacks or logic to deal with.
You’ll notice at this point that we will have build errors. This is because the compiler doesn’t know how to compare different User
objects. For this, we need to make User
conform to the Equatable
protocol.
Add this extension to your struct:
extension User: Equatable { static func ==(lhs: User, rhs: User) -> Bool { return lhs.name == rhs.name }}
Finally, we want to trigger this check. It makes the most sense to check this any time the data is updated, so within the didSet
of the viewData
.
var viewData: TrainDetailViewData {didSet {
checkIfUserIsAPassenger() }}
Because we want to update the view data after this check, we can move the didChangeData?(viewData)
call to within the check method. Simply add the call to the checkUserIsPassenger()
function.
Now we want to handle this. Within the didChangeData
block in the TrainDetailViewController
, add the code to grey out the button if the value is true.
if data.isUserAPassenger { strongSelf.disableButton()}
The disableButton
method contains the code we wrote earlier:
private func disableButton() { joinTrainButtonView.backgroundColor = UIColor.gray joinTrainButtonView.isUserInteractionEnabled = false}
Now we just need to call the check in the viewModel! Just add the function call within the response from the firestore, as shown:
self.viewData.passengers = modelsself.checkIfUserIsAPassenger()self.documents = snapshot.documents
This means anytime the data is updated, we will be checking the value. There are likely more efficient ways of doing this. Can you think of any? Perhaps within the mapping of the response from Firebase?
Now we have the main functionality of the app in place, we are in a good position to start implementing more than the MVP. In the next part, we will go through how to implement notifications, and for a user to be able to manage the trains they’ve joined. We ill also look into some database rules, to ensure that a user can’t join a train twice, for example.
Let me know any questions you may have in the comments, and as always feel free to ping me on Twitter!