UITableView가 셀을 재사용하고, 버벅이면 cellForRowAt에 브레이크포인트를 걸었다. SwiftUI에는 셀 재사용 API도, 쓸모 있는 백트레이스도 없다. 대신 요소당 뷰 개수, 뷰가 읽는 데이터(의존성), body의 비용 세 가지만 지키면 긴 목록이 빠르다. 1부는 목록을 만드는 도구 — List와 lazy 스택 고르기, ForEach 규칙, iOS 17 선언형 스크롤 API를 다룬다.1부 목표 · 29분
- 화면 요구사항을 보고
List와ScrollView+LazyVStack중 하나를 고르고, 그 이유를 한 문장으로 댄다. ForEach안의if·인라인filter·AnyView를 찾아 "요소당 뷰 1개" 구조로 고친다.onDelete·onMove·EditButton·List(selection:)로 UITableView 편집(스와이프 삭제·재정렬·다중 선택)을 옮긴다.- iOS 17 스크롤 API(
contentMargins,scrollTargetBehavior,scrollPosition(id:),scrollTransition)로 스냅되는 캐러셀을 만든다.
1. List냐 LazyVStack이냐 🔥 핵심
List는 UITableView/UICollectionView 리스트 레이아웃에 해당하는 완성품이다. 선택, 스와이프 액션, 재정렬, 당겨서 새로고침, 섹션 스타일이 들어 있다. ScrollView+LazyVStack은 재료다. 모양은 전부 직접 그리지만, 행 구분선·인셋 같은 시스템 스타일에서 자유롭다.
| UIKit | SwiftUI | 언제 |
|---|---|---|
UITableView (plain/grouped) | List + Section | 설정 화면, 받은편지함처럼 시스템 모양이 맞을 때 |
trailingSwipeActionsConfiguration… | .swipeActions(edge:) | 행 스와이프 버튼 |
UIRefreshControl | .refreshable { await … } | 당겨서 새로고침 |
UICollectionView + 커스텀 셀 | ScrollView + LazyVStack/LazyHStack | 피드, 카드, 가로 셸프처럼 디자인이 자유로운 목록 |
셀 재사용 dequeueReusable… | 없음 — 보이는 행만 만들고 나머지는 추정 | 개발자가 신경 쓸 API가 없다 |
backgroundView로 빈 화면 | ContentUnavailableView | 목록이 비었을 때 |
import SwiftUI
struct Todo: Identifiable, Hashable {
let id = UUID()
var title: String
var isDone = false
}
struct TodoListView: View {
@State private var todos: [Todo] = [Todo(title: "우유"), Todo(title: "운동")]
var body: some View {
List {
Section("오늘") {
ForEach($todos) { $todo in
Text(todo.title)
.strikethrough(todo.isDone)
.swipeActions(edge: .leading) {
Button("완료") { todo.isDone.toggle() }
.tint(.green)
}
.swipeActions(edge: .trailing) {
Button("삭제", role: .destructive) {
todos.removeAll { $0.id == todo.id }
}
}
}
}
}
.refreshable {
await reload() // 끝날 때까지 스피너가 돈다
}
.overlay {
if todos.isEmpty {
ContentUnavailableView("할 일이 없다", systemImage: "checkmark.circle")
}
}
}
private func reload() async {
try? await Task.sleep(for: .seconds(1))
}
}Lazy는 "보이는 만큼 + 조금"만 만든다
ScrollView의 내용은 기본적으로 즉시(eager) 평가된다. WWDC23 · Beyond scroll views는 이를 바꾸는 방법으로 lazy stack을 든다. VStack에 1,000개를 넣으면 1,000개의 init과 body가 첫 화면 전에 돈다.
"Since a LazyVStack doesn't load all of its views, the height of the subviews that are off-screen are estimated." — WWDC26 · Dive into lazy stacks and scrolling
화면 밖 행은 만들지 않으니 높이를 모른다. 그래서 추정한다. 이 추정 때문에 절대 content offset에 기대는 코드는 흔들린다(2부의 lazy stack 수칙 박스).
import SwiftUI
struct FeedView: View {
let posts = (1...500).map { "게시물 \($0)" }
var body: some View {
ScrollView {
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(posts, id: \.self) { post in
Text(post)
.frame(maxWidth: .infinity, minHeight: 80)
.background(.quaternary, in: .rect(cornerRadius: 12))
}
} header: {
Text("최신").font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.bar)
}
}
.padding(.horizontal)
}
}
}스와이프·선택·편집 모드가 필요하면 List. 셀 모양이 시스템과 다르고 위 기능이 필요 없으면 LazyVStack. 행이 수십 개 이하이고 고정이면 그냥 VStack도 된다.
편집 — 삭제 · 재정렬 · 다중 선택
UIKit의 commit editingStyle·moveRowAt·setEditing은 ForEach에 붙이는 모디파이어 두 개와 EditButton으로 바뀐다. onDelete·onMove는 List가 아니라 ForEach(DynamicViewContent)에 붙는다. 아래 API는 전부 iOS 13.0부터 있다(EditButton 문서).
| UIKit | SwiftUI | 메모 |
|---|---|---|
commit editingStyle: .delete | .onDelete { offsets in } | 스와이프 삭제. 인자는 IndexSet |
moveRowAt:to: | .onMove { from, to in } | (IndexSet, Int). 편집 모드에서 핸들이 뜬다 |
editButtonItem / setEditing | EditButton() / @Environment(\.editMode) | 편집 ↔ 완료 토글 |
allowsMultipleSelectionDuringEditing + indexPathsForSelectedRows | List(selection: $ids), ids: Set<ID> | 선택 목록이 곧 @State |
canEditRowAt / canMoveRowAt | .deleteDisabled(_:) / .moveDisabled(_:) | 행 단위로 끈다 |
import SwiftUI
struct Chore: Identifiable {
let id = UUID()
var title: String
var isPinned = false
}
struct ChoreEditor: View {
@State private var chores = [Chore(title: "공지", isPinned: true),
Chore(title: "빨래"), Chore(title: "청소")]
@State private var selection = Set<Chore.ID>()
var body: some View {
NavigationStack {
List(selection: $selection) {
ForEach(chores) { chore in
Text(chore.title)
.deleteDisabled(chore.isPinned) // canEditRowAt
.moveDisabled(chore.isPinned) // canMoveRowAt
}
.onDelete { chores.remove(atOffsets: $0) } // IndexSet
.onMove { chores.move(fromOffsets: $0, toOffset: $1) } // moveRowAt
}
.toolbar {
EditButton() // 편집 ↔ 완료
Button("선택 삭제") {
chores.removeAll { selection.contains($0.id) }
selection.removeAll()
}
.disabled(selection.isEmpty)
}
}
}
}"You must delete the corresponding item within action, as it will be called after the row has already been removed from the List." — onDelete(perform:) 문서
클로저가 불릴 때 행은 이미 화면에서 빠졌다. 배열에서 지우지 않으면 데이터와 화면이 어긋난다.
다중 선택은 List(selection:)에 Set<ID> 바인딩을 주면 켜진다. List 문서에 따르면 iPhone에서는 편집 모드에 들어가야 행 옆에 선택 원이 뜨고 탭으로 여러 개를 고른다. EditButton은 환경값 editMode(Binding<EditMode>?)를 토글할 뿐이다. 편집 중에만 보일 UI는 @Environment(\.editMode)를 읽고 editMode?.wrappedValue.isEditing으로 분기한다.
2. ForEach 규칙 — 요소당 뷰 개수를 일정하게 🔥 핵심
리스트 성능에서 가장 중요한 단일 규칙이다. Apple ForEach 문서에 직접 적혀 있다.
"Some containers like List or LazyVStack will query the elements within a for each lazily. To obtain maximal performance, ensure that the view created from each element in the collection represents a constant number of views."
List와 LazyVStack은 요소를 필요할 때 조회한다. 최고 성능을 내려면 요소 하나가 만드는 뷰 개수를 항상 같게 하라는 뜻이다.
이유는 WWDC23 · Demystify SwiftUI performance가 설명한다. List는 행을 필요할 때 만들지만 모든 행의 ID는 미리(eagerly) 모은다. 요소당 뷰 개수가 일정하면 ID만 훑으면 되지만, 일정하지 않으면 개수를 알려고 모든 행의 뷰를 실제로 만들어야 한다.
import SwiftUI
struct Dog: Identifiable {
let id = UUID()
var name: String
var likesFetch: Bool
}
struct DogRow: View {
let dog: Dog
var body: some View { Text(dog.name) }
}
struct BadDogList: View {
let dogs: [Dog]
var body: some View {
List {
ForEach(dogs) { dog in
if dog.likesFetch { // ① 요소당 1개 또는 0개
AnyView(DogRow(dog: dog)) // ② 개수를 전혀 알 수 없다
}
}
ForEach(dogs.filter { $0.likesFetch }) { dog in // ③ 갱신마다 전체 순회
DogRow(dog: dog)
}
}
}
}import SwiftUI
struct Dog: Identifiable {
let id = UUID()
var name: String
var likesFetch: Bool
}
@Observable final class Kennel {
var dogs: [Dog] = [] {
didSet { fetchLovers = dogs.filter(\.likesFetch) } // 데이터가 바뀔 때만
}
private(set) var fetchLovers: [Dog] = []
}
struct GoodDogList: View {
let kennel: Kennel
var body: some View {
List {
ForEach(kennel.fetchLovers) { dog in
HStack { // 행 안의 분기는 행 수에 영향 없음
Text(dog.name)
if dog.name.count > 5 {
Image(systemName: "star.fill")
}
}
}
}
}
}Scheme → Run → Arguments Passed On Launch에 -LogForEachSlowPath YES를 넣으면, 이 컨테이너 안에서 비일정 개수를 만드는 뷰를 만날 때 SwiftUI가 로그를 남긴다(ForEach 문서).
예외: 섹션을 만드는 중첩 ForEach는 괜찮다
WWDC23은 중첩 ForEach를 되도록 평평하게 하라고 하면서, 동적 섹션은 권장되는 예외로 든다. SwiftUI가 이 구조를 이해해서 빠르게 그린다.
import SwiftUI
struct Pet: Identifiable {
let id = UUID()
var name: String
}
struct ToyGroup: Identifiable {
let id: String // 장난감 이름
var pets: [Pet]
}
struct SectionedPets: View {
let groups: [ToyGroup]
var body: some View {
List {
ForEach(groups) { group in
Section(group.id) {
ForEach(group.pets) { pet in
Text(pet.name)
}
}
}
}
}
}행을 스택으로 감싸 개수를 고정할 때, listRowBackground 같은 행 모디파이어는 스택 안이 아니라 스택 뒤에 붙인다(WWDC23 10160).
3. iOS 17 스크롤 API — 여백·스냅·위치·효과
UIKit에서는 contentInset, isPagingEnabled, scrollViewWillEndDragging(_:withVelocity:targetContentOffset:), contentOffset KVO로 하던 일이다. iOS 17은 이를 전부 선언형 모디파이어로 내놨다. 모두 iOS 17.0부터 쓸 수 있다.
| UIKit | SwiftUI (iOS 17) | 한 줄 요약 |
|---|---|---|
contentInset / verticalScrollIndicatorInsets | .contentMargins(_:_:for:) | 콘텐츠와 인디케이터 여백을 따로 준다 |
isPagingEnabled | .scrollTargetBehavior(.paging) | 스크롤뷰 크기 단위로 넘김 |
targetContentOffset 조작 | .scrollTargetBehavior(.viewAligned) + .scrollTargetLayout() | 개별 뷰 모서리에 스냅 |
scrollToItem(at:) + 현재 인덱스 계산 | .scrollPosition(id:) | Binding 하나로 읽기·쓰기 양방향 |
scrollViewDidScroll에서 transform | .scrollTransition | 보이는 영역 가장자리에서 시각 효과 |
| 셀 크기 = 컬렉션뷰 폭 계산 | .containerRelativeFrame | GeometryReader 없이 컨테이너 기준 크기 |
여백: padding이 아니라 contentMargins
WWDC23 10159: 스크롤뷰에 padding을 주면 스크롤뷰 자체가 줄어서 스크롤 중 콘텐츠가 잘린다. 스크롤뷰는 전체 폭을 쓰고, 콘텐츠 여백만 늘려야 다음 카드가 살짝 보인다.
import SwiftUI
struct ClippedShelf: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(0..<10, id: \.self) { i in
Color.orange.frame(width: 240, height: 140)
}
}
}
.padding(.horizontal, 20) // 스크롤뷰 자체가 20pt씩 좁아진다
}
}import SwiftUI
struct PeekingShelf: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(0..<10, id: \.self) { i in
Color.orange.frame(width: 240, height: 140)
}
}
}
.contentMargins(.horizontal, 20, for: .scrollContent)
}
}safeAreaPadding도 비슷하게 보이지만, 스크롤뷰는 safe area를 콘텐츠와 인디케이터 모두에 적용한다. 둘을 따로 주고 싶으면 contentMargins의 for: 인자(.scrollContent / .scrollIndicators)를 쓴다(10159).
스냅 · 위치 · 효과를 한 번에
import SwiftUI
struct Palette: Identifiable, Hashable {
let id: Int
var color: Color { Color(hue: Double(id) / 10, saturation: 0.6, brightness: 0.9) }
}
struct GalleryView: View {
private let palettes = (0..<10).map(Palette.init)
@State private var currentID: Palette.ID?
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
VStack(alignment: .leading) {
Text("현재: \(currentID.map(String.init) ?? "-")")
.padding(.horizontal)
ScrollView(.horizontal) {
LazyHStack(spacing: 12) {
ForEach(palettes) { palette in
RoundedRectangle(cornerRadius: 16)
.fill(palette.color)
.aspectRatio(3 / 2, contentMode: .fit)
.containerRelativeFrame(.horizontal,
count: sizeClass == .regular ? 2 : 1,
spacing: 12)
.scrollTransition { content, phase in
content
.scaleEffect(phase.isIdentity ? 1 : 0.9)
.opacity(phase.isIdentity ? 1 : 0.6)
}
}
}
.scrollTargetLayout() // 스냅·위치 계산 대상 = 이 스택의 자식들
}
.contentMargins(.horizontal, 20, for: .scrollContent)
.scrollTargetBehavior(.viewAligned) // 손을 떼면 카드 모서리에 멈춤
.scrollPosition(id: $currentID) // 읽기: 스크롤하면 갱신 / 쓰기: 대입하면 이동
.scrollIndicators(.hidden)
Button("처음으로") {
withAnimation { currentID = palettes.first?.id }
}
.padding(.horizontal)
}
}
}"When using lazy stacks, it's important to use the scroll target layout modifier. Views outside the visible region have not yet been created." — WWDC23 · Beyond scroll views
lazy stack에서는 화면 밖 뷰가 아직 없다. 그래서 개별 뷰에 .scrollTarget()을 붙이는 대신, 레이아웃(스택) 전체에 .scrollTargetLayout()을 붙여야 어디에 멈출지 계산된다.
scrollTransition의 content는 VisualEffect다. scaleEffect·opacity·rotationEffect·offset처럼 콘텐츠 크기를 바꾸지 않는 효과만 된다. .font처럼 크기를 바꾸는 모디파이어는 빌드되지 않는다(10159). 또 lazy stack에서는 효과로 뷰를 원래 자리 밖(보이는 영역 안쪽)으로 밀어 넣지 않는다 — lazy stack은 원래 위치로 가시성을 판단한다(WWDC26 321).
.scrollIndicators(.hidden)은 마우스가 연결되면 인디케이터를 다시 보여준다. 마우스로는 인디케이터 없이 가로 스크롤이 어렵기 때문이다. 정말 숨기려면 .never를 쓰되, 이전/다음 버튼(위 예제의 scrollPosition 쓰기)을 대안으로 둔다(10159).
iOS 18은 ScrollPosition 타입과 .scrollPosition(_:)을 추가했다. id뿐 아니라 가장자리·특정 오프셋으로도 이동한다. 또 .onScrollGeometryChange, .onScrollTargetVisibilityChange, .onScrollVisibilityChange로 스크롤 상태에 반응한다(모두 iOS 18.0+). iOS 17 타깃에서는 if #available(iOS 18, *)로 감싼다.
import SwiftUI
@available(iOS 18, *)
struct JumpToShowcase: View {
@State private var position = ScrollPosition(idType: String.self)
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<50, id: \.self) { Text("Step \($0)").frame(height: 60) }
Text("쇼케이스").font(.title).id("showcase-header")
}
}
.scrollPosition($position)
.overlay(alignment: .bottom) {
Button("쇼케이스로") {
withAnimation { position.scrollTo(id: "showcase-header") }
}
}
}
}✅ 복습 · 5분
List가 "요소당 뷰 개수가 일정해야" 빠른 이유는?
if, AnyView) 개수를 알기 위해 모든 행의 뷰를 실제로 만들어야 한다.가로 캐러셀에서 .padding(.horizontal, 20) 대신 무엇을 쓰고, 왜?
.contentMargins(.horizontal, 20, for: .scrollContent). padding은 스크롤뷰 자체를 좁혀서 스크롤 중 카드가 잘린다. contentMargins는 스크롤뷰는 전체 폭 그대로 두고 콘텐츠만 안쪽으로 밀어 다음 카드가 보이게 한다.iPhone에서 List(selection: $ids)(Set)를 줬는데 탭해도 여러 행이 안 골라진다. 무엇이 빠졌나?
EditButton()을 두거나 editMode 환경값을 .active로 바꾼다. 키보드·트랙패드가 있는 iPad·Mac은 편집 모드 없이도 된다.struct EvenList: View {
var body: some View {
List {
ForEach(0..<6, id: \.self) { i in
if i.isMultiple(of: 2) {
Text("\(i)")
}
}
}
}
}🔮 코드 예측: 위 List에는 행이 몇 개 보이고, 성능 규칙상 무엇이 문제인가?
ForEach(Array(stride(from: 0, to: 6, by: 2)), id: \.self)처럼 데이터 단계에서 거르면 요소당 1개가 된다. -LogForEachSlowPath YES로 돌리면 로그로도 확인된다.🔁 지난 Day 복습 (Day 4): ForEach(items, id: \.self)에서 같은 문자열이 두 번 나오면 무엇이 문제인가?
Identifiable의 id)를 쓴다.목록이 느려졌을 때 원인을 좁히는 법을 다룬다. Self._printChanges()로 "왜 body가 또 돌았나"를 찾고, 의존성을 좁히고, body 안의 무거운 일을 모델로 옮기고, Instruments로 히치를 잰다.