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

One Tap to Walk There

~14 min · swiftui, state-machine, navigation, ui-testing, swift

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

One button, three maps, one function

Look at a card in the recommended-places list at the top of this track: the place's name, my note, the trip it belongs to, an orange Go here on foot button with a walking figure, and a ⋯ menu that holds Show on map and Remove pin. The orange button is the whole point of the list. A recommendation Dad cannot act on in one tap is just a note.

The map offers the same act. On Apple's map a pin is a button. On Google's map, tapping the pink marker lands in mapView(_:didTap:). On Kakao's map, tapping the point lands in poiDidTapped. All four entry points, list and three maps, call one function: AppModel.walk(to:). That function refuses during a simulation or for an invalid pin, and otherwise wraps the pin as a PendingDestination whose source is .pin, then hands it to offer. There is no pin-specific routing anywhere. A pin becomes a Place (provider pippa, carrying its door when I named one) and walks the same road as every other destination from outside.

A pin is always a walk

Recommendations are things you walk to: a bar, a museum door, a bakery around the corner. So when a pin's destination is taken, the first line of take switches the travel mode: if case .pin = destination.source { setTravelMode(.walking) }. The route then comes from whichever provider is active, Apple, Kakao or Google, so the same tap works at home and abroad. The feature landed in ba5d7c5, the commit that answered todo #350.

Compare my map guides from the first lesson. Tapping a guide's place pin calls go(toGuide:label:), which only asks for a route preview in the current travel mode. It never starts guidance by itself. A recommendation is a stronger signal (I saved it on purpose, and Dad tapped Go), so it goes further, but it still stops at the same door when something else is running.

Never silently replace a route

Pathfinder's handoff rule is older than pins: a destination from outside (my navigate handoff, a pathfinder://navigate link, a pin I put on the map) never silently replaces an active route, or a route Dad is still choosing. offer is where that rule lives.

  • Idle (no trip, and the stage is browse): a pin is taken at once. A route is asked, and guidance starts.
  • Guiding or previewing: the pin waits in pending and a notice says Pippa sent and the place's name. A preview counts as busy, because Dad is in the middle of choosing.
  • Dad accepts: acceptPending ends the running drive (its reason: "switched to a new destination") or cancels the preview first, and only then takes the pin.

take is careful at the end too. Guidance starts only if the routing it just asked for produced a fresh preview, from the same provider, to this very destination. A failed or cancelled route can never start an older preview that happened to still be on screen.

The app's test suite pins the behaviour down. testPinTapStartsWalkingAndWaitsBeforeReplacingADrive starts a driving trip, taps a pin (the drive keeps going, the pin is pending), accepts (now it is a walk to the pin), ends it, taps another pin while idle (a walk starts at once), and finally checks that the provider was asked for exactly [.driving, .walking, .walking].

The button that lost its words (#383)

On a walk in Seville, Dad opened the list and every card showed an orange capsule with no text. Todo #383 is that regression, not the feature's birth. It came right after todo #363 moved the button onto CwkKit's shared .prominentControl style, so that labels would stay on one line.

The cause was two SwiftUI behaviours stacking. The kit's control label fixed the button's size, which asks the label for its ideal size. Inside a List row, a Label answers that proposal with its icon alone. Then the row tinted that icon with the accent colour, which was the capsule's own fill. Orange on orange, no title. Outside a List the same button looked fine, which is why the kit's own suite never saw it.

The fix landed in two places. cwkSiblingKit b65cbab made OneLineLayout offer the label a wide proposal instead of asking for its ideal size, so every family app on that style got it at its next build. Pathfinder's button now names .labelStyle(.titleAndIcon), so the icon is white on the fill. The kit deliberately leaves label styles to callers, so a caller's .iconOnly still wins. And a new test, ControlButtonVisibilityTests, draws the real sheet off-screen in dark and light, finds the first filled capsule and reads its pixels: the label must stand out from the fill and span at least 90% of its title's width. On the old code it finds zero label pixels.

An action from outside proposes; the person in the middle of something disposes. Make the idle case instant and the busy case a question, and route every source through the same function, so the rule cannot be forgotten by the one entry point added last.

Code

Pathfinder app, App/AppModel.swift: taken when idle, asked about when busy (verbatim excerpt)·swift
  private func offer(_ destination: PendingDestination) {
    guard !isSimulating else { return }
    if trip == nil && stage == .browse {
      switch destination.source {
      case .pippa, .pin:
        Task { await take(destination) }
      case .link:
        Task { await choose(destination.place) }
      }
      return
    }
    pending = destination
    switch destination.source {
    case .pippa, .pin: show("Pippa sent \(destination.place.name)")
    case .link: show("\(destination.place.name) is waiting")
    }
  }
Pathfinder app, Views/RecommendedPinsView.swift: the card's walk button after #383 (verbatim excerpt)·swift
                  Button("Go here on foot", systemImage: "figure.walk") {
                    dismiss()
                    model.walk(to: pin)
                  }
                  .buttonStyle(.prominentControl)
                  // A List row tints a Label's icon with the accent, the
                  // capsule's own fill: name the style so it stays white.
                  .labelStyle(.titleAndIcon)
A minimal sketch (compiled and run with swiftc): the offer rule as a tiny state machine, routing and readiness checks left out·swift
// A minimal sketch of the offer rule: a pin tap starts a walk when nothing
// is happening, and waits for a yes when a trip or a route choice is on screen.
enum Mode: String { case driving, walking }
enum Stage { case browse, preview, guiding }
struct Trip { let destination: String; let mode: Mode }

final class Navigator {
  var stage = Stage.browse
  var trip: Trip?
  var mode = Mode.driving
  var pending: String?
  var notice = ""

  func walk(toPin name: String) { offer(name) }

  private func offer(_ name: String) {
    if trip == nil && stage == .browse { take(name); return }
    pending = name
    notice = "Pippa sent \(name)"                    // asked, never replaced
  }

  func acceptPending() {
    guard let name = pending else { return }
    pending = nil
    if trip != nil { trip = nil; stage = .browse }   // the drive gives way first
    else if stage == .preview { stage = .browse }    // so does a route being chosen
    take(name)
  }

  private func take(_ name: String) {
    mode = .walking                                  // a pin is always a walk
    trip = Trip(destination: name, mode: mode)
    stage = .guiding
  }
}

let nav = Navigator()
nav.trip = Trip(destination: "Home", mode: .driving)
nav.stage = .guiding
nav.walk(toPin: "Tapas bar")
print(nav.trip!.destination, nav.trip!.mode, "| pending:", nav.pending ?? "-", "|", nav.notice)
nav.acceptPending()
print(nav.trip!.destination, nav.trip!.mode, "| pending:", nav.pending ?? "-")

let idle = Navigator()
idle.walk(toPin: "Museum")
print(idle.trip!.destination, idle.trip!.mode, "| pending:", idle.pending ?? "-")

// Output:
// Home driving | pending: Tapas bar | Pippa sent Tapas bar
// Tapas bar walking | pending: -
// Museum walking | pending: -

External links

Exercise

List every way something from outside can change what your app's main screen is doing: a deep link, a push notification, a sync, a shared item, a widget tap. For each, write which function it enters. If they enter different functions, route them all through one offer function with exactly two outcomes, 'take it now' when the user is idle and 'hold it and ask' when the user is busy, and write the test that taps the outside source in the middle of a busy state.
Hint
Define 'busy' honestly. Pathfinder counts a route preview as busy, not just active guidance, because replacing a choice the user is in the middle of making is as rude as replacing the trip. Your app's version of a preview is probably a half-filled form or an open editor.

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.