Students will build a simple alarm app to practice intermediate table view features, protocols, the delegate pattern, CoreData, and UserNotifications.
Fork, then clone this repository. Change your working branch to Starter
.
Take a moment to look through the project and get familiar with the current state. Notice that the starter
branch includes an Xcode project with CoreData
already imported. Other points to note include: xcdatamodel
is blank, the project has been organized into a basic file structure, your info.plist
has been moved to Resources
and its file path updated, and the project's default ViewController
has been deleted.
Tip: If you don’t see anything in your project or file folder, you likely need to pull from the remote starter branch.
On the storyboard
file you should see two TableViewControllers
. The first TableViewController
has a custom cell with two labels, and a UI Switch. The labels have default text that signifies what data they will showcase. The Switch is set to On
and will be used to toggle if the alarm is active or not.
The second TableViewController has three static
sections. Within each cell, in each section, is a single view element. Section 0
has a UIDatePicker
that has been set to Date and Time. Section 1
has a TextField
that the user will use to give their alarm a title. Section 2
has a button that the user will use to toggle whether the alarm is active or not.
There are two separate segues - one from the (+) button and one from the cell.
It’s good practice to run the app here and see if it builds. You should see a tableview with a plus button. Pressing that (+) button should navigate you to the detail view.
Go back through the storyboard and write down what items are missing. Are the views subclassed? Are the segues or cells properly identified?
The .xcuserDataModel
is the true model for this application. It's here that we will define the properties. Create a new Entity named Alarm
and give it four properties. Add to Alarm
, in no particular order — fireDate, isEnabled, title, and UUIDString. What data type do you think matches these properties best?
- title: String value for the title attribute
- isEnabled: Bool value for the enabled attribute.
- fireDate: Date value for the fireDate attribute. This is when the alarm will trigger
- uuidString: A randomly generated unique identifier. We use this string to keep track of each Alarm `Object`
Define those properties in your .xcdatamodel
.
Create a swift
file named Alarm+convienience
. There are a few different types of initializers we can use to create our Alarm
objects, but there is not one that is perfect for it. To create an initializer that will allow us to pass in all the values we want, we define what’s called a convenience Initializer
.
Explore Initializers and Convenience Initializers in Apple's Developer Documentation.
Convenience initializers are written in the same style as normal designated (or member-wise) initializers; with the convenience
modifier placed before the init
keyword, separated by a space.
* `convenience init(){}`
Extend Alarm
and create your convenience initializer inside of the extension
- Import
CoreData
- Make sure the initializer has parameters for
title
,isEnabled,
fireDate
,uuidString
, andcontext
. Each parameter needs to take in the right data type. Note:context
will be of typeNSManagedObjectContext
- Define uuidString to have a default value of a
UUID
class initialized, specially theuuidString
property. Ex:UUID().uuidString
- Define
context
with a default value ofCoreDataStack.context
. - Inside the body of the initializer set your
Alarm
properties and call theNSManagedObject
convenience initializer. Pass incontext
from your own convenience initializer. Ex:self.init(context: context)
Create an AlarmController
model object controller that will manage and serve Alarm
objects to the rest of the application.
- Create an
AlarmController.swift
file and define a newAlarmController
class inside - Create a
sharedInstance
property and assign a value of anAlarmController
, initialized - Add a computed property,
alarms
, and set it to be an array ofAlarm
objects. Create a fetch request within the computed property andreturn
the results of the fetch request. - Create the following CRUD function signatures:
createAlarm(withTitle title: String, and fireDate: Date)
update(alarm: Alarm, newTitle: String, newFireDate: Date, isEnabled: Bool)
toggleIsEnabledFor(alarm: Alarm)
delete(alarm: Alarm)
saveToPersistentStore()
- Create should create an
Alarm
object and then call yoursaveToPersistentStore()
method - Update should update the passed in
Alarm
object with the new values that were also passed in. Should you save here? - toggleIsEnabledFor should simply flip the boolean status of the isEnabled property on an
Alarm
. Surely you should save this too. - Delete should access the context on the
CoreDataStack
and call the delete method. As always, let’s save that change. - saveToPersistentStore should access the context on the
CoreDataStack
and call the save method — or at least it shouldtry
to. Be sure tocatch
, as well
Build a custom table view cell to display an Alarm
object's data. The cell should display the title
, the fireDate
, and have a switch
to display and toggle whether the Alarm
is enabled or not.
- Add a new
cocoa touch class
file calledAlarmTableViewCell
as a subclass ofUITableViewCell
- Delete the awakeFromNib() and setSelected() functions
- Assign the new class to the prototype cell on the first
TableViewController
scene inMain.storyboard
- Create an IBOutlet for the
alarmTitleLabel
- Create an IBOutlet for the
alarmFireDateLabel
- Create an IBOutlet for the
Switch
, name itisEnabledSwitch
- Create an IBAction for the
Switch
namedisEnabledSwitchToggled
which you will implement using a custom protocol in the next step
- Add an
updateViews()
function that takes in a single parameter of typeAlarm
. - In the body of this function, assign the
Alarm
properties to their corresponding outlets.alarmTitleLabel.text =
alarmFireDateLabel.text =
isEnabledSwitch.isOn
The alarmFireDateLabel.text
can only accept String
but we only really have a Date
value to give. Take a moment and do a search online for how to convert a Date
to a String
.
Wouldn’t it be cool if we could override how the Date
struct works and give it the ability to convert to a String
automatically? Because we are iOS Developers and basically Jedi - we can do many things that others may consider to be … unnatural.
Rather than writing all the logic required to convert a String
from Date
here in the updateViews
function, we can clean up our code and explore extensions even more in-depth. Comment out or delete the code you wrote assigning a value to the alarmFireDateLabel.text
1. Create a new `.swift` file named `DateHelper`
2. Extend the `Date` struct
3. Declare a function called `stringValue` and return a `String`
4. Within the `stringValue` function initialize a `DateFormatter` class named `formatter`
5. Set the `dateStyle` to your preferred style. I prefer `.medium`
6. Set the `timeStyle` to your preferred style. I prefer `.medium`
7. Return the `formatter.string` from self.
func stringValue() -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter.string(from: self)
}
Navigate back to your custom cell and set the alarmFireDateLabel.text
value to alarm.fireDate!.stringValue()
. We can be confident in FORCE UNWRAPPING
the fireDate
because a Alarm
object cannot be initialized without one.
How cool is that?
In this next section, you will write a protocol
for the AlarmTableViewCell
to delegate handling a switch toggle to the AlarmListTableViewController
, which we have not created yet, adopt the protocol, and use the delegate method to update the isEnabled
and reload the cell.
- Declare a protocol named
AlarmTableViewCellDelegate
at the top of yourAlarmTableViewCell
.swift file- Remember, Protocols are declared above the class
- In the body of your protocol, define a
alarmWasToggled(sender: AlarmTableViewCell)
function- (Keep in mind, you only declare a function signature in the protocol, no function body allowed here)
- Declare the protocol can interact with class level objects
: class
- Add a weak optional delegate property on the AlarmTableViewCell
- Hint: remember to make this a weak var*
- Call the delegates protocol function in the
isEnabledSwitchToggled
IBAction
Lets wire up our first TableViewController
- Add a new
cocoa touch class
file calledAlarmListTableViewController
as a subclass ofUITableViewController
- Add a new
cocoa touch class
file calledAlarmDetailTableViewController
as a subclass ofUITableViewController
- Recall that the first
TableViewController
has no subclass. Subclass it now with thelistVC
- Recall that the second
TableViewController
has no subclass. Subclass it now with thedetailVC
- Remove any unneeded boilerplate code
- Implement the
UITableViewDatasource
functions using thealarms
source of truth - In the
cellForRow(at:)
optionally type cast the cell to be your custom cell.- Set the cell's identifier here and on the
storyboard
- Using the cell's
row
property on theindexPath
pull thealarm
from the source of truth - Call the
updateViews
function on the cell and pass in thealarm
- Set the cell's identifier here and on the
We now need to set the AlarmListTableViewController
to be the delegate for the protocol we declared on the AlarmTableViewCell
file.
- Extend the
AlarmListTableViewController
to adopt theAlarmTableViewCellDelegate
protocol.- Add required protocol stubs
- Get the
indexPath
for the sender - Using the
row
property of theindexPath
you just created, pull out the correspondingalarm
object - Call the
toggleIsEnabledFor(alarm: Alarm)
function from your Model Controller - Call the
updateViews(with:)
function on the sender - Navigate back to the
cellForRow(at:)
and assign your delegate - Rejoice for your protocol and delegate are complete.
Fill in the prepare(for segue: UIStoryboardSegue, sender: Any?)
function on the AlarmListTableViewController
to properly prepare the next view controller for the segue.
- Identify what segue was triggered
- If you have not already, now would be a great time to set the segue identifier on the
storyboard
- If you have not already, now would be a great time to set the segue identifier on the
- Identify what the
indexPath
is for the cell that triggered this segue. Be sure to properly guard against a nil value. - Optionally type cast the
destination
of the segue to theAlarmDetailViewController
. Be sure to properly guard against this failing - Using the
row
property of theindexPath
you created above, pull out the correspondingAlarm
object - Assign this
alarm
to thealarm
receiver on theAlarmDetailViewController
- This has not been created yet. Navigate to the
AlarmDetailViewController
and declare an optionalAlarm
object.
- This has not been created yet. Navigate to the
All we have left to do now is the delete method. Please implement that now.
Let’s wire up our second TableViewController
. This should already be subclassed because of an earlier step, if it’s not, please do so now.
- If you have not already, declare your receiver
- Declare an optional
Alarm
object.
- Declare an optional
- Remove any unneeded boilerplate code
- We need a way to keep track of whether the alarm is enabled or not. Declare a Bool named
isAlarmOn
and set the default value totrue
. - Create an IBOutlet for the
alarmFireDatePicker
- Create an IBOutlet for the
alarmTitleTextField
- Create an IBOutlet for the
Button
, name italarmIsEnabledButton
- Create an IBAction for the on
Button
, name italarmIsEnabledButtonTapped
- Create an IBAction for the save
Button
, name itsaveButtonTapped
- Create the following helper method signatures:
updateView()
designIsEnabledButton()
Lets start with updateViews()
- updateView needs to guard against the
alarm
receiver not having a value. - With the unwrapped
alarm
we can set the proper values to thealarmFireDatePicker
andalarmTitleTextField
. - Update the
isAlarmOn
Bool to the value of theisEnabled
property on thealarm
- Call your
designIsEnabledButton()
here
Lets finish up the designIsEnabledButton
function
- Write a
switch
statement on theisAlarmOn
property with two casestrue
andfalse
- For the
true
case set thebackgroundColor
of thealarmIsEnabledButton
to a color of your choosing. I like.white
- Set the title of the
alarmIsEnabledButton
to a string of your choosing. I like “Enabled” - For the
false
case set thebackgroundColor
of thealarmIsEnabledButton
to a color of your choosing. I like.darkGray
- Set the title of the
alarmIsEnabledButton
to a string of your choosing. I like “Disabled”
- For the
func designIsEnabledButton() {
switch isAlarmOn {
case true:
alarmIsEnabledButton.backgroundColor = .white
alarmIsEnabledButton.setTitle("Enabled", for: .normal)
case false:
alarmIsEnabledButton.backgroundColor = .darkGray
alarmIsEnabledButton.setTitle("Disabled", for: .normal)
}
}
- Unwrap the
text
from thealarmTitleTextField
, and check to make sure the value is not an empty string. - Conditionally unwrap the
alarm
receiver- If the
receiver
has a valid value, call yourupdate(alarm:
function from yoursharedInstance
- If the
reciever
does not have a valid value, call yourcreateAlarm(withTitle)
function from yoursharedInstance
- If the
- Pop this ViewController off the view stack
- Conditionally unwrap the
alarm
receiver- If the
receiver
has a valid value call yourtoggleIsEnabledFor
function from yoursharedInstance
- Set the
isAlarmOn
property to the value of theisEnabled
property of thealarm
- Set the
- If the
receiver
does not have a valid value call set theisAlarmOn
property to the opposite value - Call your
designIsEnabledButton
function outside of the conditional unwrap but within the @IBAction
- If the
Build and run the application. Check for bugs. At this point you should have a solid working application. The alarms should be able to be created, updated, and persist across app launches. The final task is to implement User Notifications to alert the user when their alarm triggers.
Register for local notifications when the app launches.
- In the
AppDelegate.swift
file, adopt theUNUserNotificationCenterDelegate
protocol. - Then, in the
application(_:didFinishLaunchingWithOptions:)
function, request notification authorization on an instance ofUNUserNotificationCenter
.
- note: See UserNotifications Documentation for furthur instruction: https://developer.apple.com/documentation/usernotifications/asking_permission_to_use_notifications
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (accepted, error) in
if !accepted{
print("Notification access has been denied")
}
}
UNUserNotificationCenter.current().delegate = self
You will need to schedule local notifications each time you enable an alarm and cancel local notifications each time you disable an alarm. Seeing as you can enable/disable an alarm from both the list and detail view, we normally would need to write a scheduleUserNotifications(for alarm: Alarm)
function and a cancelUserNotifications(for alarm: Alarm)
function on both of our view controllers. However, using a custom protocol and a protocol extension, we can write those functions only once and use them in each of our view controllers as if we had written them in each view controller.
You will need to heavily reference Apple's documentation on UserNotifications: https://developer.apple.com/documentation/usernotifications
- Create a new
.swift
file namedAlarmScheduler
- Declare a
protocol AlarmScheduler
. This protocol will need two functions:scheduleUserNotifications(for alarm:)
andcancelUserNotifications(for alarm: Alarm)
. - Below your protocol, create a protocol extension,
extension AlarmScheduler
. In there, you can create default implementations for the two protocol functions. - Your
scheduleUserNotifications(for alarm: Alarm)
function should create an instance ofUNMutableNotificationContent
and then give that instance a title and body. You can also give that instance a default sound to use when the notification goes off usingUNNotificationSound.default()
. - After you create your
UNMutableNotificationContent
, create an instance ofUNCalendarNotificationTrigger
. In order to do this you will need to createDateComponents
using thefireDate
of youralarm
.
-
note: Use the
current
property of theCalendar
class to call a method which returns dateComponents from a date. -
note: Be sure to set
repeats
in theUNCalendarNotificationTrigger
initializer totrue
so that the alarm will repeat daily at the specified time.
- Now that you have
UNMutableNotificationContent
and aUNCalendarNotificationTrigger
, you can initialize aUNNotificationRequest
and add the request to the notification center object of your app.
- note: In order to initialize a
UNNotificationRequest
you will need a unique identifier. If you want to schedule multiple requests (which we do with this app) then you need a different identifier for each request. Thus, use theuuidString
property on yourAlarm
object as the identifier.
- Your
cancelLocalnotification(for alarm: Alarm)
function simply needs to remove pending notification requests using theuuid
property on theAlarm
object you pass into the function.
- note: Look at documentation for
UNUserNotificationCenter
and see if there are any functions that will help you do this. https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/1649517-removependingnotificationrequest
- Navigate back to your model controller and conform your
AlarmController
Class to theAlarmScheduler
protocol. Notice how the compiler does not make you implement the schedule and cancel functions from the protocol? This is because by adding an extension to the protocol, we have created default implementation of these functions for all classes that conform to the protocol. - Go through each of the CRUD functions and schedule / cancel the User Notifications based on the needs of that method.
- When an alarm is created it will need the alert scheduled.
- When an alarm is updated, cancel the first alert and then schedule the new one after the update is applied.
- When the alarm is enabled and disabled, handle the notifications accordingly
- When an alarm is deleted, we need to cancel the alert
The last thing you need to do is set up your app to notify the user when an alarm goes off and they still have the app open. In order to do this we are going to use the UNUserNotificationCenterDelegate
protocol.
- In your
application(_:didFinishLaunchingWithOptions:)
function, set the delegate of the notification center to equalself
.
- note:
UNUserNotificationCenter.current().delegate = self
- Then call the delegate method
userNotificationCenter(_:willPresent:withCompletionHandler:)
and use thecompletionHandler
to set yourUNNotificationPresentationOptions
.
- note:
completionHandler([.alert, .sound])
Build and run the app. Check for bugs. At this time, you should have a full working application that used a Protocol and Delegate, a custom Protocol, and uses local alerts. Well done!
Please refer to CONTRIBUTING.md.
© DevMountain LLC, 2019- 2020. Unauthorized use and/or duplication of this material without express and written permission from DevMountain, LLC is strictly prohibited. Excerpts and links may be used, provided that full and clear credit is given to DevMountain with appropriate and specific direction to the original content.