ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI로 캘린더 직접 구현하기
    SwiftUI 2023. 5. 25. 12:04

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

    이번 포스팅에서는 오랜만에 SwiftUI로 뚝닥뚝닥 해보는 시간입니다🙋🏻

    뭘 뚝닥뚝닥 해볼지 고민하다가 그냥 밑도 끝도 없이 캘린더를 간단하게 직접 만들어보고 싶어졌습니다!

    그래서 캘린더를 커스텀하게 SwiftUI로 구현하는 포스팅이 될것 같네요🕺🏻

     

    기본적으로 제공되는 DatePicker

    우선 SwiftUI에서 기본적으로 DatePicker라는 API를 제공해주고 아래와 같이 우리가 익숙한 캘린더의 형태를 나타낼 수 있게 아주 쉽게 도와줍니다.

    https://developer.apple.com/documentation/swiftui/datepicker

     

    DatePicker | Apple Developer Documentation

    A control for selecting an absolute date.

    developer.apple.com

     

    사용은 요런식으로 할 수 있어요.

    datePickerStyle을 graphical로 지정해주면 됩니다.

    @State private var date = Date()
    
    var body: some View {
        DatePicker(
            "Start Date",
            selection: $date,
            displayedComponents: [.date]
        )
        .datePickerStyle(.graphical)
    }

    물론 이런 캘린더를 써도 되지만 SwiftUI의 큰 장점중 하나인 커스텀이 너무 자유자재로 쉽다라는걸 이용해볼까해요.

    실제로 저 형태가 아닌 다른 형태와 액션 로직들이 들어가야하는 경우도 무궁무진 하니까요!

     

    그래서 이번 포스팅에서는 대략적으로 저런 기본적인 형태를 가져가면서 커스텀하게 만들어볼께요!

     

    커스텀한 캘린더 구현하기

    우선 코드로 보는게 제일 나을것 같아요.

     

    선코드 후설명 ㄱㄱ~

    import SwiftUI
    
    struct CalenderView: View {
      @State var month: Date
      @State var offset: CGSize = CGSize()
      @State var clickedDates: Set<Date> = []
      
      var body: some View {
        VStack {
          headerView
          calendarGridView
        }
        .gesture(
          DragGesture()
            .onChanged { gesture in
              self.offset = gesture.translation
            }
            .onEnded { gesture in
              if gesture.translation.width < -100 {
                changeMonth(by: 1)
              } else if gesture.translation.width > 100 {
                changeMonth(by: -1)
              }
              self.offset = CGSize()
            }
        )
      }
      
      // MARK: - 헤더 뷰
      private var headerView: some View {
        VStack {
          Text(month, formatter: Self.dateFormatter)
            .font(.title)
            .padding(.bottom)
          
          HStack {
            ForEach(Self.weekdaySymbols, id: \.self) { symbol in
              Text(symbol)
                .frame(maxWidth: .infinity)
            }
          }
          .padding(.bottom, 5)
        }
      }
      
      // MARK: - 날짜 그리드 뷰
      private var calendarGridView: some View {
        let daysInMonth: Int = numberOfDays(in: month)
        let firstWeekday: Int = firstWeekdayOfMonth(in: month) - 1
    
        return VStack {
          LazyVGrid(columns: Array(repeating: GridItem(), count: 7)) {
            ForEach(0 ..< daysInMonth + firstWeekday, id: \.self) { index in
              if index < firstWeekday {
                RoundedRectangle(cornerRadius: 5)
                  .foregroundColor(Color.clear)
              } else {
                let date = getDate(for: index - firstWeekday)
                let day = index - firstWeekday + 1
                let clicked = clickedDates.contains(date)
                
                CellView(day: day, clicked: clicked)
                  .onTapGesture {
                    if clicked {
                      clickedDates.remove(date)
                    } else {
                      clickedDates.insert(date)
                    }
                  }
              }
            }
          }
        }
      }
    }
    
    // MARK: - 일자 셀 뷰
    private struct CellView: View {
      var day: Int
      var clicked: Bool = false
      
      init(day: Int, clicked: Bool) {
        self.day = day
        self.clicked = clicked
      }
      
      var body: some View {
        VStack {
          RoundedRectangle(cornerRadius: 5)
            .opacity(0)
            .overlay(Text(String(day)))
            .foregroundColor(.blue)
          
          if clicked {
            Text("Click")
              .font(.caption)
              .foregroundColor(.red)
          }
        }
      }
    }
    
    // MARK: - 내부 메서드
    private extension CalenderView {
      /// 특정 해당 날짜
      private func getDate(for day: Int) -> Date {
        return Calendar.current.date(byAdding: .day, value: day, to: startOfMonth())!
      }
      
      /// 해당 월의 시작 날짜
      func startOfMonth() -> Date {
        let components = Calendar.current.dateComponents([.year, .month], from: month)
        return Calendar.current.date(from: components)!
      }
      
      /// 해당 월에 존재하는 일자 수
      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 changeMonth(by value: Int) {
        let calendar = Calendar.current
        if let newMonth = calendar.date(byAdding: .month, value: value, to: month) {
          self.month = newMonth
        }
      }
    }
    
    // MARK: - Static 프로퍼티
    extension CalenderView {
      static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "MMMM yyyy"
        return formatter
      }()
      
      static let weekdaySymbols = Calendar.current.veryShortWeekdaySymbols
    }

     

    그럼 이제 하나씩 뜯어볼까요?

     

    1️⃣ CalenderView

    우선 가장 뼈대가 되는 최상위 캘린더 뷰입니다.

    현재 달력의 월의 정보를 들고 있을 month 프로퍼티와 드래그 제스쳐로 월을 변경해주기 위한 offset 마지막으로 특정 일자를 클릭하면 추가될 뷰를 위한 clickedDate라는 프로퍼티를 들고 있습니다.

    여기서 clickedDate는 해당 일자들에 대한 Date 타입으로 고유하게 가져갑니다.

     

    그 다음으로, VStack을 이용해 월과 연도 표시인 헤더뷰와 실제 일자를 표시할 캘린더그리드뷰를 가집니다.

    드래그를 위해 dragGesture 모디파이어를 이용해 실제 좌우 100 기준을 두어 현재 month의 값을 변경해주도록 합니다.

     

    2️⃣ headerView

    월과 연도 표기 그리고 요일(일~토) 표기를 위한 뷰로 간단히 구성합니다.

     

    3️⃣ calendarGridView

    다음으로 실제 일자별로 그려줄 그리드 뷰를 만들어줍니다.

    daysInMonth는 현재 해당하는 월이 가진 날짜 수를 의미합니다.

    firstWeedDay는 현재 해당하는 월의 첫번째 날짜가 어떤 요일로 Int값을 갖는지를 체크해줘요.

    일요일부터 시작하기에 일요일은 1부터 토요일은 7로 끝나게 되죠.

    -1을 해주는 이유는 아래에서 돌 ForEach 배열의 인덱스와 맞추기 위함입니다.

    배열은 0 인덱스부터 시작하니까요!

     

    그 다음으로 VStack과 LazyVGrid를 통해 실제 뷰를 그려줍니다.

    한 행에 7개(일~월)씩 셀을 가집니다.

    돌려주는 갯수는 위에서 해당 월이 가진 날짜 수인 daysInMonth에 월의 첫 날짜 요일 Int값인 firstWeedDay를 더해준 수 만큼 돌려줘요.

     

    여기서 왜 두 값을 합쳐서 반복을 돌려줄까요?

     

    이유는 캘린더의 모든 날짜를 올바르게 배치하고 해당 월의 첫째 날이 시작되기 전에는 빈 셀로 채워진 공백 뷰를 만들기 위함입니다.

    예를들어, 6월은 30일까지 있습니다.

    여기서 6월의 첫 시작 요일이 월요일이라면 해당 값은 -1하여 1이 됩니다.

    그렇기에 31개의 셀을 그려주게 되면 정확한 수의 셀이 생성되어 캘린더에 위치할것이고 날짜가 시작되기 전의 셀이 필요한 만큼 채워질거라 올바르게 정렬된 캘린더를 구현할 수 있습니다.

     

    그 다음으로 현재 index가 해당 월의 첫 날짜의 요일에 미치지 않았다면 클리어한 뷰를 그려줍니다.

    그 외라면 이제 정말 뷰를 그리기 위해 CellView를 그려주죠.

    CellView에는 현재 일자인 day와 클릭이 되었는지인 clicked 변수를 담아서 호출해줍니다.

    여기서 day는 현재 인덱스에서 시작 요일 Int값을 뺀것에 1을 더해 원래 해당하는 요일을 매칭해줍니다.

     

    마지막으로 해당 호출해온 CellView가 터치되었을때 전역적으로 둔 clickedDate에 해당 날짜가 존재하면 제거하고 미존재 시 추가하도록 탭 제스쳐를 붙여줍니다🙌

     

    4️⃣ CellView

    이제 실제 하나하나의 셀뷰를 공통적으로 만들어줍니다.

    간단하게 day와 클릭된 상태인지 체킹하는 clicked 프로퍼티를 두고 입맛대로 커스텀하게 구현해줄 수 있습니다!

     

    요렇게 뷰를 그릴 수 있고 이제 그럼 로직들을 살펴볼까요?

     

    5️⃣ getDate

    해당 메서드는 해당 월의 시작날짜로부터 넘겨받은 day가 위치한 날짜 즉 Date를 반환해줍니다.

     

    6️⃣ startOfMonth

    이 메서드는 해당 월의 시작 날짜를 Date로 반환해줍니다.

    그렇기에 getDate 내에서 해당 메서드를 to 인자에 넣어 시작점을 대입해주어 사용합니다.

     

    7️⃣ numberOfDays

    해당 메서드는 해당 월에 존재하는 일자 수를 Int 값으로 반환해줍니다.

     

    8️⃣ firstWeekdayOfMonth

    해당 메서드는 해당 월의 첫 날짜가 갖는 해당 주의 몇번째 요일인지 Int값으로 반환해줍니다.

    위에서 일요일은 1, 토요일은 6의 정해진 값을 갖는다고 했죠?

    이것의 값을 뽑아줍니다.

    즉, 6월의 첫번째 날짜 즉 6월 1일이 어떤 요일로 해당되는 값을 갖는지를 뽑아줍니다.

     

    9️⃣ changeMonth

    마지막으로 해당 메서드는 좌우 드래그 시 현재 보여지는 캘린더의 월을 변경해주기 위해 전역 변수인 month 값을 변경해줍니다.

     

    자 그럼 이제 찐막으로 위에서 Self.dateFormatter와 Self.weekdaySymbols를 사용한 Static 프로퍼티를 보겠습니다.

     

    🔟 dateFormatter와 weekdaySymbols

    dateFormatter는 현재 날짜를 가지고 특정 원하는 형태로 표기될 수 있도록 해줍니다.

    weekdaySymbols는 일요일부터 토요일에 해당하는 요일 심볼을 어떻게 표기할지 정해줍니다.

    예를들어 veryShortWeekdaySymbols로 가져간다면 일요일은 일, 월요일은 월로 간략히 표기되죠.

    해당 반환 타입은 [String] 입니다.

     

    요렇게 아주 간단하게 커스텀한 캘린더를 만들 수 있어요!
    원한다면 살을 더 붙이고 버튼도 넣으면서 입맛대로 커스터마이징 할 수 있습니다.
    DatePicker를 사용하지 않아도 쉽게 해결할 수 있죠?
    그럼 동작 시켜볼까요?🕺🏻

     

    동작 해보기

     

    마무리

    DatePicker 안써도 충분히 쉽게 할 수 있고 더 보완하면서 커스텀하게 살을 붙일 수 있는것 같아요.

    SwiftUI를 할때 이런 부분들이 너무 재밌더라구요🎉

Designed by Tistory.