본문 바로가기

iOS

[iOS] Calendar 직접 만들어보기 (2) - 무한 수평 스크롤, 주간 월간 캘린더 전환

 

이번 글은 무한 수평 스크롤에 대해서 써보려고 합니다! 

이전 글에 대한 다음 글이니 보고 오시면 더 좋을 듯 해요 🤗

2021.07.08 - [🍎] - [iOS] Calendar 직접 만들어보기 (1) - 한 달 달력

무한 수평 스크롤

이 기능은 솔직히 처음 시작할 때 감도 오지 않았습니다. 아니, 사실 collectionView로 해보면 되겠지 생각했어요. 한번에 한 달만 보여지는 달력이니까 collectionView로 보여지는 월 하나를 하나의 Section안에 넣은 후, Int.max만큼의 Section을 만들고 처음 시작을 중간에서 시작하면 되겠구나~라고 생각했습니다. 하지만 엄밀히 말하면 이것은 끝이 정해져있는 스크롤이라 무한 스크롤이 아니자나요..! 😿

 

그래서 이미 만들어져 있는 라이브러리는 어떤 방식으로 UI가 구성되어 있는 지 찾아보았습니다. CVCalendar를 뜯어본 결과, 아래 그림과 같이 ScrollView의 paging속성을 이용하기로 결정했습니다!

 

생각한 방법

달력의 가로가 device의 가로와 같게 개발을 하였습니다. scrollView의 contentSize를 가로의 3배로 만든 후, 왼쪽 또는 오른쪽 스크롤을 할 때마다 contentOffset을 중간 위치로 바꾸고 세 개의 달력의 내용을 업데이트 해주면 됩니다. 예를 들어 다음 달이 보고 싶다면 왼쪽으로 스크롤을 하게 됩니다. 이렇게 되면 다음달의 내용을 중간에 오게 만들고 다다음달은 오른쪽, 현재달은 왼쪽 달력에 내용을 업데이트 시켜주고 contentOffset의 위치를 다시 위 그림처럼 중간으로 만들어줍니다.

 

 

1️⃣ scrollView 만들기

lazy var contentScrollView: UIScrollView = {
    let scrollView = UIScrollView()
    scrollView.translatesAutoresizingMaskIntoConstraints = false
    scrollView.isPagingEnabled = true
    scrollView.showsHorizontalScrollIndicator = false
    scrollView.delegate = self
    return scrollView
}()

scrollView를 위와 같이 만들어줍니다. 여기서 생각해야 할 점은 isPagingEnabled 속성을 true로 해준다는 것입니다. 또한 scrollIndicator는 false로 해줘서 사용자가 다시 중앙 위치로 돌아왔다는 것을 모르게 해줍니다.

 

2️⃣ 달력 collectionView 3개(previous, present, following) 만들기

lazy var previousCalendarCollectionView: UICollectionView = {
    let layout = UICollectionViewFlowLayout()
    layout.sectionInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
    
    let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
    collectionView.backgroundColor = .white
    collectionView.showsHorizontalScrollIndicator = false
    collectionView.showsVerticalScrollIndicator = false
    collectionView.isScrollEnabled = false
    collectionView.translatesAutoresizingMaskIntoConstraints = false
    collectionView.allowsMultipleSelection = false
    collectionView.delegate = self
    collectionView.dataSource = self
    collectionView.register(DateCVCell.self, forCellWithReuseIdentifier: "DateCVCell")
    collectionView.collectionViewLayout.invalidateLayout()
    return collectionView
}()

lazy var presentedCalendarCollectionView: UICollectionView = {
    let layout = UICollectionViewFlowLayout()
    layout.sectionInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
    
    let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
    collectionView.backgroundColor = .white
    collectionView.showsHorizontalScrollIndicator = false
    collectionView.showsVerticalScrollIndicator = false
    collectionView.isScrollEnabled = false
    collectionView.translatesAutoresizingMaskIntoConstraints = false
    collectionView.allowsMultipleSelection = false
    collectionView.delegate = self
    collectionView.dataSource = self
    collectionView.register(DateCVCell.self, forCellWithReuseIdentifier: "DateCVCell")
    collectionView.collectionViewLayout.invalidateLayout()
    return collectionView
}()

