본문 바로가기
Jetpack Compose

[Jetpack Compose] Compose로 Calendar, Horizontal Calendar 예쁘게 직접 만들기 (주간 캘린더, 월간 캘린더)

by JongSeok 2024. 5. 12.

이전에 xml로 개발할 때에는 캘린더 관련 라이브러리나 레퍼런스가 많았는데 컴포즈는 아직까지는 참고할 레퍼런스가 많지 않은 것 같습니다.

안드로이드에서 제공하는 기본 캘린더는 너무 못생겼고, 다른 사람들이 만들어놓은 라이브러리를 사용하는 경우 100% 입맛대로 커스텀하기에는 제한적인 부분들이 있기 때문에 욕심이 생겨 외부 라이브러리를 사용하지 않고 직접 캘린더를 만들어봤습니다.

 

Jetpack Compose를 사용하며 캘린더 개발로 고통받는 다른 안드로이드 개발자들에게 조금이나마 도움이 되었으면 좋겠습니다 :)

포스팅에 모든 내용과 코드를 담을 수 없어 레포지토리 링크를 첨부합니다.

 

GitHub - Ojongseok/Compose_Calendar_Practice

Contribute to Ojongseok/Compose_Calendar_Practice development by creating an account on GitHub.

github.com


캘린더 UI

화면 상단에는 수평 방향의 주간 캘린더, BottomSheet가 펼쳐지며 월간 캘린더가 보여집니다. (주간 캘린더와 수평 캘린더 모두 좌우로 스와이프 가능한 ViewPager로 구성되고, 좌우로 스와이프 하더라도 현재 선택 중인 날짜가 유지됩니다.)

최초 실행 시 캘린더의 선택 날짜는 오늘 날짜로 선택되어 있고, 일요일을 한 주의 시작으로 설정했습니다. 그리고 주간 캘린더의 경우 현재 달과 이전 달, 다음 달의 일자를 구분하기 위해 조금 옅은 컬러로 미리보기를 제공합니다.

 

주간 캘린더

주간 캘린더를 구성하는 방법을 간단히 살펴보겠습니다.

// 선택 중인 날짜
val selectedDate by viewModel.selectedDate.collectAsState()    
// 선택된 달(Month)의 전체 주(Week)
val totalWeeks = getWeeksOfMonth(selectedDate.year, selectedDate.monthValue, selectedDate.month.maxLength())
// 선택 중인 주(Week)
val selectedWeeks = getWeeksOfMonth(selectedDate.year, selectedDate.monthValue, selectedDate.dayOfMonth)

val pagerState = rememberPagerState(pageCount = {totalWeeks}, initialPage = selectedWeeks-1)

주간 캘린더를 수평 방향으로 스와이프 가능한 HorizontalPager로 구성하기 위해 초기 페이지 수(pageCount)와 오늘 날짜가 포함된 주(initialPage)를 계산해야 합니다.

fun getWeeksOfMonth(year: Int, month: Int, day: Int): Int {
    val calendar = Calendar.getInstance()
    calendar.set(year, month-1, day)
    return calendar.get(Calendar.WEEK_OF_MONTH)
}

날짜 계산에는 java.util 패키지에 포함된 Calendar 클래스를 사용합니다.

@Composable
fun HorizontalCalendar(
    ...
    modifier: Modifier = Modifier
) {
    val selectedDate by viewModel.selectedDate.collectAsState()    // 현재 선택된 날짜
    val totalWeeks = getWeeksOfMonth(selectedDate.year, selectedDate.monthValue, selectedDate.month.maxLength())   // 선택된 달이 총 몇 주인지
    // 7일 단위로 1주, 2주, 3주, 4주, 5주, 6주를 보관하는 2차원 리스트
    val weekList = List<MutableList<LocalDate>>(totalWeeks) { mutableListOf() }    

    // 1일에서 마지막 일까지 n-1주차에 맞게 weekList에 저장
    for (i in 1..selectedDate.month.maxLength()) {
        val week = getWeeksOfMonth(selectedDate.year, selectedDate.monthValue, i)
        weekList[week-1].add(LocalDate.of(selectedDate.year, selectedDate.monthValue, i))
    }

    // 첫째주 리스트의 비어있는 날짜 채우기
    while(weekList[0].size != 7) {
        weekList[0].add(0, weekList[0][0].minusDays(1))
    }
    // 마지막주 리스트의 비어있는 날짜 채우기
    while(weekList[totalWeeks-1].size != 7) {
        weekList[totalWeeks-1].add(weekList[totalWeeks-1][weekList[totalWeeks-1].size-1].plusDays(1))
    }
    
    ...
    HorizontalPager(
            state = pagerState,
        ) {
        ...
    }
}

