~15 min · universal-ios, swiftui, navigationsplitview, size-classes, xcodegen
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A row may move; it must not leave."
One App, Not an iPhone App and an iPad App
Every family iOS app is one universal target: TARGETED_DEVICE_FAMILY set to 1,2, one binary, one App Store Connect record, one TestFlight build that lands on both the phone and the iPad. There are no separate targets or apps per device, and the code does not branch on the device model. The interface adapts to the space it is given, which is what size classes describe. A horizontally regular iPad shows columns; an iPhone, and an iPad app squeezed narrow in Split View or Slide Over, is horizontally compact and shows a stack. Code that asks "is this an iPad?" gets the second case wrong, which is the reason the rule is written in terms of size class and not device.
NavigationSplitView Does Most of the Work
NavigationSplitView with a List(selection:) sidebar and a detail view is the family's default shape. In regular width it shows the sidebar and detail side by side. In compact width the same view collapses into a navigation stack, where choosing a row pushes the detail and the back button pops it. One declaration, both behaviours, and the prose editor's iPad and iPhone replicas are the same app.
The Detail That Popped by Itself
The collapse has one sharp edge. On iPhone, native Pippa's chat sometimes popped back to the sidebar on its own right after a new conversation's first reply, and the open conversation's title fell back to the default. It happened once after closing a Files picker, so the picker was blamed. It was not the picker. One temporary log line printing the selected id and whether the sidebar's data contained it answered it in a single reproduction: at the moment the reply landed, the conversation had just been bound to its server id, so it had left the list of local-only chats, while the sidebar's server list had been fetched before the chat existed. For a few frames the selected row was in neither list. In compact width, a List(selection:) whose selected row disappears can drop the pushed detail, and it does so only sometimes, depending on timing.
The fix was structural. The open record stays on its local row until the server list is fetched again, the list is refetched right after a send the list does not know about, and local and server rows are drawn in one collection, so the handover is a row moving inside one list rather than a removal from one list and an insertion into another. The rule generalizes to any selection-driven master and detail on iPhone: the selected identity must exist in the list at every render.
Code
project.yml: one universal target·yaml
targets:
SparkMobile:
type: application
platform: iOS
deploymentTarget: "17.0"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.example.spark.mobile
TARGETED_DEVICE_FAMILY: "1,2" # 1 = iPhone, 2 = iPad: one target, one binary, one record
A split view whose selected row can move between sources but never leave the list·swift
import SwiftUI
struct Note: Identifiable, Hashable, Sendable {
let id: String // the client-minted id: stable before AND after the engine knows it
var title: String
var isOnEngine: Bool
}
@MainActor
@Observable
final class Library {
var serverNotes: [Note] = [] // fetched from the engine
var localNotes: [Note] = [] // written here, not yet listed by the engine
/// One list for the sidebar. A note moves from local to server by id; it never leaves.
var rows: [Note] {
let known = Set(serverNotes.map(\.id))
return localNotes.filter { !known.contains($0.id) } + serverNotes
}
func note(_ id: String?) -> Note? { rows.first { $0.id == id } }
}
struct LibraryView: View {
@State private var library = Library()
@State private var selection: String?
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
// Columns on a regular-width iPad, a stack on iPhone and in a narrow iPad split.
NavigationSplitView {
List(library.rows, selection: $selection) { note in
Text(note.title).tag(note.id)
}
.navigationTitle("Notes")
} detail: {
if let note = library.note(selection) {
Text(note.title).navigationTitle(note.title)
} else {
ContentUnavailableView("No Note Selected", systemImage: "doc.text")
}
}
.onChange(of: library.rows) {
// The class of bug: in compact width, a selected row that vanishes can pop the detail.
if let selection, library.note(selection) == nil {
print("selection \(selection) is not in the sidebar rows (size class: \(String(describing: sizeClass)))")
}
}
}
}
Put LibraryView in SparkMobile and run it on an iPhone simulator and an iPad simulator, then put the iPad app into Split View at its narrowest and note when the sidebar collapses. Next, reproduce the pop on purpose: select a note on iPhone, then remove it from both lists for one run-loop turn before adding it back with the same id, and watch the detail. Finally, restore the single rows collection, where the note moves from local to server under its one client-minted id, and show that the same handover no longer pops.
Hint
Task { @MainActor in … } is enough to split the removal and the re-insertion across a turn. Keep the onChange log line: printing whether the selected id is in the rows at each change is the fastest way to see this class of bug.
Progress
Progress is local-only — sign in to sync across devices.