Implement the CallKit extension for iOS - Part 1: User interface
You will need Xcode 10+ installed on your machine.
Introduction
Apple’s CallKit framework for iOS is often used to create integrated VoIP apps for the iPhone.
CallKit also lets you add numbers to the phone block list and Caller ID lists. This is the capability we will be exploring in this tutorial series.
In this tutorial you will create an iOS app that you can use to add names and numbers to a caller ID list and add numbers to a block list.
In the second part you will create a CallKit extension that provides these lists to the iOS phone app.
The key concepts covered by this tutorial are:
- Sharing Core Data between an iOS app and an iOS extension
- Reloading a CallKit extension from an iOS app
- Debugging an iOS app extension
Prerequisites
- A Mac with Xcode 10
- An iPhone (You cannot use the simulator to test CallKit extensions)
- Familiarity with Xcode and storyboards
- Familiarity with general UIKit programming
Building the App
Getting Started
Start by opening Xcode and creating a new iOS Single View application and naming it CallKitTutorial. Make sure you select Swift as the language and that you tick Use Core Data.
The app has a simple user interface. It has a table view that lists the numbers that we want to identify and to block.
Setting the scene
Open the Main.Storyboard
file and select the view controller. From the Editor menu select Embed In->Navigation Controller. This will add a navigation bar to the top of the scene.
You can downloaded a completed storyboard file from this tutorial’s GitHub repository.
Now, let’s add a segmented control to the navigation bar. This will select between the ID and Block lists.
- Drag a Segmented Control to the middle of the navigation bar in the View Controller scene.
- A blue outline will appear. Drop the segmented control and it will appear in the navigation bar.
- Select the Segmented Control. Using the property inspector, change the title of the first segment to “ID” and the second segment to “Block”.
Next, we will add the “+” button to the right hand side of the navigation bar:
- Drag a Bar Button Item from the object library and drop it on the right hand side of the navigation bar
- Using the property inspector, change the bar button’s System Item to Add
We need a button to refresh the CallKit extension:
- Drag a Button from the object library and drop it near the bottom, center of the scene.
- Select the button.
- Use the Add New Constraints button (|⁃◻︎⁃|) to add a constraint between the bottom of the button and the nearest neighbor, which is the main view for the scene.
- Set the value to 8.
- Use the Add New Alignment Constraint button |⊟ to add a “Horizontally in container” constraint
- Change the button’s title to “Reload extension”
The final item we need in this scene is a table view that will display the numbers:
- Drag a Table View from the object library to the center of the scene.
- Use the Add New Constraints button (|⁃◻︎⁃|) to add constraints with a value of 0 on the top, left and right and 12 to the bottom.
The table view needs a cell to display names and numbers.
Select the table view and use the property inspector to add and configure a prototype cell:
- Use the properties inspector to set the number of Prototype Cells to 1.
- Select the added cell and set its style to Subtitle
- Set the cell’s Reuse Identifier to CallerCell
Making connections
We need to connect the items in the view controller scene to the view controller code. Open ViewController.swift
. Add properties for the table view & the segmented control and set the datasource
and delegate
for our table view:
// CallKitTutorial/ViewController.swift
@IBOutlet weak var callerType: UISegmentedControl!
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
self.tableView.dataSource = self
self.tableView.delegate = self
self.loadData()
}
Switch back to the Main.storyboard
and use the Connections inspector to connect from new referencing outlet to the View Controller:
- Connect the table view to the
tableView
outlet. - Connect the segmented control to the
callerType
outlet.
If you run your app at this point you should see something like the screen shot above.
###Core Data
While it is tempting to think of Core Data as a database, it is actually an object persistence system. A persistent store saves these objects. The most frequently used persistent store type is an SQLite database.
In Swift you can create objects and those objects can hold values in their properties. Those values can be simple, such as integers or strings, or they can be a reference to other objects. Core Data is very similar.
In Core Data you define entities, rather that object classes. Entities have attributes rather than properties. Xcode creates Swift code that defines objects with properties that correspond to your entities and attributes.
Our application needs to store a list of numbers. They might be numbers we want to block, or numbers we want to identify. Numbers we want to identify need an associated name. We will track number update and deletion to provide changes to the CallKit extension.
Attribute | Type | Notes |
---|---|---|
isBlocked | Boolean | true if this number is on the “block” list |
isRemoved | Boolean | true if this number has been removed from its list |
name | String | The name associated with the number (if it isn’t on the “block” list) |
number | Integer 64 | The phone number |
updatedDate | Date | The updated date/time for the entry |
Phone number formats: You may be wondering why
number
is a 64-bit integer and not, say, a string. There are lots of different ways to represent a phone number. Local, national, international: “8123-1234”, “(02) 8123-1234” and “+61 2 8123-1234” are all different ways of writing the same phone number. The last form is e.164. If you strip away the formatting (+, - and spaces) you get a string of digits “61281231234”. These digits can be stored as an integer. The largest value of a 64-bit integer is 9,223,372,036,854,775,807. That is enough to store any current phone number.
Selecting Use Core Data when we created the app caused Xcode to create an empty data model, CallKitTutorial.xcdatamodeld
, and add Core Data code to the AppDelegate.swift
file.
We need to add an entity and some attributes to the Core Data model.
- Open the
CallKitTutorial.xcdatamodeld
file. - Click Add Entity
- Change the entity name to
Caller
- Click the “+” sign under the attribute list to add a new attribute
- Change the attribute name to
isBlocked
and set the type toBoolean
- Add another attribute
isRemoved
, typeBoolean
- Add
name
, typeString
- Add
number
typeInteger 64
- Add
updatedDate
typeDate
Your Core Data model should now look like this:
The Single View App template puts the Core Data code into the AppDelegate.swift
file. This is OK for a stand-alone app, but we are going to need Core Data code in our app and in the CallKit extension.
We will create a Framework to hold the Core Data code. This will make it easier to share code between the app and the extension.
- Select File->New->Target…
- Scroll to the bottom of the new target window and select Cocoa Touch Framework
- Name it
CallerData
You will see a new folder, CallerData
in the project navigator. Let’s move our Core Data model into the new framework.
- Drag the
CallKitTutorial.xcdatamodeld
file into theCallerData
folder in the project navigator pane. - Select the data model file. Use the checkboxes to change its Target Membership, in the file properties pane, from CallKitTutorial to CallerData.
We will need a file in the framework to hold our Core Data code. Press ⌘-N to create a new file. Select Swift File and name it CallerData.swift
. Make sure that it is being added to the CallerData framework target.
Open this new file and add the following lines to import Core Data and add a class declaration:
// CallerData/CallerData.swift
import Foundation
import CoreData
public final class CallerData {
public init() {
}
}
If you open the AppDelegate.swift
file and look for the Core Data stack section. You will see a declaration for a variable named persistentContainer
and a function saveContext
. It will look like this:
// CallKitTutorial/AppDelegate.swift
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "CallKitTutorial")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
// MARK: - Core Data Saving support
func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
- Select that text (be careful not to select the last } in the file) and cut it to the clipboard
- Open
CallerData.swift
and paste the code in after theinit
function. - Change the
applicationWillTerminate
function inAppDelegate.swift
to remove the lineself.saveContext()
Note: The
saveContext
function needs to be declared aspublic
to ensure that we can still call it from our app after moving it into theCallerData
framework. Simply add thepublic
modifier as you see below
CallerData.swift
should now look like:
// CallerData/CallerData.swift
import Foundation
import CoreData
public final class CallerData {
public init() {
}
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "CallKitTutorial")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
// MARK: - Core Data Saving support
public func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
Playing outside the sandbox
Apps on iOS run inside a sandbox. This is a limited environment that ensures an app can only access its own data. It is an important part of iOS security.
Usually, the sandbox isn’t a problem. Your app only needs to access its own data or system data (such as contacts or photos) with the user’s permission. For this app, we need to share data with our CallKit extension. Even though you are creating both the app and the extension, they each have their own sandbox.
It turns out that iOS provides a solution for this problem; App Groups. Apps (and extensions) that are signed by the same developer team can use an App Group to share files and KeyChain items securely.
We will need to set up an App Group for our project. The first step is to enable the App Groups capability. To do this, select the top-level project item (CallKitTutorial) in the project navigator and then select the CallKitTutorial target. Turn on the switch next to App Groups in the Capabilities tab.
Xcode will update your application provisioning profile to add the App Group entitlement. Once complete, you will see three ticks under Steps.
We now need to add an App Group that the app and the extension will share. App group names must be unique and start with “group.”. Apple recommends adopting a reverse domain name style, as with app bundle IDs. Use something like group.com.mydomain.CallKitTutorial
.
- Click the + sign under “App Groups”
- Enter your App Group name in the dialog box
- Click “OK”
Once again, you will see Xcode updating your provisioning profile. If you haven’t chosen a unique name, you will get an error. Uncheck the group with the error and try again with a different name.
Now that we have associated our app with the App Group, the Core Data code that Xcode added for us needs to change to use it.
Change the persistentContainer
variable declaration in CallerData.swift
to:
// CallerData/CallerData.Swift
lazy var persistentContainer: NSPersistentContainer = {
let momdName = "CallKitTutorial"
let groupName = "YOUR_APP_GROUP_NAME"
let fileName = "demo.sqlite"
guard let modelURL = Bundle(for: type(of: self)).url(forResource: momdName, withExtension:"momd") else {
fatalError("Error loading model from bundle")
}
guard let mom = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Error initializing mom from: \(modelURL)")
}
guard let baseURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupName) else {
fatalError("Error creating base URL for \(groupName)")
}
let storeUrl = baseURL.appendingPathComponent(fileName)
let container = NSPersistentContainer(name: momdName, managedObjectModel: mom)
let description = NSPersistentStoreDescription()
description.url = storeUrl
container.persistentStoreDescriptions = [description]
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
Tip: Make sure you change the
groupName
value to match the group you created earlier.
This purpose of this code is to:
- Get the URL for the Core Data model from the application bundle.
- Initialize an instance of
NSManagedObjectModel
using the model. - Get a URL that refers to the container for our application group. This is a directory that is accessible to the apps that share access to the application group.
- Append a filename to this directory. This filename will be hold the sqlite database used by the Core Data store.
- Create an
NSPersistentContainer
that uses the managed object model - Create an
NSPersistentStoreDescription
that uses the application group file. - Tell the container to use persistent store description.
- And finally, load the persistent store.
Putting things into context
When working with Core Data, you frequently need access to the relevant NSManagedObjectContext
. The managed object context we will need can be retrieved through the viewContext
property of the persistentContainer
. To make this a bit easier, we will add a computed variable to CallerData.swift
that provides access to this property:
public var context: NSManagedObjectContext {
return self.persistentContainer.viewContext
}
Fetching results
It is quite common to use table views and Core Data together. Apple has provided an object that helps with this task; The NSFetchedResultsController
.
You provide the controller with an NSFetchRequest
that retrieves the data you need.
The controller calls its delegate
with results that initially match the query and when the set of results changes. This makes it easy to update the tableview as items are inserted and deleted in Core Data.
For our application we will need two sets of results; Numbers to block and numbers to identify. Let’s add a function to CallData.swift
that will return a NSFetchedRequest
for either of these lists. We will also include options for a “since” date and whether to include “removed” items. We will use these later in the CallKit extension.
Add the following function to CallerData.swift
:
// CallerData/CallerData.swift
public func fetchRequest(blocked: Bool, includeRemoved: Bool = false, since date: Date? = nil) -> NSFetchRequest<Caller> {
let fr: NSFetchRequest<Caller> = Caller.fetchRequest()
var predicates = [NSPredicate]()
let blockedPredicate = NSPredicate(format:"isBlocked == %@",NSNumber(value:blocked))
predicates.append(blockedPredicate)
if !includeRemoved {
let removedPredicate = NSPredicate(format:"isRemoved == %@",NSNumber(value:false))
predicates.append(removedPredicate)
}
if let dateFrom = date {
let datePredicate = NSPredicate(format:"updatedDate > %@", dateFrom as NSDate)
predicates.append(datePredicate)
}
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fr.predicate = predicate
fr.sortDescriptors = [NSSortDescriptor(key: "number", ascending: true)]
return fr
}
The function creates several predicates:
- One to select either blocked or non-blocked numbers.
- One to filter removed numbers if
includeRemoved
isfalse
- One to return records only after any specified date
These predicates are then combined using an and
condition using NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
and applied to the NSFetchRequest
. The fetched objects will be sorted in ascending order of number
.
The final CallerData.swift
should look like this:
// CallerData/CallerData.swift
import Foundation
import CoreData
public final class CallerData {
public init() {
}
// MARK: - Core Data stack
lazy var persistentContainer: NSPersistentContainer = {
let momdName = "CallKitTutorial"
let groupName = "group.me.wilko.CallKitTutorial"
let fileName = "demo.sqlite"
guard let modelURL = Bundle(for: type(of: self)).url(forResource: momdName, withExtension:"momd") else {
fatalError("Error loading model from bundle")
}
guard let mom = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Error initializing mom from: \(modelURL)")
}
guard let baseURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupName) else {
fatalError("Error creating base URL for \(groupName)")
}
let storeUrl = baseURL.appendingPathComponent(fileName)
let container = NSPersistentContainer(name: momdName, managedObjectModel: mom)
let description = NSPersistentStoreDescription()
description.url = storeUrl
container.persistentStoreDescriptions = [description]
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
public var context: NSManagedObjectContext {
return self.persistentContainer.viewContext
}
public func saveContext () {
let context = persistentContainer.viewContext
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
public func fetchRequest(blocked: Bool, includeRemoved: Bool = false, since date: Date? = nil) -> NSFetchRequest<Caller> {
let fr: NSFetchRequest<Caller> = Caller.fetchRequest()
var predicates = [NSPredicate]()
let blockedPredicate = NSPredicate(format:"isBlocked == %@",NSNumber(value:blocked))
predicates.append(blockedPredicate)
if !includeRemoved {
let removedPredicate = NSPredicate(format:"isRemoved == %@",NSNumber(value:false))
predicates.append(removedPredicate)
}
if let dateFrom = date {
let datePredicate = NSPredicate(format:"updatedDate > %@", dateFrom as NSDate)
predicates.append(datePredicate)
}
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fr.predicate = predicate
fr.sortDescriptors = [NSSortDescriptor(key: "number", ascending: true)]
return fr
}
}
Adding data to the view
Now we are ready to connect the data to the view controller.
Open CallKitTutorial/ViewController.swift
and add the following imports to the top of the file:
// CallKitTutorial/ViewController.swift
import CoreData
import CallKit
import CallerData
These lines tell the compiler that we are going to use the CoreData
& CallKit
frameworks as well as our own CallerData
framework.
We need to add some more properties to the view controller code:
// CallKitTutorial/ViewController.swift
private var showBlocked: Bool {
return self.callerType.selectedSegmentIndex == 1
}
lazy private var callerData = CallerData()
private var resultsController: NSFetchedResultsController<Caller>!
- The first is a computed variable that returns
true
if the segmented control has “Blocked” (item 1) selected. - The second is an instance of our
CallerData
object that we use to interact with CoreData. It islazy
, which means that it won’t be created until the first time it is used. - The final property is the
NSFetchedResultsController
that provides the data for the table view. It is an implicitly unwrapped optional (!
) so it must be assigned a value before it is referenced or the app will crash.
Let’s add code to do that:
// CallKitTutorial/ViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
self.tableView.dataSource = self
self.tableView.delegate = self
self.loadData()
}
private func loadData() {
self.navigationItem.title = self.showBlocked ? "Blocked":"ID"
let fetchRequest:NSFetchRequest<Caller> = self.callerData.fetchRequest(blocked: self.showBlocked)
self.resultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.callerData.context, sectionNameKeyPath: nil, cacheName: nil)
self.resultsController.delegate = self
do {
try self.resultsController.performFetch()
self.tableView.reloadData()
} catch {
print("Failed to fetch data: \(error.localizedDescription)")
}
}
@IBAction func callerTypeChanged(_ sender: UISegmentedControl) {
self.loadData()
}
@IBAction func unwindFromSave(_ sender: UIStoryboardSegue) {
}
This adds a function, loadData
, that creates an NSFetchedResultsController
to get the data we need and reload the table view. You can see how the showBlocked
variable provides a convenient shortcut to the segmented control’s value.
The loadData
function is called when the view controller is loaded and when the segmented control’s value is changed. The callerTypeChanged
function handles this second case.
The Add/Edit scene, that we will add shortly, will use the unwindFromSave
function and we will use the reloadTapped
function in part two of this tutorial.
Don’t panic if you see an error on the line self.resultsController.delegate = self
. We will fix this shortly.
Laying it on the table
Having fetched the data, we want to display it in the table view. To do that we need to implement the UITableViewDataSource
protocol.
Scroll to the bottom of ViewController.swift
and add the following lines:
// CallKitTutorial/ViewController.swift
extension ViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
return self.resultsController.sections?.count ?? 0
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.resultsController.fetchedObjects?.count ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CallerCell", for: indexPath)
let caller = self.resultsController.fetchedObjects![indexPath.row]
cell.textLabel?.text = caller.isBlocked ? String(caller.number) : caller.name ?? ""
cell.detailTextLabel?.text = caller.isBlocked ? "" : String(caller.number)
return cell
}
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
switch editingStyle {
case .delete:
if let caller = self.resultsController.fetchedObjects?[indexPath.row] {
caller.isRemoved = true
caller.updatedDate = Date()
self.callerData.saveContext()
}
default:
break
}
}
}
This adds an extension to the ViewController
class that tells the compiler that it conforms to the UITableViewDataSource
and UITableViewDelegate
protocols.
There are three functions that provide the data to the table through the UITableViewDataSource
protocol:
numberOfSections
- This returns the number of sections from the fetched results controller or 0 if there are no sections. We will have 1 section.numberOfRowsInSection
- This returns the number of rows in the tableview. This is either the number of objects fetched or 0 if there are none.cellForRowAt
- This returns a table view cell for the nominated row. We retrieve a cell and aCaller
object from the results. If blocked numbers are being shown then we show the number. If identification numbers are being shown we show the name and the number.
The final function is part of the UITableViewDelegate
protocol. The table view calls this function when the user performs when an editing operation on the table. The only editing operation we want to support is the delete
operation. This operation results from the user swiping left on the table row and tapping the delete button.
When this happens the isRemoved
property is set to true
, the updatedDate
property is set to the current date & time and the updated Caller
object is saved.
Why isn’t the Caller simply deleted? We will see later that the CallKit extension can process incremental updates. To do this, the data needs to hold transactions rather than only the current records.
Fetched results delegate
The NSFetchedResultsController
makes it easy to react to changes in the set of fetched objects. Whenever there is an update to an object that matches the query, the controller calls its delegate
to provide details of the event.
Add another extension to implement the NSFetchedResultsControllerDelegate
:
// CallKitTutorial/ViewController.swift
extension ViewController: NSFetchedResultsControllerDelegate {
// 1. Changes are coming from the NSFetchedResultsController`
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.beginUpdates()
}
// 2. Process a change...
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
let newIndexPath: IndexPath? = newIndexPath != nil ? IndexPath(row: newIndexPath!.row, section: 0) : nil
let currentIndexPath: IndexPath? = indexPath != nil ? IndexPath(row: indexPath!.row, section: 0) : nil
switch type {
case .insert:
self.tableView.insertRows(at: [newIndexPath!], with: .automatic)
case .delete:
self.tableView.deleteRows(at: [currentIndexPath!], with: .fade)
case .move:
self.tableView.moveRow(at: currentIndexPath!, to: newIndexPath!)
case .update:
self.tableView.reloadRows(at: [currentIndexPath!], with: .automatic)
@unknown default:
fatalError()
}
}
//3. All changes have been delivered
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
self.tableView.endUpdates()
}
}
There are three functions in this extension:
controllerWillChangeContent
- This is called prior to one or more updates being delivered to the delegate. Our code tells the tableview that updates are coming. The tableview will hold any visual updates until a subsequent call toendUpdates
didChange
- This is called when the result objects change. There are four possible updates:insert
- A new object has been inserted into the results. We tell the table view about this by callinginsertRows
.delete
- An object has been removed from the results. We tell the table view about this by callingdeleteRows
.move
- An object has been moved up or down the list. We tell the table view about this by callingmoveRow
.update
- An object has been updated. We ask the table view to reload the relevant row so that the new data is shown.
controllerDidChangeContent
- This is called after all updates have been delivered todidChange
. Our code tells the table view that updates are now complete and it can perform any required visual updates (inserting, removing or reloading rows).
Adding data
If you run the app now, you will be able to select between the two number lists, but there is no data to display. Let’s make that “+” do something.
First, let’s prepare our existing view controller:
- Open the
Main.storyboard
file - Select the segmented control.
- Using the Connections Inspector, make a connection between “Value Changed” and the
callerTypeChanged
function. - Drag a View Controller from the object library.
- Click on the “+” button in the navigation bar of the first scene and, while holding ctrl, drag a connection to the new view controller.
- When you release the mouse select “Show” from the popup menu.
- Select the new segue object between the two scenes and set its identifier to “addCaller”
- Click on the Caller Cell item in the first scene’s object list.
- ctrl-drag to the second scene and select “show” from the popup menu.
- Again, select the new segue object between the two scenes and set its identifier to “editCaller”
Now we need to add some controls to the new View Controller so that we can enter and save data.
- Drag a “Navigation Item” from the object library to the new view controller.
- Drag a “Bar Button Item” to the right hand side of the navigation bar.
- Change the button’s “System Item” to “Save” in the attribute inspector
- ctrl-drag from the yellow “View Controller” icon at the top of the scene to the “Exit icon” and select unwindFromSave from the popup.
- Select the new Unwind segue from the left hand object navigator and set its identifier to “unwindFromSave”
- Drag a label from the object library to the top left of the view controller. The exact position doesn’t matter.
- Change the label to “Caller name”
- Place a text field beside this label.
- Select both the label and the text field (select one and then select the other while holding command).
- Select "Editor->Embed in->Stack view menu item.
- Place a label under the first label and change it to “Number”
- Place another text field next to this label.
- Embed the label and text field in a stack view as you did with the first pair.
- Select the two stack views and embed them in a stack view.
- Select this newest stack view and use the |⁃◻︎⁃| button to add top, left and right constraints with a value of 20.
- Set the Alignment of the newest stack view to fill.
- Select the first text field and ctrl-drag to the second and select “Equal Widths” from the popup menu.
Your new scene should look like this:
Now we can create some code for this scene. Create a new Cocoa Touch Class by pressing ⌘-N. Make it a subclass of UIViewController
and name it AddEditViewController
. Ensure you add it to the CallKitTutorial target
Open the new AddEditViewController.swift
file and add
// CallKitTutorial/ViewController.swift
import CallerData
to the top of the file. We also need to add the following properties:
// CallKitTutorial/ViewController.swift
@IBOutlet weak var callerName: UITextField!
@IBOutlet weak var callerNumber: UITextField!
@IBOutlet weak var saveButton: UIBarButtonItem!
var caller: Caller? {
didSet {
self.updateUI()
}
}
var callerData: CallerData!
var isBlocked = false
These define:
- Properties for the name and number text fields.
- A property for the save button in the navigation bar.
- A property for the
Caller
that we are adding or editing. - A property for our
CallerData
Core Data framework object. - A property that tracks whether we are displaying a blocking number or an identification number.
Open the new view controller scene in Main.Storyboard
file. Use the identity inspector to set the scene’s class to AddEditViewController
.
Use the connections inspector to connect the two text fields and the save button to the outlets we defined in the class.
It’s the little things
Attention to small details can really improve the user’s experience of your app. There are a couple of details we can address in our add/edit screen:
- Ensure that the Save button is disabled if the data isn’t correct.
- Ensure that only digits can be entered into the number field
Add the following code to AddEditViewController.swift
:
// CallKitTutorial/AddEditViewController.swift
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.updateUI()
}
private func updateUI() {
guard let caller = self.caller,
let callerName = self.callerName,
let callerNumber = self.callerNumber else {
return
}
callerName.text = caller.name
callerNumber.text = caller.number != 0 ? String(caller.number):""
self.navigationItem.title = caller.name
self.updateSaveButton()
}
private func updateSaveButton() {
self.saveButton.isEnabled = false
guard let name = self.callerName.text,
let number = self.callerNumber.text else {
return
}
self.saveButton.isEnabled = !(name.isEmpty || number.isEmpty)
}
@IBAction func textChanged(_ sender: UITextField) {
self.updateSaveButton()
}
This code calls the updateUI
function when the view is about to appear.
UpdateUI
verifies that the Caller
property has a value and that that object has both a callerName
and a callerNumber
. These values are put into the text field. The screen title is also updated to the caller’s name.
updateSaveButton
ensures that the save button is only enabled if the Caller
’s name and number are not empty.
textChanged
is an action function that we will used to update the save button state as the text fields change.
This takes care of our first requirement.
We can meet the second requirement by checking whether we have a valid number after the user enters each character. This requires a UITextFieldDelegate
function. Add the following code to the bottom of AddEditViewController.swift
:
// CallKitTutorial/AddEditViewController.swift
extension AddEditViewController: UITextFieldDelegate {
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
guard let text = textField.text,
let textRange = Range(range, in: text) else {
return false
}
let updatedText = text.replacingCharacters(in: textRange,
with: string)
if textField == self.callerNumber {
if updatedText.isEmpty {
return true
}
if Int64(updatedText) == nil {
return false
}
} else if textField == self.callerName {
self.navigationItem.title = updatedText
}
return true
}
}
Entering a character triggers this delegate function. Returning true
permits the change. Returning false
prevents it.
The function uses the existing text field value and the proposed change to determine the new value for the field.
The function permits the change if the text field is the number field and the new value can convert to an Int64
.
If the text field is the name field, the change is always permitted. The code also assigns the new value to the screen title.
In order for code to be called, we need to set our view controller as the delegate
for the text fields. Replace the viewDidLoad
function in CallKitTutorial/AddEditViewController.swift
with the following:
// CallKitTutorial/AddEditViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
self.callerName.delegate = self
self.callerNumber.delegate = self
self.title = ""
}
Finally, we need to add a “save” function to CallKitTutorial/AddEditViewController
:
// CallKitTutorial/AddEditViewController.swift
@IBAction func saveTapped(_ sender: UIBarButtonItem) {
if let numberText = self.callerNumber.text,
let number = Int64(numberText) {
let caller = self.caller ?? Caller(context: self.callerData.context)
caller.name = self.callerName.text
caller.number = number
caller.isBlocked = self.isBlocked
caller.isRemoved = false
caller.updatedDate = Date()
self.callerData.saveContext()
}
self.performSegue(withIdentifier: "unwindFromSave", sender: self)
}
This code first ensures that both text fields have valid data.
If an entry is being edited, it retrieves the Caller
Core Data object supplied by the previous view controller. If we are adding a new entry, it creates a new Caller
object. The properties of this object are then set and saved.
Finally, the unwindFromSave
segue is then invoked to exit this view controller.
The storyboard scene requires connections for these functions to work.
Open the AddEditViewController
scene in Main.storyboard
. Using the Connections Inspector:
- Connect Editing Changed in the Sent Event section and the
textChanged
function for both text fields. - Connect the selector outlet of the save button and
saveTapped
function in the view controller.
Passing the caller
We have one last piece of code to add to add to ViewController.swift
. We need code to pass the CallerData
object and the object to be edited to the AddEditViewController
. This is done in prepareForSegue
.
Open ViewController.swift
and add the following function:
// CallKitTutorial/AddEditViewController.swift
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let dest = segue.destination as? AddEditViewController {
dest.isBlocked = self.showBlocked
dest.callerData = self.callerData
if let cell = sender as? UITableViewCell,
let indexPath = tableView.indexPath(for: cell),
let caller = self.resultsController.fetchedObjects?[indexPath.row] {
dest.caller = caller
}
}
}
This function is called when a segue is triggered. It sets the isBlocked
and callerData
properties.
If the sender
(The item that triggered the segue) is a table view cell, we know that the user wants to edit a row. We find the row for the cell and the object for that row. That object is passed to the AddEditViewController
.
At this point you can run the app and you will be able to add, edit and remove both identification and block numbers.
Conclusion
In this tutorial we have created a simple application that uses Core Data to store phone number blocking and identification lists.
- We extracted the Core Data code into a framework so that it is available for re-use in part two.
- We used an Application Group so that the extension we create in part two can access the data from this app
- We implemented basic data validation for the data entered.
In part two we will create a CallKit Extension that provides the entered data to iOS for use in blocking and identifying callers.
If you are looking for a further challenge, you could use PhoneNumberKit to format and display the phone numbers. This framework can also be used to allow the user to enter numbers in common formats.
The completed code for parts one and two of this tutorial series is available on GitHub.
20 July 2019
by Paul Wilkinson