Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

Haptics Need a Session

~15 min · watchos, haptics, corelocation, swiftui, swift

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A watch that navigates nothing still needs a navigation session

The watch does not measure the route. The phone does that, and the glance tells the wrist what it found. Yet the watch app starts a continuous location session of its own as soon as it is open and the phone is guiding a walk or a drive. The reason is not location at all. It is the taps.

watchOS has haptics made for turns: navigationLeftTurn, navigationRightTurn, navigationGenericManeuver. Apple's page for them is short and strict: you can only use this haptic type when your app is running a continuous background location session. The same session is what keeps the watch app alive with the wrist down and brings it back on a wrist raise. So WatchNavigationSession exists, in its own header's words, for exactly that, and "the phone stays the navigator".

Its settings say how little it asks for. The activity type is .fitness on a walk and .automotiveNavigation on a drive. The accuracy is kCLLocationAccuracyNearestTenMeters with a 10 m distance filter, because the watch's own fix only draws the dot on its little map, and a coarse one spares the battery. Background updates are allowed only while the session runs, and the watch's Info.plist declares the location background mode that makes them possible.

When In Use on the wrist too

Pathfinder asks for location When In Use, never Always, and the watch keeps that rule (architecture invariant 5). It has a price: watchOS lets such a session start only with the app in front. WatchModel.updateSession encodes it in one condition: start when the phone guides, the trip has not arrived, the scene is active, and no session runs yet; stop when the guidance ends or arrives. A session that has started keeps running with the wrist down. One that never started cannot be started from the background, and that is the honest limit the architecture writes down: the watch taps while its session runs, started with its app in front.

The first time, the session asks for permission and remembers what it was about to do (pending), so a yes starts it without another tap. A no is said on the controls page in plain words: "Allow location for Pathfinder in the watch's Settings to tap the turns."

The cue: numbered so it taps once

The phone decides when a tap is due and the watch decides whether to play it. On every guidance moment that deserves a tap (the prepare and imminent stages of a turn, the arrival, going off route, a camera) the phone bumps a serial and puts a Cue with that serial, its kind and the time into the glance. The early stage gets no tap: it is too far ahead to act on.

The watch's side is tapIfDue, and every line answers a way it could go wrong:

  • Once per serial. A cue is played only if its serial is higher than the last one handled. That matters more than it looks: the phone keeps its last cue and puts it in every glance it publishes until the next one, so the same cue reaches the watch again and again. A relaunch does not reset it either, because the last serial is kept in UserDefaults.
  • Consumed before it is judged. The serial is recorded first, then freshness is checked. A cue older than Cue.freshFor = 20 seconds is history: a watch that wakes to an old context does not buzz for a corner already passed, and that cue will never buzz later either.
  • Numbers that never go backwards. The phone seeds its serial from the clock, so a relaunched phone starts above any number the watch has already handled. That holds as long as cues come slower than one a second, which turns do.
  • One tapper per turn. The watch plays the cue only if its session runs, the phone app is on screen, or the trip is a drive. On a walk with the phone app in the background and no watch session, the phone's notification taps the wrist instead (track 5 has both halves of that rule).

WatchHaptics then picks the feel: the navigation haptics while the session runs, the plain .notification tap without one, and .success for arriving and .retry for going off route either way.

Three pages, and a map that must not be touched

