WatchKit: A Coordinator pattern for Page-based Navigation

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 WKInterfaceObjects 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 WKInterfaceControllers 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!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s