Compose에서 Calendar 기능을 지원하고 있지 않아서 외부 Calendar 라이브러리를 적용했다. 다양한 버전이 있었고 그 중 하나의 버전을 사용했다. 사용하는 과정에서 어려웠던 부분이나 알아두면 좋은 부분을 소개하려고 한다.

이번 포스팅에서 모든 코드를 소개한 것이 아닌 핵심 함수만 소개했기 때문에 부분적으로 필요한 함수들은 아래의 깃 허브에서 component/calendar 패키지에서 확인하길 바란다.
Github 주소

블로그에 설명한 코드를 따라오면 아래의 결과를 구현할 수 있다.


image

종속성 추가

  • build.gradle.kts (Module 수준)
    implementation ("com.kizitonwose.calendar:compose:2.4.1")
    

Calendar

캘린더에서 설명하기 전 알아야 할 기본 변수에 대해 설명하겠다.

// adjacentMonths = 500L
val currentMonth = remember { YearMonth.now() }
val startMonth = remember { currentMonth.minusMonths(adjacentMonths) }
val endMonth = remember { currentMonth.plusMonths(adjacentMonths) }
val today = remember { LocalDate.now() }
val daysOfWeek = remember { daysOfWeek() }

adjacentMonths = 500L 인 경우 아래의 로그 처럼 결과가 나온다. image

currentMonth 는 현재 yy-mm 을 나타낸 것이고, today는 현재 yy-mm-dd 를 나타낸 것이다.
daysOfWeek는 일요일 ~ 월요일 까지 1주일을 List 타입으로 나태냈다.
currentMonth.minusMonths(month) 는 현재 월에서 month 만큼의 월을 뺀 이전의 월을 나타낸다. 예를 들어 현재 2024년 1월이라고 가졍했을 때 currentMonth.minusMonths(2)인 경우 startMonth는 “2023-11” 이 되는 셈이다.
currentMonth.plusMonths(month) 는 당연히 현재 월에서 month 만큼의 월을 더한 다음의 월을 나타내는 것이므로 설명은 생략하겠다.

다음으로는 Calendar 현재 상태를 알 수 있는 CalendarState 객체를 만들어준다.

val state = rememberCalendarState(
    startMonth = startMonth,  // 처음 시작하는 월
    endMonth = endMonth,      // 마지막으로 끝나는 월
    firstVisibleMonth = currentMonth,      // 처음 화면에 보이는 월
    firstDayOfWeek = daysOfWeek.first(),   // 일주일 중 시작하는 요일, 여기서는 일요일부터 시작
)
val coroutineScope = rememberCoroutineScope()
val visibleMonth = rememberFirstMostVisibleMonth(state, viewportPercent = 90f) // 화면에 지속적으로 보이게 되는 월

SimpleCalendarTitle

Calendar에 헤더부분에 해당하는 컴포저블 함수이다. 아래의 사진에서 파랑색 타원으로 덮어져 있는 부분이다. image

  • 코드
    SimpleCalendarTitle(
        modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp),
        currentMonth = visibleMonth.yearMonth, // ex) 2024-01, currentMonth.displayText() 하면 String 타입, "January 2024"로 반환
        goToPrevious = {
            coroutineScope.launch {
                state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.previousMonth)
                Log.d("daeYoung", "${state.firstVisibleMonth.yearMonth.previousMonth}")
            }
        }, // '<' 버튼 클릭 시 이전 달로 이동
        goToNext = {
            coroutineScope.launch {
                state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.nextMonth)
                Log.d("daeYoung", "${state.firstVisibleMonth.yearMonth.nextMonth}")
            }
        }, // '>' 버튼 클릭 시 다음 달로 이동
    )
    

HorizontalCalendar

Calendat의 실질적인 데이터를 보여주는 컴포저블 함수이다. 파랑색은 monthHeader를 나타내고 보라색은 dayContent를 나타낸다.

