Combine #15: SwiftUI에서 Combine 사용하기 (1)
I’m happy to translate the article for you, but I don’t see the text you’d like translated—only the source line is provided. Could you please paste the content you want translated (excluding any code blocks or URLs you want to keep unchanged)? Once I have the text, I’ll translate it into Korean while preserving the original formatting.
UIKit과 비교
// Modelo
var conditions: String
// Vista
let label = UILabel()
label.text = conditions // copia de `conditions`
label.text는conditions의 복사본입니다.conditions가 변경될 때마다label.text를 수동으로 업데이트해야 합니다.
SwiftUI에서는 뷰가 데이터 모델의 정보를 직접 사용할 수 있어, 복사하거나 수동으로 업데이트할 필요가 없습니다.
노트: 다른 계층의 정보를 직접 결합하는 것이 어느 정도 좋은지 잘 모르겠습니다. 불변성, 보호된 변형, 계층 간 분리와 같은 개념은 유연한 시스템을 만드는 데 크게 도움이 됩니다. 정보를 중복하지 않는 것이 매우 중요하고 반응형으로 작업하는 것이 편리하다는 점은 인정하지만, 이것이 반드시 시스템 계층을 결합해야 한다는 동기가 된다고는 생각하지 않습니다.
한편, UIKit에서 데이터를 반드시 중복해야 한다는 사람은 누구입니까?
ViewController에서 코드 감소
층을 연결할 코드가 없기 때문에 ViewController가 더 작아질 수 있습니다.
노트: 이것은 논쟁의 여지가 있습니다. 이것이 SwiftUI만의 특징이라고 생각하지 않으며, 오히려 선택된 아키텍처의 결과라고 봅니다.
기본 SwiftUI 예제
다음 뷰는 화면 중앙에 “Hola” 텍스트를 표시하고, 오른쪽 상단에 “Settings” 버튼을 배치합니다. 이 버튼은 다른 화면을 위에 표시하는 데 사용됩니다.
struct PoCView: View {
// var sheetVisible = false
var body: some View {
NavigationView {
VStack {
Text("Hola")
}
.navigationBarItems(trailing:
Button("Settings") {
// sheetVisible = true
}
)
}
}
}
왜 “Cannot assign to property: ‘self’ is immutable” 오류가 발생하나요?
body는 계산된 프로퍼티이며, 구조체의 저장된 상태를 변경할 수 없습니다. 이는 불변성 특성 때문입니다.mutating은 메서드(func)에만 사용할 수 있습니다.- 계산된 프로퍼티에서 구조체의 저장된 상태를 직접 수정하는 것은 원칙적으로 불가능합니다.
Source: …
솔루션: 뷰 밖에 상태 저장하기
wrappedValue 속성을 가진 State 인스턴스를 만들고, 그 상태를 body 안에서 수정합니다.
struct PoCView: View {
var sheetVisible = State(initialValue: false)
var body: some View {
NavigationView {
VStack {
Text("Hola")
}
.navigationBarItems(trailing:
Button("Settings") {
sheetVisible.wrappedValue = true
}
)
}
}
}
sheetVisible가 true일 때 시트(sheet) 표시하기
wrappedValue의 변화를 감지하려면 Publisher가 필요합니다. State는 projectedValue라는 퍼블리셔를 제공합니다.
struct PoCView: View {
var sheetVisible = State(initialValue: false)
var body: some View {
NavigationView {
VStack {
Text("Hola")
}
.sheet(isPresented: self.sheetVisible.projectedValue) {
Text("Chao")
}
.navigationBarItems(trailing:
Button("Settings") {
sheetVisible.wrappedValue = true
}
)
}
}
}
property‑wrapper @State
이 구현은 너무 흔해서 SwiftUI가 property‑wrapper @State를 도입했습니다.
wrappedValue에 접근 → 속성 이름(sheetVisible)을 통해.projectedValue(Publisher)에 접근 →$접두사($sheetVisible)를 통해.- 전체 인스턴스에 접근 →
_접두사(_sheetVisible)를 통해.
struct PoCView: View {
@State var sheetVisible = false
var body: some View {
NavigationView {
VStack {
Text("Hola")
}
.sheet(isPresented: self.$sheetVisible) {
Text("Chao")
}
.navigationBarItems(trailing:
Button("Settings") {
sheetVisible = true
}
)
}
}
}
Combine를 사용하여 콘텐츠 다운로드
api.stories()
.receive(on: DispatchQueue.main)
.sink { completion in
if case .failure(let error) = completion {
self.error = error
}
} receiveValue: { stories in
self.allStories = stories
self.error = nil
}
.store(in: &subscriptions)
api.stories()로Publisher를 생성합니다.- 값이 메인 스레드에서 수신되도록 지정합니다.
sink로 구독하면서 완료와 받은 값을 모두 처리합니다.- 구독은 이후 취소를 위해
subscriptions에 저장됩니다.
ObservableObject 데이터 모델용
ObservableObject는 its property objectWillChange를 통해 @Published 중 하나가 변경될 때마다 이벤트를 발생시킵니다. 이를 통해 SwiftUI 뷰는 모델이 변경될 때 자동으로 다시 그려집니다.
class StoriesViewModel: ObservableObject {
@Published var allStories: [Story] = []
@Published var error: Error?
private var subscriptions = Set()
init() {
loadStories()
}
func loadStories() {
api.stories()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
if case .failure(let err) = completion {
self?.error = err
}
} receiveValue: { [weak self] stories in
self?.allStories = stories
self?.error = nil
}
.store(in: &subscriptions)
}
}
뷰에서 ObservableObject 사용하기
struct StoriesView: View {
@ObservedObject var viewModel = StoriesViewModel()
var body: some View {
List(viewModel.allStories) { story in
Text(story.title)
}
.alert(item: $viewModel.error) { error in
Alert(title: Text("Error"), message: Text(error.localizedDescription))
}
}
}
@ObservedObject는 뷰의 내부 저장을 제거하고 모델에 대한 바인딩을 생성합니다.- 또한, 프로퍼티에
Publisher를 추가하여 뷰 계층 구조 내에서 구독하거나 바인딩할 수 있게 합니다.
요약
| 개념 | 무엇을 하는가 | SwiftUI에서 사용 방법 |
|---|---|---|
| 상태 | 단일 진실 원천 | @State var isOn = false |
@State | wrappedValue와 projectedValue를 제공하는 래퍼 | $isOn은 바인딩용, isOn은 읽기/쓰기용 |
ObservableObject | 변경을 알리는 데이터 모델 | @ObservedObject var vm = MyViewModel() |
| Combine | 스트림의 반응형 처리 | api.stories().receive(on:...).sink{...} |
sheet(isPresented:) | Bool에 기반한 모달 뷰를 표시합니다 | .sheet(isPresented: $showSheet) { … } |
이러한 개념을 사용하면 SwiftUI에서 선언형이고 반응형이며 잘 분리된 인터페이스를 구축할 수 있으며, Combine 생태계를 최대한 활용할 수 있습니다.
ReaderView – 오류 처리, 구독 및 SwiftUI 환경
모델 및 뷰
class ReaderViewModel: ObservableObject {
@Published var error: API.Error? = nil
@Published private var allStories = [Story]()
// …
}
struct ReaderView: View {
@ObservedObject var model: ReaderViewModel
// …
}
error가 nil이 아닐 때 알림 표시
폐기된(하지만 동작하는) 코드:
struct ReaderView: View {
var body: some View {
// …
.alert(item: self.$model.error) { error in
Alert(
title: Text("Network error"),
message: Text(error.localizedDescription),
dismissButton: .cancel()
)
}
}
}
임의의 Publisher 구독하기
ObservableObject / ObservedObject 쌍을 반드시 사용할 필요는 없습니다.
onReceive(_:perform:)를 사용해 뷰를 어떤 Publisher에도 구독시킬 수 있습니다.
Timer 예시
private let timer = Timer.publish(every: 10, on: .main, in: .common)
.autoconnect()
.eraseToAnyPublisher()
@State var currentDate = Date() // 뷰의 가변 상태
// 뷰 안에서:
.onReceive(timer) { self.currentDate = $0 }
struct ReaderView: View {
var body: some View {
// …
PostedBy(time: story.time,
user: story.by,
currentDate: self.currentDate)
// …
}
}
참고:
PostedBy는Timer가 이벤트를 발행할 때마다 다시 그려집니다.
Settings를 이용한 반응형 필터링
Settings는 수정될 때 이벤트를 발생시켜야 하는 키워드(keywords)를 저장합니다.
final class Settings: ObservableObject {
@Published var keywords = [FilterKeyword]()
}
Settings와 ReaderViewModel 연결
ReaderViewModel.filter를Settings.keywords의Publisher에 구독시킵니다.- 뷰가 업데이트되도록
filter를@Published로 노출합니다.
class ReaderViewModel: ObservableObject {
@Published var filter = [String]()
// …
}
struct HNReader: App {
let viewModel = ReaderViewModel()
let userSettings = Settings()
private var subscriptions = Set()
init() {
// filter를 keywords에 구독하도록 설정
userSettings.$keywords
.map { $0.map { $0.value } }
.assign(to: \.filter, on: viewModel)
.store(in: &subscriptions)
}
// …
}
SwiftUI Environment 사용
Environment는 Publisher 풀(예: colorScheme, locale, 시간대 등)이며 뷰 계층에 자동으로 주입됩니다.
색상 스키마에 따라 링크 색상 변경
class ReaderViewModel: ObservableObject {
@Environment(\.colorScheme) var colorScheme: ColorScheme
// …
.foregroundStyle(colorScheme == .light ? .blue : .orange)
}
Tip: 실시간으로 변화를 보려면
Debug ▶ View Debugging ▶ Configure Environment Overrides를 활성화하세요.
@EnvironmentObject – 전역 객체 주입
@EnvironmentObject는 전체 뷰 계층 구조에 어떤 객체든 주입할 수 있습니다.
WindowGroup {
ContentView()
.environmentObject(Settings(theme: "Light")) // Primer objeto
.environmentObject(Settings(theme: "Dark")) // Reemplaza al anterior
}
- key‑path를 지정할 필요가 없습니다; SwiftUI는 타입으로 매칭을 찾습니다.
- 같은 타입의
EnvironmentObject를 두 개 가질 수 없습니다.
해결 방법
1️⃣ 서로 다른 타입 사용
final class UserSettings: ObservableObject { … }
final class AdminSettings: ObservableObject { … }
.environmentObject(UserSettings())
.environmentObject(AdminSettings())
// En las vistas:
@EnvironmentObject var userSettings: UserSettings
@EnvironmentObject var adminSettings: AdminSettings
2️⃣ 사용자 정의 EnvironmentKey 정의
(여기서는 표시되지 않지만 다른 대안입니다).
재정렬이 가능한 리스트
SwiftUI는 배열 내 요소를 이동하기 위해 onMove(perform:)를 제공합니다.
.onMove { (indices: IndexSet, newOffset: Int) in
fruits.move(fromOffsets: indices, toOffset: newOffset)
}
- 요소를 마지막으로 끌어다 놓으면
newOffset은 배열 크기보다 커서 “마지막 요소 뒤”를 의미합니다. move(fromOffsets:toOffset:)는 나머지 요소들의 순서를 유지합니다.
예제 코드
// move(fromOffsets:toOffset:) using swapAt(_:_:)
// 요소를 배열에서 제거하고, 대상 인덱스가 배열의 현재 요소 개수보다 클 경우
// 제거한 요소를 배열 끝에 삽입해야 합니다
// (요소를 제거한 뒤).
let source = 0
let destination = 3 // SwiftUI에서 전달됨
let element = fruits.remove(at: source)
if destination > fruits.count {
fruits.append(element)
} else {
fruits.insert(element, at: destination)
}
질문 (미답변)
-
뷰 계층 구조의 루트 뷰.
-
시스템 상태가 저장되고 업데이트되는 유일한 지점.
-
뷰에 연결되는 첫 번째 Publisher.
-
애플리케이션의 주요 ViewModel.
body? -
body가 클래스 메서드라 인스턴스 변수에 접근할 수 없기 때문. -
body가 계산된 변수이고 구조체는 불변이기 때문. -
body가 메인 스레드에 속하기 때문. -
컴파일러가
body안에서 클로저 사용을 허용하지 않기 때문.
SwiftUI의 @State?
- 속성을 전역 Publisher로 변환한다.
- 자식 뷰들 간에 공유 참조를 만든다.
- 뷰 외부에 저장된 값을 수정하고 그 변화에 반응하도록 허용한다.
- 변수를 불변 상수로 변환한다.
ObservableObject?
- 백그라운드에서 뷰를 렌더링한다.
-
@Published가 지정된 속성이 변경될 때 이벤트를 발생시킨다. - SwiftUI 환경 의존성을 캡슐화한다.
- 여러
@State간 업데이트를 동기화한다.
SwiftUI의 @ObservedObject와 @State?
-
@ObservedObject는 뷰가 재생성될 때 값을 유지하지만@State는 그렇지 않다. -
@State는 외부 모델을 관찰하고,@ObservedObject는 자체 상태를 유지한다. -
@ObservedObject는 외부 모델(예: ViewModel)을 관찰하고,@State는 뷰의 내부 상태를 관리한다. - 동일하며, 둘 다 자동으로 Publisher를 생성한다.
뷰 계층 구조에서 동일한 타입의 @EnvironmentObject?
- SwiftUI는 각 자식 뷰마다 복사본을 만든다.
- SwiftUI는 트리에서 찾은 마지막 것을 사용한다.
- SwiftUI는 … (원본에서 텍스트가 불완전함)