[Kotlin] Calendar
Compose에서 Calendar 기능을 지원하고 있지 않아서 외부 Calendar 라이브러리를 적용했다. 다양한 버전이 있었고 그 중 하나의 버전을 사용했다. 사용하는 과정에서 어려웠던 부분이나 알아두면 좋은 부분을 소개하려고 한다.
이번 포스팅에서 모든 코드를 소개한 것이 아닌 핵심 함수만 소개했기 때문에 부분적으로 필요한 함수들은 아래의 깃 허브에서 component/calendar 패키지에서 확인하길 바란다.
Github 주소
블로그에 설명한 코드를 따라오면 아래의 결과를 구현할 수 있다.
종속성 추가
- 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 인 경우 아래의 로그 처럼 결과가 나온다.
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에 헤더부분에 해당하는 컴포저블 함수이다. 아래의 사진에서 파랑색 타원으로 덮어져 있는 부분이다.
- 코드
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를 나타낸다.
- 코드
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이 궁금해 할 수 있다.
DateSelection은 처음 선택한 시작 요일과 두번 째 선택한 마지막 요일을 저장하는 data class이다.
DateSelection을 통해 아래의 영상과 같은 효과를 낼 수 있다.
-
getSelection()
getSelection()메서드는 날짜를 클릭 시 startDate와 endDate에 맞게 데이터를 넣어주고 다시DateSelection를 반환하는 메서드이다.
startDate와 endDate에 데이터를 넣을 때는 아래와 같은 역할을 한다.- stateDate가 null이면 stateDate에 데이터를 저장
- stateDate가 null이 아니면 endDate에 데이터를 저장
- stateDate가 null이 아닐 때 선택한 날짜가 stateDate이면 startDate에 저장된 데이터 삭제
- stateDate가 null이 아닐 때 선택한 날짜가 stateDate보다 작으면 startDate에 선택한 날짜로 데이터 업데이트
전체 코드
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 사용한 블로그