lazy var followingCalendarCollectionView: UICollectionView = {
    let layout = UICollectionViewFlowLayout()
    layout.sectionInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
    
    let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
    collectionView.backgroundColor = .white
    collectionView.showsHorizontalScrollIndicator = false
    collectionView.showsVerticalScrollIndicator = false
    collectionView.isScrollEnabled = false
    collectionView.translatesAutoresizingMaskIntoConstraints = false
    collectionView.allowsMultipleSelection = false
    collectionView.delegate = self
    collectionView.dataSource = self
    collectionView.register(DateCVCell.self, forCellWithReuseIdentifier: "DateCVCell")
    collectionView.collectionViewLayout.invalidateLayout()
    return collectionView
}()

ㅎㅎㅎㅎ 무식하게 세개 만들었어요 ㅎㅎㅎㅎ 이것보다 좋은 방법이 있을텐데... 우선은 이렇게 만들게요. 

이제 이것들을 scrollView안에 추가해줄거예요.

addSubview(contentScrollView)
contentScrollView.topAnchor.constraint(equalTo: weekdayView.bottomAnchor, constant: 12).isActive = true
contentScrollView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
contentScrollView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
contentScrollView.heightAnchor.constraint(equalToConstant: 300).isActive = true

let screenSize = UIScreen.main.bounds
contentScrollView.contentSize = CGSize(width: screenSize.width * 3, height: 300)

let width = screenSize.width
let xPosition = self.frame.width * CGFloat(0)
previousCalendarCollectionView.frame = CGRect(x: xPosition, y: 0, width: width, height: 300)
contentScrollView.contentSize.width = self.frame.width * 1
contentScrollView.addSubview(previousCalendarCollectionView)

let xPosition1 = self.frame.width * CGFloat(1)
presentedCalendarCollectionView.frame = CGRect(x: xPosition1, y: 0, width: width, height: 300)
contentScrollView.contentSize.width = self.frame.width * 2
contentScrollView.addSubview(presentedCalendarCollectionView)

let xPosition2 = self.frame.width * CGFloat(2)
followingCalendarCollectionView.frame = CGRect(x: xPosition2, y: 0, width: width, height: 300)
contentScrollView.contentSize.width = self.frame.width * 3
contentScrollView.addSubview(followingCalendarCollectionView)

contentScrollView.setContentOffset(CGPoint(x: xPosition1, y: 0), animated: false)

xPosition, xPosition1, xPosition2 3개의 위치에 달력을 하나씩 넣어주고, 시작할 때는 중간위치에서 시작하게 해주었습니다. 그리고 Datasource를 정의해주어야 하는데 너무 길어서 패쓰하도록하겠습니다~! 하지만 한 달 달력 만들기 (1) 글을 보시고 오면 하실 수 있을 거예요! 

또한 previous, present, following 달을 저장할 변수들도 만들어줍니다.

   var previousMonth: Int = 0
   var previousYear: Int = 0
   
   var presentedMonth: Int = 0
   var presentedYear: Int = 0
   
   var followingMonth: Int = 0
   var followingYear: Int = 0
   
   var firstWeekdayOfPreviousMonth: Int = 0
   var firstWeekdayOfPresentedMonth: Int = 0
   var firstWeekdayOfFollowingMonth: Int = 0

 

3️⃣ 스크롤 될 때 contentOffset과 캘린더 내용 업데이트

 

extension CalendarView: UIScrollViewDelegate {
   func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity:CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
       
       switch targetContentOffset.pointee.x {
       case 0:
           scrollDirection = .left
           
       case self.frame.width * CGFloat(1):
           scrollDirection = .none
           break
       case self.frame.width * CGFloat(2):
           scrollDirection = .right
           
       default:
           break
       }
       
   }
   
   func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
       switch scrollDirection {
       case .left:
           let py = getPreviousMonth(year: presentedYear, month: presentedMonth).year
           let pm = getPreviousMonth(year: presentedYear, month: presentedMonth).month
           updateCalendarView(middle: MyDateExpression(year: py, month: pm))
       case .none:
           break
       case .right:
           let fy = getFollowingMonth(year: presentedYear, month: presentedMonth).year
           let fm = getFollowingMonth(year: presentedYear, month: presentedMonth).month
           updateCalendarView(middle: MyDateExpression(year: fy, month: fm))
       }
   }
}

scrollViewWillEndDragging함수는 Drag가 끝나고 손가락을 떼기전에 호출되는 함수입니다. 이것은 스크롤 방향을 어디로 했는지 알려줍니다. 왜 이곳에서 scroll방향을 정해 줬냐 하면 scrollView의 paging이 될 때도 있고 안 될 때도 있었습니다. 

처음에는 scrollView의 contentOffset을 이용해서 이전보다 크면 오른쪽으로 이동했구나하고 scrollDirection을 정해줬는데 오른쪽으로 스크롤을 아주 짧게 하면 넘어가지 않고 다시 원래 컨텐츠를 보여주더라구요.. 어떻게 할까 고민하다가 targetContentOffset이라고 scroll이 끝나는 위치를 예측해서 알려주는 함수가 있어서 사용했습니다.