image

  • 코드
    HorizontalCalendar(
        modifier = Modifier
            .testTag("Calendar")
            .padding(8.dp),
        state = state,
        dayContent = { day ->
            Day(
                day,
                today = today,
                selection = viewModel.selection,
            ) { day ->
                if (day.position == DayPosition.MonthDate &&
                    (day.date == today || day.date.isAfter(today))
                ) {
                    viewModel.selection = ContinuousSelectionHelper.getSelection(
                        clickedDate = day.date,
                        dateSelection = viewModel.selection,
                    )
                }
            }
            Text(
                text = "-1,300",
                color = viewModel.setExpensesColor(dayPosition = day.position),
                modifier = Modifier.align(Alignment.BottomCenter),
                fontSize = 10.sp,
            )
        },
        monthHeader = {
            MonthHeader(daysOfWeek = daysOfWeek)
        },
    )
    

Day

Day 컴포저블 함수를 통해 각 월의 1일부터 말일까지 나타내며 원하는 날짜를 클릭했을 때 이벤트 처리를 해준다.

@Composable
private fun Day(
    day: CalendarDay,
    today: LocalDate,
    selection: DateSelection,
    onClick: (CalendarDay) -> Unit,
) {
    var textColor = Color.Transparent
    Box(
        modifier = Modifier
            .aspectRatio(1f) // This is important for square-sizing!
            .clickable(
                enabled = day.position == DayPosition.MonthDate && day.date >= today,
                showRipple = false,
                onClick = { onClick(day) },
            )
            .backgroundHighlight(
                day = day,
                today = today,
                selection = selection,
                selectionColor = selectionColor,
                continuousSelectionColor = continuousSelectionColor,
            ) { textColor = it },
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = day.date.dayOfMonth.toString(),
            color = textColor,
            fontSize = 16.sp,
            fontWeight = FontWeight.Medium,
        )
    }
}

여기서 DateSelection 타입의 변수 selection이 궁금해 할 수 있다.

image

DateSelection은 처음 선택한 시작 요일과 두번 째 선택한 마지막 요일을 저장하는 data class이다.
DateSelection을 통해 아래의 영상과 같은 효과를 낼 수 있다.


  • getSelection() getSelection() 메서드는 날짜를 클릭 시 startDate와 endDate에 맞게 데이터를 넣어주고 다시 DateSelection를 반환하는 메서드이다.
    startDate와 endDate에 데이터를 넣을 때는 아래와 같은 역할을 한다.

    1. stateDate가 null이면 stateDate에 데이터를 저장
    2. stateDate가 null이 아니면 endDate에 데이터를 저장
    3. stateDate가 null이 아닐 때 선택한 날짜가 stateDate이면 startDate에 저장된 데이터 삭제
    4. stateDate가 null이 아닐 때 선택한 날짜가 stateDate보다 작으면 startDate에 선택한 날짜로 데이터 업데이트

    image

전체 코드

private val primaryColor = Color(0xFF007AFF).copy(alpha = 0.9f)
private val selectionColor = primaryColor
private val continuousSelectionColor = primaryColor.copy(alpha = 0.1f)