HorizontalPager() 컴포넌트를 생성하기 전 페이지마다 주간 캘린더를 구성할 날짜를 계산해 HorizontalPager의 총 페이지 수를 확정할 수 있도록 합니다. 이렇게 하지 않고 Pager에서 계산하게 되면 스와이프하는 사이 굉장히 어색해지더라구요..

 

selectedDate :  viewModel의 selectedDate를 참조하며 현재 선택된 날짜를 유지하고, UI에 선택된 날짜로 표시됩니다.

totalWeeks : 현재 선택된 달(Month)이 총 몇 주(Week)로 이루어져 있는지 계산합니다. 예를 들어 선택된 날짜가 2024년 5월 12일이라면 5월의 마지막 날짜는 31일이기 때문에 getWeeksOfMonth(2024, 5, 31)이 5를 반환하며 HorizontalPager의 총 페이지 수를 확정합니다.

weekList : 일주일은 7일이기 때문에 크기가 7인 리스트를 원소로 갖는 크기가 totalWeeks인 리스트를 생성합니다. 예를 들어, [[1,2,3,4,5,6,7], [8,9,10,11,12,13,14], ...] 이런 느낌인거죠?

 

하지만 1일이 항상 매달 한 주의 시작 요일이 아니기 때문에 1일의 요일을 확인해 1일의 인덱스를 조절해 주어야 합니다.

여기서 저는 이전 달에 대한 미리보기를 제공하기 위해 java.util.Calendar에서 제공하는 minusDays(), plusDays() 확장함수를 사용합니다.

2024년 5월 1일에서 하루를 빼면 2024년 4월 30일입니다. 반대로 2024년 5월 31일에서 하루를 더하면 2024년 6월 1일임을 알 수 있기 때문에 해당 날짜에서 다시 하루씩 더하고, 빼면서 리스트의 크기가 7이 될 때까지(일주일이 찰 때까지) 추가합니다.

HorizontalPager(
    state = pagerState,
) { page ->
    LazyRow(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceEvenly,
        userScrollEnabled = false
    ) {
        if (isExpanded) {
            item {
                TodayButton(
                    modifier = modifier,
                    onClickTodayButton = onClickedTodayButton
                )
            }
        }

        items(items = weekList[page]) { date ->
            HorizontalCalendarItem(
                date = date,
                selectedDate = selectedDate,
                enableSelectedMonth = selectedDate.monthValue,
                onClickDate = onClickDate
            )
        }
    }
}

이제 별도로 만들어 둔 각각의 날짜 UI와 상태를 표현할 HorizontalCalendarItem() HorizontalPager 컴포저블을 구성하면 됩니다.

HorizontalCalendarItem은 얼마든지 자유롭게 커스텀할 수 있기 때문에 따로 다루지 않겠습니다.

월간 캘린더

월간 캘린더도 주간 캘린더와 마찬가지로 HorizontalPager()로 구성됩니다. 차이점은 주간 캘린더는 HorizontalPager 컴포저블을 그리기 전 모든 날짜별 요일을 확정할 수 있었지만 월간 캘린더의 경우 시작/종료 년도 범위에 따라 1년은 1월부터 12월까지 12달로 이루어지기 때문에 HorizontalPager의 총 페이지 수만 확정할 수 있고, 현재 페이지의 위치에 따라 몇 년도 몇 월이 어떤 격자 패턴으로 그려질지 스와이프가 발생할 때마다 확인해줘야 한다는 점입니다.

object CALENDAR_RANGE {
    const val startYear = 2000
    const val lastYear = 2030
}

저는 시작 년도를 2000년 종료 년도를 2030년으로 설정했습니다.

