...
본문 바로가기

iOS

좌우 스크롤되는 캘린더뷰 만들기( feat: CompositionalLayout )

 

  • 본글은 캘린더뷰를 구현하는 과정속에서 어려웠던 점, 구현하는 과정을 기술한 글입니다.
  • 캘린더뷰는 다양한 방법으로 구현할 수 있다고 생각합니다.

 


전체 코드는 아래에서 확인할 수 있습니다

https://github.com/gustn3965/CalendarView

 

gustn3965/CalendarView

CompositionalLayout을 이용한 좌우 스크롤 가능한 캘린더뷰. Contribute to gustn3965/CalendarView development by creating an account on GitHub.

github.com

 

 


 

아래는 구현완료한 화면이다. 

항상 월간캘린더는 6줄로 나오게 했으며, 좌우로 얼마든지 이동할 수 있다. 

( 카톡 캘린더처럼 매달 6줄로 나오게 했다  )

 

 

 


우선 캘린더뷰를 어떻게 구현하면 좋을지 영감을 얻을 수 있는 좋은 글이 있다. 

달력데이터를 만들어내는 디테일함에 이마를 탁 쳤다 ㅎ 

https://www.raywenderlich.com/10787749-creating-a-custom-calendar-control-for-ios#toc-anchor-015

 

Creating a Custom Calendar Control for iOS

In this calendar UI control tutorial, you’ll build an iOS control that gives users vital clarity and context when interacting with dates.

www.raywenderlich.com

 

위 글은 한 화면안에서 버튼을 이용하여 달력을 만들어내는 구조다.

즉 좌우로 무한히 스크롤이 가능한 구조는 아니다. 

하지만 " 아, 이렇게 만들수 있구나, " 라는 귀감을 얻을 수 있다.

 

위글을 다 읽는다면 그런 캘린더뷰는 어렵지 않게 구현할 수 있다라고 생각이 든다. 

 

 


처음에는 iOS 13부터 생긴 새로운 diffableDataSource와 compositional layout을 이용하여 구현하기로 마음먹었다.

기존의 CollectionView, TableView들을 구현하는 방식과 전혀 다른 방법이다. 

새로운 기능이므로 써먹어보고 싶었다.

 

그리고 내가 필요한 캘린더의 기능은, 우선

1. 좌우로 스크롤될 것.

2. 각 cell에는 Date를 포함하고 있으면 좋겠다. 

 


raywenderlich를 읽고나서 좌우로 구현하는 방법을 생각했다.

그렇게 생각한 방법은, 크게 두가지인 것 같다. 

 

CollectionView의 numberOfItemsInsection을 무수히 많이 할당하여, 마치 무한스크롤이 가능한것처럼, 하는방법과,

보여지는 화면만 통하여 내부적으로 전,후 달만 계속 업데이트하여 정말 무한스크롤이 가능하게 하는 방법.

후자는 굉장히 어려울 것 같고, 복잡할 것 같아서 포기하고, 

전자를 선택했다.

 

레이아웃

UICollectionCompositionalLayout을 이용하여 레이아웃을 만들어준다.

달력을 잘 살펴보면, 어떤 달은 6줄, 어떤달은 5줄, 어떤달은 4줄이 있는경우가 있다.

최대 6줄이다. 카톡 캘린더도 6줄로 되어있다. 6줄을 정하는것이 편할 것 같아서 한달은 6줄로 표현하게 데이터를 만들어냈다.

간단하게 월간캘린더는 row 6줄, col 7줄이 있으면된다. 

FlowLayout으로도 매우 간단하게 구현이 가능하지만, indexPath가 아래로 향하게 된다는 문제가 있다. 

private func setupLayout(by type: CalendarType) -> UICollectionViewLayout {
        let itemSize: NSCollectionLayoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth((1.0)), heightDimension: .fractionalHeight(1.0))
        let item: NSCollectionLayoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
        
        let groupSize: NSCollectionLayoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight((1.0)))
        let group: NSCollectionLayoutGroup = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 7)
        
        let groupSize2: NSCollectionLayoutSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight((1.0)))
        let group2: NSCollectionLayoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: groupSize2, subitem: group, count: type.numberOfRows)
        let section: NSCollectionLayoutSection = NSCollectionLayoutSection(group: group2)
        section.orthogonalScrollingBehavior = .paging
        section.visibleItemsInvalidationHandler = { [weak self] _, point, _ in
            self?.changeMonthLabel(point)
        }
        let layout: UICollectionViewCompositionalLayout = UICollectionViewCompositionalLayout(section: section)
        return layout
}

 

 

 

달력데이터 생성

diffableDataSource를 이용하여, snapshot을 약 200년정도 미리 달력데이터를 만들었다. 

달력데이터는 raywenderlich를 참고하여 구현했다. 

 

 

⚠️문제점 1

200년정도 데이터를 만들어내면, 약 snapshot크기가 10만개정도 된다.

그리고 그안에서 Date를 계산하는 등 간단한 계산은 아니라고 생각된다. 

그래서인지 10만개를 생성하는데 약 1.5초정도 걸리더라. 

물론 달력을 200년치를 사용하지도 않겠지만, 그래도 제한을 둔 캘린더는 내가 생각했을때는 아니라고 판단됐고, 

또한 200년치가 1.5초는 너무 느린것 같다라고 생각했다.

 

생각을 해보니, 굳이 snapshot을 만들면서까지 할 필요가 있을까, 의문이 들었다. 

