앱에서 자주 마주치는 캘린더 UI. 직접 만들려면 막막하게 느껴질 수 있습니다. 하지만 MVVM 패턴과 컴포넌트 분리 전략을 활용하면 복잡한 캘린더도 체계적으로 구현할 수 있습니다. 오늘은 실제로 동작하는 커스텀 캘린더를 어떤 논리와 순서로 만드는지 상세히 알려드릴게요.
1단계: 프로젝트 설계 및 청사진 그리기
가장 먼저 머릿속으로 캘린더의 구조를 그립니다. 어떤 기능을 담을지, 어떤 파일로 역할을 나눌지 정하는 과정입니다.
- 기능 정의:
- 날짜 표시: 월/일을 표시하고, 주말은 색상을 다르게 표시합니다.
- 데이터 표시: 각 날짜에 수입/지출 금액을 표시합니다.
- 월 이동: 좌우 스와이프를 통해 이전/다음 월로 이동합니다.
- 무한 스크롤: 캘린더가 끝없이 이어지는 것처럼 보이게 합니다.
- 날짜 선택: 특정 날짜를 탭하면 선택 상태를 표시하고, 하단에 상세 내역을 보여줍니다.
- 구조 설계:
- 아키텍처: 모든 비즈니스 로직을 CalendarViewModel에 분리하는 MVVM 패턴을 적용합니다.
- View 컴포넌트: UICollectionView를 활용한 그리드 레이아웃이 적합합니다. 뷰의 역할을 가장 작은 단위로 쪼갭니다.
- CalendarDayCell: 하루를 나타내는 가장 작은 셀.
- CalendarView: 한 달 전체를 표시하는 뷰.
- CalendarCellWrapper: 한 달을 담는 컨테이너 셀.
- CalendarViewController: 모든 UI 컴포넌트와 뷰모델을 총괄하는 컨트롤러.