scrollViewDidEndDecelerating은 스크롤이 완전히 끝난 후 호출되는 함수입니다. 여기서 달력 내용과 scrollView의 contentOffset을 업데이트해주어야 합니다. 

private func updateCalendarView(middle date: MyDateExpression) {
       
       // 윤년 계산
       if date.month == 2 && date.year % 4 == 0 {
           numOfDaysInMonth[date.month-1] = 29
       } else {
           numOfDaysInMonth[1] = 28
       }
       
       monthView.updateYearAndMonth(to: date)
       
       presentedYear = date.year
       presentedMonth = date.month
       
       previousYear = getPreviousMonth(year: presentedYear, month: presentedMonth).year
       previousMonth = getPreviousMonth(year: presentedYear, month:presentedMonth).month
       
       followingYear = getFollowingMonth(year: presentedYear, month:presentedMonth).year
       followingMonth = getFollowingMonth(year: presentedYear, month:presentedMonth).month
       
       firstWeekdayOfPreviousMonth = getPreviousFirstWeekday()
       firstWeekdayOfPresentedMonth = getPresentedFirstWeekday()
       firstWeekdayOfFollowingMonth = getFollowingFirstWeekday()
       
       previousCalendarCollectionView.reloadData()
       presentedCalendarCollectionView.reloadData()
       followingCalendarCollectionView.reloadData()
       
       let xPosition1 = frame.width * CGFloat(1)
       contentScrollView.setContentOffset(CGPoint(x: xPosition1, y: 0), animated: false)
   }

 

먼저, 보여질 달이 윤년인지 체크해줍니다. 그리고 MonthView의 년도와 달 Label을 업데이트해주고, previous, presented, following 달들이 무엇일지 알아냅니다. 마지막으로 캘린더 컨텐츠들을 업데이트해주고 contentOffset을 중간위치로 애니메이션 없이 이동시켜줍니다.

 

 

 

이렇게 하면 수평 무한 스크롤이 완성입니다! 

 

주간, 월간 캘린더 전환 

이제 주간-> 월간, 월간 -> 주간으로 전환하는 기능을 만들어 보겠습니다. 주간은 오늘이 있는 주로 항상 돌아오도록 만들어보겠습니다.

 

1️⃣ week, month 타입의 enum 만들기

enum CalendarShape {
    case week
    case month
}

2️⃣ 업데이트 할 함수 만들기

func changeCalendarMode() {
    let date = MyDateExpression(year: currentYear, month: currentMonth)
    monthView.updateYearAndMonth(to: date)
    
    presentedYear = date.year
    presentedMonth = date.month
    
    previousYear = getPreviousMonth(year: presentedYear, month: presentedMonth).year
    previousMonth = getPreviousMonth(year: presentedYear, month: presentedMonth).month
    
    followingYear = getFollowingMonth(year: presentedYear, month: presentedMonth).year
    followingMonth = getFollowingMonth(year: presentedYear, month: presentedMonth).month
    
    firstWeekdayOfPreviousMonth = getPreviousFirstWeekday()
    firstWeekdayOfPresentedMonth = getPresentedFirstWeekday()
    firstWeekdayOfFollowingMonth = getFollowingFirstWeekday()
    
    if calendarShapeMode == .month {
        calendarShapeMode = .week
        contentScrollView.isScrollEnabled = false
    } else {
        calendarShapeMode = .month
        contentScrollView.isScrollEnabled = true
    }
    presentedCalendarCollectionView.performBatchUpdates {
        self.presentedCalendarCollectionView.reloadSections(IndexSet(integer: 0))
    } completion: { _ in
        
        self.previousCalendarCollectionView.reloadData()
        self.presentedCalendarCollectionView.reloadData()
        self.followingCalendarCollectionView.reloadData()
        
    }
    
}

다른 달에 있어도 항상 오늘이 있는 주로 돌아와야 하기 떄문에 현재 달로 바꿔줍니다. 그 후, previous, present, following 달 변수들을 업데이트 해줍니다. 그리고 calendarShapeMode를 바꿔주고, collectionView의 데이터를 업데이트해주면 됩니다. 이 때 자연스럽게 업데이트해주기 위해서 조금 찾아보았는데 DeepDiff, Differ 등을 쓰면 될 거 같습니다. 하지만 일단은 performBatchUpdates 메소드를 이용했습니다. 

 

