So you already have an app and you are trying to engage users even more... What is a better way to do that than using one of the coolest iOS 10 features - iMessage integration?
This Tuesday Apple rolled out iOS 10 together with Messages framework. It provides building blocks for creating iMessage extensions, which can be either self-contained apps or shipped inside a parent app. To get acquainted with the topic you can watch videos from WWDC 2016 (#1, #2) or follow this tutorial in which I will try to explain how to build such an extension.
ReactiveTODO
In a previous entry about UI bindings I have introduced an application called ReactiveTODO. It is my playground to improve coding skills and test some new ideas. After iOS 10 debut I came to a conclusion that the application is certainly missing a share with a friend feature. And I decided to build that. The final effect is presented below:
The design goal was to introduce a new integration with as little code as possible. The reasoning is that an indie developer would not like to spend weeks on implementing any extensions. In order to achieve the goal, I decided to reuse a todo note list, the main view controller of the application.
Make it build
The "zero step" to introduce iMessage integration for existing project is to make this project build under Xcode 8 with iOS 10 SDK. Having a code base in Swift 2.2, I decided that transitioning to Swift 2.3 is a better idea than Swift 3 for now:
- Not a lot of libraries support Swift 3 yet.
- No code changes are required to make the app work under Swift 2.3.
- Xcode handles Swift 2.3 transition surprisingly well.
How to transition to Swift 2.3?
- Download Xcode 8, install it and open existing project with it.
- Select Edit > Convert > To Current Swift Syntax... from upper menu.
- Select Convert to Swift 2.3 option and finish the dialog.
Xcode will automatically set Use Legacy Swift Language Version to Yes under build setting for the project.
Wait, is that everything? Not really. All third party frameworks have to be rebuilt with Swift 2.3 too. I am using Carthage to build those frameworks, so the first step is to update the application to the latest version for Xcode 8 support. Having latest Carthage on board, it is time to update the dependencies:
carthage update --platform ios --toolchain com.apple.dt.toolchain.Swift_2_3
Usually I do not use --platform ios
setting. This time I had to because of Cartography dependency, which does not compile Mac target for the time being. The --toolchain com.apple.dt.toolchain.Swift_2_3
switch tells Xcode to build dependencies with Swift 2.3 compiler. Otherwise, libraries that support both Swift 2.3 and Swift 3 could build with the latter, which is not compatible with the former.
As expected, the dependencies for the project will still not build correctly:
- Realm uses prebuilt 1.0.2 framework, which was generated by Swift 2.2 compiler. Fortunately, the master branch is compatible with Swift 2.3 (and Swift 3!) already, so there is an easy way out - update Cartfile to fetch from latest master revision.
github "https://github.com/realm/realm-cocoa" "master"
- ReactiveKit decided to port directly to Swift 3 (not ready yet), without Swift 2.3 transitioning period. To circumvent that issue I decided to fork both ReactiveKit & ReactiveUIKit and create
swift-23
branches for both, which are Swift 2.3 compatible[1]. It also forced me to adjust Cartfile slightly:
github "https://github.com/turekj/ReactiveKit" "swift-23"
github "https://github.com/turekj/ReactiveUIKit" "swift-23"
Voila! Dependencies now build properly and the updated app runs as smooth as silk.
Create iMessage extension
To create an empty iMessage extension, select File > New > Target.. and pick iMessage Extension option from a dialog window. It will create a new group (named after your extension) which contains a storyboard, property list and a view controller source stub.
If you examine the view controller stub closely, you will notice that it is not your usual UIViewController
instance, but rather the MSMessagesAppViewController class which derives from the former. This means that:
- You cannot directly use your existing
UIViewController
, because it is not an instance of a requiredMSMessagesAppViewController
class. - Since
MSMessagesAppViewController
is aUIViewController
subclass, full view controller API is available. This opens a possibility to attach a child view controller and it is the best way to reuse existing code.
To reuse existing view controller in an extension it has to be available for the extension target. By default, it is not. There are a few ways to achieve that:
- The easiest is to highlight a view controller's source file in a project explorer, open Utilities pane and select your extension under Target Membership section. It is necessary to do the same for all controller dependent classes.
- The much better (and slightly harder) way is to create a framework and bundle all of the shared classes inside. Then you link the framework to your extension and reuse all the sources.
If you are serious about maintaining your application, then the first option is basically a no go. Soon, you will find yourself repeating that all over again when doing some changes or implementing the next extension. Build time will skyrocket and the code quality will plummet. We will proceed with a framework approach then.
Creating a framework
To create a framework use File > New > Target... menu option again, this time picking Cocoa Touch Framework from a dialog window.
There are a couple of facts that you need to know about frameworks, extensions, and tests setup before we can proceed:
- A target can use classes and protocols defined in a framework as long as the framework is linked to the target.
- Frameworks are dynamic, which means that they have to be accessible for a target that links against them in a runtime. System frameworks (like UIKit) are embedded in the iOS itself, but any custom framework has to be copied to a device alongside the target app.
- iOS extensions are containers embedded inside a parent application.
- Unit test schemes are executed within containing application.
By combining those four rules we can come up with a setup for our application:
- iOS extension should be embedded in the main target (see Embedded Binaries under target settings).
- Any third party dependencies in form of dynamic frameworks should be both linked (see Linked Frameworks and Libraries) and embedded inside the main target.
- Our shared framework should link any third party frameworks needed, but should not embed them. The shared framework will be copied to the main target, alongside any third party dependencies.
- Our extensions should link our shared framework (and any third party frameworks needed), but should not embed them. It will be already copied to a parent container (the main target).
- Unit tests are executed inside the main target, so they do not have to either link or embed any framework directly. On the other hand, they should both link and embed test-only dependencies (an example could be a Quick library). It can be done by setting Framework Search Paths under Build Settings (linking) and Copy Files under Build Phases (embedding) in test target settings.
Considering above, to extract a framework from the main target we have to do the following:
- Move all shared classes (in my case everything, minus
AppDelegate
andmain
definition) to both the framework project group and the project target. The easiest way is to use Finder to move directories. Then, back in the Xcode app, delete recently moved files from the main target group and re-add them to the framework target group. This ensures that the target membership is set properly for all of the files. - Create a new workspace with File > New > Workspace.... The name can be exactly the same as your .xcodeproj. From now on, you should only open .xcworkspace file to use the project.
- In project navigator, under Products group there will be a .framework file for the framework target. Drag and drop this file to both Embedded Binaries and Linked Frameworks and Libraries sections in the main target settings.
- Add all necessary third party dependencies to Linked Frameworks and Libraries for the shared framework target.
- Drag and drop the shared framework product to Linked Frameworks and Libraries for the extension target. If you are using any third party dependencies directly in the extension, you should also link them in here.
The project should build now. You should be able to use classes and protocols defined in shared framework by simply adding an import SharedFrameworkTargetName
on top of the source file. Also, do not forget to set public
modifier for every class / protocol / method from the shared framework that you want to use. Otherwise it will not be visible in other module.
Sharing data between the app and the extension
Since we want to show exactly the same notes in the app and the extension, we need to share our Realm database contents. Out of the box, both of the targets use different file space, so separate databases are created. The result is that the todo notes created inside the application are not visible to iMessage extension.
Fortunately, there is an easy fix for that. First, we need to enable app group capability for both the app and the extension. This can be done by flipping the on-off switch in App Groups section under Capabilities tab in target settings. Then, a group has to be created with a + button. Name the group as group.<your_application_bundle_identifier>.
Once again, remember to do the same for both the main target and the extension target.
Now, let's introduce a helper class to use a shared database:
import Foundation
import RealmSwift
public class AppGroupRealmConfigurationFactory {
let appGroupIdentifier: String
let fileManager: NSFileManager
public init(appGroupIdentifier: String, fileManager: NSFileManager) {
self.appGroupIdentifier = appGroupIdentifier
self.fileManager = fileManager
}
public func createRealmConfiguration() -> Realm.Configuration {
let databaseDirectoryPath = self.fileManager
.containerURLForSecurityApplicationGroupIdentifier(
self.appGroupIdentifier)!
let databasePath = databaseDirectoryPath
.URLByAppendingPathComponent("database.realm")
return Realm.Configuration(fileURL: databasePath)
}
public func updateDefaultRealmConfiguration() {
Realm.Configuration.defaultConfiguration =
self.createRealmConfiguration()
}
}
where:
appGroupIdentifier
is the group identifier you set in Capabilities,fileManager
is theNSFileManager.defaultManager()
.
From now on, if updateDefaultRealmConfiguration()
method is triggered before the first access to the database, any Realm code with default configuration will use a shared database:
// Triggered once in AppDelegate
let appGroup = "group.com.jakubturek.ReactiveTODO"
let fileManager = NSFileManager.defaultManager()
let realmConfigurator = AppGroupRealmConfigurationFactory(
appGroupIdentifier: appGroup,
fileManager: fileManager)
realmConfigurator.updateDefaultRealmConfiguration()
// ... Later in a data access object
let realm = try! Realm()
// Same results are returned both in the app and in the extension
let notes = realm.objects(TODONote.self)
Implement iMessage interaction
We are ready to implement the extension itself. Right now we have following building blocks at our disposal:
- Todo note list view controller. Every entry has a title, priority (represented by an image) and a date. Example entry is shown below (marked with the red rectangle).
- An empty
MSMessagesAppViewController
from iMessage extension.
Todo note list has two capabilities:
- adding a new entry,
- marking an existing entry as completed upon cell selection (effectively removing a note from the list).
Probably the most effortless idea to integrate iMessages with that list is to create send a note feature. We can listen to an existing selection event, convert the note to a message and insert into a message box. Let's examine how to do the insertion part first.
MSMessagesAppViewController has a self-explaining activeConversation
property of type MSConversation. By going one level deeper, one can find out that MSConversation class exposes insert(_:completionHandler:)
method, which allows inserting an instance of MSMessage object to a message field. This means that we are good to go if we find a way to convert our model (TODONote) to a MSMessage. Let's look at an extract from MSMessage docs:
Before using an MSMessage object, you must set both its url and layout properties:
- Encode app-specific data in the message’s url property. Use the NSURLComponents class to easily access, set, or modify a URL’s component parts.
- Define the message’s appearance using the message’s layout property. Use the MSMessageTemplateLayout class to set an image, video, or audio file for the message. This class also defines a number of text elements, such as the message’s title, subtitle, caption, and subcaption.
We do not want to send any app-specific data, but we definitely want a layout so that our friends can see the note. The docs ask us to create an instance of MSMessageTemplateLayout, which looks like this:
This is it! We have got a game plan now:
- Add todo note list as a child view controller to our MSMessagesAppViewController implementation.
- Hook to a selection event, so that we can grab selected TODONote.
- Create a new message layout in form of MSMessageTemplateLayout instance.
- Set a couple of the layout's properties (image, caption, subcaption).
- Assign that layout to a new message instance.
- Insert that message to a message text field of the active conversation by using
insert(_:completionHandler:)
method.
Let's start by creating a MSMessage. Since our selection event handler receives a note identifier instead of a full todo note instance, we need a data access object to get a full instance (hyperlinked only to make this post more concise). Having the DAO instance, we can implement MessageFactory as follows.
@available(iOSApplicationExtension 10.0, *)
public class MessageFactory: MessageFactoryProtocol {
let todoNoteDAO: TODONoteDataAccessObjectProtocol
init(todoNoteDAO: TODONoteDataAccessObjectProtocol) {
self.todoNoteDAO = todoNoteDAO
}
public func createMessage(noteGUID: String) -> MSMessage {
let message = MSMessage()
let layout = MSMessageTemplateLayout()
message.layout = self.configureLayout(layout, forNoteGuid: noteGUID)
return message
}
private func configureLayout(
layout: MSMessageTemplateLayout,
forNoteGuid guid: String) -> MSMessageTemplateLayout {
guard let note = self.todoNoteDAO.getNote(guid) else {
return layout
}
layout.caption = note.note
layout.subcaption = // formatted note.date in here
layout.image = // UIImage initializer based on note.priority
return layout
}
}
Notice the @available
annotation. It is needed since we are working on a legacy project with the iOS 9 deployment target.
Now that we have a MSMessage factory, we have to attach it to a cell selection event. It is a high time for a small off-topic. ;-)
Reusing view controllers
We arrived at the point where a single view controller class is used both by the application and also the extension. In a view controller centric world we have to introduce a new flag to determine the cell selection logic. Let's call it isInvokedFromMessageExtension
for now:
- If
isInvokedFromMessageExtension
is set totrue
, we should invoke aMessageFactory
and insert a new message into an active conversation. We should also hide a button which creates a todo, because adding new notes is not supported in the extension. - If
isInvokedFromMessageExtension
is set tofalse
, we should mark the note as completed.
The other option would be to subclass the view controller and override some parts of the source code to achieve same effect. Either solution is not perfect.
There is a third solution which involves using a FlowController pattern. If you are not familiar with the term, I strongly advise you to read the hyperlinked article and use the pattern in future. For now, it is enough to understand that in a flow controller approach you have to conform a view controller to a protocol like this:
public protocol TODONoteListViewControllerProtocol {
var onSelectTODO: (String -> Void)? { get set }
}
Then, instead of performing any logic upon customizable actions (in our case: cell selection) you only invoke a closure:
let guid = // selected note guid
self.onSelectTODO?(guid)
Then, you roll out a master object which configures the business logic attached to closures. It is called flow controller, because the common scenario is to navigate between view controllers, but the approach is not limited to that. Thanks to this architecture, reusing a view controller is as simple as having two different flow controller objects.
Fortunately, I decided to go with a flow controller pattern from the very beginning. Let's implement a new flow controller to reuse a todo note list:
@available(iOSApplicationExtension 10.0, *)
class MessageFlowController: MessageFlowControllerProtocol {
let messageFactory: MessageFactoryProtocol
init(messageFactory: MessageFactoryProtocol) {
self.messageFactory = messageFactory
}
func startFlow(messageController: MessagesViewController) {
let todoList = messageController.noteListViewController!
todoList.onSelectTODO = { guid in
let message = self.messageFactory.createMessage(guid)
messageController.activeConversation?.insertMessage(
message, completionHandler: nil)
}
todoList.notesView.addButton.hidden = true
}
}
The final step is to configure a todo note list and add it as a child view controller:
import Cartography
import Messages
import ReactiveTODOFramework
import UIKit
class MessagesViewController: MSMessagesAppViewController {
var noteListViewController: TODONoteListViewController?
var flowController: MessageFlowController?
override func viewDidLoad() {
super.viewDidLoad()
self.createNoteListViewController()
self.configureFlow()
}
private func createNoteListViewController() {
self.noteListViewController = // assemble TODONoteListViewController
self.addChildViewController(self.noteListViewController)
self.view.addSubview(self.noteListViewController.view)
self.noteListViewController.didMoveToParentViewController(self)
constrain(self.noteListViewController.view) { v in
v.edges == v.superview!.edges
}
}
private func configureFlow() {
self.flowController = // assemble flow controller
self.flowController?.startFlow(self)
}
}
And... this is it! After the application is installed, you will be able to send a todo note via iMessage:
Final words
This tutorial explained how to add an iMessage extension to an existing application. I hope that this piece is the only thing you will ever need to do this yourself (even when you are starting an app from scratch).
I also hope that using this example I have convinced you that once you get the architecture and setup of your application right, you can implement a fully featured extension in less than 50 lines of code. That is impressive!
You can obtain a full version of ReactiveTODO application at Github.
Yes, this is as easy as updating Xcode project settings. ↩︎