앞에서 DiaryContentCell의 구조를 바꾸면서
ContentSections 구조체로 각 필드를 분리해서 전달하는 구조를 만들었다.
이제 이 데이터를 가지고 어떻게 유효성 검사를 하고,
에러가 발생했을 때 UI를 어떻게 업데이트하는지 흐름을 정리해본다.
1. 유효성 검사가 필요한 필드 정의
감정일기에서 반드시 입력되어야 하는 값은 다음과 같다.
감정 (Emotion)
상황 (Situation)
생각 / 원인 (Thought)
새로운 시각 / 반박 (Reeval)
다음 행동 (Action)
이미지는 선택사항이고, 날짜는 기본값이 Date()이기 때문에
유효성 검사 대상에서 제외했다.
2. 어떤 기준으로 설계했는가?
처음 유효성 검사를 설계할 때 기준은 크게 네 가지였다.
1. ViewModel 층에서 검증
ViewController가 직접 판단하지 않고,
비즈니스 로직은 ViewModel 안에서 처리.
2. 필드별 에러를 명확하게 구분
“그냥 실패했다”가 아니라
“어느 필드가 왜 실패했는지”를 알 수 있어야 함.
3. 한 번에 여러 필드 에러도 표현 가능
상황 + 생각 둘 다 비었으면
에러도 2개가 같이 올라가야 함.
4. MVVM + Combine으로 깔끔하게 전달
ViewModel은 @Published로 결과만 발행
ViewController는 구독해서 UI 업데이트만 담당
3. Validation 모델 만들기
먼저 “어디서 에러가 났는지”를 표현하기 위해 필드를 enum으로 정의했다.
enum DiaryField: Hashable {
case emotion
case situation
case thought
case reeval
case action
}
각 에러는 어떤 필드에서, 어떤 메시지를 보여줄지를 담는 구조체로 표현했다.
struct DiaryValidationError {
let field: DiaryField
let message: String
}
그리고 유효성 검사의 최종 결과를 감싸는 DiaryValidationResult:
struct DiaryValidationResult {
let errors: [DiaryValidationError]
var isValid: Bool {
return errors.isEmpty
}
}
이렇게 만들어두면,
1. 에러가 없으면 isValid == true
2. 에러가 하나라도 있으면 isValid == false + errors 배열에 상세 정보
이런 식으로 다룰 수 있다.
4. ViewModel에서 유효성 검사 로직 구현
4-1. ViewModel에서 검증 진입점: attemptSaveDiary
ViewController는 저장 버튼을 눌렀을 때 ViewModel의 attemptSaveDiary를 호출한다.
extension DiaryEditorViewModel {
// 유효성 검사 + 저장 진입점
func attemptSaveDiary(
situation: String,
thought: String,
reeval: String,
action: String
) {
let result = validateDiaryInputs(
situation: situation,
thought: thought,
reeval: reeval,
action: action
)
// 실패면: 결과만 publish → VC에서 UI 처리
guard result.isValid else {
validationResult = result
return
}
// 성공이면: 저장 로직 실행
saveDiary()
}
}
여기서 핵심은:
- ViewModel은 “저장 버튼 눌렀다”라는 이벤트를 받으면
1. validateDiaryInputs로 검증하고
2. 실패하면 validationResult에 에러를 publish
3. 성공하면 saveDiary()를 호출
→ VC가 직접 “이 값이 비었네?” 같은 조건문을 갖지 않는다는 점이 중요하다.
4-2. 실제 유효성 검사 함수: validateDiaryInputs
func validateDiaryInputs(
situation: String,
thought: String,
reeval: String,
action: String
) -> DiaryValidationResult {
var errors: [DiaryValidationError] = []
let trimmedEmotion = diary.emotion.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedSituation = situation.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedThought = thought.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedReeval = reeval.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAction = action.trimmingCharacters(in: .whitespacesAndNewlines)
// 1) 감정 선택 여부
if trimmedEmotion.isEmpty {
errors.append(
DiaryValidationError(
field: .emotion,
message: NSLocalizedString(
"validation_error_emotion_required",
comment: "Message shown when emotion is not selected"
)
)
)
}
// 2) [상황]
if trimmedSituation.isEmpty {
errors.append(
DiaryValidationError(
field: .situation,
message: NSLocalizedString(
"validation_error_situation_required",
comment: "Message shown when situation is empty"
)
)
)
}
// 3) [생각 / 원인]
if trimmedThought.isEmpty {
errors.append(
DiaryValidationError(
field: .thought,
message: NSLocalizedString(
"validation_error_thought_required",
comment: "Message shown when thought is empty"
)
)
)
}
// 4) [새로운 시각 / 반박]
if trimmedReeval.isEmpty {
errors.append(
DiaryValidationError(
field: .reeval,
message: NSLocalizedString(
"validation_error_reeval_required",
comment: "Message shown when reeval is empty"
)
)
)
}
// 5) [다음 행동]
if trimmedAction.isEmpty {
errors.append(
DiaryValidationError(
field: .action,
message: NSLocalizedString(
"validation_error_action_required",
comment: "Message shown when action is empty"
)
)
)
}
return DiaryValidationResult(errors: errors)
}
여기서 포인트 몇 가지:
1. trimmed... 변수들로 양쪽 공백/줄바꿈 제거
2. 각 필드가 비어 있으면 DiaryValidationError를 추가
3. 최종적으로 errors 배열만 넘김
이 덕분에,
1.“상황 + 생각 + 다음 행동” 같이 여러 필드를 동시에 비워도
2.각각의 에러가 배열에 쌓여서 한 번에 처리할 수 있다.
5. ViewModel → ViewController: Combine으로 결과 전달
ViewModel에서는 유효성 검사 결과를 @Published로 노출한다.
@Published var validationResult: DiaryValidationResult?
ViewController에서는 이를 구독해서 UI를 업데이트한다.
private func bindViewModel() {
diaryEditorVM.$validationResult
.receive(on: RunLoop.main)
.sink { [weak self] result in
guard let self, let result else { return }
self.applyValidationErrors(result.errors)
}
.store(in: &cancellables)
}
여기서 중요한 건:
- ViewModel은 유효성 검사 결과만 던진다.
- “어떻게 표시할지(UI)”는 전부 ViewController 역할.
완전히 MVVM스럽게 역할이 분리된 지점이다.
6. ViewController에서 에러를 UI로 매핑하기
6-1. 에러 적용 엔트리: applyValidationErrors(_:)
private func applyValidationErrors(_ errors: [DiaryValidationError]) {
guard !errors.isEmpty else {
clearAllValidationUI()
return
}
for error in errors {
switch error.field {
case .emotion:
showEmotionError(message: error.message)
case .situation, .thought, .reeval, .action:
showContentError(field: error.field, message: error.message)
}
}
// 첫 오류 위치로 스크롤
if let first = errors.first {
scrollToField(first.field)
}
}
여기서 하는 일:
1. 에러가 없으면 → 기존 에러 UI 전체 제거
2. 에러가 있으면 → 필드별로 분기해서 각각의 셀에 전달
3. 첫 번째 에러 난 위치로 스크롤 (scrollToField)
6-2. 기존 에러 UI 모두 지우기: clearAllValidationUI()
private func clearAllValidationUI() {
// Emotion Cell
if let cell = diaryCollectionView.cellForItem(
at: IndexPath(item: 0, section: DiaryEditorSection.emotion.rawValue)
) as? EmotionCell {
cell.clearError()
}
// Content Cell
if let cell = diaryCollectionView.cellForItem(
at: IndexPath(item: 0, section: DiaryEditorSection.content.rawValue)
) as? DiaryContentCell {
cell.clearAllErrors()
}
}
- 감정 셀(EmotionCell)
- 내용 셀(DiaryContentCell)
각각에 대해 에러 표시를 초기화한다.
6-3. 감정 섹션 에러 표시
private func showEmotionError(message: String) {
let indexPath = IndexPath(item: 0, section: DiaryEditorSection.emotion.rawValue)
guard let cell = diaryCollectionView.cellForItem(at: indexPath) as? EmotionCell else { return }
cell.showError(message: message)
}
EmotionCell 내부에서는:
func showError(message: String) {
errorLabel.text = message
errorLabel.isHidden = false
addEmotionButton.layer.borderColor = UIColor.systemRed.cgColor
addEmotionButton.layer.borderWidth = 1.0
}
func clearError() {
addEmotionButton.layer.borderColor = UIColor.clear.cgColor
addEmotionButton.layer.borderWidth = 0.0
errorLabel.isHidden = true
}
감정이 비어 있을 경우 → 빨간 border + 에러 문구 노출
감정을 선택하면 → configure(with:) 호출 후, 내부에서 상태에 맞게 에러 제거
6-4. 내용 섹션 에러 표시
private func showContentError(field: DiaryField, message: String) {
let indexPath = IndexPath(item: 0, section: DiaryEditorSection.content.rawValue)
guard let cell = diaryCollectionView.cellForItem(at: indexPath) as? DiaryContentCell else { return }
cell.showError(for: field, message: message)
}
DiaryContentCell 쪽에서는 대략 이런 느낌으로 대응한다:
func showError(for field: DiaryField, message: String) {
switch field {
case .situation:
situationSection.showError(message)
case .thought:
thoughtSection.showError(message)
case .reeval:
reevalSection.showError(message)
case .action:
actionSection.showError(message)
default:
break
}
}
각 DiaryContentView는:
- 텍스트뷰 테두리를 빨갛게
- 아래쪽에 에러 라벨 노출
이런 식으로 “당근마켓 글쓰기”처럼 필드별 안내 문구가 보이는 UX를 구성했다.
7. 첫 에러 위치로 자동 스크롤
에러가 여러 개일 때는 사용자에게 어디부터 고쳐야 하는지를 알려주는 게 중요하다.
private func scrollToField(_ field: DiaryField) {
switch field {
case .emotion:
scrollTo(section: .emotion)
case .situation, .thought, .reeval, .action:
scrollTo(section: .content)
}
}
private func scrollTo(section: DiaryEditorSection) {
let indexPath = IndexPath(item: 0, section: section.rawValue)
diaryCollectionView.scrollToItem(at: indexPath, at: .top, animated: true)
}
- 감정 에러 → 감정 섹션으로 스크롤
- 내용 에러 → 내용 섹션으로 스크롤
이 덕분에, 사용자가 “어디가 문제인지 한 번에 눈으로 확인할 수 있게 된다.
8. 입력 시 에러 제거 (UX 마무리)
마지막으로, UX 측면에서 중요한 포인트 하나 더.
- 에러가 떠 있는 상태에서
- 사용자가 해당 필드에 다시 입력을 시작하면
→ 에러 문구가 자연스럽게 사라져야 한다.
이를 위해:
- DiaryContentView.textChanged → 텍스트 변경 시 ViewController가 currentContentSections를 업데이트
- Emotion 선택 시 → EmotionCell에서 clearError() 호출
이렇게 되어 있어서,
사용자가 “상황”을 입력하기 시작하면 상황 에러 라벨이 사라지고,
감정을 선택하면 감정 에러도 바로 없어지는 흐름이 된다.
'감정일기(가칭)' 카테고리의 다른 글
| Popover 위에 “어두운 배경(딤)” 깔고 싶다 (0) | 2025.11.19 |
|---|---|
| 📌 화면 진입 시 감정 선택창 자동 오픈하기, 저장 완료 후 화면 닫기 처리 (0) | 2025.11.18 |
| 📘 DiaryContentCell 리팩토링 기록 — 단일 String 통합 방식에서 → 구조체 기반 다중 필드 관리로 개선하기 (0) | 2025.11.17 |
| 📌 iOS 파일 선택(UIDocumentPicker) 시 이미지가 안 보이던 문제 해결하기 (0) | 2025.11.15 |
| 📘 텍스트뷰가 키보드에 가리지 않도록 자동 스크롤하는 방법 (0) | 2025.11.14 |