본문 바로가기
한눈가계부/캘린더

커스텀 캘린더 만들기: 전체 설계부터 구현까지 🗓️

by 밤새는 탐험가89 2025. 8. 29.
728x90
SMALL

 앱에서 자주 마주치는 캘린더 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)
    }
}
728x90
LIST