So, I’m very late to the watchOS party. I’m just now getting my hands dirty with this platform, and am having a lot of fun with it! It’s always great to be able to develop with a new piece of hardware while already knowing the fundamentals of doing so.
WatchOS isn’t that much different than iOS. You can tell they used this platform to promote SwiftUI; the scope of an app is small, and that includes the UI. I’m currently still using Storyboards and WatchKit, but you can tell how a lot of the WKInterfaceObject
s have their analogue more in SwiftUI than in UIKit.
While getting accustomed to the platform, the first few apps I’ve knocked together favour the page-based navigation style. The thing about this is there’s no real controller for these pages, and not every use case will make Storyboard-based segue navigation possible. For example, you can’t have a next page segue from InterfaceController
A to InterfaceController
B, then from B back to A. At compile time it finds the circular reference.
No, basically all you have for managing which page is displayed are these two methods:
WKInterfaceController.reloadRootPageControllers(withNames names: [String],
contexts: [Any]?,
orientation: WKPageOrientation,
pageIndex: Int)
and the instance method:
.becomeCurrentPage()
So that doesn’t really allow us to do much. Usually an app that displays pages displays them because they share something in common, so they probably have some common purpose. Enter the Coordinator.
Enter the Coordinator
The idea I applied here which is working brilliantly is the idea of a Coordinator pattern. The Coordinator is responsible for creating a “Scene”, and managing which WKInterfaceController
s will be in that scene. The nice thing here is your app can have many coordinators based on the current context the app is in.
For example, say I create a WatchApplicationController
object, and organize it like this:
import WatchKit
class ExtensionDelegate: NSObject, WKExtensionDelegate {
let applicationController = WatchApplicationController()
func applicationDidFinishLaunching() {
applicationController.interfaceMode = .roundOnCourse
}
// ... rest of implementation
}
Then let’s look at that class. (To give some more context, in this case my WatchApplicationController
is for a watch app that is related to going golfing. Typically you either go practice at the driving range, or you go play a round of golf on a course.)
import WatchKit
class WatchApplicationController: NSObject {
enum InterfaceContext: String {
case modeSelect
case drivingRange
case roundOnCourse
}
var interfaceMode: InterfaceContext = .modeSelect {
didSet {
reloadUI(mode: self.interfaceMode)
}
}
var rangeSessionCoordinator: RangeSessionCoordinator?
var golfRoundCoordinator: GolfRoundCoordinator?
func reloadUI(mode: InterfaceContext) {
switch mode {
case .modeSelect:
break
case .drivingRange:
if rangeSessionCoordinator == nil {
rangeSessionCoordinator = RangeSessionCoordinator()
}
rangeSessionCoordinator?.showMainScene()
case .roundOnCourse:
if golfRoundCoordinator == nil {
let round = GolfRound.forTesting(numGolfers: 4)
golfRoundCoordinator = GolfRoundCoordinator(golfRound: round)
}
golfRoundCoordinator?.showMainScene()
}
}
}
(Note here I haven’t yet included the aspect of the app that deals with the WatchConnectivity
framework and passing data back and forth between the Watch and the iPhone, but you can tell that this class is likely going to be where those incoming messages from the phone will be handled.)
You can see that currently the app has 3 different “Scenes” (or Contexts) I want to show the user, based on the various features of the app. Ultimately, all these Coordinators are doing are owning a call to the method listed above WKInterfaceController.reloadRootPageControllers(...)
In the end, the basis for all my page-based navigation work with the following baseclasses, subclassed as required, likely to inject data models, and add methods to the coordinator as required for the UI. So here we go:
The Code Recipe: Your Copy-Paste job
Coordinator.swift
import Foundation
import WatchKit
extension Notification.Name {
static let becomeCurrentPageRequest = Notification.Name(rawValue: "becomeCurrentPageRequest") // the object is a String that matches the controller's identifier
}
protocol Coordinated {
var coordinator: Coordinator? { get }
// a unique identifier for addressing objects via notifications.
var coordinationId: String { get }
var pageIndex: Int { get }
var totalPages: Int { get }
}
struct PageTransition {
enum Kind {
/// Just transition to the destination
case show
/// transition to the destination, stay there for `duration`, then return to the source
case flashTarget(duration: TimeInterval)
}
let kind: Kind
let sourceCoordinationId: String
let destinationCoordinationId: String
}
struct CoordinatedConfig {
let coordinator: Coordinator?
let controllerIdentifier: String // this is given in the storyboard
let instanceId: String
let pageIndex: Int
let totalPages: Int
let context: Any? // i.e. a data model that you'll have to cast.
}
class Coordinator {
// their order in the array denotes their page index. You should set these in your init
var pageInstanceIDs: [String] = []
// these are set when a Coordinated InterfaceController didAppear()
private(set) var currentPageIndex = 0
private(set) var currentCoordinatedId = ""
// if become current page requests don't respond timely, we reload the scene from a specific page.
private var transitionNotificationTimeout: Timer?
init() {
}
func showMainScene(startingPageIndex: Int = 0) {
print("You should subclass this method in your specific coordinator. Typically you'll create a CoordinatedConfig as your contexts, and make sure the controller identifiers correspond to controllers that conform to Coordinated")
}
func move(with pageTransition: PageTransition) {
switch pageTransition.kind {
case .show:
transition(to: pageTransition.destinationCoordinationId)
case .flashTarget(let duration):
transition(to: pageTransition.destinationCoordinationId)
Timer.scheduledTimer(withTimeInterval: duration,
repeats: false) { [unowned self] (_) in
self.transition(to: pageTransition.sourceCoordinationId)
}
}
}
private func transition(to destinationId: String) {
guard let pageIndex = self.pageInstanceIDs.firstIndex(of: destinationId) else {
print("ERROR: You wanted to transition to a page that isn't managed by this coordinator!")
return
}
// should be long enough to let Notification work, but short enough to still
// keep the UI looking good.
let timeout = TimeInterval(0.5)
transitionNotificationTimeout = Timer.scheduledTimer(
withTimeInterval: timeout,
repeats: false,
block: {
[weak self] (_) in
print("Desired Destination page was not awake. Reloading Scene to desired page.")
self?.showMainScene(startingPageIndex: pageIndex)
})
// NOTE: If the intended controller has not been awoken, then this notification will not work.
// we therefore have a backup, the transitionNotificationTimeout that will reload the scene to the intended page
// we lose animation, but at least it will still work. Also can play with the time inter
NotificationCenter.default.post(name: .becomeCurrentPageRequest,
object: destinationId)
}
func notifyCurrentPage(_ pageNumber: Int, _ controllerCoordinatedId: String) {
transitionNotificationTimeout?.invalidate()
self.currentPageIndex = pageNumber
self.currentCoordinatedId = controllerCoordinatedId
}
}
And then the base class InterfaceController.swift
import WatchKit
import Foundation
class InterfaceController: WKInterfaceController, Coordinated {
// NOTE: You need to name your identifier in the Storyboard the same thing!
static var identifier: String {
let id = String(describing: self)
return id
}
// Coordinated
var coordinator: Coordinator?
var coordinationId: String = "Not Coordinated"
var pageIndex: Int = 0
var totalPages: Int = 1
override func awake(withContext context: Any?) {
guard let config = context as? CoordinatedConfig else {
print("WARNING: Possibly hooked it up wrong. If it was nil, it's likely because this is the initial view controller. Consider changing that to a splash screen.")
self.coordinator = nil
return
}
guard config.controllerIdentifier == Self.identifier else {
fatalError("This controller received a config that is not intended for it. The controller identifiers don't match.")
}
self.coordinator = config.coordinator
self.coordinationId = config.instanceId
self.pageIndex = config.pageIndex
self.totalPages = config.totalPages
registerForBecomeCurrentPageNotifications()
print("Woke with context, page \(self.pageIndex)")
}
private func registerForBecomeCurrentPageNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(handleNotification(_:)), name: .becomeCurrentPageRequest, object: nil)
}
override func willActivate() {
super.willActivate()
}
override func didAppear() {
super.didAppear()
self.coordinator?.notifyCurrentPage(self.pageIndex, self.coordinationId)
}
override func didDeactivate() {
// This method is called when watch view controller is no longer visible
super.didDeactivate()
}
@objc
private func handleNotification(_ note: Notification) {
if note.name == .becomeCurrentPageRequest {
if let controllerCoordinationId = note.object as? String {
if self.coordinationId == controllerCoordinationId {
self.becomeCurrentPage()
}
}
}
}
}
And with these 2 very useful implementations, I’m very pleased with what I can create, and it shows that it is possible to have a little more programmatic control over page-based navigation, especially if you want to animate moving to the next page and/or back again.
You should look at how CoordinatedConfig
gets passed around and how it is a pretty crucial aspect of the Coordinator
. It ultimately is what keeps a reference to your data.
In Practice: An Example Subclass
It might also be worth showing just how one sets up their scene. For example, I have 3 screens involved in my visit to the driving range: One to specify which club I am using, another screen to give feedback on each shot I take, and a third that can show some quick stats about my driving range session so far. This is how I set up that Coordinator:
import Foundation
import WatchKit
class RangeSessionCoordinator: Coordinator {
var statsPageIndex: Int = 2 // TODO: Set this when you are loading the scene
var shotPageIndex: Int = 1 // TODO: Do this differently. This is placeholder.
let rangeSession: DrivingRangeSession
var pages: [CoordinatedConfig]!
init(rangeSession: DrivingRangeSession = DrivingRangeSession()) {
let controllerNames = [
ClubSelectionController.identifier,
RangeSessionShotController.identifier,
RangeSessionStatsController.identifier
]
self.rangeSession = rangeSession
super.init()
var configs: [CoordinatedConfig] = []
for (index, controllerIdentifier) in controllerNames.enumerated() {
let config = CoordinatedConfig(coordinator: self,
controllerIdentifier: controllerIdentifier,
instanceId: UUID().uuidString,
pageIndex: index,
totalPages: controllerNames.count,
context: rangeSession)
configs.append(config)
}
self.pages = configs
self.pageInstanceIDs = configs.map { $0.instanceId }
self.statsPageIndex = self.pageIndex(of: RangeSessionStatsController.identifier)
self.shotPageIndex = self.pageIndex(of: RangeSessionShotController.identifier)
}
// will return 0 if cannot be found.
private func pageIndex(of controllerId: String) -> Int {
guard let index = self.pages.firstIndex(where: { (config) -> Bool in
return config.controllerIdentifier == controllerId
}) else {
return 0
}
return index
}
override func showMainScene(startingPageIndex: Int = 0) {
// don't need to call super
WKInterfaceController.reloadRootPageControllers(
withNames: self.pages.map { $0.controllerIdentifier },
contexts: self.pages,
orientation: .horizontal,
pageIndex: self.pageIndex(of: RangeSessionShotController.identifier)
)
}
func flashStatsPage(returnId: String) {
let statsId = self.pageInstanceIDs[statsPageIndex]
let transition = PageTransition(kind: .flashTarget(duration: 2),
sourceCoordinationId: returnId,
destinationCoordinationId: statsId)
self.move(with: transition)
}
func moveToShotController() {
let shotsId = self.pageInstanceIDs[shotPageIndex]
let transition = PageTransition(kind: .show,
sourceCoordinationId: shotsId,
destinationCoordinationId: shotsId)
self.move(with: transition)
}
}
So there you have it! I’m quite happy with this approach and can see it playing a role in future WatchOS apps. Please write me if you have comments, questions, feedback!