본문 바로가기
네이버 커넥트재단 부스트캠프/그룹프로젝트

[WeTri] CustomCalendar 만들기

by Joahnee 2023. 11. 27.

해당 게시글은 네이버부스트캠프에서 그룹 프로젝트를 진행하며 기록하는 내용입니다.

틀린 내용이 있을 수 있습니다. 
저희 프로젝트는 WeTri입니다.

많이 구경하러 와주세요!

https://github.com/boostcampwm2023/iOS08-WeTrihttps://github.com/boostcampwm2023/iOS08-WeTri

 

 

GitHub - boostcampwm2023/iOS08-WeTri: 우리가 함께 만드는, 트라이애슬론 🏃🏻 | 🏊‍♂️ | 🚴

우리가 함께 만드는, 트라이애슬론 🏃🏻 | 🏊‍♂️ | 🚴. Contribute to boostcampwm2023/iOS08-WeTri development by creating an account on GitHub.

github.com

 

오늘 게시물은 제가 CustomCalendar를 구현하면서 있었던 고민과 트러블슈팅에 대해서 기술하였습니다! 

 

🥺 어떤 형태의 CustomCalendar?

기획과 디자인을 진행하면서 대략적인 Calendar에 대한 틀만 잡아서 요일과 일만 보이는 형태로 구성을 해뒀습니다.

 

구현을 하려고 계획을 짜다보니 연,월을 선택하지 않고 모든 일을 다 나열해놓으면... 엄청많은 일을 표현해야하다보니, 기획이나 디자인을 다시해야되는데 가장 바쁠시기라서, 연에 대한 선택이나 월에 대한 선택없이 일단, 현재 연도와 월에 대한 일만 보여주기로 결정했습니다.
(추후 연, 월 선택 기능은 쉽게 추가 가능하게 비즈니스로직을 잘 작성해두었습니다!)

 

🤔 고민 내용 및 해결과정

View를 어떻게 구성하지..?

 

제가 생각했을 때에 CollectionView, ScrollView + StacView 선택지가 존재했는데요..!

 

CollectionView로 결정했습니다.

 

CollectionView가 동적으로 셀을 생성해주기 때문에 셀 생성에 대한 편의성과 Select된 Cell을 index단위로 편리하게 처리할 수 있다는 이유로 선택하게 되었습니다.

 

😓 캘린더가 나타나는 첫 화면에서 오늘 보여줄 날짜와 요일을 어떻게 구하지?

화면이 메모리상에서 올라오는 viewDidLoad()에서 ViewModel에 input을 해주고 이를 ViewModel에서 날짜를 관리해주는 UseCase를 통해 데이터를 받아오자고 판단하였습니다.

 

Date 관련된 클래스나 함수를 많이 다뤄본적이 없어서 애먹었네요.. 😅

 

DateInfo는 제가 관리하기 편하게 사용하려고 직접 정의한 타입이구요

 

나머지는 공식문서에서 찾아보면 이해하기 쉬운 로직이니 설명은 생략하겠습니다!

  func fetchAllDatesThisMonth() -> [DateInfo] {
    let today = today()
    let todayDateInfo = transform(date: today)

    guard let thisYear = Int(todayDateInfo.year),
          let thisMonth = Int(todayDateInfo.month)
    else {
      return dateInfos
    }
    let startDateComponents = DateComponents(year: thisYear, month: thisMonth)
    let endDateComponents = DateComponents(year: thisYear, month: thisMonth + 1, day: 0)

    guard let startDate = calendar.date(from: startDateComponents),
          let endDate = calendar.date(from: endDateComponents)
    else {
      return dateInfos
    }
    var currentDate = startDate
    while currentDate <= endDate {
      let day = dayFormatter().string(from: currentDate)
      let dayOfWeek = dayOfWeekFormatter().string(from: currentDate)

      dateInfos.append(
        DateInfo(
          year: "\(thisYear)",
          month: "\(thisMonth)",
          date: day,
          dayOfWeek: DayOfWeek(rawValue: dayOfWeek)?.korean
        )
      )
      guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else {
        break
      }
      currentDate = nextDate
    }
    return dateInfos
  }

 

