훌륭한 개발자가 되기 위하여
Jetpack Compose로 Custom Calendar 구현하기 본문
프로젝트에서 운동 기록을 날짜 단위로 보여주는 캘린더 UI가 필요했다.
Jetpack Compose Material3에서 기본으로 제공하는 캘린더 관련 컴포넌트는 DatePicker, TimePicker 두 가지가 있다.
이 컴포넌트들은 단순한 날짜 입력에는 적합하지만 월 단위 캘린더 UI나 날짜별 상태 표시(운동 기록 여부)를 표현하기에는 한계가 있었다.
추가로 다른 사람들이 만들어놓은 외부 라이브러리가 있었지만 내가 원하는 형태로 커스텀하기에는 제한적인 부분이 있어 Custom Calendar를 직접 구현하기로 결정했다.

요구사항 정리
- 월 단위로 좌우 스와이프 가능
- 특정 날짜 선택
- 오늘 날짜 시각적으로 구분
- 현재 달이 아닌 날짜는 흐리게 표시
- 날짜마다 운동 기록 유무 상태 표시
전체적인 구조
- 상단에 현재 연도와 월을 표시하는 헤더
- 월 단위로 이동할 수 있는 HorizontalPager
- 월별 날짜를 그리드 형태로 보여주는 영역
- 날짜 하나하나를 담당하는 Day Composable
CalendarConfig : 캘린더 설정 분리
data class CalendarConfig(
val yearRange: IntRange = 2000..2100,
val locale: Locale = Locale.getDefault(),
val startOfWeek: DayOfWeek = DayOfWeek.MONDAY
)
- yearRange : 캘린더에서 허용할 연도 범위
- locale : 국가별 Locale
- startOfWeek : 주(Week)의 시작 요일
CalendarHeader : 연도와 월 표시
Text만 사용한 단순한 Composable이다.
@Composable
fun CalendarHeader(
modifier: Modifier = Modifier,
text: String,
) {
...
Text(text = text)
...
}
}
CalendarMonthItem : 월 단위 캘린더 구성
LazyVerticalGrid를 사용해 날짜를 7열 그리드 형태로 배치했다.
현재 달의 날짜를 기준으로 Grid의 남는 공간만큼 이전 달과 다음 달의 날짜를 계산해 채우는 방식으로 구현하였다.
@Composable
fun CalendarMonthItem(
modifier: Modifier = Modifier,
currentDate: LocalDate,
selectedDate: LocalDate,
config: CalendarConfig = CalendarConfig(),
calendarStatus: List<CalendarDayStatusModel>,
onSelectedDate: (LocalDate) -> Unit
) {
val prevMonth = currentDate.minusMonths(1)
val nextMonth = currentDate.plusMonths(1)
시작 요일 계산
val firstDayOfWeek = remember(currentDate, config.startOfWeek) {
val dayValue = currentDate.dayOfWeek.value
val startValue = config.startOfWeek.value
((dayValue - startValue + 7) % 7) + 1
}
startOfWeek 설정을 기준으로 해당 월의 시작 요일 위치를 계산해 저장한다.
날짜 리스트 구성
val prevMonthDays = prevMonth.lengthOfMonth()
val leadingDays = (1..firstDayOfWeek).map { day ->
prevMonth.withDayOfMonth(prevMonthDays - firstDayOfWeek + day)
}
val currentMonthDays = (1..currentDate.lengthOfMonth()).map { day ->
currentDate.withDayOfMonth(day)
}
val remainingCells = (leadingDays.size + currentMonthDays.size) % 7
val remainingDays = if (remainingCells == 0) emptyList() else {
(1..(7 - remainingCells)).map { day ->
nextMonth.withDayOfMonth(day)
}
}
val calendarDays = leadingDays + currentMonthDays + remainingDays
- 전체 Grid 개수에서 현재 달 날짜를 제외한 만큼 이전 달, 다음 달 날짜로 채운다.
- 항상 7의 배수 형태가 되도록 구성한다.
날짜 출력
LazyVerticalGrid(
columns = GridCells.Fixed(7)
) {
items(calendarDays) { date ->
...
CalendarDay(
...
date = date,
...
) {
onSelectedDate(date)
}
}
}
CalendarDay : Grid에 출력되는 날짜
@Composable
fun CalendarDay(
modifier: Modifier = Modifier,
date: LocalDate,
isToday: Boolean,
isSelected: Boolean,
isCurrentMonth: Boolean,
isCompleted: Boolean,
hasEvent: Boolean,
onSelectedDate: (LocalDate) -> Unit
) {
...
Text(text = text)
...
}
- date : 표시할 날짜
- isToday : 오늘 날짜 여부
- isSelected : 선택된 날짜 여부
- isCurrentMonth : 현재 달 날짜인지 여부(이전/다음 달 날짜는 흐리게 표시)
- isCompleted : 운동 완료 여부
- hasEvent : 운동 기록 존재 여부
- onSelectedDate : 날짜 선택 이벤트
조건에 따라 Text 색상, 배경, 테두리 등을 변경해주면 되기 때문에 특별히 복잡한 로직은 없다.
Horizontal Pager로 월 단위 이동 구현
이제 구성한 UI를 HorizontalPager에 올려 월 단위로 스와이프할 수 있도록 구현한다.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MuscleMemoryCalendar(
modifier: Modifier = Modifier,
currentDate: LocalDate = LocalDate.now(),
config: CalendarConfig = CalendarConfig(),
calendarStatus: List<CalendarDayStatusModel>,
onSelectedDate: (LocalDate) -> Unit,
onMonthChange: (String, String) -> Unit
) {
Pager 설정
val initialPage = (currentDate.year - config.yearRange.first) * 12 + currentDate.monthValue - 1
val pageCount = (config.yearRange.last - config.yearRange.first + 1) * 12
val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { pageCount })
- 1페이지 = 1개월
- 연도 범위를 기준으로 페이지 인덱스를 계산한다.
페이지 변경 시 현재 월(Month) 업데이트
LaunchedEffect(pagerState.currentPage) {
val addMonth = (pagerState.currentPage - currentPage).toLong()
currentMonth = currentMonth.plusMonths(addMonth)
currentPage = pagerState.currentPage
}
월 변경 시 날짜 범위 계산
LaunchedEffect(currentMonth) {
val firstDayOfMonth = currentMonth.atDay(1)
val lastDayOfMonth = currentMonth.atEndOfMonth()
val calendarStart = firstDayOfMonth.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
val calendarEnd = lastDayOfMonth.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
val startDate = calendarStart.toString()
val endDate = calendarEnd.toString()
onMonthChange(startDate, endDate)
}
월(Month)이 변경되면 캘린더에 실제로 표시되는 날짜 범위를 계산해 외부로 전달한다.
Column(modifier = modifier) {
val headerText = currentMonth.dateFormat("yyyy년 M월")
CalendarHeader(text = headerText)
HorizontalPager(
state = pagerState
) {
...
CalendarMonthItem(currentDate = date)
...
}
}
'안드로이드' 카테고리의 다른 글
| 클린아키텍처에서의 예외 처리하기 (0) | 2025.11.28 |
|---|---|
| Jetpack Compose로 ViewModel 공유하기 (0) | 2025.11.13 |
| Paging 검색 기능에 Debounce를 적용하여 성능 개선하기 (0) | 2025.10.02 |
| Jetpack Compose에서 Paging3로 대용량 데이터 처리하기 (0) | 2025.09.18 |
| [JetpackCompose] Compose Side Effect (0) | 2025.08.14 |