[Compose] Custom Calendar 제작하기

h2on ·2023년 6월 8일
16

Android

목록 보기
1/4

사용자가 설정한 장소에 도착하면 알아서 체크해주는 투두리스트 두두
열심히 개발 중... 스토어 출시하면 많이들 봐줘요 😏

🐥 두두를 시작하며

개인 프로젝트 두두는 위치기반 투두리스트인데, 아무래도 캘린더 사용이 필수였다. 하지만 100% Compose 개발을 목표로 하고 있어 걸림돌이 되었다. Compose에서 Custom Calendar를 만드는 자료는 거의 없었고, 내가 원하는 디자인과 기능을 넣기 위해 직접 캘린더를 만들었다. (기록 + 가이드용은 덤 👍)

시작하기 전에

기존에는 Accompanist에서 제공하는 Beta버전의 HorizontalPager를 사용했어야 했다. 그러나 Compose 1.4.0에 HorizontalPager가 공식 지원되면서 Deprecated 됐다.


1. Compose Project 생성

Android Studio에서 Compose용 프로젝트를 생성해야 한다. Empty Compose Activity 으로 생성해야 한다.

2. HorizontalPager 구성

캘린더의 구조는 다음과 같다. (하단에서 코드와 함께 자세하게 설명할 예정이니 간략하게 어떤 구성인지만 확인하자.)

  • HorizontalCalendar
    • CalendarHeader
    • CalendarMonthItem
      • CalendarDay

2-1. HorizontalCalendar

년, 월을 표현하는 CalendarHeader와, 날짜를 표현하는 CalendarMonthItem을 가지는 최상위 Composable이다.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalCalendar(
    modifier: Modifier = Modifier,
    currentDate: LocalDate = LocalDate.now(),
    config: HorizontalCalendarConfig = HorizontalCalendarConfig(),
    onSelectedDate: (LocalDate) -> Unit
) {
	val initialPage = (currentDate.year - config.yearRange.first) * 12 + currentDate.monthValue - 1
    var currentSelectedDate by remember { mutableStateOf(currentDate) }
    var currentMonth by remember { mutableStateOf(YearMonth.now()) }
    var currentPage by remember { mutableStateOf(initialPage) }
    val pagerState = rememberPagerState(initialPage = initialPage)
	...
}

Parameter

currentDate : 현재 날짜
config : 캘린더 init에 필요한 데이터를 가지는 data class로 1970~2100년 까지의 정수 범위, Locale 등을 포함
onSelectedDate : 캘린더 날짜 클릭시 선택된 날짜를 변경하는 람다

Property

initialPage : pagerState에 들어갈 initialPage를 설정
currentSelectedDate : 현재 선택된 날짜
currentMonth : CalendarHeader 표시를 위한 년, 월
currentPage : HorizontalPager의 현재 페이지

캘린더 구현에 있어서 가장 중요한 부분이 Pager의 화면 전환 이벤트를 감지하여 핸들링 하는 것인데, LaunchedEffect를 통해 구현이 가능하다.

LaunchedEffect(pagerState.currentPage) {
	val addMonth = (pagerState.currentPage - currentPage).toLong()
    currentMonth = currentMonth.plusMonths(addMonth)
    currentPage = pagerState.currentPage
}

addMonth를 잘 보면, pagerState.currentPage - currentPage가 핵심이다. Pager를 좌우로 스와이프 할 때마다 pagerStatecurrentPage 값이 변경되기 때문에 LaunchedEffect로 들어오게 되며, currentMonthcurrentPage의 값을 수정해준다.

2-2. CalendarHeader

CalendarHeader는 딱히 설명할게 없다. 그냥 달력에 표시되는 년, 월만 받아서 띄워주면 된다.

@Composable
fun CalendarHeader(
	title: String
) {
	...
    Text(title)
    ...
}

2-3. CalendarMonthItem

HorizontalPager에서 한 페이지에 표시될 내용을 담은 Composable이다.

@Composable
fun CalendarMonthItem(
	currentDate: LocalDate,
    selectedDate: LocalDate,
    onSelectedDate: (LocalDate) -> Unit
) {
	val lastDay by remember { mutableStateOf(currentDate.lengthOfMonth()) }
    val firstDayOfWeek by remember { mutableStateOf(currentDate.dayOfWeek.value) }
    val days by remember { mutableStateOf(IntRange(1, lastDay).toList()) }
    ...
}

Parameter

currentDate : 현재 표시되는 날짜 ex) 2023-06-08
selectedDate : 달력에서 선택된 날짜
onSelectedDate : 선택된 날짜를 변경하는 람다