2단계: 핵심 컴포넌트 구현 (가장 작은 단위부터)
파일 1: CalendarDayCell
캘린더의 하루를 표현하는 가장 작은 단위입니다. 날짜, 수입, 지출 레이블을 담고, configure(with:income:expense:isSelected:) 메서드를 통해 외부 데이터를 받아 UI를 업데이트하는 역할을 합니다. 날짜가 nil일 경우, 모든 레이블을 숨겨 빈 칸처럼 보이게 하는 로직이 포함됩니다.
파일 2: CalendarView
CalendarDayCell들을 담아 한 달 전체 캘린더를 보여주는 컨테이너 뷰입니다. 내부적으로 UICollectionView의 dataSource와 delegate를 직접 구현합니다.
- 핵심 로직: 날짜 정렬 로직
- UICollectionView의 cellForItemAt 메서드에서, viewModel이 계산해준 DayData 배열을 사용합니다.
- DayData의 date가 nil인 경우, CalendarDayCell에 nil을 전달하여 빈 칸으로 표시하게 합니다.
- date가 nil이 아닌 경우, CalendarDayCell에 날짜와 금액 데이터를 전달하여 정상적으로 표시합니다.
- 핵심 메서드:
- generateDaysInMonth(): viewModel의 계산 로직을 받아 daysInMonth 배열을 생성합니다.
- didSelectItemAt(): 날짜 탭 이벤트를 감지하여 자신의 델리게이트인 CalendarCellWrapper에게 전달합니다.
3단계: 로직과 데이터 관리 (ViewModel 구현)
파일 3: CalendarViewModel
이 프로젝트의 두뇌입니다. 모든 데이터와 비즈니스 로직을 담당하며, UI는 뷰모델이 제공하는 데이터를 화면에 그리기만 합니다.
- 핵심 로직: 날짜 정렬 데이터 생성
- currentMonth의 첫날이 무슨 요일인지 계산합니다. (예: 목요일 = 5)
- 첫 요일 값에서 1을 뺀 요일 오프셋(Offset)만큼 빈 날짜(date: nil) 데이터를 생성하여 배열의 맨 앞에 추가합니다. (예: 5 - 1 = 4개의 빈 칸)
- 해당 월의 총 일수만큼 반복하여 실제 날짜 데이터를 추가합니다.
- 최종적으로 완성된 DayData 배열을 CalendarView에 제공합니다.
- 핵심 변수: @Published 속성을 사용해 데이터를 UI와 바인딩합니다.
- @Published var currentMonth: Date: 현재 월 정보
- @Published var dailySummariesByMonth: [Date: [Date: (income: Double, expense: Double)]]: 월별 일일 요약 데이터
- @Published var transactionsForSelectedDate: [Transaction]: 선택된 날짜의 상세 내역
4단계: 최종 통합 (ViewController 구현)
파일 4: CalendarCellWrapper
CalendarView와 CalendarViewController 사이의 중간 다리 역할을 합니다. 메인 컬렉션 뷰에 들어갈 셀이며, 내부에 CalendarView 인스턴스를 포함합니다. CalendarView로부터 받은 날짜 선택 이벤트를 다시 CalendarViewController에 전달하는 '델리게이트 체인'을 구축합니다.
파일 5: CalendarViewController
모든 컴포넌트를 통합하고 화면을 구성합니다.
- 두 개의 UICollectionView:
- calendarCollectionView: CalendarCellWrapper를 셀로 사용하여 무한 캘린더를 만듭니다.
- breakdownCollectionView: MainBreakdownCell을 셀로 사용하여 선택된 날짜의 거래 내역을 보여줍니다.
- 핵심 로직: 무한 스크롤 구현
- scrollViewDidEndDecelerating() 메서드에서 스크롤이 멈추면, 뷰모델에게 새로운 달 데이터 로드를 요청합니다.
- 데이터가 로드되면 reloadData()를 호출하고, scrollToItem(at: .centeredHorizontally)을 통해 항상 중앙으로 스크롤 위치를 재조정합니다. 이 트릭으로 캘린더가 끝없이 이어지는 것처럼 보이게 됩니다.
- 핵심 메서드:
- bindViewModel(): viewModel의 @Published 속성들을 구독하고, 데이터 변경 시 reloadData()를 호출하는 등 UI를 자동으로 업데이트합니다.
- calendarCellWrapper(_:didSelectDate:): CalendarCellWrapper로부터 받은 날짜 선택 이벤트를 viewModel의 selectDate(_:) 메서드로 전달하여 로직을 트리거합니다.
✅ 전체코드
/// 캘린더의 하루를 나타내는 UICollectionViewCell.
///
/// 날짜, 요일, 수입, 지출 금액 및 선택 상태를 시각적으로 표시합니다.
/// 셀의 레이아웃은 Auto Layout을 사용하며, 코드로 UI 요소를 정의하고 제약 조건을 설정합니다.
class CalendarDayCell: UICollectionViewCell {
// MARK: - Properties
// 셀의 재사용 식별자입니다.
static let reuseIdentifier: String = "CalendarDayCell"
// `todayLabel`의 trailing 제약 조건을 저장하여 동적으로 값을 변경하기 위한 프로퍼티입니다.
private var todayLabelTrailingConstraint: NSLayoutConstraint?
// MARK: - UI Elements
// 날짜(일)를 표시하는 레이블입니다.
private let dateLabel: UILabel = UILabel()
// 오늘 날짜인 경우 "오늘" 또는 "Today"를 표시하는 레이블입니다.
private let todayLabel: UILabel = UILabel()
// 해당 날짜의 총수입을 표시하는 레이블입니다.
private let incomeLabel: UILabel = UILabel()
// 해당 날짜의 총지출을 표시하는 레이블입니다.
private let expenseLabel: UILabel = UILabel()
// 날짜가 선택되었을 때 배경에 표시되는 원형 뷰입니다.
private let selectionView: UIView = UIView()
// MARK: - Initialization
// 셀이 초기화될 때 호출되며, UI를 설정합니다.
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
dateLabel.text = nil
incomeLabel.text = nil
todayLabel.text = nil
expenseLabel.text = nil
super.prepareForReuse()
}
// MARK: - View Setup
// UI 요소들을 설정하고 레이아웃 제약 조건을 활성화합니다.
private func setupViews() {
// 셀의 시각적 속성 설정
contentView.layer.cornerRadius = 4
contentView.layer.borderWidth = 0.4
contentView.layer.borderColor = UIColor.label.cgColor
// 각 레이블의 폰트, 정렬, 색상 등 설정
dateLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 16) ?? UIFont.systemFont(ofSize: 16, weight: .regular)
dateLabel.textAlignment = .left
dateLabel.textColor = .label
dateLabel.translatesAutoresizingMaskIntoConstraints = false
todayLabel.text = NSLocalizedString("today", comment: "Label for today")
todayLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 12) ?? UIFont.systemFont(ofSize: 12, weight: .regular)
todayLabel.textColor = .systemBlue
todayLabel.textAlignment = .center
todayLabel.isHidden = true
todayLabel.translatesAutoresizingMaskIntoConstraints = false
incomeLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 10) ?? UIFont.systemFont(ofSize: 10, weight: .regular)
incomeLabel.textColor = .systemGreen
incomeLabel.textAlignment = .center
expenseLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 10) ?? UIFont.systemFont(ofSize: 10, weight: .regular)
expenseLabel.textColor = .systemRed
expenseLabel.textAlignment = .center
// 선택 뷰의 시각적 속성 설정
selectionView.backgroundColor = .systemGray3
selectionView.layer.opacity = 0.5
selectionView.isHidden = true
selectionView.translatesAutoresizingMaskIntoConstraints = false
// 스택 뷰를 이용해 소득, 지출 레이블을 수직으로 정렬
let stackView = UIStackView(arrangedSubviews: [incomeLabel, expenseLabel])
stackView.axis = .vertical
stackView.distribution = .fill
stackView.spacing = 4
stackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stackView)
contentView.addSubview(dateLabel)
contentView.addSubview(todayLabel)
contentView.addSubview(selectionView)
// todayLabel의 trailing 제약 조건을 저장
let trailingConstraint = todayLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
self.todayLabelTrailingConstraint = trailingConstraint
NSLayoutConstraint.activate([
dateLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 4),
dateLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
dateLabel.topAnchor.constraint(equalTo: contentView.topAnchor),
dateLabel.heightAnchor.constraint(equalToConstant: 20),
todayLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
todayLabel.heightAnchor.constraint(equalToConstant: 12),
trailingConstraint,
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
stackView.topAnchor.constraint(equalTo: dateLabel.bottomAnchor),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
selectionView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
selectionView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
selectionView.widthAnchor.constraint(equalToConstant: 40),
selectionView.heightAnchor.constraint(equalTo: selectionView.widthAnchor)
])
selectionView.layer.cornerRadius = 10
}
// MARK: - Public Method
/// 셀의 데이터를 설정하고 UI를 업데이트합니다.
/// - Parameters:
/// - date: 표시할 날짜 정보.
/// - income: 해당 날짜의 총수입.
/// - expense: 해당 날짜의 총지출.
/// - isSelected: 셀의 선택 상태.
func configure(with date: Date?, income: Double?, expense: Double?, isSelected: Bool = false) {
guard let date = date else {
// 날짜 데이터가 없는 경우(빈 셀) 모든 라벨을 숨기고 초기화합니다.
dateLabel.text = ""
todayLabel.isHidden = true
incomeLabel.text = ""
expenseLabel.text = ""
selectionView.isHidden = true
return
}
// 선택 상태에 따라 selectionView 표시/숨김
selectionView.isHidden = !isSelected
let calendar = Calendar.current
let day = calendar.component(.day, from: date)
let isToday = calendar.isDateInToday(date)
// 오늘 날짜인 경우 todayLabel 표시
todayLabel.isHidden = !isToday
// 로컬라이제이션에 따라 "오늘" 또는 "Today" 텍스트와 제약 조건 변경
let regionCode = Locale.current.region?.identifier
if regionCode == "KR" {
todayLabel.text = "오늘"
// 한국일 경우 제약 조건의 constant 값을 -16으로 설정
todayLabelTrailingConstraint?.constant = -16
} else {
todayLabel.text = "Today"
// 그 외 지역일 경우 제약 조건의 constant 값을 -4로 설정
todayLabelTrailingConstraint?.constant = -4
}
// 날짜 라벨 텍스트 설정
dateLabel.text = "\(day)"
dateLabel.textColor = .label // 기본 색상으로 설정
// income이 0이거나 nil인 경우
if income == 0 || income == nil {
incomeLabel.text = ""
} else {
// Double 값을 NSNumber로 변환하여 포맷팅
let formattedIncome = formatCurrency(income ?? 0)
incomeLabel.text = (formattedIncome)
}
// expense가 0이거나 nil인 경우
if expense == 0 || expense == nil {
expenseLabel.text = ""
} else {
// Double 값을 NSNumber로 변환하여 포맷팅
let formattedExpense = formatCurrency(expense ?? 0)
expenseLabel.text = (formattedExpense)
}
}
/// 요일에 따라 `dateLabel`의 텍스트 색상을 변경합니다.
/// - Parameter weekday: 요일 번호 (1 = 일요일, 7 = 토요일).
func setDayOfWeekColor(_ weekday: Int) {
if weekday == 1 {
dateLabel.textColor = .systemRed // 일요일
} else if weekday == 7 {
dateLabel.textColor = .systemBlue // 토요일
} else {
dateLabel.textColor = .label // 평일
}
}
// MARK: - Private Method
/// Double 타입의 금액을 현재 지역에 맞는 화폐 형식 문자열로 변환합니다.
/// - Parameter amount: 포맷팅할 금액.
/// - Returns: 화폐 기호와 함께 포맷팅된 문자열.
private func formatCurrency(_ amount: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
if Locale.current.identifier.hasPrefix("ko") {
formatter.locale = Locale(identifier: "ko_KR")
} else {
formatter.locale = Locale.current
}
formatter.maximumFractionDigits = 0
formatter.minimumFractionDigits = 0
return formatter.string(from: NSNumber(value: amount)) ?? ""
}
}
import UIKit
// MARK: - Define a simple struct for our data
struct DayData {
var date: Date?
var income: Int?
var expense: Int?
}
// MARK: - Delegate Protocol
/// 캘린더에서 날짜 선택 이벤트를 처리하기 위한 델리게이트 프로토콜입니다.
protocol CalendarViewDelegate: AnyObject {
/// 특정 날짜가 선택되었을 때 호출됩니다.
func didSelectDate(_ date: Date)
}
/// 캘린더의 한 달 치 날짜 셀들을 표시하는 컨테이너 뷰입니다.
///
/// 이 뷰는 내부적으로 UICollectionView를 사용하여 날짜 셀들을 그리드 형태로 배치하며,
/// 날짜 데이터(`DayData`)를 기반으로 셀을 구성합니다.
/// `CalendarViewController`에서 이 뷰의 `currentMonth` 속성을 업데이트하여
/// 특정 월을 표시할 수 있습니다.
class CalendarView: UIView {
// MARK: - Properties
// 한 달 동안의 날짜 데이터를 저장하는 배열입니다.
// 이 배열은 빈 셀(padding)과 실제 날짜 데이터를 모두 포함합니다.
private var daysInMonth = [DayData]()
// 날짜 선택 이벤트를 외부에 전달하기 위한 델리게이트입니다.
weak var delegate: CalendarViewDelegate?
// 뷰가 현재 표시하고 있는 월입니다.
// 이 속성에 새로운 값이 할당될 때마다 `didSet`이 호출되어
// 해당 월에 대한 데이터를 다시 계산하고 UICollectionView를 리로드합니다.
var currentMonth: Date = Date() {
didSet {
generateDaysInMonth()
collectionView.reloadData()
}
}
// 사용자가 선택한 날짜입니다.
// 이 속성이 변경되면 캘린더 셀의 선택 상태를 업데이트하기 위해 `collectionView`를 리로드합니다.
var selectedDate: Date? {
didSet {
collectionView.reloadData()
}
}
// 캘린더에 표시될 일별 소득 및 지출 합계 데이터입니다.
private var dailySummary: [Date: (income: Double, expense: Double)] = [:]
// MARK: - UI elements
// 캘린더 날짜를 표시하기 위한 UICollectionView입니다.
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
// 커스텀 셀인 CalendarDayCell을 등록합니다.
collectionView.register(CalendarDayCell.self, forCellWithReuseIdentifier: CalendarDayCell.reuseIdentifier)
return collectionView
}()
// MARK: - Initialization
// 뷰가 코드로 초기화될 때 호출되며 UI를 설정합니다.
override init(frame: CGRect) {
super.init(frame: frame)
configureUI()
// 초기 데이터를 생성합니다.
generateDaysInMonth()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Public Method
/// 뷰컨트롤러로부터 일별 소득/지출 합계 데이터를 받아와 저장합니다.
///
/// - Parameter summary: 날짜(`Date`)를 키로 하고, 소득과 지출의 합계를 값으로 가지는 딕셔너리입니다.
func updateDailySummary(_ summary: [Date: (income: Double, expense: Double)]) {
self.dailySummary = summary
}
// MARK: - Private Method
// UI 요소를 설정하고 레이아웃 제약 조건을 활성화합니다.
private func configureUI() {
backgroundColor = .clear
addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: self.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
collectionView.dataSource = self
collectionView.delegate = self
}
/// `currentMonth`를 기준으로 한 달의 모든 날짜 데이터를 생성합니다.
///
/// 이 메서드는 빈 날짜(padding)와 실제 날짜를 포함하여 `daysInMonth` 배열을 채웁니다.
/// 예를 들어, 8월 1일이 목요일이면, 일~수요일에 해당하는 4개의 빈 날짜 데이터를
/// 배열의 맨 앞에 추가합니다.
private func generateDaysInMonth() {
daysInMonth.removeAll()
let calendar = Calendar.current
let components = calendar.dateComponents([.year, .month], from: currentMonth)
guard let startOfMonth = calendar.date(from: components) else { return }
// 해당 월의 첫 번째 날짜의 요일을 계산
let firstWeekday = calendar.component(.weekday, from: startOfMonth) // 1=일요일, 7=토요일
// 빈 칸의 개수 계산. 예: 8월 1일(목요일)은 5번째 요일이므로, 4개의 빈 칸이 필요 (일, 월, 화, 수)
let weekdayOffset = (firstWeekday - 1)
// 빈 칸 추가 (더미 데이터가 아닌 날짜가 없는 빈 DayData)
for _ in 0..<weekdayOffset {
daysInMonth.append(DayData(date: nil, income: nil, expense: nil))
}
// 해당 월의 실제 날짜 추가
let range = calendar.range(of: .day, in: .month, for: startOfMonth)!
for i in 1...range.count {
guard let day = calendar.date(byAdding: .day, value: i - 1, to: startOfMonth) else { continue }
// 실제 데이터는 뷰컨트롤러에서 받아오므로, 여기서는 더미 데이터 생성 로직을 제거했습니다.
// 뷰컨트롤러로부터 받은 dailySummary 데이터를 기반으로 셀을 구성합니다.
daysInMonth.append(DayData(date: day, income: nil, expense: nil))
}
}
}
// MARK: - UICollectionViewDataSource
extension CalendarView: UICollectionViewDataSource {
// 컬렉션 뷰에 표시할 항목의 개수를 반환합니다.
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return daysInMonth.count
}
// 지정된 인덱스 경로에 대한 셀을 반환합니다.
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CalendarDayCell", for: indexPath) as! CalendarDayCell
let dayData = daysInMonth[indexPath.item]
// 해당 날짜의 소득, 지출을 합계 가져오기
var incomeTotal: Double?
var expenseTotal: Double?
if let date = dayData.date {
let startOfDay = Calendar.current.startOfDay(for: date)
if let summary = dailySummary[startOfDay] {
incomeTotal = summary.income
expenseTotal = summary.expense
}
}
// 현재 설의 날짜가 선택된 날짜와 같은지 확인
let isSelected = Calendar.current.isDate(dayData.date ?? Date.distantPast, inSameDayAs: selectedDate ?? Date.distantFuture)
cell.configure(with: dayData.date, income: incomeTotal, expense: expenseTotal, isSelected: isSelected)
// 날짜가 있는 경우에만 요일 색상 설정
if let date = dayData.date {
let calendar = Calendar.current
let weekDay = calendar.component(.weekday, from: date)
cell.setDayOfWeekColor(weekDay)
}
return cell
}
}
// MARK: - UICollectionViewDelegateFlowLayout
extension CalendarView: UICollectionViewDelegateFlowLayout {
/// 각 셀의 크기를 반환합니다.
///
/// 컬렉션 뷰의 너비를 7로 나누고, 높이를 6으로 나누어
/// 7x6 그리드 형태의 캘린더 레이아웃을 만듭니다.
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = collectionView.frame.width / 7
let height = collectionView.frame.height / 6 // 6 rows for most months
return CGSize(width: width, height: height)
}
/// 셀이 선택되었을 때 호출됩니다.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let dayData = daysInMonth[indexPath.item]
// dayData.date가 nil이 아닐 때만 didSelectDate를 호출
if let selectedDate = dayData.date {
delegate?.didSelectDate(selectedDate)
self.selectedDate = selectedDate
}
}
}
// MARK: - Helper extension to get day of month
extension Date {
var day: Int {
return Calendar.current.component(.day, from: self)
}
}
import UIKit
/// # CalendarCellWrapper 실행 로직 상세 분석
///
/// `CalendarCellWrapper`는 **컴포넌트 합성(Composition)**과 **델리게이트 패턴(Delegate Chain)**을 활용한
/// 이벤트 전달 구조를 구현한 좋은 예시입니다.
///
/// ## 1. 컴포넌트 합성 (Composition)
/// - `CalendarCellWrapper`는 `CalendarView`라는 독립적인 컴포넌트를 내부에 포함합니다.
/// - `CalendarViewController`는 UICollectionViewCell을 다루기 위해 `CalendarCellWrapper`를 사용합니다.
/// - `CalendarCellWrapper`는 복잡한 캘린더 로직을 모두 `CalendarView`에 위임하고, 단순히 configure(with:) 메서드를 통해 월 정보를 전달합니다.
/// - 이 구조 덕분에 ViewController는 `CalendarView`의 내부 복잡성을 몰라도 됩니다.
///
/// ## 2. 델리게이트 패턴을 활용한 이벤트 전달
/// - `CalendarCellWrapper`는 델리게이트를 두 번 거치는 체인 구조로 날짜 선택 이벤트를 최종 목적지인 `CalendarViewController`로 전달합니다.
///
/// ### 이벤트 전달 흐름
/// 1. **CalendarView → CalendarCellWrapper**
/// - 사용자가 `CalendarView` 내 날짜 셀을 탭하면 `didSelectItemAt` 메서드가 호출됩니다.
/// - 이 메서드는 `calendarView.delegate`를 통해 이벤트를 `CalendarCellWrapper`로 전달합니다.
///
/// 2. **CalendarCellWrapper → CalendarViewController**
/// - `CalendarCellWrapper`는 `CalendarViewDelegate` 프로토콜을 채택하여 이벤트를 전달받습니다.
/// - 이후 자신의 델리게이트인 `CalendarCellWrapperDelegate`를 통해 이벤트를 `CalendarViewController`로 전달합니다.
///
/// ## 3. 델리게이트 체인의 장점
/// - `CalendarView`: 날짜 탭 이벤트만 알림
/// - `CalendarCellWrapper`: 이벤트를 상위 뷰로 전달
/// - `CalendarViewController`: 전달받은 이벤트에 따라 UI 업데이트 수행
///
/// - Important:
/// ➡️ 각 클래스가 자신의 역할에만 집중할 수 있어 유지보수성과 확장성이 높습니다.
class CalendarCellWrapper: UICollectionViewCell {
// MARK: -Proper ties
/// CalendarCellWrapper에서 발생하는 날짜 선택 이벤트를 외부에 전달하기 위한 델리게이트입니다.
weak var delegate: CalendarCellWrapperDelegate?
// MARK: - UI Element
/// 월별 캘린더를 렌더링하는 뷰입니다.
private let calendarView = CalendarView()
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
// 내부의 calendarView의 델리게이트를 자신(CalendarCellWrapper)으로 설정합니다.
calendarView.delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Private Method
/// 셀의 UI를 설정하고 레이아웃 제약 조건을 활성화합니다.
private func setupView() {
contentView.addSubview(calendarView)
calendarView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
calendarView.topAnchor.constraint(equalTo: contentView.topAnchor),
calendarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
calendarView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
calendarView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
}
// MARK: - Public Method
/// 셀이 표시할 월 정보를 설정하고 내부 CalendarView를 업데이트합니다.
///
/// - Parameter month: 이 셀이 표시할 월의 날짜입니다.
func configure(with month: Date) {
// 내부 calendarView의 currentMonth를 업데이트하여 해당 월을 표시합니다.
calendarView.currentMonth = month
}
/// 셀 내부의 CalendarView에 일별 소득/지출 합계 데이터를 전달합니다.
///
/// - Parameter summary: 날짜를 키로 하고, 소득과 지출 합계를 값으로 가지는 딕셔너리입니다.
func updateDailySummary(_ summary: [Date: (income: Double, expense: Double)]) {
calendarView.updateDailySummary(summary)
}
}
// MARK: - CalendarViewDelegate
extension CalendarCellWrapper: CalendarViewDelegate {
/// 내부 CalendarView로부터 날짜가 선택되었다는 이벤트를 전달받습니다.
///
/// - Parameter date: CalendarView에서 선택된 날짜.
func didSelectDate(_ date: Date) {
// 이벤트를 CalendarCellWrapper의 델리게이트(CalendarViewController)로 다시 전달합니다.
delegate?.calendarCellWrapper(self, didSelectDate: date)
}
}
// MARK: - Delegate Protocol
/// CalendarCellWrapper에서 발생하는 날짜 선택 이벤트를 처리하기 위한 델리게이트 프로토콜입니다.
protocol CalendarCellWrapperDelegate: AnyObject {
/// `CalendarCellWrapper` 내부에서 날짜가 선택되었을 때 호출됩니다.
func calendarCellWrapper(_ wrapper: CalendarCellWrapper, didSelectDate date: Date)
}
/// # CalendarViewModel 실행 로직 상세 분석
///
/// `CalendarViewModel`은 **MVVM 패턴**을 충실히 따르며,
/// UI 상태와 비즈니스 로직을 완벽하게 분리합니다.
/// 핵심 실행 로직은 다음과 같습니다.
///
/// ## 1. 초기화 및 데이터 바인딩
/// - init 메서드가 호출되면, transactionManager 객체를 주입받아 의존성을 설정합니다.
/// - bindTransactions() 를 호출하여 transactionManager의 $transactions 퍼블리셔를 구독합니다. 이 구독은 데이터 변경 감지의 핵심입니다.
/// - setupInitialData() 를 호출하여 뷰모델의 초기 상태를 설정합니다. 현재, 이전, 다음 월을 months 배열에 채우고 currentMonth와 selectedDate를 오늘 날짜로 지정합니다.
///
/// ## 2. 데이터 업데이트 트리거
/// - **Transaction 변경**
/// - 사용자가 새로운 거래를 추가/삭제하거나 수정할 때, transactionManager의 $transactions 퍼블리셔가 새로운 값을 발행합니다. bindTransactions() 메서드의 sink 클로저가 이 변경을 감지하고, recalculateCalendarData()를 호출하여 뷰모델의 모든 상태를 일괄적으로 갱신합니다.
/// - **사용자 상호작용**
/// - 월 변경: 사용자가 캘린더를 스크롤하여 다른 월을 선택하면 뷰컨트롤러는 selectMonth(_:)를 호출합니다. 이 메서드는 recalculateCalendarData()를 호출하여 현재 월에 대한 데이터를 다시 계산합니다.
/// - 날짜 선택: 사용자가 특정 날짜를 탭하면 뷰컨트롤러는 selectDate(_:)를 호출합니다. 이 메서드는 transactionsForSelectedDate 속성만 업데이트합니다. 월별 요약 데이터는 그대로 유지되므로 불필요한 재계산을 피할 수 있습니다.
/// - 스크롤 시 월 로드: 뷰컨트롤러는 캘린더의 끝에 도달할 때 loadNewMonths()를 호출합니다. 이 메서드는 months 배열에 새로운 이전/다음 달을 추가하여 무한 스크롤 효과를 만듭니다.
///
/// ## 3. 데이터 계산 로직 (`recalculateCalendarData`)
/// - **일별 요약**
/// - months 배열에 있는 각 월에 대해 filterTransactions를 사용하여 월별 거래를 필터링합니다. 그 후 calculateDailySummary()를 호출하여 각 일의 수입/지출을 계산하고, 이 데이터를 dailySummariesByMonth 딕셔너리에 저장합니다.
/// - **월별 요약**
/// - currentMonth에 해당하는 거래만 필터링한 후, calculateMonthlySummary()를 사용하여 총 수입과 지출을 각각 계산하여 monthlyIncome과 monthlyExpense에 할당합니다.
/// - **선택된 날짜 거래**
/// - selectedDate에 해당하는 거래를 필터링하여 transactionsForSelectedDate에 할당합니다.
///
/// - Important:
/// 모든 데이터 갱신은 `recalculateCalendarData()`에서 일괄 관리하여
/// 일관성을 보장합니다.
// MARK: - ViewModel
final class CalendarViewModel {
// MARK: - Properties
// TransactionManager는 ViewModel의 유일한 데이터 소스입니다.
// 모든 트랜잭션 데이터는 이곳에서 가져옵니다.
let transactionManager: TransactionManager
// Combine 프레임워크의 구독을 관리하기 위한 `Set`입니다.
// 뷰모델이 메모리에서 해제될 때 모든 구독이 자동으로 취소됩니다.
private var cancellables = Set<AnyCancellable>()
// 캘린더 관련 계산을 수행하는 데 사용되는 유틸리티 객체입니다.
private let calendar = Calendar.current
// MARK: - Published Properties (UI State)
// 현재 화면에 표시되는 월을 나타내는 "Date" 값입니다.
// 이 속성이 변경되면 뷰(ViewController)가 캘린더 UI를 업데이트합니다.
@Published private(set) var currentMonth: Date = Date()
// 사용자가 선택한 특정 날짜를 나타내는 "Date" 값입니다.
// 이 속성이 변경되면 선택된 날짜의 상세 내역이 업데이트합니다.
@Published private(set) var selectedDate: Date = Date()
// 선택된 날짜에 해당하는 모든 거래 목록입니다.
// 이 배열의 변경을 관찰하여 UI에 일별 거래 내역을 표시합니다.
@Published private(set) var transactionsForSelectedDate: [ExpenseModel] = []
// 현재 월의 총 소득입니다.
@Published private(set) var monthlyIncome: Double = 0.0
// 현재 월의 총 지출입니다.
@Published private(set) var monthlyExpense: Double = 0.0
// 화면에 표시될 이전, 현재, 다음 달의 "Date" 배열입니다.
@Published private(set) var months: [Date] = []
// 월별, 일별로 집계된 소득 및 지출 요약 데이터입니다.
// 캘린더 셀에 각 일의 소득, 지출을 표시하는데 사용됩니다.
@Published private(set) var dailySummariesByMonth: [Date: [Date: (income: Double, expense: Double)]] = [:]
// MARK: - Initialization
// `CalendarViewModel`을 초기화합니다.
// - Parameter transactionManager: 모든 거래 데이터를 관리하는 객체입니다.
init(transactionManager: TransactionManager) {
self.transactionManager = transactionManager
self.bindTransactionManager()
self.setupInitialData()
}
// MARK: - Data Binding
/// `transactionManager`의 `$transactions` 퍼블리셔를 구독하여 데이터 변경에 반응합니다.
///
/// 데이터가 변경되면 `recalculateCalendarData` 메서드를 호출하여 뷰모델의 모든 상태를 갱신합니다.
private func bindTransactionManager() {
// TransactionManager의 전체 데이터가 변경될 때마다 뷰모델 상태를 업데이트합니다.
transactionManager.$transactions
.receive(on: RunLoop.main)
.sink { [weak self] allTransactions in
guard let self = self else { return }
// 전체 데이터가 업데이트되면, 모든 캘린더 데이터를 재계산합니다.
self.recalculateCalendarData(from: allTransactions)
}
.store(in: &cancellables)
}
// MARK: - Public API (외부 접근 가능 메서드)
/// 캘린더를 위한 초기 데이터를 설정합니다.
///
/// 현재 날짜를 기준으로 이전, 현재, 다음 월을 `months` 배열에 설정하고,
/// `currentMonth` 및 `selectedDate`를 오늘 날짜로 초기화합니다.
func setupInitialData() {
let today = Date()
let previousMonth = calendar.date(byAdding: .month, value: -1, to: today)!
let nextMonth = calendar.date(byAdding: .month, value: 1, to: today)!
// 핵심: 초기 배열 크기를 3으로 설정
self.months = [previousMonth, today, nextMonth]
self.currentMonth = today
self.selectedDate = today
self.recalculateCalendarData(from: transactionManager.transactions)
}
/// 사용자가 캘린더를 스크롤하여 월을 변경할 때 호출됩니다.
///
/// - Parameter month: 새로 선택된 월의 `Date` 객체입니다.
func selectMonth(_ month: Date) {
if !calendar.isDate(currentMonth, equalTo: month, toGranularity: .month) {
self.currentMonth = month
self.recalculateCalendarData(from: transactionManager.transactions)
}
}
/// 사용자가 특정 날짜를 탭할 때 호출됩니다.
///
/// - Parameter date: 사용자가 선택한 날짜의 `Date` 객체입니다.
func selectDate(_ date: Date) {
self.selectedDate = date
self.transactionsForSelectedDate = self.filterTransactions(
self.transactionManager.transactions,
for: date,
granularity: .day
)
}
// 개선 loadNewMonths
/// 캘린더 스크롤 시 새로운 월을 로드하고 months 배열을 업데이트합니다.
/// - Parameter isForward: 스크롤 방향 (true: 다음 달, false: 이전 달)
func loadNewMonths(isForward: Bool) {
// ⚠️ 수정된 부분
let currentCenterMonth = self.months[1]
if isForward {
// 다음 달로 스크롤한 경우: 현재 달을 이전 달로, 다음 달을 현재 달로, 새 다음 달을 추가
let newCurrentMonth = calendar.date(byAdding: .month, value: 1, to: currentCenterMonth)!
let newNextMonth = calendar.date(byAdding: .month, value: 1, to: newCurrentMonth)!
self.months = [currentCenterMonth, newCurrentMonth, newNextMonth]
self.currentMonth = newCurrentMonth
} else {
// 이전 달로 스크롤한 경우: 현재 달을 다음 달로, 이전 달을 현재 달로, 새 이전 달을 추가
let newCurrentMonth = calendar.date(byAdding: .month, value: -1, to: currentCenterMonth)!
let newPreviousMonth = calendar.date(byAdding: .month, value: -1, to: newCurrentMonth)!
self.months = [newPreviousMonth, newCurrentMonth, currentCenterMonth]
self.currentMonth = newCurrentMonth
}
// 새로운 월 데이터를 계산
self.recalculateCalendarData(from: transactionManager.transactions)
}
// MARK: - Private Helper Methods (내부 보조 메서드)
/// 모든 캘린더 관련 데이터를 재계산합니다.
///
/// `transactionManager`의 데이터가 변경되거나, 월이 변경될 때 호출됩니다.
/// 이 메서드는 다음 세 가지 작업을 순차적으로 수행합니다:
/// 1. `months` 배열에 포함된 모든 월에 대한 일별 요약 데이터를 계산합니다.
/// 2. `currentMonth`의 총 수입 및 지출을 계산합니다.
/// 3. `selectedDate`에 해당하는 거래 내역을 필터링합니다.
/// - Parameter allTransactions: 계산에 사용될 전체 거래 목록입니다.
private func recalculateCalendarData(from allTransactions: [ExpenseModel]) {
// 1. 일별 요약 데이터 계산
var newDailySummaries: [Date: [Date: (income: Double, expense: Double)]] = [:]
for month in self.months {
let transactionsForMonth = self.filterTransactions(allTransactions, for: month, granularity: .month)
newDailySummaries[month] = self.calculateDailySummary(for: transactionsForMonth)
}
self.dailySummariesByMonth = newDailySummaries
// 2. 월별 소득, 지출 계산
let transactionsForSelectedMonth = self.filterTransactions(allTransactions, for: self.currentMonth, granularity: .month)
self.monthlyIncome = self.calculateMonthlySummary(transactionsForSelectedMonth, type: .income)
self.monthlyExpense = self.calculateMonthlySummary(transactionsForSelectedMonth, type: .expense)
// 3. 선택된 날짜의 트랜잭션 업데이트
self.transactionsForSelectedDate = self.filterTransactions(allTransactions, for: self.selectedDate, granularity: .day)
}
/// 주어진 거래 목록에서 특정 날짜 및 기간 단위에 해당하는 항목을 필터링합니다.
///
/// - Parameters:
/// - transactions: 필터링할 거래 목록입니다.
/// - date: 필터링 기준 날짜입니다.
/// - granularity: 필터링의 세부 단위(예: `.month`, `.day`)입니다.
/// - Returns: 필터링 조건을 만족하는 `ExpenseModel` 배열입니다.
private func filterTransactions(_ transactions: [ExpenseModel], for date: Date, granularity: Calendar.Component) -> [ExpenseModel] {
return transactions.filter { transaction in
calendar.isDate(transaction.date, equalTo: date, toGranularity: granularity)
}
}
/// 주어진 트랜잭션 목록에 대한 일별 소득, 지출을 계산합니다.
///
/// - Parameter transactions: 계산에 사용될 거래 목록입니다.
/// - Returns: 일별로 그룹화된 (소득, 지출) 튜플 딕셔너리입니다.
private func calculateDailySummary(for transactions: [ExpenseModel]) -> [Date: (income: Double, expense: Double)] {
var dailySummary: [Date: (income: Double, expense: Double)] = [:]
for transaction in transactions {
// 여기서 startOfDay란, “그 날의 시작 시각(자정)”
// 예를 들어 2025-08-27 09:15, 2025-08-27 22:40 두 시간 모두 startOfDay를 적용하면 동일하게 2025-08-27 00:00이 됩니다.
let startOfDay = calendar.startOfDay(for: transaction.date)
// 해당 날짜 키로 누적 중인 합계를 가져오거나, 없으면 0으로 시작
var currentSummary = dailySummary[startOfDay] ?? (income: 0, expense: 0)
switch transaction.transaction {
case .income:
currentSummary.income += Double(transaction.amount)
case .expense:
currentSummary.expense += Double(transaction.amount)
}
dailySummary[startOfDay] = currentSummary
}
return dailySummary
}
/// 주어진 거래 목록의 총 소득 또는 지출을 계산합니다.
///
/// - Parameters:
/// - transactions: 계산할 거래 목록입니다.
/// - type: 계산할 거래 유형 (`.income` 또는 `.expense`)입니다.
/// - Returns: 계산된 총 금액입니다.
private func calculateMonthlySummary(_ transactions: [ExpenseModel], type: TransactionType) -> Double {
return transactions
.filter { $0.transaction == type }
.map { Double($0.amount) }
.reduce(0, +)
}
}
/// # CalendarViewController 실행 로직 상세 분석
///
/// 1. MVVM 패턴의 핵심 역할
/// CalendarViewController는 MVVM 패턴을 충실히 따릅니다.
/// - ViewModel에 의존성 주입: init(transactionManager:)를 통해 CalendarViewModel을 생성하고 주입받습니다. 이는 뷰컨트롤러가 데이터 관리 로직으로부터 완전히 분리되었음을 의미합니다.
/// - Combine을 이용한 데이터 바인딩: bindViewModel() 메서드에서 뷰모델의 @Published 속성들(예: $currentMonth, $dailySummariesByMonth)을 구독합니다.
/// - 데이터가 변경되면 sink 클로저가 실행되어 reloadData()나 updateHeaderLabels()와 같은 UI 업데이트 로직만 수행합니다. 뷰컨트롤러는 데이터를 직접 다루지 않습니다.
///
/// 2. 두 개의 UICollectionView 관리
/// - calendarCollectionView: 무한 스크롤 캘린더를 표시합니다. 이 컬렉션 뷰는 CalendarCellWrapper를 셀로 사용하며, 각 셀이 한 달을 나타냅니다. dataSource에서 뷰모델의 months 배열을 사용하여 셀의 개수를 결정합니다.
/// - breakdownCollectionView: 선택된 날짜의 거래 내역을 표시합니다.
/// - dataSource에서 뷰모델의 transactionsForSelectedDate 배열을 사용하여 항목을 결정합니다.
///
/// 3. scrollViewDidEndDecelerating을 통한 무한 스크롤 로직
/// - 이 메서드는 무한 스크롤 캘린더를 구현하는 핵심 부분입니다.
/// - 사용자의 스크롤이 멈추면 scrollView.contentOffset.x를 이용해 현재 페이지를 계산합니다.
/// - 페이지가 배열의 양 끝(0 또는 count-1)에 도달하면 calendarViewModel.loadNewMonths(isForward:)를 호출하여 뷰모델에게 새로운 월 데이터를 준비하도록 요청합니다.
/// - 무한 스크롤의 핵심: 뷰모델이 months 배열을 업데이트하면, 뷰컨트롤러는 reloadData()를 호출하여 UI를 갱신한 다음, scrollToItem(at: IndexPath(item: 1, ...))을 통해 컬렉션 뷰를 항상 가운데(인덱스 1)로 이동시킵니다. 이 트릭 덕분에 사용자는 캘린더가 계속 이어진다고 느끼게 됩니다.
///
/// 4. 델리게이트 패턴의 활용
/// - CalendarViewController는 CalendarCellWrapperDelegate를 채택하여 CalendarCellWrapper로부터 전달되는 날짜 선택 이벤트를 처리합니다.
/// - calendarCellWrapper(_:didSelectDate:) 메서드에서, 뷰컨트롤러는 선택된 날짜를 뷰모델의 selectDate() 메서드로 전달합니다. 뷰모델은 이 날짜를 기준으로 거래 내역을 필터링하고, 바인딩된 transactionsForSelectedDate 속성을 업데이트하여 breakdownCollectionView가 자동으로 리로드되도록 합니다.
final class CalendarViewController: UIViewController {
// MARK: - ViewModel & Dependencies
// 뷰컨트롤러의 상태를 관리하고 비즈니스 로직을 처리하는 뷰모델.
private var calendarViewModel: CalendarViewModel
// Combine 구독을 관리하여 메모리 누수를 방지합니다.
private var cancellables = Set<AnyCancellable>()
// MARK: - UI Elements
/// 캘린더 상단의 월별 수입, 지출, 총액을 표시하는 뷰입니다.
private let headerView = UIView()
private let monthLabel = UILabel()
private let incomeLabel = UILabel()
private let expenseLabel = UILabel()
private let totalLabel = UILabel()
private let weekDayStackView = UIStackView()
// MARK: - Two UICollectionViews
/// 월별 캘린더를 가로로 스크롤하여 표시하는 컬렉션 뷰입니다.
private lazy var calendarCollectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createCalendarLayout())
// 월 단위로 페이지를 넘기는 효과
collectionView.isPagingEnabled = true
collectionView.backgroundColor = .clear
collectionView.showsHorizontalScrollIndicator = false
collectionView.translatesAutoresizingMaskIntoConstraints = false
// CalendarCellWrapper는 뷰모델에서 받은 데이터를 각 날짜 셀에 전달하는 역할을 합니다.
collectionView.register(CalendarCellWrapper.self, forCellWithReuseIdentifier: "CalendarCellWrapper")
return collectionView
}()
/// 선택된 날짜의 세부 거래 내역을 세로로 스크롤하여 표시하는 컬렉션 뷰입니다.
private lazy var breakdownCollectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createBreakdownLayout())
collectionView.backgroundColor = .clear
collectionView.showsVerticalScrollIndicator = false
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.register(MainBreakdownCell.self, forCellWithReuseIdentifier: MainBreakdownCell.reuseIdentifier)
return collectionView
}()
// MARK: - Initialization
/// `CalendarViewController`를 초기화하고 뷰모델에 의존성 주입을 수행합니다.
///
/// - Parameter transactionManager: `CalendarViewModel`을 초기화하는 데 필요한 데이터 관리 객체입니다.
init(transactionManager: TransactionManager) {
// ViewModel을 생성하고 의존성 주입을 수행합니다.
self.calendarViewModel = CalendarViewModel(transactionManager: transactionManager)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindViewModel()
configureNavigation()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// 뷰 레이아웃이 완료된 후, 초기 스크롤 위치를 설정합니다.
// ViewModel의 `months` 배열의 중간 항목(인덱스 1)으로 스크롤하여 무한 스크롤의 시작점을 만듭니다.
if !calendarViewModel.months.isEmpty {
let initialIndexPath = IndexPath(item: 1, section: 0)
calendarCollectionView.scrollToItem(at: initialIndexPath, at: .centeredHorizontally, animated: false)
}
}
// MARK: - Data Binding with Combine
/// `CalendarViewModel`의 `@Published` 속성들을 구독하여 UI를 업데이트합니다.
///
/// Combine을 사용하여 뷰모델의 데이터 변화에 반응적으로 화면을 갱신합니다.
private func bindViewModel() {
// ViewModel의 `currentMonth` 속성 변경을 구독합니다.
// 이는 헤더의 월 레이블과 총액을 업데이트하는 데 사용됩니다.
calendarViewModel.$currentMonth
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.updateHeaderLabels()
}
.store(in: &cancellables)
// ViewModel의 `dailySummariesByMonth` 변경을 구독합니다.
// 이 속성이 변경되면 캘린더 컬렉션 뷰를 리로드합니다.
calendarViewModel.$dailySummariesByMonth
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.calendarCollectionView.reloadData()
}
.store(in: &cancellables)
// ViewModel의 `transactionsForSelectedDate` 변경을 구독합니다.
// 이 속성이 변경되면 세부 내역 컬렉션 뷰를 리로드합니다.
calendarViewModel.$transactionsForSelectedDate
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.breakdownCollectionView.reloadData()
}
.store(in: &cancellables)
// ViewModel의 `months` 배열 변경을 구독합니다.
// 무한 스크롤 로직에서 새로운 달이 추가될 때 캘린더를 리로드합니다.
calendarViewModel.$months
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.calendarCollectionView.reloadData()
}
.store(in: &cancellables)
}
// MARK: - UI Update Methods
/// 헤더의 월, 수입, 지출, 총액 레이블을 업데이트합니다.
private func updateHeaderLabels() {
let income = calendarViewModel.monthlyIncome
let expense = calendarViewModel.monthlyExpense
let balance = income - expense
monthLabel.text = formattedMonth(for: calendarViewModel.currentMonth)
incomeLabel.text = NSLocalizedString("chart_label_income", comment: "Label for income") + " " + formatCurrency(income)
expenseLabel.text = NSLocalizedString("chart_label_expense", comment: "Label for expense") + " " + formatCurrency(expense)
totalLabel.text = NSLocalizedString("Balance_Amount", comment: "Label for balance") + " " + formatCurrency(balance)
}
// MARK: - Private Methods
/// 캘린더 컬렉션 뷰의 레이아웃을 생성합니다.
private func createCalendarLayout() -> UICollectionViewFlowLayout {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.itemSize = CGSize(width: view.bounds.width - 16, height: 320)
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
return layout
}
/// 세부 내역 컬렉션 뷰의 레이아웃을 생성합니다.
private func createBreakdownLayout() -> UICollectionViewFlowLayout {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
layout.itemSize = CGSize(width: view.bounds.width - 16, height: 48)
layout.minimumLineSpacing = 8
layout.minimumInteritemSpacing = 8
return layout
}
private func setupUI() {
let customSecondaryBackground = UIColor(named: "CustomSecondaryBackground")
view.backgroundColor = customSecondaryBackground
// MARK: - Header View
headerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(headerView)
incomeLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 16) ?? UIFont.systemFont(ofSize: 16, weight: .regular)
incomeLabel.textColor = .systemGreen
incomeLabel.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(incomeLabel)
expenseLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 16) ?? UIFont.systemFont(ofSize: 16, weight: .regular)
expenseLabel.textColor = .systemRed
expenseLabel.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(expenseLabel)
totalLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 16) ?? UIFont.systemFont(ofSize: 16, weight: .regular)
totalLabel.textColor = .label
totalLabel.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(totalLabel)
// MARK: - Weekday Stack View
weekDayStackView.axis = .horizontal
weekDayStackView.distribution = .fillEqually
weekDayStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(weekDayStackView)
let weekdays = [
NSLocalizedString("sun", comment: "Sunday"),
NSLocalizedString("mon", comment: "Monday"),
NSLocalizedString("tue", comment: "Tuesday"),
NSLocalizedString("wed", comment: "Wednesday"),
NSLocalizedString("thu", comment: "Thursday"),
NSLocalizedString("fri", comment: "Friday"),
NSLocalizedString("sat", comment: "Saturday")
]
for day in weekdays {
let label = UILabel()
label.text = day
label.textAlignment = .center
label.font = UIFont(name: "Ownglyph_daelong-Rg", size: 16) ?? UIFont.systemFont(ofSize: 16, weight: .regular)
if day == NSLocalizedString("sun", comment: "Sunday") {
label.textColor = .systemRed
} else if day == NSLocalizedString("sat", comment: "Saturday") {
label.textColor = .systemBlue
} else {
label.textColor = .label
}
weekDayStackView.addArrangedSubview(label)
}
// MARK: - Collection Views
view.addSubview(calendarCollectionView)
view.addSubview(breakdownCollectionView)
// Constraints
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 60),
incomeLabel.topAnchor.constraint(equalTo: headerView.topAnchor, constant: 4),
incomeLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 20),
expenseLabel.topAnchor.constraint(equalTo: incomeLabel.bottomAnchor, constant: 12),
expenseLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 20),
totalLabel.topAnchor.constraint(equalTo: headerView.topAnchor, constant: 4),
totalLabel.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -20),
weekDayStackView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 12),
weekDayStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
weekDayStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
weekDayStackView.heightAnchor.constraint(equalToConstant: 30),
// 캘린더 컬렉션 뷰 제약 조건
calendarCollectionView.topAnchor.constraint(equalTo: weekDayStackView.bottomAnchor),
calendarCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
calendarCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
calendarCollectionView.heightAnchor.constraint(equalToConstant: 320),
// 세부 내역 컬렉션 뷰 제약 조건
breakdownCollectionView.topAnchor.constraint(equalTo: calendarCollectionView.bottomAnchor, constant: 16),
breakdownCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
breakdownCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
breakdownCollectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
// DataSource 및 Delegate 설정
calendarCollectionView.dataSource = self
calendarCollectionView.delegate = self
breakdownCollectionView.dataSource = self
breakdownCollectionView.delegate = self
}
private func configureNavigation() {
monthLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 20) ?? UIFont.systemFont(ofSize: 20, weight: .regular)
monthLabel.textAlignment = .center
monthLabel.textColor = .label
monthLabel.text = formattedMonth(for: calendarViewModel.currentMonth)
// 가로폭을 넉넉히 하기 위한 컨테이너뷰
let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 52))
monthLabel.frame = containerView.bounds
monthLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight]
containerView.addSubview(monthLabel)
navigationItem.titleView = containerView
let backButton = UIBarButtonItem(title: "", style: .plain, target: self, action: nil)
backButton.tintColor = UIColor.label
self.navigationItem.backBarButtonItem = backButton
let closeLabel: UILabel = UILabel()
closeLabel.text = NSLocalizedString("Close", comment: "Label for close screen")
closeLabel.font = UIFont(name: "Ownglyph_daelong-Rg", size: 20) ?? UIFont.systemFont(ofSize: 20, weight: .regular)
closeLabel.textColor = .systemRed
closeLabel.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTappedDismiss))
closeLabel.addGestureRecognizer(tapGesture)
let dismissButton = UIBarButtonItem(customView: closeLabel)
self.navigationItem.leftBarButtonItem = dismissButton
}
private func formattedMonth(for date: Date) -> String {
let dateFormatter = DateFormatter()
let regionCode = Locale.current.region?.identifier
if regionCode == "KR" {
dateFormatter.locale = Locale(identifier: "ko_KR")
dateFormatter.setLocalizedDateFormatFromTemplate("yyyyMMMM")
} else {
dateFormatter.locale = Locale(identifier: "en_US")
dateFormatter.setLocalizedDateFormatFromTemplate("MMM yyyy")
}
return dateFormatter.string(from: date)
}
private func formatCurrency(_ amount: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
if Locale.current.identifier.hasPrefix("ko") {
formatter.locale = Locale(identifier: "ko_KR")
} else {
formatter.locale = Locale.current
}
formatter.maximumFractionDigits = 0
formatter.minimumFractionDigits = 0
return formatter.string(from: NSNumber(value: amount)) ?? ""
}
@objc private func didTappedDismiss() {
self.dismiss(animated: true)
}
}
// MARK: - UICollectionViewDataSource
extension CalendarViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
if collectionView == calendarCollectionView {
return calendarViewModel.months.count // ViewModel의 `months` 배열을 사용
} else if collectionView == breakdownCollectionView {
return calendarViewModel.transactionsForSelectedDate.count // ViewModel의 `transactionsForSelectedDate`를 사용
}
return 0
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if collectionView == calendarCollectionView {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CalendarCellWrapper", for: indexPath) as! CalendarCellWrapper
let month = calendarViewModel.months[indexPath.item]
cell.configure(with: month)
cell.delegate = self
// ViewModel에서 캐시된 일별 요약 데이터를 가져와서 셀에 전달합니다.
if let dailySummary = calendarViewModel.dailySummariesByMonth[month] {
cell.updateDailySummary(dailySummary)
}
return cell
} else if collectionView == breakdownCollectionView {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MainBreakdownCell.reuseIdentifier, for: indexPath) as! MainBreakdownCell
let item = calendarViewModel.transactionsForSelectedDate[indexPath.row] // ViewModel의 데이터를 사용
cell.configure(with: item, isHidden: true)
return cell
}
fatalError("Unknown collection view")
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if collectionView == breakdownCollectionView {
let item = calendarViewModel.transactionsForSelectedDate[indexPath.row] // ViewModel의 데이터를 사용
let id = item.id
// 기존의 AddTransactionViewController 로직은 그대로 유지됩니다.
let editVC = AddTransactionViewController(mode: .edit(id: id), transactionManager: calendarViewModel.transactionManager)
navigationController?.pushViewController(editVC, animated: true)
}
}
}
// MARK: - UICollectionViewDelegate
extension CalendarViewController: UICollectionViewDelegate {
// 사용자가 스크롤을 멈췄을 때 호출됩니다.
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if scrollView == calendarCollectionView {
let page = Int(scrollView.contentOffset.x / scrollView.frame.width)
let isForward = page > 1
// ViewModel에게 스크롤 방향을 알리고 새로운 월을 요청
// 이 메서드는 ViewModel 내부에서 months 배열을 업데이트
calendarViewModel.loadNewMonths(isForward: isForward)
// ViewModel의 months 배열이 업데이트된 후,
// UICollectionView를 현재 가운데 항목(인덱스 1)으로 재배치하여
// 무한 스크롤 효과를 유지합니다.
let indexPath = IndexPath(item: 1, section: 0)
calendarCollectionView.reloadData()
self.calendarCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
}
}
}
// MARK: - CalendarCellWrapperDelegate
extension CalendarViewController: CalendarCellWrapperDelegate {
/// 캘린더 셀에서 특정 날짜가 선택되었을 때 호출됩니다.
///
/// 델리게이트 패턴을 통해 `CalendarCellWrapper`로부터 전달된 날짜 선택 이벤트를 처리합니다.
func calendarCellWrapper(_ wrapper: CalendarCellWrapper, didSelectDate date: Date) {
// 사용자가 날짜를 탭하면 ViewModel에 선택된 날짜를 알려줍니다.
calendarViewModel.selectDate(date)
}
}'한눈가계부 > 캘린더' 카테고리의 다른 글
| popoverPresentationController 사용하여 연도 + 월을 선택하기 (0) | 2025.09.17 |
|---|