ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI로 캘린더 직접 구현하기 (2탄 - 가로 캘린더 버전)
    SwiftUI 2023. 6. 5. 10:19

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

    이번 포스팅에서는 SwiftUI로 커스텀하게 가로 캘린더를 구현해보는 포스팅을 해보겠습니다🙋🏻

     

    이전 아래 포스팅에서 SwiftUI를 통해 기본적으로 제공해주는 DatePicker 대신 커스텀하게 일반적인 달력 형태의 캘린더를 구현해봤었는데요.

    https://green1229.tistory.com/362

     

    SwiftUI로 캘린더 직접 구현하기

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

    green1229.tistory.com

    이번에는 일반적인 달력 형태보다 약간 아래와 같은 요런 가로 느낌의 캘린더를 직접 커스텀하게 그리고 간단하게🔥 만들어보려구요!

    좌우 스크롤을 통해 일정을 넘기고 월도 넘길 수 있는 그냥 어떻게 보면 한줄짜리 캘린더를 구현해보는 여정이 되겠습니다😲

     

    자 그럼 바로 한번 구현해볼까요?

     

    가로 캘린더 구현하기

    우선 선코드 후설명 (설명까지 필요 없을 수도 있지만..!?)  ㄱ ㄱ~

    import SwiftUI
    
    struct HCalendarView: View {
      @State private var selectedDate = Date()
      private let calendar = Calendar.current
      
      var body: some View {
        VStack(alignment: .center, spacing: 20) {
          monthView
          
          ZStack {
            dayView
            blurView
          }
          .frame(height: 30)
          .padding(.horizontal, 20)
        }
      }
      
      // MARK: - 월 표시 뷰
      private var monthView: some View {
        HStack(spacing: 30) {
          Button(
            action: {
              changeMonth(-1)
            },
            label: {
              Image(systemName: "chevron.left")
                .padding()
            }
          )
          
          Text(monthTitle(from: selectedDate))
            .font(.title)
          
          Button(
            action: {
              changeMonth(1)
            },
            label: {
              Image(systemName: "chevron.right")
                .padding()
            }
          )
        }
      }
      
      // MARK: - 일자 표시 뷰
      @ViewBuilder
      private var dayView: some View {
        let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: selectedDate))!
        
        ScrollView(.horizontal, showsIndicators: false) {
          HStack(spacing: 10) {
            let components = (
              0..<calendar.range(of: .day, in: .month, for: startDate)!.count)
              .map {
                calendar.date(byAdding: .day, value: $0, to: startDate)!
              }
            
            ForEach(components, id: \.self) { date in
              VStack {
                Text(day(from: date))
                  .font(.caption)
                Text("\(calendar.component(.day, from: date))")
              }
              .frame(width: 30, height: 30)
              .padding(5)
              .background(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? Color.green : Color.clear)
              .cornerRadius(16)
              .foregroundColor(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? .white : .black)
              .onTapGesture {
                selectedDate = date
              }
            }
          }
        }
      }
      
      // MARK: - 블러 뷰
      private var blurView: some View {
        HStack {
          LinearGradient(
            gradient: Gradient(
              colors: [
                Color.white.opacity(1),
                Color.white.opacity(0)
              ]
            ),
            startPoint: .leading,
            endPoint: .trailing
          )
          .frame(width: 20)
          .edgesIgnoringSafeArea(.leading)
          
          Spacer()
          
          LinearGradient(
            gradient: Gradient(
              colors: [
                Color.white.opacity(1),
                Color.white.opacity(0)
              ]
            ),
            startPoint: .trailing,
            endPoint: .leading
          )
          .frame(width: 20)
          .edgesIgnoringSafeArea(.leading)
        }
      }
    }
    
    // MARK: - 로직
    private extension HCalendarView {
      /// 월 표시
      func monthTitle(from date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
        return dateFormatter.string(from: date)
      }
      
      /// 월 변경
      func changeMonth(_ value: Int) {
        guard let date = calendar.date(
          byAdding: .month,
          value: value,
          to: selectedDate
        ) else {
          return
        }
        
        selectedDate = date
      }
      
      /// 요일 추출
      func day(from date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.setLocalizedDateFormatFromTemplate("E")
        return dateFormatter.string(from: date)
      }
    }

     

    길어보이지만 뜯어보면 짧습니다!

     

    우선 뷰부터 로직 순으로 한번 살펴볼께요ㅎㅎ

     

    1️⃣ HCalendarView

    우선 가장 뼈대가 되는 가로 캘린더 뷰를 보시면 담아줄 월과 일자를 VStack으로 구성하고 적절한 간격과 프레임을 잡아줍니다.

    또한 @State 상태 변수로 선택된 일자인 selectedDate를 가집니다.

    기본값으로 뷰를 생성하면 현재 일자로 만들어집니다.

     

    그럼 뼈대가 잡혔으니 안에 채워 넣을 월/일자에 대해 한번 구성해볼께요🕺🏻

     

    2️⃣ monthView

    현재 월을 보여주고 이전 이후 월로도 이동할 수 있는 뷰 구성입니다.

    좌 우측에 아이콘을 두고 각 아이콘의 액션은 changeMonth 메서드를 이용해 이전, 이후 월로 상태 값을 변경하도록 해줍니다.

    그리고 Title로는 해당 연월만 표시할 수 있도록 monthTitle 메서드를 통해 추출해줍니다.

     

    그 다음으로 가장 중요한 부분들인 일자를 표시하는 dayView와 조금 더 이쁘게 표현할 blurView를 보시죠!

     

    3️⃣ dayView

    월까지 표시했다면 이제 일자를 표기하고 날짜도 선택할 수 있어야겠죠?

    먼저 현재 일자에 대한 해당 월의 첫날을 표기하는 startDate 값을 얻어냅니다.

    그 다음으로 해당 월에 날짜들이 담긴 components를 구성해줘야 하는데요, 우선적으로 calendar의 range 메서드를 이용해 해당 월의 일자가 몇개인지 count해주고 map을 돌려 일자를 하나씩 올려가면서 Date를 구성해줍니다.

    즉, components는 [Date] 타입이 되는것이죠.

     

    여기까지 따라왔다면 이제 뷰를 그리면 됩니다.

    먼저 가로 캘린더를 구현하기로 했으니 ScrollView와 HStack을 이용하여 원하는 뷰를 담아 구현해줍니다.

    요일과 일자를 표기하기 위해 요일은 내부 day 메서드를 이용해 축약 요일 표현을 해주고 일자는 기본 제공되는 calendar의 component를 활용해 .day로 실제 일자를 표시해줍니다.

    그리고 적절한 레이아웃을 주고 선택된 일자와 아닌 일자에 대해 배경 및 폰트 색상을 변경해주면 됩니다!

    마지막으로 일자를 사용자가 선택하면 내부적으로 selectedDate 상태 값을 변경해야 함으로 onTapGesture에서 이를 수행해줍니다.

     

    자 여기까지만 하더라도 하고자하는 가로 캘린더 뷰를 완성할 수 있지만, 스크롤 뷰 내 좌 우 양끝으로 넘어갈때 날짜가 뚝 사라져버리니까 예쁘지가 않죠...

     

    그래서 양 끝에 적절한 블러 효과를 주도록 조금 디자인적 요소를 가미해볼까요?😃

     

    4️⃣ blurView

    LinearGradient를 이용해 양쪽 끝의 blur 효과를 가진 뷰를 구성해줍니다.

    양쪽 끝이니 startPoint와 stopPoint에 leading과 trailing의 순서를 변경해주는거 잊지 말아야 합니다!🫠

    아 참고로 dayView와 blurView를 사용하는 부분에서 ZStack으로 감싸줘야하고 순서는 dayView 다음 blurView를 위치시켜야 정상적인 표현이 가능해요.

    순서가 바뀌면 blurView가 아래에 깔리기때문에 블러 표현이 묻힙니다.

     

    이제 요렇게 하면 뷰 끝~~
    정말 마지막으로 뷰에서 사용되어 왔던 내부 로직들 간단히 살펴보고 끝내겠습니다.

     

    5️⃣ monthTitle

    해당연월 표기를 위해 DateFormatter를 이용해 MMMM yyyy와 같은 형태로 연월을 표기하도록 Date를 구성하고 string으로 반환해줍니다.

     

    6️⃣ chageMonth

    연월 타이틀에서 양측 아이콘을 통해 연월을 변경해줄때 실제 내부적으로 사용되는 selectedDate 상태값을 변경해주기 위한 메서드입니다.

    value를 받아 현재 selectedDate로 부터 month를 value 만큼 감소, 증가 시킨 날짜를 다시 selectedDate에 넣어주면 되죠!

     

    7️⃣ day

    요일 표기를 위한 메서드로 축약형으로 표현해줍니다.

    monthTitle과 마찬가지로 E와 같은 형태로 요일을 표기해 Date를 구성하고 string으로 반환해주죠.

     

    자 정말 끝이기전에~ 전체 코드를 보실때 한가지 다른점이 없으셨나요!?
    왜 dayView를 프로퍼티로 구성할때 @ViewBuilder를 붙여주었을까요?

     

    @ViewBuilder를 붙여주는 이유

    결론적으로는 SwiftUI에서 뷰 생산성을 높이고 간결하게 만들기 위함입니다.

    dayView 내에서는 복잡한 ForEach를 돈다거나 하는 로직적인 부분들이 섞인 뷰 계층 구조를 가지게되죠.

    만약 if, else와 같은 조건문이나 반복문들이 들어간 뷰도 마찬가지로 그냥 구성하면 어떤 뷰를 보여줄지 즉, 리턴해줄지를 명확하지 않아 컴파일이 추론을 하지 못하고 에러가 납니다.

     

    그런데 사실 해당 dayView 코드에서도 ScrollView 앞에 return을 붙여준다면 필요하지 않습니다.

    왜냐하면 명시적으로 어떤 코드, 뷰를 리턴할지가 명확하니까요.

    다만 해당 코드에서는 startDate 변수를 내부에서 선언하기에 어떤걸 리턴해줄지 모호하게 되기에 에러가 나죠.

     

    즉 최종으로 정리하자면 함수의 뷰를 반환하기 위해 명확하면 사실 some View를 채택하기에 해주지 않아도 되지만 명확하지 않다면 return으로 명확하게 해주거나 @ViewBuilder를 붙여줘야 합니다🙋🏻

     

    자 찐으로 끝! 한번 시연해볼까요?

     

    동작 시연하기

    하고자하는 부분들이 잘 완성된것 같아요ㅎㅎ

    확실히 블러가 있으니 좀 더 스크롤 시 자연스럽게 끝부분을 처리할 수 있습니다!

    이제 더 필요한 디자인이나 커스텀한 부분이 있다면 자유롭게 추가해보면 좋을것 같네요🙌

     

    마무리

    하나씩 구현해나가는거 존잼🏄🏻‍♂️

    위 코드는 아래 깃헙 레포에서 확인하실 수 있습니다!

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

     

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

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

    github.com

Designed by Tistory.