Property

lastDay : lengthOfMonth()를 사용하여 이번 달의 마지막 날짜를 구함 ex) 29,30,31
firstDayOfWeek : 이번 달의 시작 요일
days : 이번 달의 전체 날짜 ex) 1, 2, 3, ... 30, 31

이제 일반적으로 알고 있는 달력의 모습을 만들어보자. CalendarMonthItem에서는 요일을 표시하는 Composable과 이번 달의 전체 날짜를 그리는 Composable을 포함한다.

Column(modifier = modifier) {
	DayOfweek() // 요일 표시
    LazyVerticalGrid(columns = GridCells.Fixed(7)) {
        // 처음 날짜가 시작하는 요일 전까지 빈 박스를 생성한다.
    	for (i in 0 until firstDayOfWeek) { 
        	item {
            	Box()
            }
        }
       	items(days) { day ->
        	// 이번 달의 날짜를 day로 치환하여 CalendarDay로 넘긴다.
        	val date = currentDate.withDayOfMonth(day) 
            CalendarDay(date)
        }
    }
}

아래는 혹시 필요한 사람이 있을지도 몰라서 DayOfWeek() 코드를 첨부한다.

@Composable
fun DayOfWeek() {
	Row(modifier = modifier) {
    	DayOfWeek.values().forEach { dayOfWeek -> 
        	//NARROW : 요일 표시 방법을 Sunday가 아니라 S처럼 짧게 표시
            //Locale.KOREAN : 월,화,수,목,금,토,일 처럼 한글로 설정
        	Text(dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN))
        }
    }	
}

2-4. CalendarDay

마지막으로 년, 월, 일 을 표시하는 Composasble 중에서 을 표시한다.

@Composasble
fun CalendarDay(
	date: LocalDate,
    isToday: Boolean,
    isSelected: Boolean,
    onSelectedDate: (LocalDate) -> Unit
) {
	...
}

Parameter

date : 표시할 날짜
isToday : 현재 날짜가 오늘인지 판별
isSelected : 현재 날짜가 선택됐는지 판별
onSelectedDate : 선택된 날짜를 변경하는 람다

CalendarDay 는 받은 날짜만 Text로 표시해주면 되기 때문에 특별히 중요한 코드는 없다.

Column(modifier) {
	val textColor = if(isSelected) ... else ...
    Text(date.dayOfMonth.toString())
}

3. 전체적인 코드

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalCalendar(...) {
	LaunchedEffect(pagerState.currentPage) {
        val addMonth = (pagerState.currentPage - currentPage).toLong()
        currentMonth = currentMonth.plusMonths(addMonth)
        currentPage = pagerState.currentPage
    }

    LaunchedEffect(currentSelectedDate) {
        onSelectedDate(currentSelectedDate)
    }
    
    Column(modifier = modifier) {
        val headerText = currentMonth.dateFormat("yyyy년 M월")
        val pageCount = (config.yearRange.last - config.yearRange.first) * 12
        CalendarHeader(
            modifier = Modifier.padding(20.dp),
            text = headerText
        )
        HorizontalPager(
            pageCount = pageCount,
            state = pagerState
        ) { page ->
            val date = LocalDate.of(
                config.yearRange.first + page / 12,
                page % 12 + 1,
                1
            )
            if (page in pagerState.currentPage - 1..pagerState.currentPage + 1) { // 페이징 성능 개선을 위한 조건문
                CalendarMonthItem(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 20.dp),
                    currentDate = date,
                    selectedDate = currentSelectedDate,
                    onSelectedDate = { date ->
                        currentSelectedDate = date
                    }
                )
            }
        }
    }
}

@Composable
fun CalendarHeader(
    modifier: Modifier = Modifier,
    text: String,
) {
    Box(modifier = modifier) {
        Text(
            text = text,
            style = BlackN12
        )
    }
}

