Calendar
시작하며
제작 중인 앱 ReReminder(가칭) Stashit는 물건과 구매 내역을 기록할 수 있는 앱이다. 기록 앱 특성상 날짜 관련 데이터를 가장 많이 다루게 되었고 이를 한눈에 메인 화면에서 확인할 수 있게 캘린더에 나타내고자 하였다. 찾아보니 몇 개의 라이브러리가 존재하는 듯하였지만 추후에 업데이트에 용이하도록 직접 만들어 보았다.
실행 화면&기능 소개
• 좌우 스와이프
- 선택한 날짜가 이동한 페이지에 존재하지 않는다면 이동한 페이지의 첫째 날을 선택함
- 현재 페이지의 달에 포함되지 않은 날짜를 터치하면 그날을 선택하고 해당 달로 페이지를 이동함
- 화면 상단에 올해는 X월로 표현하고 나머지는 XXXX. XX로 표현함
• 상하 스와이프
- 위아래로 스와이프 시 화면 절반 또는 화면 전체 비율로 변경함
- 화면 절반 비율일 때 선택한 날짜와 현재와의 차이를 표시함
- 날짜 구역을 터치할 시 높이 50%로 변경함
• 홈 내비게이션 버튼
- 홈 내비게이션 버튼을 터치하면 오늘을 선택하고 현재 달로 페이지를 이동함
- 홈 내비게이션 버튼을 터치하면 높이 비율이 50%로 변경됨
코드 설명
캘린더는 일 -> 주 -> 월 단계로 제작하였으며 해당 3단계를 설명한 후에 페이저의 스와이프 기능을 설명하겠다. 필자는 날짜 관련 클래스로 LocalDateTime을 사용하고 있으며 캘린더에서 보여줄 정보에는 분초 단위가 필요하지 않아 LocalDate로 변환해서 캘린더에 전달해 주었다. 각자 본인이 사용하는 클래스에 맞춰 파라미터를 변경해서 사용하면 된다.
- State holder
// 화면 높이 클래스
enum class CalendarSize {
HALF, FULL
}
data class CalendarState(
val datePagerState: PagerState
) {
var snapState by mutableStateOf(CalendarSize.FULL)
var selectedDate: LocalDate by mutableStateOf(LocalDate.now())
val currentDate: LocalDate // 연산 프로퍼티
get() = LocalDate.now()
val currentPageYM: YearMonth // 연산 프로퍼티
get() = YearMonth.from(currentDate).plusMonths((datePagerState.currentPage - Int.MAX_VALUE / 2).toLong())
// 페이지에 표시할 날짜들을 계산하는 함수(연월을 입력받음)
fun getDaysOfMonth(yearMonth: YearMonth): List<LocalDate> {
// 입력받은 연월의 1일을 토대로 시작일 계산(시작점을 일요일로 설정)
val startOfMonth = yearMonth.atDay(1).with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
// 입력받은 연월의 마지막 날을 토대로 종료일 계산(종료점을 토요일로 설정)
val endOfMonth = yearMonth.atEndOfMonth().with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
// 하루씩 증가하는 날짜 시퀀스 생성(종료점까지) 후 리스트화
return generateSequence(startOfMonth) { it.plusDays(1) }
.takeWhile { !it.isAfter(endOfMonth) }
.toList()
}
// 날짜 차이 계산
fun calculateDaysDifference(): String {
val daysDifference = selectedDate.toEpochDay() - currentDate.toEpochDay()
return when {
daysDifference == 0L -> "오늘"
daysDifference == 1L -> "내일"
daysDifference == -1L -> "어제"
daysDifference > 0 -> "${daysDifference}일 후"
else -> "${-daysDifference}일 전"
}
}
// 화면 이동 시에도 정보가 유지되도록 세이버 설정
companion object {
fun Saver(
datePagerState: PagerState
): Saver<CalendarState, Any> = listSaver(
save = {
listOf(
it.snapState,
it.selectedDate
)
},
restore = { savedValue ->
CalendarState(
datePagerState = datePagerState
).apply {
snapState = savedValue[0] as CalendarSize
selectedDate = savedValue[1] as LocalDate
}
}
)
}
}
@Composable
fun rememberCalendarState(
// 무한 스크롤이 가능하도록 설정
datePagerState: PagerState = rememberPagerState(Int.MAX_VALUE / 2) { Int.MAX_VALUE },
): CalendarState {
return rememberSaveable(
datePagerState,
saver = CalendarState.Saver(datePagerState)
) {
CalendarState(
datePagerState = datePagerState
)
}
}
• 화면 높이를 필자는 절반과 전체 두 가지로 설정했지만 클래스에 항목을 추가해 더 많은 높이를 설정하면 된다.
• 페이저는 무한 스와이프가 가능하도록 Int의 최댓값으로 사이즈를 설정하고 시작 페이지를 최댓값의 절반으로 설정해 준다.
- Day Composable 함수
@Composable
fun CalendarDay(
date: LocalDate,
isToday: Boolean,
isSelected: Boolean,
isVisibleMonth: Boolean,
onClick: () -> Unit
) {
// 날짜 텍스트 배경색 - 일요일은 빨강, 나머지는 검정
val circleColor = remember(isToday, date.dayOfWeek) {
when {
isToday && date.dayOfWeek == DayOfWeek.SUNDAY -> Color.Red
isToday -> Color.Black
else -> Color.Transparent
}
}
// 날짜 글자색 - 오늘이라면 배경색과 대비되는 하양,
// 그외 일요일은 빨강, 나머지는 검정으로 하되 현재 달이 아니라면 투명도 조절
val textColor = remember(isToday, isVisibleMonth) {
when {
isToday -> Color.White
!isCurrentMonth && date.dayOfWeek == DayOfWeek.SUNDAY -> Color.Red.copy(alpha = 0.3f)
!isCurrentMonth -> Color.DarkGray.copy(alpha = 0.3f)
date.dayOfWeek == DayOfWeek.SUNDAY -> Color.Red
else -> Color.Black
}
}
Surface(
onClick = onClick,
color = Color.White,
shape = RoundedCornerShape(6.dp),
border = BorderStroke(1.dp, Color.Black).takeIf { isSelected },
interactionSource = NoRippleInteractionSource()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(top = 2.5.dp)
.size(20.dp)
.background(circleColor, CircleShape)
) {
Text(
text = date.dayOfMonth.toString(),
fontSize = 12.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Normal,
color = textColor
)
}
}
}
}
이 함수는 날짜, 오늘 여부, 선택 여부, 현재 월 여부, 클릭 이벤트를 입력받는다.
• date와 isVisibleMonth는 처음에 한 번만 계산되어 입력받기에 불변하며 isToday와 isSelected는 각각 시간의 흐름과 사용자의 행동에 따라 변화되어 새로 입력받는 값이다.
• 현재 월 여부는 오늘을 포함하는 월을 말하는 것이 아닌 현재 페이지가 보여주고 있는 월의 일인지를 뜻한다.
• 클릭 이벤트는 위에서 말한 터치 시 높이를 50%로 조정하고 그 날짜를 선택하는 람다 함수이다.
• 배경색은 자정을 지날 때 오늘이 바뀌는 것을 반영하기 위해 remember의 키로 isToday를 입력받는다.
• 글자색은 마찬가지로 배경색이 있으면 흰색으로 바꾸어야 하니 isToday를 입력받는다.
- Week, Month Composable 함수(Pager)
필자는 현재 주를 표현하지 않기 때문에 주와 월 단계를 합쳤으며, 만약 현재 선택한 주를 테두리로 표현하고 싶다던가 추가로 원하는 작업이 있다면 별도의 함수로 분리해서 작업하는 것도 좋다.
HorizontalPager(
state = state.datePagerState,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) { page ->
// 페이저의 인덱스를 사용해 연월 계산(리멤버로 성능 최적화)
val pageYearMonth = remember { YearMonth.from(state.currentDate).plusMonths((page - Int.MAX_VALUE / 2).toLong()) }
// 연월을 사용해 계산한 페이지에 표시할 날짜 리스트
val dayOfMonth = remember { state.getDaysOfMonth(pageYearMonth) }
Column(
modifier = Modifier
.fillMaxSize()
) {
dayOfMonth.chunked(7).forEach { week ->
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
week.forEach { date ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
// 리멤버로 성능 최적화, isToday와 isSelected는 변화하는 값을 키로 사용
val isToday = remember(date, state.currentDate) { date == state.currentDate }
val isSelected = remember(date, state.selectedDate) { date == state.selectedDate }
val isVisibleMonth = remember { YearMonth.from(date) == pageYearMonth }
CalendarDay(
date = date,
isToday = isToday,
isSelected = isSelected,
isVisibleMonth = isVisibleMonth,
onClick = {
state.selectedDate = date
onClick() // 높이 조절
}
)
}
}
}
}
}
}
getDaysOfMonth 함수의 값으로는 페이지에 표시할 날짜들을 받아오게 되는데 예를 들어 1일이 화요일이라면 월요일과 일요일 자리에 표시할 이전 달의 두 날을 포함한 즉, 7*5 또는 7*6의 날짜들을 받아오게 된다. 그리고 받아온 값을 일 단계 함수에 각 파라미터를 계산해서 전달해 준다.
• 오늘 여부는 state에 있는 연산 프로퍼티인 오늘(LocalDate.now())과 비교해 준다.
• 선택 여부는 state에 있는 가변값인 LocalDate 형식과 비교해 준다.
• 현재 월 여부는 위에서 리멤버로 계산해둔 연월과 각 날짜의 연월을 비교한다.
- 스와이프 기능(LaunchedEffect)
LaunchedEffect(state.datePagerState.currentPage) {
// datePager 현재 페이지가 변경되면(직접 넘겨도) 선택된 날짜를 현재 페이지 첫째 날로 변경
state.currentPageYM.takeIf { it != YearMonth.from(state.selectedDate) }?.let { state.selectedDate = it.atDay(1) }
}
LaunchedEffect(state.selectedDate) {
// 선택된 날짜가 현재 페이지에 없으면, 해당 페이지로 datePager 스크롤
state.currentPageYM.takeIf { it != YearMonth.from(state.selectedDate) }?.let { pageMonth -> state.datePagerState
.animateScrollToPage(state.datePagerState.currentPage + (1.takeIf { state.selectedDate.isAfter(pageMonth.atEndOfMonth()) } ?: -1)) }
}
state의 currentPageVM은 페이저의 currentPage를 사용하는 연산 프로퍼티이므로 LaunchedEffect가 실행될 때 자동으로 값이 바뀐다.
- Calendar Composable 함수
@Composable
fun Calendar(
modifier: Modifier,
state: CalendarState,
onClick: () -> Unit
) {
LaunchedEffect(state.datePagerState.currentPage) {
// datePager 현재 페이지가 변경되면(직접 넘겨도) 선택된 날짜를 현재 페이지 첫째 날로 변경
state.currentPageYM.takeIf { it != YearMonth.from(state.selectedDate) }?.let { state.selectedDate = it.atDay(1) }
}
LaunchedEffect(state.selectedDate) {
// 선택된 날짜가 현재 페이지에 없으면, 해당 페이지로 datePager 스크롤
state.currentPageYM.takeIf { it != YearMonth.from(state.selectedDate) }?.let { pageMonth -> state.datePagerState
.animateScrollToPage(state.datePagerState.currentPage + (1.takeIf { state.selectedDate.isAfter(pageMonth.atEndOfMonth()) } ?: -1)) }
}
Column(
modifier = modifier
.fillMaxSize()
.background(Color.White)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.Center)
.padding(start = 8.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { }
)
) {
Text(
text = buildAnnotatedString {
withStyle(
block = { append(state.currentPageYM.run { takeIf { year != LocalDate.now().year }?.let { "${year}. $monthValue" } ?: "$monthValue" }) },
style = SpanStyle(
fontSize = 23.sp,
fontWeight = FontWeight.Medium,
baselineShift = BaselineShift(-0.015f)
)
)
state.currentPageYM.takeIf { it.year == LocalDate.now().year }?.let { append("월") }
},
fontSize = 22.sp,
lineHeight = 22.sp,
fontWeight = FontWeight.SemiBold
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_down),
contentDescription = "날짜 선택",
modifier = Modifier
.size(22.dp)
)
}
Row(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 4.dp)
) {
IconButton(
onClick = onNavigate,
content = {
Icon(
painter = painterResource(R.drawable.ic_add_box),
contentDescription = "추가",
modifier = Modifier
.size(26.dp)
)
}
)
IconButton(
onClick = { },
content = {
Icon(
painter = painterResource(R.drawable.ic_notification),
contentDescription = "알림",
modifier = Modifier
.size(26.dp)
)
}
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
listOf("일", "월", "화", "수", "목", "금", "토").forEach { dayText ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
) {
Text(
text = dayText,
fontSize = 12.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.Medium,
color = Color.Red.takeIf { dayText == "일" } ?: Color.Black
)
}
}
}
HorizontalPager(
state = state.datePagerState,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) { page ->
val pageYearMonth = remember { YearMonth.from(state.currentDate).plusMonths((page - Int.MAX_VALUE / 2).toLong()) }
val dayOfMonth = remember { state.getDaysOfMonth(pageYearMonth) }
Column(
modifier = Modifier
.fillMaxSize()
) {
dayOfMonth.chunked(7).forEach { week ->
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
week.forEach { date ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
val isToday = remember(date, state.currentDate) { date == state.currentDate }
val isSelected = remember(date, state.selectedDate) { date == state.selectedDate }
val isVisibleMonth = remember { YearMonth.from(date) == pageYearMonth }
CalendarDay(
date = date,
isToday = isToday,
isSelected = isSelected,
isVisibleMonth = isVisibleMonth,
onClick = {
state.selectedDate = date
onClick()
}
)
}
}
}
}
}
}
}
}
사용 방법(BoxWithConstraints)
val configuration = LocalConfiguration.current
BoxWithConstraints(
modifier = Modifier
.statusBarsPadding()
) {
val halfHeight = remember { maxHeight / 2 }
val fullHeight = remember { maxHeight }
var calendarHeight by remember { mutableStateOf(if (state.snapState == CalendarSize.FULL) fullHeight else halfHeight) }
// 부드러운 애니메이션
val animatedHeight by animateDpAsState(calendarHeight)
Column(
modifier = Modifier
.pointerInput(Unit) {
detectVerticalDragGestures(
onVerticalDrag = { change, dragAmount ->
change.consume()
// 상하 무한 제스처 방지
calendarHeight = (calendarHeight + dragAmount.toDp()).coerceIn(halfHeight, fullHeight)
},
onDragEnd = {
when (state.snapState) {
CalendarSize.HALF -> if (calendarHeight > halfHeight) {
state.snapState = CalendarSize.FULL
calendarHeight = fullHeight
}
CalendarSize.FULL -> if (calendarHeight < fullHeight) {
state.snapState = CalendarSize.HALF
calendarHeight = halfHeight
}
}
}
)
}
) {
Calendar(
state = state,
onClick = {
state.snapState = CalendarSize.HALF
calendarHeight = halfHeight
},
modifier = Modifier
.fillMaxWidth()
.height(animatedHeight)
)
HorizontalDivider(
color = Color(0xFFE0E0E0),
thickness = 1.dp,
modifier = Modifier
.padding(16.dp, 8.dp, 16.dp, 0.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
// BoxWithConstraints는 height에서 사용할 수 없음
.height(configuration.screenHeightDp.dp - animatedHeight)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 20.dp)
) {
Text(
text = state.calculateDaysDifference(),
fontSize = 17.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.SemiBold
)
Box(
modifier = Modifier
.size(2.dp)
.background(Color.Black, CircleShape)
)
Text(
text = state.selectedDate.format(DateTimeFormatter.ofPattern("M. d. (E)")),
fontSize = 15.sp,
lineHeight = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(0xFF999999)
)
}
}
}
}
화면의 절반을 측정하기 위해 BoxWithConstraints를 사용했으며 본인이 원하는 방법으로 바꾸어서 사용하면 된다.
마치며
LocalDate의 경우 Compose에서 불안정한 매개변수로 판단해 매번 재구성을 하기 때문에 성능에 좋지 않은 영향을 끼치는데 LocalDate를 포함하는 stable한 클래스를 직접 만들거나 안정성 구성 파일을 만들어 LocalDate를 안정적인 것으로 인식하게 할 수 있다. 만약 LocalDate를 그대로 사용할 것이라면 아래 링크를 참고해 성능 최적화를 하는 것을 추천한다.
그 외 궁금한 점이나 피드백은 댓글이나 카톡으로 연락해 주시면 최대한 빠르게 답변해드리겠습니다.
https://open.kakao.com/o/syg9ngUf
Reference
https://developer.android.com/develop/ui/compose/performance/stability?hl=ko
Compose의 안정성 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 안정성 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose는 유형을 안정적이거나 불안정
developer.android.com