Calendar에서 Item을 눌렀을 때, 어떻게 해당 요일에 관련된 데이터를 가져올 것인가?

 

일반적인 방법으로는 이번달의 요일과 일의 데이터를 배열 형태로 갖고 있는것입니다.

 

그래서 시도해봤습니다..!

 

터치된 인덱스의 일과 요일을 데이터를 통해 UseCase에서 현재 연, 월과 합쳐서 서버로 보내서 기록을 가져오는 겁니다.

 

저의 코드를 보시면 dateProvideUseCase로 터치된 index의 DateInfo를 리턴해주고 recordUpdateUseCase로 DateInfo를 받아서 서버와 통신하여 기록을 가져온답니다.

//RecordListViewModel.swift

let selectedRecords = input.selectedDate
  .flatMap { [weak self] indexPath -> AnyPublisher<[Record], Error> in
    guard let self else {
      return Fail(error: BindingError.viewModelDeinitialized).eraseToAnyPublisher()
    }
    guard let dateInfo = dateProvideUsecase.selectedDateInfo(index: indexPath.item) else {
      return Fail(error: BindingError.dateNotFound).eraseToAnyPublisher()
    }
    guard let date = dateProvideUsecase.transform(dateInfo: dateInfo) else {
      return Fail(error: BindingError.dateNotFound).eraseToAnyPublisher()
    }
    return recordUpdateUsecase.execute(date: date)
  }
  .map { records -> RecordListState in
    .sucessRecords(records)
  }
  .eraseToAnyPublisher()

 

대략적인 시나리오 그림을 그려봤습니다..(레포지토리나 DTO관련 그림은 생략된 코드이해를 돕기 위한 대략적으로 그린 그림입니다)

 

이 부분에서 백엔드 분들과 이야기한 부분이 있는데요!

 

바로 기록에 대한 요청을 할 때 body에 "yyyy-mm-DD"의 형태로 줄것인지?

Date객체가 Swift에도 존재한다면 Date객체로 서버에 넘길 것인지?

 

에서 저희는 후자를 선택했습니다.

 

표준이 정해져있는데 굳이 String으로 변환해서 보낼 필요는 없다고 판단했기 때문입니다.(전자는 백엔드 분들이 저희가 저렇게 하면 편하지 않을까 싶어서 배려해주신다고 주신 선택지입니다 ㅎㅎ)

 

 

😱 셀에 색상이 랜덤으로 칠해진다..? 왜지 ...?

셀을 선택하면 색상이 칠해지도록 했는데요

 

흠.. 컬렉션뷰를 옆으로 스크롤할 때 마다 셀이 Reuse되다보니 색상까지 함께 재사용되는것 같더라구요

 

그래서 Reuse되기전에 호출되는 함수인 prepareForReuse()함수를 사용해서 Cell이 Reuse될 때 평소 색상으로 변경했더니 선택한 색상까지 색상이 해제됩니다.. 그도 그럴것이 색상설정값을 reuse될 때 아예 해제해버리니 이런일이 발생하겠죠?

해결방안을 생각해보았읍니다..

 

DiffableDatasource를 사용하고 있으므로 Delegate에서 선택되는 cell의 indexPath를 계속해서 최신화해주고 이를 cell이 reuse될 때 선택된 indexPath에 해당하는 셀들을 색칠하도록 적용하는것입니다.

 

DiffableDataSource를 정의하는 부분의 cellProvider와 cell을 등록하는 cellRegistration부분에서 Cell의 Reuse될 때 호출되는 것을 확인하였습니다. 공식문서에서는 둘 다의 클로저에서 cell들이 data나 보여지는것에대한 초기화를할 수 있는 영역이라고 합니다.

 

제가 경험한 두 개의 차이를 먼저 말씀드리면,

 

cellProvider는 Cell이 Reuse될 때, Cell단위로는 초기화할 수 없고 collectionView 단위로 할 수 있도록 클로저가 주어집니다.

 

cellRegistraion은 Cell이 Reuse될 때, Cell 별로 초기화할 수 있었습니다.

 