@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun Calendar(adjacentMonths: Long = 500, viewModel: StatisticesViewModel = viewModel()) {
    val currentMonth = remember { YearMonth.now() }
    val startMonth = remember { currentMonth.minusMonths(adjacentMonths) }
    val endMonth = remember { currentMonth.plusMonths(adjacentMonths) }
    val today = remember { LocalDate.now() }
    val daysOfWeek = remember { daysOfWeek() }
    Log.d(
        TAG,
        "currentMonth: $currentMonth\nstartMonth: $startMonth\nendMonth: $endMonth\ntoday: $today\ndaysOfWeek: $daysOfWeek",
    )

    val simpleDateTime = viewModel.selection.startDate?.format(
        DateTimeFormatter.ofPattern("dd"))

    Log.d("daeYoung", "selection: ${viewModel.selection}, startDate: $simpleDateTime")

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentHeight(),
        shape = RoundedCornerShape(20.dp),
        elevation = CardDefaults.cardElevation(5.dp),
        border = BorderStroke(width = 1.dp, color = Color(0xFFE1E2E9)),
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .background(Color.White),
        ) {
            val state = rememberCalendarState(
                startMonth = startMonth,
                endMonth = endMonth,
                firstVisibleMonth = currentMonth,
                firstDayOfWeek = daysOfWeek.first(),
            )
            val coroutineScope = rememberCoroutineScope()
            val visibleMonth = rememberFirstMostVisibleMonth(state, viewportPercent = 90f)
//            Log.d(
//                "daeYoung",
//                "visibleMonth: $visibleMonth\nvisibleMonth.yearMonth: ${visibleMonth.yearMonth}",
//            )
            SimpleCalendarTitle(
                modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp),
                currentMonth = visibleMonth.yearMonth, // ex) 2024-01, currentMonth.displayText() 하면 String 타입, "January 2024"로 반환
                goToPrevious = {
                    coroutineScope.launch {
                        state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.previousMonth)
                        Log.d("daeYoung", "${state.firstVisibleMonth.yearMonth.previousMonth}")
                    }
                },
                goToNext = {
                    coroutineScope.launch {
                        state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.nextMonth)
                        Log.d("daeYoung", "${state.firstVisibleMonth.yearMonth.nextMonth}")
                    }
                },
            )
            Divider(
                thickness = 1.dp,
                color = Color(0xFFD9D9D9),
                modifier = Modifier.padding(bottom = 8.dp),
            )
            HorizontalCalendar(
                modifier = Modifier
                    .testTag("Calendar")
                    .padding(8.dp),
                state = state,
                dayContent = { day ->
                    Day(
                        day,
                        today = today,
                        selection = viewModel.selection,
                    ) { day ->
                        if (day.position == DayPosition.MonthDate &&
                            (day.date == today || day.date.isAfter(today))
                        ) {
                            viewModel.selection = ContinuousSelectionHelper.getSelection(
                                clickedDate = day.date,
                                dateSelection = viewModel.selection,
                            )
                        }
                    }
                    Text(
                        text = "-1,300",
                        color = viewModel.setExpensesColor(dayPosition = day.position),
                        modifier = Modifier.align(Alignment.BottomCenter),
                        fontSize = 10.sp,
                    )
                },
                monthHeader = {
                    MonthHeader(daysOfWeek = daysOfWeek)
                },
            )
        }
    }


}

@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun MonthHeader(daysOfWeek: List<DayOfWeek>) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .testTag("MonthHeader"),
    ) {
        for (dayOfWeek in daysOfWeek) {
            Text(
                modifier = Modifier.weight(1f),
                textAlign = TextAlign.Center,
                fontSize = 15.sp,
                text = dayOfWeek.displayText(),
                fontWeight = FontWeight.Medium,
            )
        }
    }
}

@RequiresApi(Build.VERSION_CODES.O)
@Composable
private fun Day(
    day: CalendarDay,
    today: LocalDate,
    selection: DateSelection,
    onClick: (CalendarDay) -> Unit,
) {
    var textColor = Color.Transparent
    Box(
        modifier = Modifier
            .aspectRatio(1f) // This is important for square-sizing!
            .clickable(
                enabled = day.position == DayPosition.MonthDate && day.date >= today,
                showRipple = false,
                onClick = { onClick(day) },
            )
            .backgroundHighlight(
                day = day,
                today = today,
                selection = selection,
                selectionColor = selectionColor,
                continuousSelectionColor = continuousSelectionColor,
            ) { textColor = it },
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = day.date.dayOfMonth.toString(),
            color = textColor,
            fontSize = 16.sp,
            fontWeight = FontWeight.Medium,
        )
    }
}

Reference

Calendar 라이브러리 Github 주소
Calendar 코드 디테일 설명
Compose에서 Calendar 사용한 블로그