The watch app is a vertical page stack. While a trip is guided: the turn ahead, then the way on a map (only when the route's line came along, so never on a Kakao or Google trip), then the controls with Pippa, the voice and End Route. With nothing guided, the first page is Dad's saved places instead.

The map page taught the one watchOS lesson in this track that is about layout. On the watch simulator, a swipe on an interactive SwiftUI Map panned the map instead of paging, so the controls below it could not be reached (coop learning #735, fixed in fc2558d). The map is now Map(position:interactionModes: []), a glance rather than a map to work, and a swipe pages as it does everywhere else. Its dot is the watch's own fix while the session runs, and the phone's matched position otherwise. The learning records one honest gap: the swipe was proven on the simulator, and the Digital Crown on a device was not tested.

Split "when" from "whether". The side that knows the route decides when a tap is due and numbers it. The side that holds the wrist decides whether to play it: once per number, only while fresh, and only if no one else is already tapping.

Code

Watch/WatchNavigationSession.swift: WatchHaptics, verbatim·swift
/// The taps: left and right have their own while the navigation session runs;
/// without one the wrist gets the plain notification tap.
enum WatchHaptics {
  @MainActor
  static func play(_ kind: NavigationGlance.Cue.Kind, navigating: Bool) {
    let device = WKInterfaceDevice.current()
    switch kind {
    case .left: device.play(navigating ? .navigationLeftTurn : .notification)
    case .right: device.play(navigating ? .navigationRightTurn : .notification)
    case .maneuver: device.play(navigating ? .navigationGenericManeuver : .notification)
    case .arrive: device.play(.success)
    case .offRoute: device.play(.retry)
    case .camera: device.play(.notification)
    }
  }
}
Watch/WatchModel.swift: tapIfDue, verbatim·swift
  /// One tap per cue, while it is still about a turn ahead. On a walk with
  /// the phone app in the background and no session of ours, the phone's
  /// notification taps the wrist instead, so this stays quiet then.
  private func tapIfDue(_ glance: NavigationGlance) {
    guard let cue = glance.cue, cue.serial > tappedSerial else { return }
    tappedSerial = cue.serial
    UserDefaults.standard.set(cue.serial, forKey: Self.tappedKey)
    guard Date().timeIntervalSince(cue.at) <= NavigationGlance.Cue.freshFor else { return }
    guard navigation.isRunning || glance.phoneActive || glance.mode == .driving else { return }
    WatchHaptics.play(cue.kind, navigating: navigation.isRunning)
  }
A minimal sketch: once per serial, never stale, remembered across launches (compiled with swiftc and run)·swift
import Foundation

// A minimal sketch of the watch's tap rule: one tap per cue serial, never a
// stale one, and a serial remembered across launches. Times are seconds,
// passed in. `stored` stands in for UserDefaults. The "who taps" guard
// (session, phone app in front, or a drive) is left out here.
enum Kind: String { case left, right, maneuver, arrive, offRoute, camera }

struct Cue {
  let serial: Int
  let kind: Kind
  let at: TimeInterval
}

func haptic(_ kind: Kind, navigating: Bool) -> String {
  switch kind {
  case .left: return navigating ? "navigationLeftTurn" : "notification"
  case .right: return navigating ? "navigationRightTurn" : "notification"
  case .maneuver: return navigating ? "navigationGenericManeuver" : "notification"
  case .arrive: return "success"
  case .offRoute: return "retry"
  case .camera: return "notification"
  }
}

let freshFor: TimeInterval = 20
var stored = 0  // survives a relaunch

func tapIfDue(_ cue: Cue?, now: TimeInterval, navigating: Bool) -> String {
  guard let cue, cue.serial > stored else { return "no tap (already handled)" }
  stored = cue.serial  // consumed first, tapped or not
  guard now - cue.at <= freshFor else { return "no tap (stale, \(Int(now - cue.at)) s old)" }
  return "tap: \(haptic(cue.kind, navigating: navigating))"
}

// The phone seeds its serial from the clock, so a relaunched phone starts
// above any number the watch has already handled.
var phoneSerial = 1_700_000_000
func nextCue(_ kind: Kind, at: TimeInterval) -> Cue {
  phoneSerial += 1
  return Cue(serial: phoneSerial, kind: kind, at: at)
}

let left = nextCue(.left, at: 100)
print("glance with a left cue:      ", tapIfDue(left, now: 101, navigating: true))
print("same glance read again:      ", tapIfDue(left, now: 102, navigating: true))
print("watch relaunched, same glance:", tapIfDue(left, now: 130, navigating: true))
let right = nextCue(.right, at: 200)
print("woke 45 s after a right cue: ", tapIfDue(right, now: 245, navigating: true))
let arrive = nextCue(.arrive, at: 300)
print("arrival, no session running: ", tapIfDue(arrive, now: 301, navigating: false))
phoneSerial = 1_700_000_500  // the phone relaunched: a new clock-seeded start
let maneuver = nextCue(.maneuver, at: 400)
print("after a phone relaunch:      ", tapIfDue(maneuver, now: 401, navigating: false))

// Output:
// glance with a left cue:       tap: navigationLeftTurn
// same glance read again:       no tap (already handled)
// watch relaunched, same glance: no tap (already handled)
// woke 45 s after a right cue:  no tap (stale, 45 s old)
// arrival, no session running:  tap: success
// after a phone relaunch:       tap: notification

External links

Exercise

Find a one-shot effect in an app of yours that is driven by state the app re-reads: a sound on a new message, a toast after a sync, a banner on a status change. Make it fire on the same state twice: reload the screen, restart the app, replay the last update. Does it fire again? Add a monotonic number to the event, persist the last one handled, and add an age limit. Then write the three tests from the sketch: read twice, relaunch, stale.
Hint
If the producer can restart, its counter must not restart below numbers it already handed out; seeding from a clock is one cheap way, as long as events come slower than the clock ticks. Persist the consumer's last number in storage that survives a relaunch, and store it before you act.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.