3️⃣ CollectionView에서 week모드일때와 month모드일 때 DataSource 구분하기

 

Cell의 수는 위와 같이 week모드일때는 7로 반환해줍니다. Cell의 내용은 몇 일이냐 인데 이것은 오늘을 포함하는 주의 시작하는 일요일과 끝나는 토요일을 구해서 넣어주면 됩니다!

 

 

개발하면서 만난 이슈 정리

1. 제대로 된 요일과 Date타입이 맞나 디버그창에 print할 때 날짜가 하루 전날로 나오고 시간도 한국시간과 맞지 않는 이슈

let components = Calendar.current.dateComponents([.year,.month], from: Date())
print(Calendar.current.date(from: components)!)

// 2021-06-30 15:00:00 +0000

 

달력도 제대로 나오고, 계산도 분명 제대로 되어서 로직도 맞는데 디버그창에 출력만 하면 현재 날짜, 시간과 다르게 나오는 것입니다..

위 같은 경우에도 이번 달의 첫번 째 날의 Date를 구한 것인데 7/1 이 아니라 6/30이 나오는 것이었습니다.. 달력을 만들기 전에 Dates and Times를 공부하면서 알게 된 달력을 locale을 한국으로 맞추어도... 이상하게 찍히던데 왜 이런 지 정말 많이 찾아보았네요.

나중에 알게 된 점은 설정값은 한국시간인데 프린트할 때는 미국시간으로 나온다는 점이었습니다........... ㅠㅠ

그러니.. 프린트하면 의도한 시간과 다르게 나와도 그대로 진행하면 좋을 듯 해요!!

 

2. 달력 스크롤하면 자꾸 중간에 멈추는 이슈

달력을 오른쪽으로 스크롤을 하는데 paging되서 다음 달력이 나와야 하는데 중간에 턱 멈춰버리는 문제가 발생했었습니다. 사실 이유가 어이가 없었는데 30분동안 찾아봤네요.. scrollViewWillEndDragging 함수가 호출될 때 contentOffset을 중간으로 옮겨주는 함수를 호출해주었기 때문이었습니다. scroll이 끝나지도 않았는데 또 contentView를 움직여버리니 에러가 발생하는 것이었습니다. scrollViewDidEndDecelerating함수에 넣어주니 잘 동작되었습니다.

 

3. paging속성이 스크롤을 하면 넘어갈지 아닐지 모르는 이슈

달력은 scrollView의 paging속성을 이용해서 구현하였는데 스크롤을 해도 넘어가지 않을 때도 있었는데 이를 어떻게 알까가 고민이었습니다. 항상 넘어간다고 코드를 짜고 진행하다가 이 이슈를 발견하고 어떻게 하지..?! 고민이 되었습니다. 하지만 scrollViewWillEndDragging 함수에는 어느 위치에서 스크롤이 멈출 지 알려주는 매개변수가 있었기 때문에 쉽게 해결할 수 있었습니다. 그 위치가 현재와 같다면 넘어가지 않은 경우이기 때문입니다.

 

4. 빠르게 스크롤을 하게 된다면 아직 scrollView의 contentOffset이 중간으로 스크롤되지 않아서 더이상 스크롤 되지 않는 이슈

이건 크게 문제가 되지않아서 아직 고치지 않았습니다. 하지만 방법이 방금 생각났어요. 지금은 달력 3개를 담은 contentSize를 가진 scrollView를 만들었는데 5개로 만들면 더 가로 길이가 길어지기 때문에 이 문제가 해결될 거 같다고 생각이 듭니다!

 

달력을 만들면서 ViewModel로 다시 만들어볼까 생각을 했습니다. VC의 코드량이 거대하네요... ㅋㅋㅋ 

글이 너무 길어져서 전체 코드는 첨부를 하지 않았습니당! 나중에 Rx를 이용해서 리팩토링을 하게 되면 그 내용도 써볼려구요.. 방법 위주로 봐주시면 좋을 것 같아요!

 

글을 보고 이해가 안 가는 부분이 있다면 혹은 더 좋은 방법이 있으시다면 언제든 댓글 달아주세요!!! 

읽어주셔서 감사합니다.

 

'iOS' 카테고리의 다른 글

[HIG] 읽기 시작  (0) 2022.01.01
[WWDC 19] 디퍼블 데이터 소스  (0) 2021.12.30
[iOS] Calendar 직접 구현해보기 (1) - 한 달 달력  (0) 2021.07.08
[CoreAnimation] 애니메이션 구현 - CoreBasicAnimation  (0) 2021.05.02
[Moya] 란?  (0) 2021.04.18