// 현재 선택된 날짜
val selectedDate by viewModel.selectedDate.collectAsState()
// 초기 선택된 페이지 (년/월)
val initialPage = (selectedDate.year - CALENDAR_RANGE.startYear) * 12 + selectedDate.monthValue - 1
// 페이저의 총 페이지 수
val pageCount = (CALENDAR_RANGE.lastYear - CALENDAR_RANGE.startYear) * 12

val pagerState = rememberPagerState(pageCount = {pageCount}, initialPage = initialPage)

var currentDate by remember { mutableStateOf(selectedDate) }
var currentPage by remember { mutableIntStateOf(initialPage) }

// 스와이프가 발생할 때 마다 리컴포지션 발생, currentDate, currentPage 변경
LaunchedEffect(key1 = pagerState.currentPage) {
    val swipe = (pagerState.currentPage - currentPage).toLong()
    currentDate = currentDate.plusMonths(swipe)
    currentPage = pagerState.currentPage
}

1년은 12달로 이루어지기 때문에 시작/종료 년도를 설정한다면 HorizontalPager의 총 페이지 수를 확정할 수 있고, 사용자에 의해 스와이프가 발생할 때마다 현재 페이지를 유지할 수 있다면 페이지 인덱스에 따라 년/월을 특정할 수 있습니다.

그리고 현재 보고 있는 페이지의 년도와 월을 알기 때문에 1일의 요일과 위치를 알아내 캘린더를 그려주면 됩니다.

 

본인 같은 경우 LaunchedEffect() 블럭을 이용해 pagerState.currentPage가 변경될 때마다 리컴포지션을 발생시켜 캘린더를 새로 그려줄 수 있도록 구성했습니다. (pagerState.currentPage는 사용자가 페이저를 스와이프할 때 마다 발생합니다.)

@Composable
fun CalendarInBottomSheet(
    pagerState: PagerState,
    selectedDate: LocalDate,
    onSelectedDate: (LocalDate) -> Unit,
    modifier: Modifier = Modifier
) {
    HorizontalPager(
        state = pagerState
    ) { page ->
        val date = LocalDate.of(
            CALENDAR_RANGE.startYear + page / 12,
            page % 12 + 1,
            1
        )

        CalendarMonth(
            selectedDate = selectedDate,
            currentDate = date,
            onSelectedDate = {
                onSelectedDate(it)
            }
        )
    }
}

위 코드에서 date는 페이저의 상태에 따라 현재 사용자가 보고 있는 페이지 인덱스에 따라 년/월을 특정해 CalendarMonth() 커스텀 UI 컴포넌트에서 격자 패턴의 한 달을 표현할 수 있도록 하기 위함입니다.

@Composable
fun CalendarMonth(
    selectedDate: LocalDate,
    currentDate: LocalDate,
    onSelectedDate: (LocalDate) -> Unit,
    modifier: Modifier = Modifier
) {
    val lastDay by remember { mutableIntStateOf(currentDate.lengthOfMonth()) }    // 해당 달의 마지막 날
    val firstDayOfWeek by remember { mutableIntStateOf(currentDate.dayOfWeek.value) }  // 1일
    val days by remember { mutableStateOf(IntRange(1, lastDay).toList()) }    // 해당 달의 총 일수 리스트

   ...
    LazyVerticalGrid(
       ...
    ) {
        // 1일이 시작하는 요일 전까지 공백 생성, 일요일부터 시작할 수 있도록 +1
        for (i in 1 until firstDayOfWeek +1) {
            item {
                Box(modifier = Modifier.size(32.dp))
            }
       }
    }
}

이런식으로 말이죠


많은 양의 코드와 연산 로직을 글로 설명하려니 굉장히 두서가 업네요...

그치만 Compose로 캘린더를 그리는 레퍼런스가 거의 없어 제가 고민했던 부분들이 다른 분들께 작게나마 도움이 되었으면 싶어 포스팅을 올립니다.

해당 글에 있는 내용만으로 캘린더를 온전히 따라 하는 건 어려울 것 같고 위 레포지토리에서 코드를 내려받아 빌드해서 보시면 좀 더 이해하기 수월할 것 같습니다!!

728x90
반응형