~15 min · universal-ios, swiftui, navigationsplitview, size-classes, xcodegen
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"행은 움직여도 돼. 떠나면 안 돼."
아이폰 앱과 아이패드 앱이 아니라 앱 하나
가족 iOS 앱은 전부 유니버설 타깃 하나야. TARGETED_DEVICE_FAMILY는 1,2이고, 바이너리도 하나, App Store Connect 레코드도 하나, 폰과 아이패드에 모두 설치되는 TestFlight 빌드도 하나야. 기기마다 타깃이나 앱을 따로 두지 않고, 코드가 기기 모델로 갈라지지도 않아. 인터페이스는 주어진 공간에 맞춰가고, 그 공간을 설명하는 게 크기 클래스야. 가로가 regular인 아이패드는 열을 보여주고, 아이폰이나 Split View, Slide Over로 좁아진 아이패드 앱은 가로가 compact라서 스택을 보여줘. "이거 아이패드야?"라고 묻는 코드는 두 번째 경우에서 틀린 답을 내. 규칙을 기기가 아니라 크기 클래스 기준으로 적은 게 그래서야.
일은 대부분 NavigationSplitView가 해
List(selection:) 사이드바와 상세 뷰를 가진 NavigationSplitView가 가족의 기본 구조야. 가로가 regular면 사이드바와 상세를 나란히 보여줘. compact면 같은 뷰가 내비게이션 스택으로 접혀서, 행을 고르면 상세가 밀려 들어오고, 뒤로 버튼을 누르면 빠져나와. 선언 하나로 두 동작이 다 되고, 글쓰기 편집기의 아이패드와 아이폰 복제본도 같은 앱이야.
혼자 튕겨 나간 상세 화면
접히는 구조엔 까다로운 함정이 하나 있어. 아이폰의 네이티브 Pippa에서 새 대화의 첫 답이 오자마자 가끔 채팅이 혼자 사이드바로 튕겨 나갔고, 열려 있던 대화 제목이 기본값으로 돌아갔어. 한 번은 파일 선택기를 닫은 직후에 일어나서 선택기가 의심받았어. 근데 범인은 선택기가 아니었어. 선택된 id와, 사이드바 데이터에 그 id가 들어 있는지를 찍는 임시 로그 한 줄로 재현 한 번 만에 답이 나왔어. 답이 도착한 순간 그 대화는 방금 서버 id를 받아서 로컬 전용 채팅 목록을 떠났고, 사이드바의 서버 목록은 그 채팅이 생기기 전에 받아온 거였어. 몇 프레임 동안 선택된 행이 어느 목록에도 없었던 거지. compact 너비에선 선택된 행이 사라지면 List(selection:)가 밀어 넣었던 상세를 튕겨낼 수 있어. 그것도 타이밍에 따라 가끔만.
고친 건 구조였어. 열린 기록은 서버 목록을 다시 받아올 때까지 로컬 행에 남아. 목록이 모르는 전송이 끝나면 바로 목록을 다시 받아오고, 로컬 행과 서버 행은 한 컬렉션에서 그려. 그러면 넘겨주는 순간에도 행이 한 목록에서 빠져 다른 목록에 들어가는 대신, 한 목록 안에서 움직이기만 해. 이 규칙은 아이폰에서 선택으로 움직이는 목록과 상세 화면 전부에 통해. 선택된 식별자는 매번 그릴 때마다 목록 안에 있어야 해.
Code
project.yml: 유니버설 타깃 하나·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
선택된 행이 출처 사이를 옮겨 다녀도 목록을 절대 떠나지 않는 분할 뷰·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)))")
}
}
}
}
SparkMobile에 LibraryView를 넣고 아이폰 시뮬레이터와 아이패드 시뮬레이터에서 돌려. 그리고 아이패드 앱을 Split View로 가장 좁게 만들어서 사이드바가 언제 접히는지 적어. 이어서 튕김을 일부러 재현해. 아이폰에서 노트를 고르고, 런 루프 한 바퀴 동안 두 목록에서 다 뺐다가 같은 id로 다시 넣고, 상세를 지켜봐. 마지막으로 rows 컬렉션 하나로 되돌려서, 노트가 클라이언트가 만든 id 하나로 로컬에서 서버로 옮겨갈 때 같은 넘겨주기가 더는 안 튕기는 걸 보여줘.
Hint
빼는 동작과 다시 넣는 동작을 런 루프 한 바퀴 간격으로 떼어놓으려면 Task { @MainActor in … }면 충분해. onChange 로그 줄은 남겨둬. 바뀔 때마다 선택된 id가 행에 있는지 찍는 게 이런 버그를 보는 가장 빠른 방법이야.
Progress
Progress is local-only — sign in to sync across devices.