본문 바로가기
감정일기(가칭)

📘 DiaryWriteViewController 설계 문서 (함수·프로퍼티 단위 상세 버전)

by 밤새는 탐험가89 2025. 12. 9.
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에 적용하는 메서드.

동작 방식

  1. 감정 선택 스텝은 항상 **첫 번째 페이지(0번 인덱스)**라고 가정.
    • IndexPath(item: 0, section: 0) 지정.
  2. 현재 화면에 보이는 셀 중에서 해당 indexPath의 셀을 가져옴.
    • 이 시점에 감정 스텝이 화면에 없을 수도 있으므로 guard let 사용.
  3. 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개 이상의 감정을 선택했는지 검사.
  • 동작 순서:
    1. 현재 스텝 타입을 가져옴 (steps[currentStepIndex]).
    2. ViewModel의 canProceedToNextStep(currentStep:) 호출.
    3. false면: Alert는 이미 바인딩된 errorMessage를 통해 표시됨 → VC에서는 단순히 return.
    4. 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는:

  1. ViewModel과의 바인딩 (bindingsVM)
  2. 페이지 이동 제어 (setupPagingControlCallbacks, scrollTo)
  3. 스텝별 셀 생성 및 콜백 연결 (cellForItemAt)
  4. ViewModel 상태를 실제 UI에 반영 (updateEmotionSelectionEnabled, showAlert)

를 담당하는, 철저히 “화면 제어/연결자” 역할을 하는 클래스야.

728x90
LIST