Students will build a simple alarm app to practice intermediate table view features, protocols, the delegate pattern, NSCoding, UILocalNotifications, and UIAlertControllers.
Students who complete this project independently are able to:
- Implement a master-detail interface
- Implement the
UITableViewDataSource
protocol - Implement a static
UITableView
- Create a custom
UITableViewCell
- Write a custom delegate protocol
- Wire up view controllers to model object controllers
- Work with
NSDate
andNSDateComponents
- Add staged data to a model object controller
- Create model objects that conform to the
NSCoding
protocol - Create model object controllers that use
NSKeyedArchiver
andNSKeyedUnarchiver
for data persistence - Present and respond to user input from
UIAlertController
s - Schedule and cancel
UILocalNotification
s - Create custom protocols
- Implement protocol functions using protocol extensions to define protcol function behavior across all conforming types
Set up a basic List-Detail view hierarchy using a UITableViewController for a AlarmListTableViewController and a AlarmDetailTableViewController. Use the provided screenshots as a reference.
- Add a
UITableViewController
scene that will be used to list alarms - Embed the scene in a
UINavigationController
- Add an Add system bar button item to the navigation bar
- Add a class file
AlarmListTableViewController.swift
and assign the scene in the Storyboard - Add a
UITableViewController
scene that will be used to add and view alarms- note: We will use a static table view for our Alarm Detail view, static table views should be used sparingly, but they can be useful for a table view that will never change, such as a basic form. You can make a table view static by selecting the table view on the
UITableViewController
, going to the Attribute Inspector, and changing the content dropdown from Dynamic Prototypes to Static Cells.
- note: We will use a static table view for our Alarm Detail view, static table views should be used sparingly, but they can be useful for a table view that will never change, such as a basic form. You can make a table view static by selecting the table view on the
- Add a show segue from the Add button from the first scene to the second scene.
- Add a show segue from the prototype cell form the first scene to the second scene.
- Add a class file
AlarmDetailTableViewController.swift
and assign the scene in the Storyboard
Build a custom table view cell to display alarms. The cell should display the alarm time and name and have a switch that will toggle whether or not the alarm is enabled.
It is best practice to make table view cells reusable between apps. As a result, you will build a SwitchTableViewCell
rather than an AlarmTableViewCell
that can be reused any time you want a cell with a switch. You will create outlets and actions from Interface Builder in this custom cell, and create an alarm
property with a didSet
observer used to populate the cell with details about the alarm.
- Add a new
SwitchTableViewCell.swift
as a subclass of UITableViewCell. - Configure the prototype cell in the Alarm List Scene in
Main.storyboard
to be an instance ofSwitchTableViewCell
- Design the prototype cell as shown in the screenshots: two labels, one above the other, with a switch to the right.
- note: Stack views are great. Think about using a horizontal stack view that has a vertical stack view inside of it, and a switch inside of it. Then the vertical stack view will have two labels in it
- Create an IBOutlet to the custom cell file for the label named
timeLabel
. - Create an IBOutlet to the custom cell file for the label named
nameLabel
. - Create an IBOutlet to the custom cell file for the switch named
alarmSwitch
. - Create an IBAction for the switch named
switchValueChanged
which you will implement using a custom protocol later in these instructions.
Build a static table view as the detail view for creating and editing alarms.
- Static table views do not need to have UITableViewDataSource functions implemented. Instead, you can create outlets and actions from your prototype cells directly onto the view controller (in this case
AlarmDetailTableViewController
) as you would with other types of views. - If you haven't already, go to your Storyboard, select your detail table view and in the Attribute Inspector change the style to grouped and the sections to 3.
- In section 1, drag a date picker onto the prototype cell and add proper constraints.
- In section 2, drag a text field onto the prototype cell and add the proper constraints and placeholder text.
- In section 3, drag a button onto the prototype cell and add the proper constraints and title. This button will be used to enable/disable existing alarms.
- Create IBOutlets for the three items listed above, and create and IBAction for the button titled
enableButtonTapped
. - If you haven't already, add a bar button item to the right side of the navigation bar, change it to system item Save in the Attribute Inspector, and create an IBAction called
saveButtonTapped
.- You will need to add a Navigation Item to the Navigation Bar before you can add the bar button.
You have been given a file called Alarm.swift
that contains your Alarm model object. This model object makes extensive use of NSDates and NSDateComponents. Although we did not make you create this class from scratch, we expect you to understand why it was made and how each line of code works.
Create an Alarm model class that will hold a fireTimeFromMidnight, name, an enabled property, and computed properties for fireDate and fireTimeAsString. The fireTimeFromMidnight property will store the time of day that the alarm should go off. The fireDate property will be used in part 2 of this project to schedule notifications to the user for the alarm, and the fireTimeAsString will be used to display the time of day that the alarm should go off.
- Your Alarm class will keep track of the time each day that the alarm should file, the name of the alarm, and whether or not the alarm is enabled.
fireTimeFromMidnight
stores an NSTimeInterval, which represents the number of seconds from midnight.name
is simply aString
representing the name, andenabled
is aBool
that we will set to true if the alarm is enabled and false otherwise. - A UUID is a Universally Unique Identifier. The
uuid
on the Alarm object will be used later to schedule and cancel local notifications - Add properties for
fireTimeFromMidnight
as anNSTimeInterval
,name
as aString
,enabled
as aBool
, anduuid
as aString
. A UUID is a Universally Unique Identifier. This will be used in part two of this project for identifying, scheduling, and canceling local notifications. - The computed property for
fireDate
is a computed property that each day will compute theNSDate
that should be used to schedule a local notification for that alarm. fireTimeAsString
will be used to represent the time you want the alarm to fire. This is simply for the UI.
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. - Add an
alarms
array property with an empty array as a default value. - Create an
addAlarm(fireTimeFromMidnight: TimeInterval, name: String)
function that creates an alarm, adds it to thealarms
array, and returns the alarm. - Create an
update(alarm: Alarm, fireTimeFromMidnight: TimeInterval, name: String)
function that updates an existing alarm's fire time and name. - Create a
delete(alarm: Alarm)
function that removes the alarm from thealarms
array- note: There is no 'removeObject' function on arrays. You will need to find the index of the object and then remove the object at that index. Refer to documentation if you need to know how to find the index of an object.
- note: If you face a compiler error, you may need to check that you have properly implented the Equatable protocol for
Alarm
objects
- Create a static
shared
property which stores a shared instance.- note: Review the syntax for creating shared instance properties
Add mock alarm data to the AlarmController. Once there is mock data, teams can serialize work, with some working on the views with visible data and others working on implementing the controller logic. This is a quick way to get objects visible so you can begin building the views.
There are many ways to add mock data to model object controllers. We will do so using a computed property.
- Create a
mockAlarms:[Alarm]
computed property that holds a number of stagedAlarm
objects- Initialize a small number of
Alarm
objects to return with varying properties
- Initialize a small number of
- When you want mock data, set self.alarms to self.mockAlarms in the initializer. Remove it when you no longer want mock data.
- note: If you have not added an initializer, add one.
Wire up the Alarm List Table View and implement the property observer pattern on the SwitchTableViewCell
class.
Fill in the table view data source functions required to display the view.
- Add a property
var alarm: Alarm?
to yourSwitchTableViewCell
class. - Add a
didSet
observer that updates the labels to the time and name of the alarm, and updates thealarmSwitch.on
property so that the switch reflects the proper alarmenabled
state. - On your
AlarmListTableViewController
fill in the two requiredUITableViewDataSource
functions, using thealarms
array fromAlarmController.sharedInstance
. In thecellForRowAtIndexPath
data source function you will need to cast your dequeued cell as aSwitchTableViewCell
and set the cell'salarm
property, being sure it pass it the right alarm from thealarms
array fromAlarmController.sharedInstance
. - Implement the
UITableViewDataSource
tableView(_:, commit:, forRowAt:)
method to enable swipe-to-delete. Be sure to call the appropriateAlarmController
method before deleting the row.- At this point you should be able to run your project and see your table view populated with your mock alarms, displaying the proper switch state. You should also be able to delete rows, and segue to a detail view (this detail view won't actually display an alarm yet, but the segue should still occur). Also note that you can toggle the switch, but that the
enabled
property on the model object the cell is displaying isn't actually changing.
- At this point you should be able to run your project and see your table view populated with your mock alarms, displaying the proper switch state. You should also be able to delete rows, and segue to a detail view (this detail view won't actually display an alarm yet, but the segue should still occur). Also note that you can toggle the switch, but that the
Write a protocol for the SwitchTableViewCell
to delegate handling a toggle of the switch to the AlarmListTableViewController
, adopt the protocol, and use the delegate function to mark the alarm as enabled or disabled, and reload the cell.
- Add a protocol named
SwitchTableViewCellDelegate
to the top of theSwitchTableViewCell
class file - Define a
switchCellSwitchValueChanged(cell: SwitchTableViewCell)
function - Add a weak, optional delegate property on the SwitchTableViewCell, require the delegate to have adopted the delegate protocol
- note:
weak var delegate: ButtonTableViewCellDelegate?
- note: If the compiler throws an error, it is likely because your protocol must be restricted to class types.
- note:
- Update the
switchValueChanged(_:)
IBAction to check if a delegate is assigned, and if so, call the delegate protocol function - Adopt the protocol in the
AlarmListTableViewController
class - Implement the
switchCellSwitchValueChanged(cell:)
delegate function to capture the alarm as a variable, toggle alarm's enabled property and reload the table view.
Create functions on the detail table view controller to display an existing alarm and setup the view properly.
- Add an
alarm
property of typeAlarm?
toAlarmDetailTableViewController
. This will hold an alarm if the view is displaying an existing alarm, and will be nil if the view is for creating a new alarm. - Create a private
updateViews()
function that will populate the date picker and alarm title text field with the current alarm's date and title. This function that will hide the enable button ifself.alarm
is nil, and otherwise will set the enable button to say "Disable" if the alarm inself.alarm
is enabled, and "Enable" if it is disabled. You may consider changing background color and font color properties as well to make the difference between the two button states clear. *note: You must guard against the alarm being nil, or the view controller's view not yet being loaded and properly handle these cases. - Create a
didSet
property observer on thealarm
property that will callupdateViews()
when the alarm property changes. - In
viewDidLoad
, callupdateViews()
to display an alarm if there is an existing alarm.
Fill in the prepareForSegue
function on the AlarmListTableViewController
to properly prepare the next view controller for the segue.
- On the
AlarmListTableViewController
, add an if statement to theprepareForSegue
function checking that the segue's identifier matches the identifier of the segue that goes from a cell to the detail view. - Get the destination view controller from the segue and cast it as an
AlarmDetailTableViewController
. - Get the indexPath of the selected cell from the table view.
- Use the
indexPath.row
to get the correct alarm that was tapped from theAlarmController.sharedInstance.alarms
array. - Set the
alarm
property on the destination view controller equal to the above alarm.- If the compiler presents an error when trying to do this, you either forgot to cast the destination view controller as an
AlarmDetailTableViewController
or forgot to give theAlarmDetailTableViewController
a property titlealarm
of typeAlarm?
. - At this point you should be able to run your project and see your table view populated with your mock alarms, displaying the proper switch state. You should also be able to delete rows, and segue to a detail view from a cell. This detail view should display the proper time of the alarm, the proper title, and the proper state of the enable/disable button.
- If the compiler presents an error when trying to do this, you either forgot to cast the destination view controller as an
Fill in the saveButtonTapped
function on the detail view so that you can add new alarms and edit existing alarms.
- Use
DateHelper.thisMorningAtMidnight
to find the time interval between this morning at midnight and thedatePicker.date
. - Unwrap
self.alarm
and if there is an alarm, call yourAlarmController.sharedInstance.updateAlarm
function and pass it the time interval you just created and the title from the title text field. - If there is no alarm, call your
AlarmController.sharedInstance.addAlarm
function to create and add a new alarm.- note: You should be able to run the project and have what appears to be a fully functional app. You should be able to add, edit, and delete alarms, and enable/disable alarms. We have not yet covered how to alert the user when time is up, so that part will not work yet, but we'll get there.
Make your Alarm
object conforom to the NSCoding protocol so that we persist alarms across app launches using NSKeyedArchiver and NSKeyedUnarchiver.
- Adopt the NSCoding protocol and add the required
init?(coder aDecoder: NSCoder)
andencodeWithCoder(aCoder: NSCoder)
functions. You should review NSCoding in the documentation before continuing. - Inside each, you will use the NSCoder provided from the initializer or function to either encode your properties using
aCoder.encodeObject(object, forKey: key)
or decode your properties usingaDecoder.decodeObjectForKey(key)
.- note: It is best practice to create static internal keys to use in encoding and decoding (ex.
private let kName = "name"
)
- note: It is best practice to create static internal keys to use in encoding and decoding (ex.
Add persistence using NSKeyedArchiver and NSKeyedUnarchiver to the AlarmController. Archiving is similar to working with NSUserDefaults, but uses NSCoders to serialize and deserialize objects instead of our initWithDictionary
and dictionaryRepresentation
functions. Both are valuable to know and be comfortable with.
NSKeyedArchiver serializes objects and saves them to a file on the device. NSKeyedUnarchiver pulls that file and deserializes the data back into our model objects.
Because of the way that iOS implements security and sandboxing, each application has it's own 'Documents' directory that has a different path each time the application is launched. When you want to write to or read from that directory, you need to first search for the directory, then capture the path as a reference to use where needed.
It is best to separate that logic into a separate function that returns the path. Here is an example function:
static private var persistentAlarmsFilePath: String? {
let directories = NSSearchPathForDirectoriesInDomains(.documentDirectory, .allDomainsMask, true)
guard let documentsDirectory = directories.first as NSString? else { return nil }
return documentsDirectory.appendingPathComponent("Alarms.plist")
}
This function accepts a string as a key and will return the path to a file in the Documents directory with that name.
- Add a private, static, computed property called
persistentAlarmsFilePath
which returns the correct path to the alarms file in the app's documents directory as described above. - Write a private function called
saveToPersistentStorage()
that will save the current alarms array to a file using NSKeyedArchiver- note: ``NSKeyedArchiver.archiveRootObject(self.alarms, toFile: persistentAlarmsFilePath)`
- Write a function called
loadFromPersistentStorage()
that will load saved Alarm objects and set self.alarms to the results- note: Capture the data using
NSKeyedUnarchiver.unarchiveObjectWithFile(persistentAlarmsFilePath)
, unwrap the Optional results and set self.alarms
- note: Capture the data using
- Call the
loadFromPersistentStorage()
function when the AlarmController is initialized - Call the
saveToPersistentStorage()
any time that the list of alarms is modified- note: You should now be able to see that your alarms are saved between app launches.
Register for local notifications when the app launches.
- In the
AppDelegate.swift
file in theapplication(_:didFinishLaunchingWithOptions:)
function, create an instance of UIUserNotificationSettings. - Using the above settings, register user notification settings with the application's shared application.
- note: Without this, the user will not ever be notified, even if you have schedule a local notification
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 scheduleLocalNotification(for alarm: Alarm)
function and a cancelLocalNotification(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.
- In your
AlarmController
file but outside of the class, create aprotocol AlarmScheduler
. This protocol will need two functions:scheduleLocalNotification(for alarm: Alarm)
andcancelLocalNotification(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
scheduleLocalNotification(for alarm: Alarm)
function should create an instance of a UILocalNotification, give it an alert title, alert body, and fire date. You will also need to set it'scategory
property to something unique (hint: the unique identifier we put on each alarm object is pretty unique). It should also be set to repeat at one day intervals. You will then need to schedule this local notification with the application's shared application. - Your
cancelLocalnotification(for alarm: Alarm)
function will need to get all of the application's scheduled notifications usingUIApplication.sharedApplication.scheduledLocalNotifications
. This will give you an array of local notifications. You can loop through them and cancel the local notifications whose category matches the alarm usingUIApplication.sharedApplication.cancelLocalNotification(notification: notification)
. - Now go to your list view controller and detail view controller and make them conform to the
AlarmScheduler
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 the implementation of these functions for all classes that conform to the protocol. - Go to your
AlarmListTableViewController
. In yourswitchCellSwitchValueChanged
function you will need to schedule a notification if the switch is being turned on, and cancel the notification if the switch is being turned off. You will also need to cancel the notification when you delete an alarm. - Go to your
AlarmDetailTableViewController
. YourenableButtonTapped
action will need to either schedule or cancel a notification depending on its state, and will also need to call yourAlarmController.sharedInstance.toggleEnabled(for alarm: Alarm)
function if it isn't being called already. YoursaveButtonTapped
method will need to schedule a notification when saving a brand new alarm, and will need to cancel and re-set a notification when saving existing alarms (this is because the user may have changed the time for the alarm).
At this point, the app should schedule alarms, and should present local notifications to the user when the app is not opened and the alarm goes off. We still want to present an alert to the user when the app is open and the alarm goes off.
- Go to the
AppDelegate.swift
file and add the functionapplication(application: UIApplication, didReceiveLocalNotification notification: UILocalNotification)
. This will be called when a local notification fires and the user has the app opened. - Initialize a UIAlertController of style
.Alert
. Add a dismiss action to it, and present it from thewindow?.rootViewController
property.
The app should now be finished. Run it, look for bugs, and fix anything that seems off.
Please refer to CONTRIBUTING.md.
© DevMountain LLC, 2015-2016. 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.