ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI로 캘린더 직접 구현하기 (3탄 - 보완된 캘린더)
    SwiftUI 2023. 6. 29. 17:44

    안녕하세요. 그린입니다🍏

    이번 포스팅에서는 이전에 SwiftUI로 커스텀 캘린더를 구현한적이 있습니다.

    https://green1229.tistory.com/362

     

    SwiftUI로 캘린더 직접 구현하기

    안녕하세요. 그린입니다🍏 이번 포스팅에서는 오랜만에 SwiftUI로 뚝닥뚝닥 해보는 시간입니다🙋🏻 뭘 뚝닥뚝닥 해볼지 고민하다가 그냥 밑도 끝도 없이 캘린더를 간단하게 직접 만들어보고

    green1229.tistory.com

     

    기본적인 구현의 포스팅이니 먼저 보고오셔도 좋습니다🙋🏻

     

    여기서 발전시켜서 좀 더 부가적인 기능을 넣어 캘린더의 기능을 보완해봤습니다🏄🏻‍♂️

     

    어떤 기능들이 들어갔나요?

    1️⃣ 현재 월 달력에서도 이전 이후 날짜가 보이면서 실제 클릭 등의 동작은 되지 않도록 추가

    2️⃣ 이전 / 이후 달에 대해 최대 나타낼 수 있는 limit 제공 (앞 뒤 3달까지만 월 변경 이동 등)

    3️⃣ 특정 날짜를 탭했을때 표시하고 더 커스텀화 시킬 수 있도록 확장성 추가

    4️⃣ 오늘 날짜에 대해 표시

     

    이정도로 기능을 보완해봤어요!

     

    먼저 한번 어떤 달력과 기능이 완성되는지 움짤로 보시죠😃

    이런거 하려해요!

     

    그럼 코드로 쉽게 보시죠😁

     

    View

    struct CalenderView: View {
      @State private var month: Date = Date()
      @State private var clickedCurrentMonthDates: Date?
      
      init(
        month: Date = Date(),
        clickedCurrentMonthDates: Date? = nil
      ) {
        _month = State(initialValue: month)
        _clickedCurrentMonthDates = State(initialValue: clickedCurrentMonthDates)
      }
      
      var body: some View {
        VStack {
          headerView
          calendarGridView
        }
      }
      
      // MARK: - 헤더 뷰
      private var headerView: some View {
        VStack {
          HStack {
            yearMonthView
            
            Spacer()
            
            Button(
              action: { },
              label: {
                Image(systemName: "list.bullet")
                  .font(.title)
                  .foregroundColor(.black)
              }
            )
          }
          .padding(.horizontal, 10)
          .padding(.bottom, 5)
          
          HStack {
            ForEach(Self.weekdaySymbols.indices, id: \.self) { symbol in
              Text(Self.weekdaySymbols[symbol].uppercased())
                .foregroundColor(.gray)
                .frame(maxWidth: .infinity)
            }
          }
          .padding(.bottom, 5)
        }
      }
      
      // MARK: - 연월 표시
      private var yearMonthView: some View {
        HStack(alignment: .center, spacing: 20) {
          Button(
            action: {
              changeMonth(by: -1)
            },
            label: {
              Image(systemName: "chevron.left")
                .font(.title)
                .foregroundColor(canMoveToPreviousMonth() ? .black : . gray)
            }
          )
          .disabled(!canMoveToPreviousMonth())
          
          Text(month, formatter: Self.calendarHeaderDateFormatter)
            .font(.title.bold())
          
          Button(
            action: {
              changeMonth(by: 1)
            },
            label: {
              Image(systemName: "chevron.right")
                .font(.title)
                .foregroundColor(canMoveToNextMonth() ? .black : .gray)
            }
          )
          .disabled(!canMoveToNextMonth())
        }
      }
      
      // MARK: - 날짜 그리드 뷰
      private var calendarGridView: some View {
        let daysInMonth: Int = numberOfDays(in: month)
        let firstWeekday: Int = firstWeekdayOfMonth(in: month) - 1
        let lastDayOfMonthBefore = numberOfDays(in: previousMonth())
        let numberOfRows = Int(ceil(Double(daysInMonth + firstWeekday) / 7.0))
        let visibleDaysOfNextMonth = numberOfRows * 7 - (daysInMonth + firstWeekday)
        
        return LazyVGrid(columns: Array(repeating: GridItem(), count: 7)) {
          ForEach(-firstWeekday ..< daysInMonth + visibleDaysOfNextMonth, id: \.self) { index in
            Group {
              if index > -1 && index < daysInMonth {
                let date = getDate(for: index)
                let day = Calendar.current.component(.day, from: date)
                let clicked = clickedCurrentMonthDates == date
                let isToday = date.formattedCalendarDayDate == today.formattedCalendarDayDate
                
                CellView(day: day, clicked: clicked, isToday: isToday)
              } else if let prevMonthDate = Calendar.current.date(
                byAdding: .day,
                value: index + lastDayOfMonthBefore,
                to: previousMonth()
              ) {
                let day = Calendar.current.component(.day, from: prevMonthDate)
                
                CellView(day: day, isCurrentMonthDay: false)
              }
            }
            .onTapGesture {
              if 0 <= index && index < daysInMonth {
                let date = getDate(for: index)
                clickedCurrentMonthDates = date
              }
            }
          }
        }
      }
    }
    
    // MARK: - 일자 셀 뷰
    private struct CellView: View {
      private var day: Int
      private var clicked: Bool
      private var isToday: Bool
      private var isCurrentMonthDay: Bool
      private var textColor: Color {
        if clicked {
          return Color.white
        } else if isCurrentMonthDay {
          return Color.black
        } else {
          return Color.gray
        }
      }
      private var backgroundColor: Color {
        if clicked {
          return Color.black
        } else if isToday {
          return Color.gray
        } else {
          return Color.white
        }
      }
      
      fileprivate init(
        day: Int,
        clicked: Bool = false,
        isToday: Bool = false,
        isCurrentMonthDay: Bool = true
      ) {
        self.day = day
        self.clicked = clicked
        self.isToday = isToday
        self.isCurrentMonthDay = isCurrentMonthDay
      }
      
      fileprivate var body: some View {
        VStack {
          Circle()
            .fill(backgroundColor)
            .overlay(Text(String(day)))
            .foregroundColor(textColor)
          
          Spacer()
          
          if clicked {
            RoundedRectangle(cornerRadius: 10)
              .fill(.red)
              .frame(width: 10, height: 10)
          } else {
            Spacer()
              .frame(height: 10)
          }
        }
        .frame(height: 50)
      }
    }

    뷰에서는 각 뷰 영역을 잘게 쪼개 컴포넌트로 만들어 사용할 수 있도록 구성했습니다.

    해당 뷰를 사용할때는 아무것도 넘겨주지 않아도 되고 필요하다면 특정 날짜 Date 타입을 넘겨 처음 나타나는 월을 보여줄 수 있습니다.

     

    필요한 CalendarView

    private extension CalenderView {
      var today: Date {
        let now = Date()
        let components = Calendar.current.dateComponents([.year, .month, .day], from: now)
        return Calendar.current.date(from: components)!
      }
      
      static let calendarHeaderDateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "YYYY.MM"
        return formatter
      }()
      
      static let weekdaySymbols: [String] = Calendar.current.shortWeekdaySymbols
    }

    해당 뷰에서 필요한 프로퍼티들을 익스텐션하여 추가해줍니다.

    오늘 날짜인지 비교하기 위해 필요한 today와 헤더에 연월 표기를 위한 formatter 그리고 요일 표시 형식의 프로퍼티 정도 들어갑니다.

     

    그리고 대망의 로직들을 보시죠!

     

    내부 로직 메서드

    private extension CalenderView {
      /// 특정 해당 날짜
      func getDate(for index: Int) -> Date {
        let calendar = Calendar.current
        guard let firstDayOfMonth = calendar.date(
          from: DateComponents(
            year: calendar.component(.year, from: month),
            month: calendar.component(.month, from: month),
            day: 1
          )
        ) else {
          return Date()
        }
        
        var dateComponents = DateComponents()
        dateComponents.day = index
        
        let timeZone = TimeZone.current
        let offset = Double(timeZone.secondsFromGMT(for: firstDayOfMonth))
        dateComponents.second = Int(offset)
        
        let date = calendar.date(byAdding: dateComponents, to: firstDayOfMonth) ?? Date()
        return date
      }
      
      /// 해당 월에 존재하는 일자 수
      func numberOfDays(in date: Date) -> Int {
        return Calendar.current.range(of: .day, in: .month, for: date)?.count ?? 0
      }
      
      /// 해당 월의 첫 날짜가 갖는 해당 주의 몇번째 요일
      func firstWeekdayOfMonth(in date: Date) -> Int {
        let components = Calendar.current.dateComponents([.year, .month], from: date)
        let firstDayOfMonth = Calendar.current.date(from: components)!
        
        return Calendar.current.component(.weekday, from: firstDayOfMonth)
      }
      
      /// 이전 월 마지막 일자
      func previousMonth() -> Date {
        let components = Calendar.current.dateComponents([.year, .month], from: month)
        let firstDayOfMonth = Calendar.current.date(from: components)!
        let previousMonth = Calendar.current.date(byAdding: .month, value: -1, to: firstDayOfMonth)!
        
        return previousMonth
      }
      
      /// 월 변경
      func changeMonth(by value: Int) {
        self.month = adjustedMonth(by: value)
      }
      
      /// 이전 월로 이동 가능한지 확인
      func canMoveToPreviousMonth() -> Bool {
        let currentDate = Date()
        let calendar = Calendar.current
        let targetDate = calendar.date(byAdding: .month, value: -3, to: currentDate) ?? currentDate
        
        if adjustedMonth(by: -1) < targetDate {
          return false
        }
        return true
      }
      
      /// 다음 월로 이동 가능한지 확인
      func canMoveToNextMonth() -> Bool {
        let currentDate = Date()
        let calendar = Calendar.current
        let targetDate = calendar.date(byAdding: .month, value: 3, to: currentDate) ?? currentDate
        
        if adjustedMonth(by: 1) > targetDate {
          return false
        }
        return true
      }
      
      /// 변경하려는 월 반환
      func adjustedMonth(by value: Int) -> Date {
        if let newMonth = Calendar.current.date(byAdding: .month, value: value, to: month) {
          return newMonth
        }
        return month
      }
    }

    해당 기능을 위해 필요한 메서드들을 구현했습니다.

    전체적으로 date를 가지고 계산하는 로직들이 구성되어 있습니다.

     

    마지막으로 그려줄때 오늘 날짜인지 비교해주기 위해 Date 타입을 익스텐션하여 구성해줄 수 있어요.

     

    Date extension

    extension Date {
      static let calendarDayDateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "MMMM yyyy dd"
        return formatter
      }()
      
      var formattedCalendarDayDate: String {
        return Date.calendarDayDateFormatter.string(from: self)
      }
    }

    formatter 형식으로 연월일만 비교하여 오늘 날짜인지 파악할 수 있습니다.

     

    이렇게 로직은 조금 복잡할 수 있지만 빠진 기능없이 간략하게 캘린더를 구현해볼 수 있습니다!

     

    마무리

    해당 캘린더에서 뷰들은 자유롭게 커스텀이 가능하니 SwiftUI로 어떤 캘린더 요구사항이 오더라도 나름 쉽게 구현할 수 있습니다.

    해당 프로젝트 코드들은 아래 제 깃헙 레포에서도 확인하실 수 있습니다.

    https://github.com/GREENOVER/playground/tree/main/calendar

     

    GitHub - GREENOVER/playground: 학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터

    학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

    github.com

Designed by Tistory.