저는 cellProvider에서는 cell이 Reuse될 때 마다, Input으로 ViewModel이 갖고있는 현재 선택되어 있는 Cell의 index정보를 Output으로 넘겨주어 Output에서 cell의 index를 통해 초기화해주는 작업을 진행했습니다.

//RecordCalendarViewModel.swift
let reuse = input.calendarCellReuse
  .flatMap { [weak self] _ -> AnyPublisher<IndexPath, Error> in
    guard let currentSelectedIndexPath = self?.currentSelectedIndexPath else {
      return Fail(error: BindingError.invalidCurrentSelectedIndexPath).eraseToAnyPublisher()
    }
    return Just(currentSelectedIndexPath)
      .setFailureType(to: Error.self)
      .eraseToAnyPublisher()
  }
  .map { indexPath -> RecordCalendarState in
    .selectedIndexPath(indexPath)
  }
  .eraseToAnyPublisher()

return Publishers
  .Merge3(appearTotalDateInfo, reuse, appearTodayIndex)
  .eraseToAnyPublisher()
}

//RecordCalendarViewController.swift
func render(output: RecordCalendarState) {
//...
    case let .selectedIndexPath(indexPath):
      guard let cell = calendarCollectionView.cellForItem(at: indexPath) as? CalendarCollectionViewCell else {
        return
      }
      cell.configureTextColor(isSelected: true)
    }
}

 

확실히 잘 적용되는것을 볼 수 있었습니다.

 

 

😵‍💫 시작화면에서 오늘날짜의 캘린더 초기선택을 진행

저는 appear될 때 DateProvideUseCase를 통해, 오늘 날짜를 가져와서 해당 cell을 select 해놓는 방법을 생각했습니다.

 

시작화면에서 날짜가 현재 collectionView에서 보이는 cell이라면 선택처리(색상 입히기)가 가능했습니다.

 

하지만, 시작하자마자 화면에서 보이지 않는다면 셀에 색상이 초기화되지 않았습니다.

 

그래서 Cell이 Reuse될 때 마다 감지해서 오늘 날짜의 IndexPath를 선택한 색상처리하려고 해도 안됩니다.

 

selectCell함수에서 cell이 존재하지 않는다고 return이 되어버리네요...

 

알고보니 cellForItem(at:) 메서드는 현재 화면에 표시되고 있는 cell에 대해서만 나타난다고 합니다..!

 

끝까지 cell을 보이고 돌아와도 cellForItem(at:)은 먹통입니다.. 이론상 cell이 Reuse될 때마다 stream을 받아서.. 되야하는데 안되네요..? 

 

그래서 cellRegistraion을 활용해서 Reuse되는 cell들을 초기화해줬는데요..!

 

이 부분을 꺼렸던 이유가 render함수에서 viewModel을 통해 나온 Output을 관리하기 때문인데요.

 

이 부분외의 다른 부분에서 output과 관련된 로직이나 뷰모델이 가지고있는 데이터를 가져오는 로직을 사용하면 output이 다른곳에서 사용되는것 같았습니다.

 

하지만, ViewController가 ViewModel의 데이터를 직접참조해서 가져오는게 MVVM 구조에서는 크게 어색한게 아니라고 판단했습니다. 결국 ViewController와 ViewModel은 1:1 관계이고 ViewController가 ViewModel에 대한 의존은 가지고 있는게 맞는 구조라고 생각했기 때문입니다. 그래서 private(set)으로 사용했습니다 ㅎㅎ..

//RecordCalendarViewController.swift

let cellRegistration = RecordCalendarCellRegistration { [weak self] cell, indexPath, itemIdentifier in
  if indexPath.item == self?.viewModel.currentSelectedIndexPath?.item {
    cell.configureTextColor(isSelected: true)
  }
  return cell.configure(
    calendarInformation: CalendarInforamtion(
      dayOfWeek: itemIdentifier.dayOfWeek,
      date: itemIdentifier.date
    )
  )
}

 

아래는 결과적으로 성공한 화면입니다!

성공!

 

댓글