@Composable
fun CalendarMonthItem(
    modifier: Modifier = Modifier,
    currentDate: LocalDate,
    selectedDate: LocalDate,
    onSelectedDate: (LocalDate) -> Unit
) {
    val lastDay by remember { mutableStateOf(currentDate.lengthOfMonth()) }
    val firstDayOfWeek by remember { mutableStateOf(currentDate.dayOfWeek.value) }
    val days by remember { mutableStateOf(IntRange(1, lastDay).toList()) }

    Column(modifier = modifier) {
        DayOfWeek()
        LazyVerticalGrid(
            modifier = Modifier.height(260.dp),
            columns = GridCells.Fixed(7)
        ) {
            for (i in 1 until firstDayOfWeek) { // 처음 날짜가 시작하는 요일 전까지 빈 박스 생성
                item {
                    Box(
                        modifier = Modifier
                            .size(30.dp)
                            .padding(top = 10.dp)
                    )
                }
            }
            items(days) { day ->
                val date = currentDate.withDayOfMonth(day)
                val isSelected = remember(selectedDate) {
                    selectedDate.compareTo(date) == 0
                }
                CalendarDay(
                    modifier = Modifier.padding(top = 10.dp),
                    date = date,
                    isToday = date == LocalDate.now(),
                    isSelected = isSelected,
                    onSelectedDate = onSelectedDate
                )
            }
        }
    }
}


@Composable
fun CalendarDay(
    modifier: Modifier = Modifier,
    date: LocalDate,
    isToday: Boolean,
    isSelected: Boolean,
    onSelectedDate: (LocalDate) -> Unit
) {
    val hasEvent = false // TODO
    Column(
        modifier = modifier
            .wrapContentSize()
            .size(30.dp)
            .clip(shape = RoundedCornerShape(10.dp))
            .conditional(isToday) {
                background(gray07)
            }
            .conditional(isSelected) {
                background(gray0)
            }
            .conditional(!isToday && !isSelected) {
                background(gray08)
            }
            .noRippleClickable { onSelectedDate(date) },
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center

    ) {
        val textColor = if (isSelected) gray09 else gray0
        Text(
            modifier = Modifier,
            textAlign = TextAlign.Center,
            text = date.dayOfMonth.toString(),
            style = BoldN12,
            color = textColor
        )
        if (hasEvent) {
            Box(
                modifier = Modifier
                    .size(4.dp)
                    .clip(shape = RoundedCornerShape(4.dp))
                    .conditional(isSelected) {
                        background(gray09)
                    }
                    .conditional(!isSelected) {
                        background(gray0)
                    }
            )
        }
    }
}

@Composable
fun DayOfWeek(
    modifier: Modifier = Modifier
) {
    Row(modifier) {
        DayOfWeek.values().forEach { dayOfWeek ->
            Text(
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f),
                text = dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.KOREAN),
                style = BlackN12,
                textAlign = TextAlign.Center
            )
        }
    }
}

4. 약간의 성능 개선(?)

기존 HorizontalCalendar 페이저 내부에 CalendarMonthItem을 호출하는 부분을 보면 모든 페이지에 대해 CalendarMonthItem을 호출하고 있다. 이런 낭비를 막고자 조건문 하나를 추가했다.

HorizontalPager(...) { page -> 
	val date = ...
    // 이 부분에 페이징 성능 개선을 위해 조건문을 추가했다.
    if (page in pagerState.currentPage - 1..pagerState.currentPage + 1)	{
    	CalendarMonthItem(...)
    }
}

해당 코드로 인해 스와이프시 약간의 성능 향상이 체감되었다. 😁

결과물

마치며

사실 나도 Compose로 커스텀 캘린더를 구현하며 새벽까지 개발하면서 꽤 스트레스를 많이 받았다. 아무리 리서치해도 도움되는 자료가 나오지 않고, 라이브러리들 조차도 내가 원하는게 없었다. 그래서 직접 만들었고, 혹시라도 나처럼 Compose로 Calendar를 구현 에정이거나 구현에 애를 먹고 있는 사람에게 조금이라도 도움이 될 수 있도록 글로 남기게 되었다. 새벽까지 개발하며 급하게 만든 탓인지 수정해야할 부분도 많이 보이고 미흡한 부분도 많지만, 그래도 해당 글이 조금이라도 도움이 되었으면 좋겠다.

두두에는 꽤 재밌는 기능들이 많이 기획되어 있기 때문에 개발하면서 도움이 될 만한 것들은 자주 글로 남길 예정이다. 스토어 랭킹에 두두가 보이는 그날까지.. 💪

profile
돈 버는 백수가 꿈

6개의 댓글

comment-user-thumbnail
2023년 6월 18일

오 신박한 기능이네요! 어서 다운 받아 사용해보고 싶은걸요 ㅎㅎㅎ :)

1개의 답글
comment-user-thumbnail
2023년 11월 20일

개꿀맛 포스팅이네요 ㅎㄷㄷㄷㄷㄷ

1개의 답글
comment-user-thumbnail
2023년 12월 25일

깃 허브 소스 알 수 있을까요?

1개의 답글