구현하는 과정에서 snapshot은 작은 데이터에서는 굉장히 구현하기 편하고, 

또 다양한 변경사항이 많은 경우에 적합하다라고 생각이 들었다.

 

 

더 나은 방법 

그래서 10만개만큼을 snapshot을 만들지않고, 기존 datasource방법을 사용해보기로 했다.

생각해보면 date들을 미리 만들어둘 필요는 없다라고 생각됐다.

date하나 만드는건 많은 비용이 들지않을거라 판단했고, 

컬렉션뷰에서 cellForItem을 호출할때, date를 만들어내는걸로 반대로 생각했다. 

 

우선, 기존 datasource - cellForItemsInSection에 10만개로 설정하는건 굉장히 빨랐다. 아니 몇천년, 몇만년을 해도 굉장히 빨랐다.

이점에서 우선 합격이다.

 

그리고, 반대로 IndexPath에서 Date를 만들어내는방법은,

우선 달력이 표시할 구간을 설정한다. 

예를들어, 2000년부터 2100년까지 보여준다라고 가정하면, 

한화면에서 보여주는 달력은 월을 의미하고, 42개의 item이 존재한다.

그럼, 2000년부터 2100년까지 100년이있으므로, 100년은 12(개월) * 100이 된다.

즉, 화면은 총 1200개가 된다는 것이고, indexPath는 총 1_200*42 = 50_400개가 된다 

 

그럼 몫과, 모듈러를 적절히 이용한다면, 오늘날 indexPath를 구할 수 있고,  현재 indexPath의 속한 월을 구할 수 있다. 

그러므로 indexPath를 통해서 특정 날짜를 구할 수 있게된다. 

 

이렇게 한다면 훨씬 코드가 짧아졌다. 

이전에는 raywenderlich의 날짜생성 메소드가 필요했는데, 

이제는 indexPath를 Date로 바꿔주는 하나의 메소드만 필요하게됐다! 

 

 

⚠️문제점 2

이제 여기서 문제점은, 초기 화면은 오늘날로 보여주는게 지당하다. 

그러면 컬렉션뷰의 scrollToItem을 이용해야하는데, 

거기선 indexPath가 필요하다.

즉 오늘날의 indexPath를 구해서 하면된다. 

구현하라고 하면 가능하지만, 뭔가 마음에 안들었다. 

 

더 더 나은 방법 

시작날, 마지막날을 정하기보다는, 그냥 필요한 indexPath만 지정하여, 그 중간값을 오늘날쯤으로 잡으면 안될까? 

그러니까, 중간값을 통하여 이제 indexPath를 다시 계산하는거다.

기존에는 구간을통해서 현재날짜를 찾아냈지만, 이제는 중간값으로부터 얼만큼 날짜들이 떨어져있는지로 계산하면된다. 

이것의 장점은 오늘날이 어떤 indexPath인지 고민할 필요가없다는 장점이 있다.

 

 

사실상 Date를 계산하는 메서드 몇개와,  레이아웃 생성 메서드

indexPath만으로 날짜들을 계산하는 아래 메서드하나면 좌우스크롤되는 캘린더뷰를 구현할 수 있다. 

나누기연산, 모듈러연산만 조금해주면 구할 수 있다. 

    /// IndexPath를 통해서 Date를 알아낸다.
    ///
    /// todayIndexPath가 오늘날이 포함되는 주, 또는 달이된다. page는 하나의 frame 단위를 의미한다.
    ///
    /// 그러므로, 나누기 연산을 통해, todayIndexPath가 포함되는 page를 알 수 있고, 현재 indexPath가 포함되는 page알 수 있다.  그리고 indexPath에 해당하는 달의 첫번째 weekday를 구하여, 모듈러 연산을 통해, firstWeekDay로부터 offset만큼의 해당 날짜를 구할 수 있다.
    private func calculateDateFrom(_ indexPath: IndexPath) -> CalendarDay? {
        let todayPage: Int = todayIndexPath.item / type.numberOfCellInPage
        let indexPathPage: Int = indexPath.item / type.numberOfCellInPage
        
        guard let todayPageMonthDate: Date = Date().firstDayOfMonth(),
              let indexPathPageMonthDate: Date = todayPageMonthDate.month(by: indexPathPage - todayPage) else {
            return nil
        }
        
        var indexPathPageFirstDayWeekday: Int = indexPathPageMonthDate.firstDayWeekday() - startWeekDay.rawValue
        indexPathPageFirstDayWeekday = indexPathPageFirstDayWeekday < 0 ? 6 : indexPathPageFirstDayWeekday
        let dist: Int = indexPath.item % type.numberOfCellInPage
        var isContainedInMonth: Bool = false
        
        guard let date: Date = indexPathPageMonthDate.day(by: dist - indexPathPageFirstDayWeekday),
              let monthDist = date.firstDayOfMonth()?.months(from: indexPathPageMonthDate)
        else {
            return nil
        }
        indexPathDic[date, default: []].insert(indexPath)
        if monthDist == 0 {
            isContainedInMonth = true
        }
        return CalendarDay(date: date, isContainedInMonth: isContainedInMonth)
    }

 

'iOS' 카테고리의 다른 글

RxSwift, RxCocoa 정리  (0) 2021.08.18
CoreData와 CloudKit 연동하기  (2) 2021.08.18
각 주제별 WWDC 참고용  (0) 2021.07.09
WidgetKit 정리  (0) 2021.07.07
Core Data 정리  (0) 2021.07.04