Continuum is a simple photo sharing service. Students will bring in many concepts that they have learned, and add more complex data modeling, Image Picker, CloudKit, and protocol-oriented programming to make a Capstone Level project spanning multiple days and concepts.
Most concepts will be covered during class, others are introduced during the project. Not every instruction will outline each line of code to write, but lead the student to the solution.
Students who complete this project independently are able to:
- follow a project planning framework to build a development plan
- follow a project planning framework to prioritize and manage project progress
- implement basic data model
- use staged data to prototype features
- implement search using the system search controller
- use the image picker controller and activity controller
- use container views to abstract shared functionality into a single view controller
- Check CloudKit availability
- Save data to CloudKit
- Fetch data from CloudKit
- Query data from CloudKit
- use subscriptions to generate push notifications
- use push notifications to run a push based sync engine
If you were in an interview and a developer asked you why you chose to use CloudKit, what would your answer be? "Because my mentors taught me", would be a lazy answer. Be confident with your decision to show you know what you're talking about. My reasons below would be as follows.
- CloudKit is native to Xcode and iOS development. You don't have to download anything. It forces you to become a better Apple programmer by following their conventions and design principles.
- Its free!
- It provides free iCloud user authentication.
- Apple-level privacy protection.
- It comes with a wealth of good resources, from Apple Programming Guides, WWDC videos on cloudKit best practices, and documentation.
All of these apps use cloudKit. Millions of users make use of these app every day. Beyond this, the concepts you'll learn when working with CloudKit will apply to almost any backend you use throughout your career.
- follow a project planning framework to build a development plan
- follow a project planning framework to prioritize and manage project progress
- implement basic data model
- use staged data to prototype features
Follow the development plan included with the project to build out the basic view hierarchy, basic implementation of local model objects, model object controllers, and helper classes. Build staged data to lay a strong foundation for the rest of the app.
- Fork and Clone the starter project from the Devmountain Github Project
- Switch over to the starter branch by typing
git checkout starter
into terminal. - Now, branch from starter to your own local develop branch where you can begin your coding. To do this, type
git checkout -b develop
into terminal.
In this section you will: Implement the view hierarchy in Storyboards. The app will have a tab bar controller as the initial controller. The tab bar controller will have two tabs.
The first is a navigation controller that has a PostListTableViewController that will display the list of posts, and will also use a UISearchBar to display search results. The PostListTableViewController will display a list of Post
objects and segue to a Post
detail view.
The second tab is a separate navigation controller that will hold a view controller to add new posts.
- Add a
UITabBarController
as the initial viewController of the storyboard. Delete both of the view controller that are attached to the Tab Bar Controller when you drag it out. - Add a
UITableViewController
Timeline scene, embed it in aUINavigationController
, Make the navigation controller your first tab in the tab bar controller. (hint: control + drag from the tab bar controller to the navigation controller and select "view controllers" under the "Relationship Segue" section in the contextual menu) - Make the
UITableViewController
from step 2 aPostListTableViewController
Cocoa Touch file subclass ofUITableViewController
and assign the subclass to the storyboard scene - Add a
UITableViewController
Post Detail scene, add a segue to it from the prototype cell ofPostListTableViewController
scene - Create a
PostDetailTableViewController
Cocoa Touch subclass ofUITableViewController
and assign it to the Post Detail scene - Add a
UITableViewController
Add Post scene, embed it into aUINavigationController
. Make this navigation controller your second tab in the tab bar controller. - Add a
AddPostTableViewController
Cocoa Touch subclass ofUITableViewController
and assign it to the Add Post scene
Your Storyboard should be simple skeleton resembling the set up below:
Continuum will use a simple, non-persistent data model to locally represent data stored on CloudKit.
In this section you will:
Start by creating model objects. You will want to save Post
objects that hold the image data, and Comment
objects that hold text. A Post
should own an array of Comment
objects.
Create a Post
model object that will hold image data and comments.
- Add a new
Post
class to your project. - Add a
photoData
property of typeData?
, atimestamp
Date
property, acaption
of typeString
, and acomments
property of type[Comment]
. You will get a “undeclared type” error as we have not created theComment
model object. Ignore this for now - Add a computed property,
photo
, with a getter which returns aUIImage
initialized using the data inphotoData
and a setter which adjusts the value of thephotoData
property to match that of thenewValue
for UIImage. Notice, the initalizer forUIImage(data: )
is failable and will return an optional UIImage and thatnewValue.jpegData(compressionQuality: )
optional data. You will need to handle these optionals by makingphotoData
andphoto
optional properties.
Computed Photo Property
var photo: UIImage?{
get{
guard let photoData = photoData else {return nil}
return UIImage(data: photoData)
}
set{
photoData = newValue?.jpegData(compressionQuality: 0.5)
}
}
- Add an initializer that accepts a photo, caption, timestamp, and comments array. Provide a default value for the
timestamp
argument equal to the current date i.e.Date()
and a default value for thecomments
of an empty array. Becausephoto
is a computed property, you may get this error: You can solve this by moving the initialization ofphoto
i.e.self.photo = photo
to the last line of the initalizer.
In this section you will:
Create a Comment
model object that will hold user-submitted text comments for a specific Post
.
- Add a new
Comment
class to your project. - Add a
text
property of typeString
and atimestamp
Date
property - Add an initializer that accepts text and timestamp. Provide a default value for the
timestamp
argument equal to the current date, so it can be omitted if desired.
In this section you will:
Add and implement the PostController
class that will be used for CRUD operations.
- Add a new
PostController
class file. - Add a
shared
singleton property. - Add a
posts
Source of Truth property initialized as an empty array. - Add an
addComment
function that takes atext
parameter as aString
, aPost
parameter, and a completion closure which takes in aResult<Comment, PostError>
and returns Void.
- Note: You will need to create a
PostError.swift
file with an enumPostError
subclass ofLocalizedError
. We will fill out the cases for this when we implement CloudKit. - For now this function will only initialize a new comment and append it to the given post's comments array. The completion will be used when CloudKit is implemented
- Add a
createPostWith
function that takes an image parameter as aUIImage
, a caption as aString
, and a completion closure which takes in aResult<Post?, PostError>
and returnsVoid
. - The function will need to initialize a post from the image and caption and append the post to the
PostController
sposts
property (think source of truth). The completion handler will be utilized with CloudKit integration
Note: These CRUD functions will only work locally right now. We will integrate CloudKit further along in the project
In this section you will:
Implement the Post List Table View Controller. You will use a similar cell to display posts in multiple scenes in your application. Create a custom PostTableViewCell
that can be reused in different scenes.
- Implement the scene in Interface Builder by creating a custom cell with an image view that fills most of the cell, a label for the posts caption, and another label for displaying the number of comments a post has. With the caption label selected, turn the number of lines down to 0 to enable this label to spread to the necessary number of text lines. Constrain the UI elements appropriately. Your
PostTableViewCell
should look similar to the one below.
- Create a
PostTableViewCell
Cocoa Touch file, subclass the table view cell from the previous step in your storyboard and add the appropriate IBOutlets. - In your
PostTableViewCell
add apost
variable, and implement anupdateViews
function to thePostTableViewCell
to update the image view with the post’sphoto
, and each of the labels with the relevant information from the post. Call the function in the didSet of thepost
variable - Keeping with the aesthetic of our favorite original photo sharing application, give the imageView an aspect ratio of 1:1. You will want to do this for all Post Image Views within the app to maintain consistency. Place a sample photo in your storyboard and explore the options of
Aspect Fill
,Aspect Fit
andScale to Fill
. The master project will be usingAspect Fill
withClips to Bounds
On. - Implement the
UITableViewDataSource
functions for thePostListTableViewController
. Use the source of truth from thePostController
to populate the tableView. - Implement the
prepare(for segue: ...)
function to check the segue identifier, capture the detail view controller, index path, selected post, and assign the selected post to the detail view controller.- note: You will need to add an optional
post
property to thePostDetailTableViewController
.
- note: You will need to add an optional
In this section you will: Implement the Post Detail View Controller. This scene will be used for viewing post images and comments. Users will also have the option to add a comment, share the image, or follow the user that created the post.
Use the table view's header view to display the photo and a toolbar that allows the user to comment, share, or follow. Use the table view cells to display comments.
- Add a UIView to the header of the
PostDetailTableViewController
- Add a vertical
UIStackView
to the UIView in the Header of the table view. Add aUIImageView
and a horizontalUIStackView
to the stack view. Add 'Comment', 'Share', and 'Follow Post'UIButtons
to the horizontal stack view. Set the horizontal stack view to have a center alignment and Fill Equally distribution. Set the Vertical Stack View to have Fill alignment and Fill distribution. - Constrain the image view to an aspect ratio of 1:1
- Constrain the vertical Stack View to be centered horizontally and vertically in the header view and equal to 80% of the width of the header view (i.e. the users screen width).
- In
PostDetailTableViewController.swift
create an IBOutlet from the Image View namedphotoImageView
and connect IBActions from each button in our horizontal stack view. - The cells of this tableView should support comments that span multiple lines without truncating them and a timestamp for each comment. Set the
UITableViewCell
to the subtitle style. Set the number of lines for the cells title label to zero. - Add an
updateViews
function that will update the scene with the details of the post. Implement the function by setting thephotoImageView.image
and reloading the table view. Use adidSet
on thepost
variable to callupdateViews
. - Implement the
UITableViewDataSource
functions to populate the tableView with the post’s array of comments (post.comments). - In the IBAction for the 'Comment' button, implement the IBAction by presenting a
UIAlertController
with a text field, a Cancel action, and an 'OK' action. Implement the 'OK' action to initialize a newComment
via thePostController
and reload the table view to display it. Leave the completion closure in theaddComment
function blank for now.- note: Do not create a new
Comment
if the user has not added text. Leave the Share and Follow button IBActions empty for now. You will fill in implementations later in the project.
- note: Do not create a new
In this section you will: Implement the Add Post Table View Controller. You will use a static table view to create a simple form for adding a new post. Use three sections for the form:
Section 1: Large button to select an image, and a UIImageView
to display the selected image
Section 2: Caption text field
Section 3: Add Post button
Until you implement the UIImagePickerController
, you will use a staged static image to add new posts.
- In the attributes inspector of the
AddPostTableViewController
, assign the table view to use static cells. Adopt the 'Grouped' cell style. Add three sections. - Build the first section by creating a tall image selection/preview cell. Add a 'Select Image'
UIButton
that fills the cell. Add an emptyUIImageView
that also fills the cell. Make sure that the button is on top of the image view so it can properly recognize tap events (lower in the list hierarchy on the left). - Build the second section by adding a
UITextField
that fills the cell. Assign placeholder text so the user recognizes what the text field is for. - Build the third section by adding a 'Add Post'
UIButton
that fills the cell. - Add an IBOutlet for your
UIImage
, and add an IBAction and IBOutlet to the 'Select Image'UIButton
that assigns a static image to the image view (use the empty state space drawing in Assets.xcassets from the starter project for prototyping this feature), and removes the title text from the button.- note: It is important to remove the title text so that the user no longer sees that a button is there, but do not remove the entire button, that way the user can tap again to select a different image (i.e. do not hide the button).
- Add an IBAction to the 'Add Post'
UIButton
that checks for animage
andcaption
. If there is animage
and acaption
, use thePostController
to create a newPost
. Guard against either the image or a caption is missing. Leave the completion closure in thecreatePostWith
function empty for now. - After creating the post, you will want to navigate the user back to
PostListTableViewController
of the application. You will need to edit the Selected View Controller for your apps tab bar controller. You can achieve this by setting theselectedIndex
property on the tab bar controller.
self.tabBarController?.selectedIndex = 0
- Add a 'Cancel'
UIBarButtonItem
as the left bar button item. Implement the IBAction to bring the user back to thePostListTableViewController
using the same line of code from the previous step. - Override
ViewDidDisappear
to reset the Select Image Button's title back to "Select Image”, reset the imageView's image to nil, and remove the any text from the caption textField. - Navigate back to the
PostListTableViewController
. OverrideviewWillAppear
to reload the tableView.
Consider that this Photo Selection functionality could be useful in different views and in different applications. New developers will be tempted to copy and paste the functionality wherever it is needed. That amount of repetition should give you pause. Don't repeat yourself (DRY) is a shared value among skilled software developers.
Avoiding repetition is an important way to become a better developer and maintain sanity when building larger applications.
Imagine a scenario where you have three classes with similar functionality. Each time you fix a bug or add a feature to any of those classes, you must go and repeat that in all three places. This commonly leads to differences, which leads to bugs.
You will refactor the Photo Selection functionality (selecting and assigning an image) into a reusable child view controller in Part 2.
At this point you should be able to view added post images in the Timeline Post List scene, add new Post
objects from the Add Post Scene, and add new Comment
objects from the Post Detail Scene. Your app will not persist or share data yet.
Use the app and polish any rough edges. Check table view cell selection. Check text fields. Check proper view hierarchy and navigation models. You’re app should look similar to the screenshots below:
- implement search using UISearchBarDelegate
- use the image picker controller and activity controller
Add and implement search functionality to the search view. Implement the Image Picker Controller on the Add Post scene.
In this section you will:
Build functionality that will allow the user to search for posts with captions that have specific text in them. For example, if a user creates a Post
with a photo of a waterfall, and the caption mentions the waterfall, the user should be able to search the Timeline view for the term 'water' and filter down to that post (and any others with water in the captions).
In this section you will:
Add a SearchableRecord
protocol that requires a matchesSearchTerm
function. Update the Post
object to conform to the protocol.
- Add a new
SearchableRecord.swift
file. - Define a
SearchableRecord
protocol with a requiredmatches(searchTerm: String)
function that takes asearchTerm
parameter as aString
and returns aBool
.
Consider how each model object will match to a specific search term. What searchable text is there on a Post
?
- Update the
Post
class to conform to theSearchableRecord
protocol. Returntrue
if thePost
caption
matches the search term (keep case sensitivity in mind), otherwise returnfalse
.
You can use a Playground to test your SearchableRecord
and matches(searchTerm: String)
functionality and understand what you are implementing.
In this section you will:
Use a UISearchbar to allow a user to search through different posts for the given search text. This will require the use of the of the SearchableRecord
protocol and the Post model's implementation of the matches(searchTerm: String)
function. The PostListTableViewController
will need to conform to the UISearchBarDelegate
and implement the appropriate delegate method.
- Add a
UISearchBar
to the headerView of thePostListTableViewController
scene in the main storyboard. Check theShows Cancel Button
in the attributes inspector. Create an IBOutlet from the search bar to thePostListTableViewController
class. - Add a
resultsArray
property in thePostListTableViewController
class that contains an array ofSearchableRecord
set to an empty array - Add an
isSearching
property at the top of the class which stores aBool
value set tofalse
by default - Create a computed property called
dataSource
as an array ofSearchableRecord
which will return theresultsArray
ifisSearching
istrue
and thePostController.shared.posts
ifisSearching
isfalse
.
var dataSource: [SearchableRecord]
var dataSource: [SearchableRecord] {
return isSearching ? resultsArray : PostController.shared.posts
}
- Refactor the
UITableViewDataSource
methods to populate the tableView with the newdataSource
property. If you get any errors in yourcellForRowAt
about mismatching type, optionally cast your post as a Post (let post = dataSource[indexPath.row] as? Post). *(note: you will also have to update yourprepareForSegue
to use thedataSouce
property as well) - In
ViewWillAppear
set the results array equal to thePostController.shared.posts
. - In
ViewDidLoad
set the Search Bar's delegate property equal toself
- Adopt the UISearchBarDelegate protocol in an extension on
PostListTableViewController
, and implement thesearchBar(_:textDidChange:)
function. Within the function, using the .filter method, filterPostController.shared.posts
using thePost
object'smatches(searchTerm: String)
function and setting theresultsArray
equal to the results of the filter. CalltableView.reloadData()
at the end of this function. - Implement the
searchBarCancelButtonClicked(_ searchBar:)
function, using it to set the results array equal toPostController.shared.posts
then reload the table view. You should also set the searchBar's text equal to an empty String and resign its first responder. This will return the feed back to its normal state of displaying all posts when the user cancels a search. - Implement the
searchBarSearchButtonClicked
and resign the searchBar's first responder. - Implement the
searchBarTextDidBeginEditing
and setisSearching
totrue
. - Implement the
searchBarTextDidEndEditing
, setisSearching
tofalse
, and set the text in the search bar to an empty string. - Now if you notice, if you search for something, but then delete all text in the searchBar, our tableView is empty. In our
searchBar(_:textDidChange:)
, create an if-else statement that will check if searchText is not empty. Move the code we used to filter into the case that searchText is not empty. Otherwise, setresultsArray
equal toPostController.shared.posts
then reload the tableView.
Add Several Posts with a variety of captions and comments. Test whether you can successfully search the posts using the search bar.
In this section you will: Implement the Image Picker Controller in place of the prototype functionality you built previously.
- In the
AddPostTableViewController
, create a function calledpresentImagePickerActionSheet
which we will call in the 'Select Image' IBAction. - In the body of the function, we will need to create an instance of
UIImagePickerController
. (let imagePickerController = UIImagePickerController()). - Assign
imagePickerController
's delegate to self. - Create a
UIAlertController
with anactionSheet
style which will allow the user to select from picking an image in their photo library or directly from their camera. - Use
UIImagePickerController
to check to make sure eachUIImagePickerController.SourceType
is available, and for each that is, add the appropriate action to theUIAlertController
above. - You will also need to add a cancelAction and present the alert to the user.
- In an extension, conform the
AddPostTableViewController
toUIImagePickerControllerDelegate
andUINavigationControllerDelegate
(these can be on the same line). - Implement the
UIImagePickerControllerDelegate
functiondidFinishPickingMediaWithInfo
to capture the selected image and assign it to the image view. You will also set the text on the 'Select Image' button to an empty string. Please read through the documentation for UIImagePickerController and its delegate
- note: Be sure to add a
Privacy - CameraUsageDescription
andPrivacy - PhotoLibraryUsageDescription
to your appsInfo.plist
. These strings will be displayed in the Alert Controller apple presents to ask users for specific permissions.
You should now be able to select and initialize posts with the photos from your camera or photo library. You will need to test the camera feature on an actual iPhone as the simulator does not support a camera.
In this section you will: Refactor the photo selection functionality from the Add Post scene into a child view controller.
Child view controllers control views that are a subview of another view controller. It is a great way to encapsulate functionality into one class that can be reused in multiple places. This is a great tool for any time you want a similar view to be present in multiple places.
In this instance, you will put 'Select Photo' button, the image view, and the code that presents and handles the UIImagePickerController
into a PhotoSelectorViewController
class. You will also define a protocol for the PhotoSelectorViewController
class to communicate with it's parent view controller.
Use a container view to embed a child view controller into the Add Post scene.
A Container View defines a region within a view controller's view subgraph that can include a child view controller. Create an embed segue from the container view to the child view controller in the storyboard.
- Open
Main.storyboard
to your Add Post scene. - Search for Container View in the Object Library and add it to the cell in your table view that contains the imageView and 'Select Photo' button.
- note: The Container View object will come with a view controller scene. You can use the included scene, or replace it with another scene. For now, use the included scene.
- Set up constraints so that the Container View fills the entire cell.
- Move or copy the Image View and 'Select Photo' button to the container view controller.
- Be sure to constrain the imageView and 'Select Photo' button to fill the container view.
- Create a new
PhotoSelectorViewController
file as a subclass ofUIViewController
and assign the class to the new embedded scene in Interface Builder. - Create the necessary IBOutlets and IBActions, and migrate your Photo Picker code from the Add Post view controller class. Delete the old code from the Add Post view controller class. Check for any broken or duplicate outlets in your Interface Builder scenes.
- Our
PhotoSelectorViewController
will also need a viewDidDisappear function. Migrate code from ourAddPostTableViewController
that references our 'Select Button' and imageView over to ourPhotoSelectorViewController
(resetting the caption textfield will still need to remain in ourAddPostTableViewController
's viewDidDisappear ) *note: you will have errors on yourAddPostTableViewController
page since you are referencing outlets that no longer exist. We will fix these errors soon. Feel free to ignore those for now or comment out the code for the time being
You now have a container view which can be referenced and reused throughout your app. In this version of the app, we will only use the scene once, but the principle remains.
In this section: Your child view controller needs a way to communicate events to it's parent view controller. This is most commonly done through delegation. Define a child view controller delegate, adopt it in the parent view controller, and set up the relationship via the embed segue.
- Define a new
PhotoSelectorViewControllerDelegate
protocol in thePhotoSelectorViewController
file with a requiredphotoSelectorViewControllerSelected(image: UIImage)
function that takes aUIImage
parameter to pass the image that was selected.- note: This function will tell the assigned delegate (the parent view controller, in this example) what image the user selected.
- Add a weak optional delegate property to the
PhotoSelectorViewController
. - Call the delegate function in the
didFinishPickingMediaWithInfo
function, passing the selected media to the delegate. - In the
AddPostTableViewController
, create a new property calledselectedImage
of type optionalUIImage
- Adopt the
PhotoSelectViewControllerDelegate
protocol in theAddPostTableViewController
and implement thephotoSelectViewControllerSelectedImage
function to assign the selected image to the newly createdselectedImage
property. - In the
AddPostTableViewController
scene, clean up any errors you have by usingselectedImage
to create a new post.
Note the use of the delegate pattern. You have encapsulated the Photo Selection workflow in one class, but by implementing the delegate pattern, each parent view controller can implement its own response to when a photo was selected.
You have declared a protocol, adopted the protocol, but you now must assign the delegate property on the instance of the child view controller so that the PhotoSelectViewController
can communicate with its parent view controller. This is done by using the embed segue, which is called when the Container View is initialized from the Storyboard, which occurs when the view loads.
- Assign a segue identifier to the embed segue in the Storyboard file
- Implement the
prepare(forSegue: ...)
function in theAddPostTableViewController
to check for the segue identifier, capture thedestinationViewController
as aPhotoSelectorViewController
, and assignself
as the child view controller's delegate.
In this section you will:
Use the UIActivityController
class to present a share sheet from the Post Detail view. Share the image and the text of the first comment.
- Add an IBAction from the Share button in your
PostDetailTableViewController
if you have not already. - Initialize a
UIActivityViewController
with thePost
's image and the caption as the shareable objects. - Present the
UIActivityViewController
.
- Check CloudKit availability
- Save data to CloudKit
- Fetch data from CloudKit
Following some of the best practices in the CloudKit documentation, add CloudKit to your project as a backend syncing engine for posts and comments. Check for CloudKit availability, save new posts and comments to CloudKit, and fetch posts and comments from CloudKit.
When you finish this part, the app will support syncing photos, posts, and comments from the device to CloudKit, and pulling new photos, posts, and comments from CloudKit. You will implement push notifications, subscriptions, and basic automatic sync functionality in Part Four.
- Import CloudKit in the
Post.swift
file - Add a recordID property to your
Post
class of typeCKRecord.ID
. Update the Post initializer to take in aCKRecord.ID
with a default value ofCKRecord.ID(recordName: UUID().uuidString)
- To save your photo to CloudKit, it must be stored as a
CKAsset
.CKAsset
s must be initialized with a file path URL. In order to accomplish this, you need to create a temporary directory that copies the contents of thephotoData: Data?
property to a file in a temporary directory and returns the URL to the file. This is going to be a 2 step process.
- 3.1. Save the image temporarily to disk
- 3.2. Create the CKAsset
var imageAsset: CKAsset?
var imageAsset: CKAsset? {
get {
let tempDirectory = NSTemporaryDirectory()
let tempDirecotryURL = URL(fileURLWithPath: tempDirectory)
let fileURL = tempDirecotryURL.appendingPathComponent(UUID().uuidString).appendingPathExtension("jpg")
do {
try photoData?.write(to: fileURL)
} catch let error {
print("Error writing to temp url \(error) \(error.localizedDescription)")
}
return CKAsset(fileURL: fileURL)
}
}
The whole point of the above computed property is to read and write for our photo property. Look up CKAsset
, it can only take a fileURL.
- We will need a way of converting local
Post
objects into a type which can be saved to CloudKit (i.e. CKRecords). To achieve this, we will extend CloudKit’sCKRecord
class and add a convenience initializer which takes in a singlePost
instance.- Initialize a
CKRecord
with recordType of “Post” and recordID of the post’s recordID property. - Set the values of the CKRecord with the post’s properties. CloudKit only supports saving Foundational Types (save dictionaries) and will not allow saving
UIImage
orComment
instances. We will therefore need to save aCKAsset
instead of an image. We will ignore comments for now, and come back to them using a process called back referencing. - Note: Setting the values of this glorified dictionary will require many hardcoded strings which can lead to typo errors especially in larger projects. Consider creating a constants struct to hold each of these string values.
- Initialize a
CKRecord Extension
extension CKRecord {
convenience init(post: Post) {
self.init(recordType: PostConstants.typeKey, recordID: post.recordID)
self.setValuesForKeys([
PostConstants.captionKey : post.caption,
PostConstants.timestampKey : post.timestamp
])
if let postPhoto = post.imageAsset {
self.setValue(postPhoto, forKey: PostConstants.photoKey)
}
}
}
struct PostConstants {
static let typeKey = "Post"
static let captionKey = "caption"
static let timestampKey = "timestamp"
static let commentsKey = "comments"
static let photoKey = "photo"
}
- Add a failable initializer to
Post
which takes in a CKRecord.- Remember, a CKRecord is little more than a glorified dictionary. Pull all of the necessary values out of the CKRecord, casting and unwrapping them as necessary, then call the original designated initializer you edited in the previous step
- You will need to first get the CKAsset back from the CKRecord then use its
fileURL
property to initializeData
- Remember to initialize the posts recordID property with the ckRecord’s recordID.
- We will initialize comments with an empty array for now.
- Note: The strings you use to pull values out of the CKRecord will need to exactly match their respective strings in your CKRecord convenience initializer. If you failed to implement a constants struct in the previous step, please reconsider your decision, and use that same constants struct here
In this section you will:
Make the same structural adjustments for you Comment model object to integrate it with CloudKit.
Extend CKRecord to add a convenience initializer which takes in a Comment. Write a failable initializer for your Comment which takes in a CKRecord.
- Add A CKRecord.ID property to the ‘Comment’ model.
- Adjust your designated initializer to take in a CKRecord.ID with a default initializer value of a new CKRecord.ID initialized with the name of a new, unique uuid.
CKRecord.ID(recordName: UUID().uuidString)
- Extend CKRecord to add a convenience initializer which takes in a comment. Initialize a new CKRecord using a recordType of “Comment” and the comment object’s recordID. Set the values of the CKRecord to each of the comments properties.
- Note: You should follow the same pattern of using a constants struct for referencing hard coded strings for CloudKit keys.
You will likely run into some issues as you try to save the comment’s post property to CloudKit. You will not be able to set the value of a CKRecord as Post
(CloudKit does not support saving custom types). Rather we will need to use a strategy called back referencing to coordinate the relationship between posts and comments. Rather than a comment, directly containing a post, a comment in CloudKit is going to maintain a reference to a Post. You can think about this distinction as the difference between one webpage containing the entire contents of another web page vs containing a url link to that other webpage. We will use the posts recordID property as our analogous “url” in this example.
CloudKit contains a special class for creating references like this called CKRecord.Reference
. Please read through the documentation for CKRecord.Reference
here . Using a CKRecord.Reference
is preferable to just saving the post’s recordID as a string because CloudKit will then handle writing operations for us on the relationship. For example, if I delete a post, a CKRecord.Reference
may allow CloudKit to automatically delete all of its associated comments.
-
Add a
postReference
property of typeCKRecord.Reference?
to the comment class. Adjust your Comment initializer for this new property. (Note: You may get errors in your PostController file pertaining to this new property on a Comment..we will fix those soon)) -
Revisit your convenience initializer on
CKRecord
which takes in a comment and add the postReference to the record being created. -
Add a failable convenience initializer on the
Comment
class which takes in aCKRecord
. Unwrap the necessary properties for a comment from the ckRecord and call the designated initializer we wrote earlier.
If the user isn't signed into their iCloud account, they will not be able to save or fetch data using CloudKit. Most of the features wouldn't fully work. If they are not signed in, we want to let the user know immediately. If they are signed into iCloud, we want the app to continue as usual. Take a moment and think about this, if the user is signed in 'do something' if the user isn't signed in 'do something else'. What would our function signature look like? You will want to do this when the app first launches.
- In the
AppDelegate
, write a function to check the iCloud Account Status of a iPhone user.
- The
default()
Singleton of theCKContainer
class has anaccountStatus
function that can check the users status. There are 4 options, forCKAccountStatus
which you can read about here.
-
In the completion of
CKContainer.default().accountStatus
, write a switch statement based on the users status inside the closure where you can handle each case as necessary. You will need a@escaping
completion closure to handle the events if the user is signed in or not. If the users account status is anything other than.available
we’ll need to call the completion passing infalse
and present an alert to notify the user that they are not signed in. Note: In this case you will not use the completion of this function for anything more than a print statement; however, it is good practice to include an escaping completion for any function which makes asynchronous calls in order to give yourself or other developer using your code the opportunity to run code when the call has completed.- Create an extension on UIView controller and add a function
presentSimpleAlertWith(title: String, message: String?)
that presents an alert with the passed in title and message and only has an OK button. You'll call this function within yourcheckAccountStatus(completion: @escaping (Bool) -> Void)
Based on the users status you'll provide the proper Error Message to inform the user.
If you attempt to present an alert in this class, you'll notice an error. That's because
AppDelegate
isn't a subclass ofUIViewController
nor should it be. We don't have access to anyUIViewController
yet.- Access the
window.rootViewController
property of your AppDelegate. This will give you access to your applications initial ViewController. In our case that will be the TabBarController. Since UITabBarController is a subclass of UIViewController, this object will have access to thepresentSimpleAlertWith(title: String, message: String?)
function we wrote on the extension ofUIViewController
.
- Create an extension on UIView controller and add a function
-
Call this newly minted function in the
application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
delegate method to check the user’s account status when the app is launched.
Take a moment and try this on your own if you get stuck here is the code for the AppDelegate and Extension of UIViewController.
I swear I spent 10 minutes on my own before clicking this button
import UIKit
import CloudKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
checkAccountStatus { (success) in
let fetchedUserStatment = success ? "Successfully retrieved a logged in user" : "Failed to retrieve a logged in user"
print(fetchedUserStatment)
}
return true
}
func checkAccountStatus(completion: **@escaping** (Bool) -> Void) {
CKContainer.default().accountStatus { (status, error) in
if let error = error {
print("Error checking accountStatus \(error) \(error.localizedDescription)")
completion(false); return
} else {
DispatchQueue.main.async {
let tabBarController = self.window?.rootViewController
let errrorText = "Sign into iCloud in Settings"
switch status {
case .available:
completion(true);
case .noAccount:
tabBarController?.presentSimpleAlertWith(title: errrorText, message: "No account found")
completion(false)
case .couldNotDetermine:
tabBarController?.presentSimpleAlertWith(title: errrorText, message: "There was an unknown error fetching your iCloud Account")
completion(false)
case .restricted:
tabBarController?.presentSimpleAlertWith(title: errrorText, message: "Your iCloud account is restricted")
completion(false)
}
}
}
}
}
}
*(In a Separate File)*
extension UIViewController {
func presentSimpleAlertWith(title: String, message: String?) {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let okayAction = UIAlertAction(title: "Okay", style: .cancel, handler: nil)
alertController.addAction(okayAction)
present(alertController, animated: true)
}
}
In this section you will:
Update the PostController
to support pushing and pulling data from CloudKit.
In order to enable the sharing functionality of this application, we will need to save Post and Comment Records to CloudKit’s public database.
let publicDB = CKContainer.default().publicCloudDatabase
-
Update the
createPost
function, using the convenience initializer we wrote onCKRecord
to turn the post into a ckRecord. -
Use CloudKit’s
save(_:completionHandler:)
function which you can read more about here. You will need to handle any errors and call the completion on yourcreatePostWith(photo:caption:completion:)
function inside the completionHandler of the save function.
- Add any error cases you see fit to your
PostError.swift
file to avoid errors and be sure to include alocalizedDescription
for each case
At this point you should be able to save a post record and see it in your CloudKit dashboard. You dashboard should look similar to this (after marking the recordName as queryable)
- Update the
addCommentToPost
function to create a postReference from the post that was passed in to our function. Use that reference as the missing parameter of our comment object. Then create aCKRecord
using the convenience initializer which takes in a comment onCKRecord
. - Again use CloudKit’s
save(_:completionHandler:)
function to save the comment to the database. If you are wondering where the documentation for that function went, it’s still here (: Handle any error thrown in the save function completion and call your own completion accordingly.
At this point, each new Post
or Comment
should be pushed to CloudKit when new instances are created from the Add Post or Post Detail scenes.
Note: The safest practice for calling your own completions here is to unwrap the record passed back by CloudKit’s save completion, then initialize a Post or Comment respectively, and complete with that object.
There are a number of approaches you could take to fetching new records. For Continuum, we will simply be fetching (or re-fetching, after the initial fetch) all the posts at once. Note that while we are doing it in this project, it is not an optimal solution. We are doing it here so you can master the basics of CloudKit first.
- Add a
fetchPosts
function that has a completion closure which takes in an array of optional posts[Post]?
and returnsVoid
. - Use the
publicDB
property to perform a query. - We will need to make a
CKQuery
and aNSPredicate
. The predicate value will be set to true which means it will fetch every post. - Handle any errors that may have been passed back, unwrap the records, and
compactMap
across the array of records calling your failable initializerinit?(record: CKRecord)
on each one. This will return a new array of posts fetched from our publicDB. - Don't forget to set your local array to the new array of posts. This is how the TVC will populate all our posts. And call completion.
We're going to create a function that will allow us to fetch all the comments for a specific post we give it.
- Add a
fetchComments(for post: Post, ...)
function that has a completion closure which takes in an optional array of comments[Comment]?
and returnsVoid
. - Call your
publicDB
to perform a query for all the comments for the given post.
- Because we don't want to fetch every comment ever created, we must use a different
NSPredicate
then the default one. Create a predicate that checks the value of the correct field that corresponds to the postCKReference
on the Comment record against theCKReference
you created in the previous step.
- Add a second predicate to includes all of the commentID's that have NOT been fetched.
let postRefence = post.recordID
let predicate = NSPredicate(format: "%K == %@", CommentConstants.postReferenceKey, postRefence)
let commentIDs = post.comments.compactMap({$0.recordID})
let predicate2 = NSPredicate(format: "NOT(recordID IN %@)", commentIDs)
let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, predicate2])
let query = CKQuery(recordType: "Comment", predicate: compoundPredicate)
- In the completion closure of the perform(query) , follow the common pattern of checking for errors, making sure the records exist, then create an array of comments using the array of records.
- Append the contents of the newly created array of comments to the posts comments array.
- Call your completion and pass in the comments which were fetched.
- In the
PostListTableViewController
add a new function to request a full sync operation that takes in an optional completion closure. Implement the function by calling thefetchPosts
function on thePostController
. Reload the tableView in the completion. - Call the function in the
viewDidLoad
to initiate a full sync when the user first opens the application.
You may have noticed that it takes a long time to fetch the results from CloudKit. Moreover, there is a major bug. Post objects, when they are initially fetched from CloudKit will display a comment count of 0 in the PostListTableViewController. In order to display these with our current app structure, we would need to fetch all of the post, then for each post, go fetch all of its comments. This is a heavy ask for CloudKit and could hang out UI fairly quickly. Meanwhile we don’t even need the data for those comments until a user clicks into the detail view for post. We will need to refactor our Post
model to keep track of how many comments it has, and delay the fetching of comments until a user click on the detail page for a post.
- Add a commentCount variable of type
Int
to thePost
class - Adjust the
Post
initializers and the convenience initializer on CKRecord which takes in a post. - Add functionality to the
PostController
’saddComment
function to increment the post’s commentCount by 1. Make sure you update the value of this integer in CloudKit. You will need to use the CKModifyRecordsOperation class to do this. - Adjust the
PostTableViewCell
to populate the comment count label with this new property.
Note: Changing our model in this way will make any old Post
objects saved in the database incompatible with our new setup. Clear your database to avoid any stagnant old data.
- In
viewDidLoad()
of thePostDetailTableViewController
call your fetch comments function and reload the tableView
At this point the app should support basic push and fetch syncing from CloudKit. Use your Simulator and your Device to create new Post
and Comment
objects. Check for and fix any bugs.
When you tap on a post cell it should bring you to the detailVC. The comments that belong to that post should be fetched.
- Use subscriptions to generate push notifications
In this section you will:
Implement Subscriptions and push notifications to create a simple automatic sync engine. Add support for subscribing to new Post
records and for subscribing to new Comment
records on followed Post
s. Request permission for remote notifications. Respond to remote notifications by initializing the new Post
or Comment
with the new data.
*When you finish this part, the app will support sending push notification when new records are created in CloudKit. *
You will:
Update the PostController
class to manage subscriptions for new posts and new comments on followed posts. Add functions for following and unfollowing individual posts.
When a user follows a Post
, he or she will receive a push notification and automatic sync for new Comment
records added to the followed Post
.
You will:
Create and save a subscription for all new Post
records.
- Add a function
subscribeToNewPosts
that takes an optional completion closure withBool
andError?
parameters.- note: Use an identifier that describes that this subscription is for all posts.
- Initialize a new CKQuerySubscription for the
recordType
of 'Post'. Pass in a predicate object with its value set totrue
. - Save the subscription to the public database. Handle any error which may be passed out of the completion handler and complete with true or false based on whether or not an error occurred while saving.
- Call the
subscribeToNewPosts
in the initializer for thePostController
(you will have to add this) so that each user is subscribed to newPost
records saved to CloudKit.
You will:
Create and save a subscription for all new Comment
records that point to a given Post
- Add a function
addSubscriptionTo(commentsForPost post: ...)
that takes aPost
parameter and an optional completion closure which takes in aBool
andError
parameters. - Initialize a new NSPredicate formatted to search for all post references equal to the
recordID
property on thePost
parameter from the function. - Initialize a new
CKQuerySubscription
with a record type ofComment
, the predicate from above, asubscriptionID
equal to the posts record name which can be accessed usingpost.recordID.recordName
, with theoptions
set toCKQuerySubscription.Options.firesOnRecordCreation
- Initialize a new
CKSubscription.NotificationInfo
with an empty initializer. You can then set the properties ofalertBody
,shouldSendContentAvailable
, anddesiredKeys
. Once you have adjusted these settings, set thenotificationInfo
property on the instance ofCKQuerySubscription
you initialized above. - Save the subscription you initialized and modified in the public database. Check for an error in the ensuing completion handler.
- Please see the CloudKit Programming Guide and CKQuerySubscription Documentation for more detail.
In this section:
The Post Detail scene allows users to follow and unfollow new Comment
s on a given Post
. Add a function for removing a subscription, and another function that will toggle a subscription for a given Post
.
-
Add a function
removeSubscriptionTo(commentsForPost post: ...)
that takes aPost
parameter and an optional completion closure withsuccess
anderror
parameters. -
Implement the function by calling
delete(withSubscriptionID: ...)
on the public data base. Handle the error which may be returned by the completion handler. If there is no error complete withtrue
.- note: Use the unique identifier you used to save the subscription above. Most likely this will be your unique
recordName
for thePost
.
- note: Use the unique identifier you used to save the subscription above. Most likely this will be your unique
-
Add a function
checkSubscription(to post: ...)
that takes aPost
parameter and an optional completion closure with aBool
parameter. -
Implement the function by fetching the subscription by calling
fetch(withSubscriptionID: ...)
passing in the uniquerecordName
for thePost
. Handle any errors which may be generated in the completion handler. If theCKSubscription
is not equal to nil complete withtrue
, else complete withfalse
. -
Add a function
toggleSubscriptionTo(commentsForPost post: ...)
that takes aPost
parameter and an optional completion closure withBool
, andError
parameters. -
Implement the function by calling the
checkForSubscription(to post:...)
function above. If a subscription does not exist, subscribe the user to comments for a given post by calling theaddSubscriptionTo(commentsForPost post: ...)
; if one does, cancel the subscription by callingremoveSubscriptionTo(commentsForPost post: ...)
.
In this section you will:
Update the Post Detail scene's Follow Post
button to display the correct text based on the current user's subscription. Update the IBAction to toggle subscriptions for new comments on a Post
.
- Update the
updateViews
function to call thecheckSubscriptionTo(commentsForPost: ...)
on thePostController
and set appropriate text for the button based on the response. You will need to add an IBOutlet for the button if you have not already. - Implement the
Follow Post
button's IBAction to call thetoggleSubscriptionTo(commentsForPost: ...)
function on thePostController
and update theFollow Post
button's text based on the new subscription state.
In this section you will: Update the Info.plist to declare backgrounding support for responding to remote notifications. Request the user's permission to display remote notifications.
- Go to the Project File. In the "capabilities" tab, turn on Push Notifications and Background Modes. Under Background Modes, check Remote Notifications.
- In the
AppDelegate
didFinishLaunchingWithOptions
function, request the user's permission to display notifications.- note: Use the
requestAuthorization
function that is a part ofUNUserNotificationCenter
.
- note: Use the
- Register the App to receive push notifications
application.registerForRemoteNotifications()
Add an activity indicator view that shows on screen while the fetch posts function is running and that is hidden once the posts have been fetched.