Workstation4 / Blog / SwiftUI
SwiftUI SwiftData UX iOS

Rebuilding Sequence around play-first instead of edit-first.

The early build of Sequence had a quiet problem: tapping a routine opened it straight into editing. Looking back, that felt backwards. I almost never launch a timer app to rearrange steps; I launch it to run one. So I took a week to rebuild the app around the play button instead of the pencil, and the interesting part was how much of that change lived in the navigation layer and the data model rather than in the pixels.

The wrong default

In the first version, tapping a routine in the list pushed you into a SwiftUI Form full of editable fields. That made sense while I was building the persistence layer, because I was thinking of a routine as a structure to assemble. But the moment you actually want to use the app, that framing gets in the way. You came to follow a workout, a morning ritual, a focus block. You did not come to fiddle with durations.

The fix was not cosmetic. I rebuilt navigation around a single explicit flow, and I made it explicit in the type system. Every destination in the app is now a case of one Route enum, which keeps navigation centralized instead of scattered across ad hoc destination handlers:

enum Route: Hashable {
    case sequenceOverview(UUID)   // primary tap destination
    case editSequence(UUID)       // secondary, reached via a menu
    case playSequence(UUID, autostart: Bool = false)
    case resumeActiveRun
    case newSequence
    case newSequenceFromTemplate(String)
}

The comments are the whole argument. sequenceOverview is the primary tap destination from the list; editSequence moved behind a menu. Tapping a routine now answers "what is this, and do I want to run it right now," and the play button becomes the obvious next move. The run itself is modeled as a tiny status enum the engine drives: notStarted, running, completed. Editing still exists, it is just a side door rather than the front entrance.

Language shapes the model

While I was at it, I changed a word. Internally, each unit of a routine is a Card: that name survives in the SwiftData model (CardModel) and the engine, where it is a fine name for a polymorphic step. But "Card" had leaked into the interface, and people do not think of a routine as a stack of cards. They think of it as a list of things to do. So in everything user-facing, Cards became Actions: "Add Action," "No actions yet," the Actions section header.

This sounds like a find-and-replace, and on the surface it is. Underneath, it nudged the whole mental model toward play-first. An Action is something you perform; a Card is something you arrange. Keeping the old name at the persistence boundary and the new name at the UI boundary turned out to be a clean split: the model layer stays stable while the words people read match the verb they came to do.

A player that holds still

The running player is the screen people stare at the most, so small instabilities there feel disproportionately distracting. My first version let the layout shift when you paused: controls swapped, elements resized, and the whole view jumped. When you are mid-routine and you pause to catch your breath, the last thing you want is the screen rearranging itself.

The new player is one VStack(spacing: 0) with a fixed skeleton: progress dots at the top, the current action, an "Up Next" preview, a fainter "Then" preview, a Spacer(), and the controls pinned to the bottom. Pausing does not add or remove any of that structure. The pause state only changes content inside the stable frame: a small PAUSED badge appears next to the progress dots, and the big timer numerals dim from .primary to .secondary. Nothing reflows.

The previews are layered so you always see a little of the future without it dominating. The card after the current one renders at 0.9 opacity, the one after that at 0.6, and once more than two actions remain ahead I collapse the rest into a single "+N more" indicator rather than listing them. The dots themselves carry state too: the current dot is 10pt, the rest are 8pt, so your eye finds your place instantly.

The bug that stable identity solved

One subtle fix mattered more than its size suggested, and it was satisfying to track down. Reordering steps in the editor animated badly: rows would glitch and flicker as they moved, as if the wrong items were sliding around. The cause was identity. The list had been keyed on array offsets, so when an item moved, SwiftUI saw position 2 and position 3 swap their contents rather than seeing one specific item travel from one slot to another.

SwiftUI animates by diffing identity. If identity is positional, a reorder looks to the diff like "the content of every affected row changed at once," which is exactly the wrong story to animate. The story I wanted was "this one item moved." The fix was to lean on the stable UUID each card already carried in CardModel, so the list is keyed on the item rather than its index.

// Before: identity is the position, so a move looks like
// every row's content mutated at once.
ForEach(Array(sortedCards.enumerated()), id: \.offset) { _, card in
    CardRowView(card: card)
}

// After: CardModel is Identifiable by its UUID, so a move
// animates as a single row gliding to its new slot.
ForEach(sortedCards) { card in
    CardRowView(card: card)
}
.onMove(perform: moveCards)

There was a second, related decision hiding here. The cards are not stored in display order; each CardModel has an orderIndex, and the view derives sortedCards by sorting on it. So onMove cannot just rearrange an array and walk away. It reorders a local copy, then writes the new positions back so the stored order and the visible order stay in sync:

private func moveCards(from source: IndexSet, to destination: Int) {
    var cards = sortedCards
    cards.move(fromOffsets: source, toOffset: destination)
    for (index, card) in cards.enumerated() {
        card.orderIndex = index   // persist the new order
    }
    sequence.updatedAt = Date()
    try? modelContext.save()
}

With stable identity, the reorder animation became what I had hoped for all along: one row picking itself up and sliding home while the others ease aside. Nothing about the data model changed. The framework just finally understood what was actually happening.

Identity is not a detail you add for correctness alone. In a declarative UI, identity is the story the animation system tells. Get it wrong and even correct data can look broken.

Why the timer survives the lock screen

One thing that quietly made the play-first rebuild trustworthy: the engine never counts ticks. All timer state is derived from timestamps, not from a repeating timer firing every second. The runner stores a start Date and the paused intervals, and computes remaining or elapsed seconds on demand. That means when the app is backgrounded, the screen locks, or the system pauses your timer to save power, the math is still correct the instant you come back, because it is just arithmetic over wall-clock dates. A play-first app has to be honest about time even when nobody is looking at it, and timestamp-based state is what makes that possible without fragile background tasks.

Takeaways

  • Design around the verb people came to do. For a timer app, that verb is play, not edit. Encode it in the navigation: make the primary destination the overview, and put the editor behind a menu.
  • A typed Route enum keeps that decision honest. The cases document intent and stop navigation logic from sprawling.
  • Match vocabulary to intent at the UI boundary while letting the model keep its own names. Cards stayed Cards in CardModel; they became Actions everywhere a person reads.
  • Keep the most-watched screen still. A fixed player skeleton with only content changing on pause feels calm instead of jarring.
  • In SwiftUI, stable identity is what makes reorder animations honest. Key lists on a model's UUID, not array offsets, and write the new order back if you persist it separately.
  • Derive time from timestamps, not ticks, so the app stays correct through backgrounding and lock screens.
W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →