728x90
SMALL
1. 클래스 개요
DiaryWriteViewController는 감정일기 작성 화면의 **전체 UI 흐름과 ViewModel과의 연결(바인딩)**을 담당하는 View 계층이다.
Core 역할은 다음과 같다:
- ViewModel과 상태 바인딩(Combine)
- 페이지 이동(Prev/Next) 제어
- Step별 CollectionView 셀 렌더링
- 에러 메시지를 Alert로 표시
- 감정 선택 화면과 ViewModel 사이의 이벤트 연결
비즈니스 로직은 ViewModel이 처리하며,
ViewController는 오직 UI·네비게이션·Interaction만 담당하도록 설계되어 있다.
2. 프로퍼티 설명
private var diaryWriteVM: DiaryWriteViewModel
private var cancellables = Set<AnyCancellable>()
2.1 diaryWriteVM: DiaryWriteViewModel
- 역할: 이 화면에서 사용하는 모든 상태/검증/저장 로직을 담고 있는 ViewModel.
- VC에서 하는 일:
- @Published된 값들을 구독해서 UI 업데이트.
- 감정 선택 시 trySelectEmotion, 페이지 이동 시 canProceedToNextStep 호출.
- 특징:
- VC는 데이터를 직접 소유하지 않고, ViewModel을 단일 진실(source of truth)로 취급.
2.2 cancellables: Set<AnyCancellable>
- 역할: Combine 스트림 구독을 보관하는 저장소.
- 왜 필요한가:
- diaryWriteVM.$errorMessage, diaryWriteVM.$canSelectEmotion을 sink로 구독하면, 반환된 AnyCancellable을 강하게 잡고 있어야 스트림이 유지됨.
- VC가 deinit될 때 이 Set이 해제되면서 구독도 자동으로 정리됨.
- 설계 포인트:
- viewDidLoad()에서 bindingsVM() 한 번만 호출 → cancellables에 모든 구독이 저장됨.
3. ViewModel 바인딩 관련 메서드
3.1 bindingsVM()
private func bindingsVM() {
diaryWriteVM.$errorMessage
.compactMap { $0 }
.sink { [weak self] message in
self?.showAlert(message: message)
}
.store(in: &cancellables)
diaryWriteVM.$canSelectEmotion
.sink { [weak self] allowed in
self?.updateEmotionSelectionEnabled(allowed)
}
.store(in: &cancellables)
}
역할
- ViewModel의 @Published 프로퍼티들을 UI에 연결(바인딩)하는 핵심 메서드.
- viewDidLoad()에서 한 번만 호출되며, 이 화면에서 필요한 reactive 흐름을 모두 세팅한다.
내부 구독 1 – errorMessage
- 스트림: diaryWriteVM.$errorMessage
- compactMap { $0 }:
- errorMessage는 String? 타입이므로, nil인 경우는 무시.
- 실제 에러가 발생했을 때만 downstream으로 흘러가도록 필터링.
- sink 동작:
- 새로운 에러 메시지가 들어오면 showAlert(message:) 호출.
- → ViewModel은 Alert 로직을 알지 못하고, 단순히 message 만 전송.
- → VC가 Alert를 띄우는 책임만 가짐.
내부 구독 2 – canSelectEmotion
- 스트림: diaryWriteVM.$canSelectEmotion
- 역할:
- 감정 선택 UI를 활성/비활성화해야 할 때 사용.
- updateEmotionSelectionEnabled(_:) 호출:
- 현재 감정 선택 스텝이 화면에 표시 중일 때, EmotionStepCell에 selection 가능 여부를 전달.
3.2 updateEmotionSelectionEnabled(_ allowed: Bool)
private func updateEmotionSelectionEnabled(_ allowed: Bool) {
let indexPath = IndexPath(item: 0, section: 0)
guard let cell = writeCollectionView.cellForItem(at: indexPath) as? EmotionStepCell else {
return
}
cell.updateSelectEnabled(allowed)
}
역할
- ViewModel이 발행한 canSelectEmotion 값을 실제 화면의 감정 선택 UI에 적용하는 메서드.
동작 방식
- 감정 선택 스텝은 항상 **첫 번째 페이지(0번 인덱스)**라고 가정.
- IndexPath(item: 0, section: 0) 지정.
- 현재 화면에 보이는 셀 중에서 해당 indexPath의 셀을 가져옴.
- 이 시점에 감정 스텝이 화면에 없을 수도 있으므로 guard let 사용.
- EmotionStepCell의 updateSelectEnabled(_:)을 호출해서:
- 내부 EmotionCategoryCell들의 버튼 활성/비활성 처리.
- UI적으로는 버튼이 흐려지거나(알파값) 터치 막힘.
설계 포인트
- 이 메서드는 **“셀 내부 UI를 건드리는 것은 StepCell에게 위임”**한다.
- VC는 “어느 스텝에 어느 상태를 줄지”만 결정하고, 세부 UI는 셀에게 맡김.
4. 페이징 컨트롤 관련 메서드
4.1 setupPagingControlCallbacks()
func setupPagingControlCallbacks() {
pagingControlView.onTapPrev = { [weak self] in
guard let self else { return }
let prev = max(self.currentStepIndex - 1, 0)
self.scrollTo(step: prev)
}
pagingControlView.onTapNext = { [weak self] in
guard let self else { return }
let currentStep = self.steps[self.currentStepIndex]
// 먼저 현재 단계에서 다음 단계로 넘어갈 수 있는지를 ViewModel에 검토 요청
let allowed = self.diaryWriteVM.canProceedToNextStep(currentStep: currentStep)
guard allowed else {
return
}
let next = min(self.currentStepIndex + 1, self.steps.count - 1)
self.scrollTo(step: next)
}
}
역할
- 하단의 pagingControlView(Prev/Next 버튼, 페이지 Dot 등)에 대해
탭 이벤트 발생 시 어떤 동작을 할지 정의하는 메서드.
onTapPrev
pagingControlView.onTapPrev = { [weak self] in
guard let self else { return }
let prev = max(self.currentStepIndex - 1, 0)
self.scrollTo(step: prev)
}
- 역할: 이전 스텝으로 이동.
- 동작:
- currentStepIndex - 1을 계산하되, 최소 0을 보장 (음수 방지).
- scrollTo(step:)를 호출해 화면 스크롤 및 인덱스 업데이트.
- 검증 없음:
- 이전 스텝으로 돌아가는 것은 제한하지 않으므로 ViewModel 검증 필요 없음.
onTapNext
pagingControlView.onTapNext = { [weak self] in
guard let self else { return }
let currentStep = self.steps[self.currentStepIndex]
let allowed = self.diaryWriteVM.canProceedToNextStep(currentStep: currentStep)
guard allowed else { return }
let next = min(self.currentStepIndex + 1, self.steps.count - 1)
self.scrollTo(step: next)
}
- 역할: 다음 스텝으로 이동하기 전에 "지금 상태로 넘어가도 되는지"를 ViewModel에게 묻는다.
- 핵심 포인트:
- “이동해도 괜찮은가?”라는 비즈니스 판단은 VC가 하지 않고, ViewModel이 한다.
- .emotion 스텝에서는 최소 1개 이상의 감정을 선택했는지 검사.
- 동작 순서:
- 현재 스텝 타입을 가져옴 (steps[currentStepIndex]).
- ViewModel의 canProceedToNextStep(currentStep:) 호출.
- false면: Alert는 이미 바인딩된 errorMessage를 통해 표시됨 → VC에서는 단순히 return.
- true면: scrollTo(step:)로 실제 페이지 이동.
5. UICollectionViewDataSource 관련 메서드
5.1 numberOfItemsInSection
func collectionView(_ collectionView: UICollectionView,
numberOfItemsInSection section: Int) -> Int {
return steps.count
}
- 전체 스텝의 개수를 기반으로 페이지 수를 결정.
- 예: .emotion, .situation, .thought, .reeval, .action → 5개라면 5 페이지.
5.2 cellForItemAt
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let step = steps[indexPath.item]
let title = steps[indexPath.item].titleKey
let guide = steps[indexPath.item].guideKey
switch step {
case .emotion:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: EmotionStepCell.reuseIdentifier,
for: indexPath
) as! EmotionStepCell
cell.configure(
title: NSLocalizedString(title, comment: ""),
guide: NSLocalizedString(guide, comment: ""),
categories: EmotionCategory.allCases.filter { $0 != .none }
)
cell.onEmotionSelected = { [weak self] category, subs in
print("선택된 감정: \(category), 세부감정: \(subs)")
let selectedEmotions = EmotionSelection(category: category, subEmotion: subs)
self?.diaryWriteVM.trySelectEmotion(selectedEmotions)
}
cell.onTrySelectEmotion = { [weak self] category, subs in
guard let self else { return false }
let selection = EmotionSelection(category: category, subEmotion: subs)
return self.diaryWriteVM.trySelectEmotion(selection)
}
return cell
default:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentView.backgroundColor = randomColor(indexPath.item)
return cell
}
}
Case .emotion (EmotionStepCell)
configure(...)
- title, guide: 현재 스텝에 맞는 문구 (NSLocalizedString 사용).
- categories: .none을 제외한 모든 EmotionCategory 전달.
onEmotionSelected
- 클릭한 결과가 최종적으로 반영되었을 때 호출되는 콜백.
- 지금은 디버깅 로그와 함께 ViewModel에 trySelectEmotion 호출 → 이 부분은 사실상 onTrySelectEmotion로 충분해서 나중에 정리 가능.
onTrySelectEmotion
- 감정이 “선택될 수 있는지”를 물어보는 콜백.
- StepCell → VC → ViewModel로 이벤트 흐름을 이어주는 브릿지.
- ViewModel이 true/false로 응답하면 StepCell이 UI 업데이트 여부를 결정.
정리
이 ViewController는:
- ViewModel과의 바인딩 (bindingsVM)
- 페이지 이동 제어 (setupPagingControlCallbacks, scrollTo)
- 스텝별 셀 생성 및 콜백 연결 (cellForItemAt)
- ViewModel 상태를 실제 UI에 반영 (updateEmotionSelectionEnabled, showAlert)
를 담당하는, 철저히 “화면 제어/연결자” 역할을 하는 클래스야.
728x90
LIST
'감정일기(가칭)' 카테고리의 다른 글
| 📘 감정일기 작성 화면에서 날짜 선택 UI는 어떻게 설계해야 할까?— 달력을 직접 넣을까, 시트로 띄울까? 구조적 고민과 결론 정리 (0) | 2025.12.10 |
|---|---|
| 📘 단계형 UI(Form Wizard)에서 유효성 검사를 어떻게 설계할까?— 감정일기 작성 화면(DiaryWrite)을 구현하면서 얻은 설계 인사이트 (0) | 2025.12.09 |
| 📘 DiaryWriteViewModel – 설계 문서 (Logic Blueprint) (0) | 2025.12.09 |
| 📘 Emotion Selection Logic - 설계 문서(개발 전 단계 로직 정리) (0) | 2025.12.09 |
| ❌ 한 셀만 열리고, 나머지는 닫히는 상태(sync)가 유지되지 않는다... (0) | 2025.12.08 |