본문 바로가기
Android

[안드로이드] Material CalendarView - 캘린더 제대로 커스텀하기(with. Range, Select, OtherDays, 주말 설정)

by JongSeok 2023. 8. 24.

며칠 동안 삽질하면서 직접 커스텀한 캘린더입니다.
아무리 구글링을 해봐도 제가 이번에 만든 캘린더보다 실제 캘린더에 가깝고, 완성도 있는 커스텀 캘린더 샘플은 찾기 힘들었어요..  ;ㅅ;

 

캘린더 개발로 고통받는 다른 분들도 유용하게 사용하시길 바라며 공유합니다-! 😄😄

(사용된 컬러는 다크/라이트 모드 대응때문에 흑백으로 대비시키고 있지만 자유롭게 바꾸셔도 됩니다.) 

 

개발일 : 2023년 08월 24일 입니다
16일 ~ 22일을 선택한 결과

어때요? 예쁘죠? 예쁘다해.


캘린더에서 날짜를 지정하고 기간을 설정할 수 있는 기능을 만들어야 했습니다.

이런저런 API와 안드로이드 OS에서 제공하는 캘린더 관련 라이브러리를 찾아보았지만 입맛에 맞는 라이브러리를 찾기 힘들더라구요..

 

구현할 캘린더에서 필요한 두 가지 조건은 

   1. 기간 / 범위(Range) 설정이 가능해야 한다.

   2. Dialog 형태로 구현이 가능하되 캘린더에 추가적인 UI까지 커스텀이 가능해야 한다.      였습니다.

 

커스텀을 시도하다 놓아줬던 라이브러리를 말씀드리자면 처음에 'CosmoCalendar'라는 라이브러리를 발견했습니다.

Range 설정이 가능했지만 Dialog로 캘린더를 띄운 이유에서인지 날짜가 선택되었을 때 UI가 의도대로 작동하지 않아 다른 방법을 찾아야 했어요..

 

그다음으로는 안드로이드 OS에서 제공하는 CalendarView입니다.

과거에 못생긴 기본 캘린더에 비해 많이 예뻐졌고, 커스텀이 가능했지만 결정적으로 날짜를 단일 선택만 가능할 뿐 기간 설정이 불가능했습니다..

기간(Range) 선택이 가능한 Date Range Picker가 있지만 커스텀이 제한적이고, 무엇보다 안 예뻐서 패쓰.


돌고 돌아 Material CalendarView

 

최근에 나온 Material Design3도 그렇고 Material에서 제공하는 UI 컴포넌트들이 사용성도 좋아지고 디자인 자체도 점점 세련되게 바뀌는 것 같아요.

물론 기본 UI 컴포넌트만 추가했을 때에는 볼품없어 이리저리 커스텀을 해줘야 합니닷.

 

코드와 샘플을 보겠습니다.

 

먼저 app 수준 Build.gradle에 의존성을 추가합니다.

implementation 'com.github.prolificinteractive:material-calendarview:2.0.1'

구글 서치를 하며 다른 레퍼런스에서 1.4.3 버전을 사용하는 경우도 많았고, 1.4.3과 2.0.1에 동작하는 메소드와 속성들이 조금 차이가 있어 저는 2.0.1로 진행했습니다.

 

그리고 캘린더를 띄울 xml에 MaterialCalendarView 컴포넌트를 추가합니다.

<com.prolificinteractive.materialcalendarview.MaterialCalendarView
        android:id="@+id/calendar_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="24dp"
        android:layout_marginTop="24dp"
        android:theme="@style/CalenderViewCustom"
        app:mcv_selectionMode="range"    // 캘린더를 Range Mode로 설정
        app:mcv_showOtherDates="all"     // 선택된 달(Month) 외 날짜 보이기 
        app:mcv_firstDayOfWeek="sunday"    // 일주일의 시작을 일요일로 설정
        app:mcv_rightArrow="@drawable/ic_arrow_right_cal"    // 우측으로 아이콘
        app:mcv_leftArrow="@drawable/ic_arrow_left"     // 좌측으로 아이콘
        app:mcv_dateTextAppearance="@style/CalenderViewDateCustomText"    
        app:mcv_weekDayTextAppearance="@style/CalenderViewWeekCustomText"/>

캘린더에 지정해 둔 스타일을 보겠습니다.

<!-- 캘린더의 날짜(Day)의 스타일 설정 -->
<style name="CalenderViewCustom" parent="Theme.AppCompat">
    <item name="android:textColor">@drawable/calendar_selector_textcolor</item>
    <item name="android:textStyle">bold</item>
</style>

<!-- 캘린더의 날짜(Day)의 스타일 설정 -->
<style name="CalenderViewDateCustomText" parent="android:TextAppearance.DeviceDefault.Small">
    <item name="android:textColor">@drawable/calendar_selector_textcolor</item>
</style>

<!-- 캘린더의 요일에 적용되는 스타일 -->
<style name="CalenderViewWeekCustomText" parent="android:TextAppearance.DeviceDefault.Small">
    <item name="android:textColor">@color/text100</item>
</style>

<!-- 연, 월을 표시하는 헤더에 적용되는 스타일 -->
<style name="CalendarWidgetHeader">
    <item name="android:textSize">18sp</item>
    <item name="android:textColor">@color/text100</item>
</style>

모두 추가해 줍니다-!

저는 라이트 / 다크 모드에 따라 색상을 변경하느라 colors.xml에 있는 색상을 참조했습니다.

 

calendar_selector_textcolor.xml

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item
            android:state_checked="true"     // 선택된 경우
            android:color="@color/gray0" />
    <item
            android:state_pressed="true"     // Range 내부인 경우
            android:color="@color/gray0" />
    <item
            android:state_checked="false"    // 선택되지 않은 경우
            android:color="@color/text100" />
    <item
            android:state_pressed="false"    // Range 내부가 아닌 경우
            android:color="@color/text100" />
</selector>

캘린더의 날짜가 선택되거나 선택된 범위에 속하는 경우 날짜(숫자)의 색상을 지정합니다.

 

이제 캘린더의 날짜가 선택되었거나 선택된 범위에 속하는 경우 해당 날짜의 배경을 변경할 drawable을 생성합니다.

calendar_selector.xml

<?xml version="1.0" encoding="utf-8"?>
<selector android:exitFadeDuration="@android:integer/config_shortAnimTime" xmlns:android="http://schemas.android.com/apk/res/android">
    <item
            android:state_checked="true"
            android:drawable="@drawable/transparent_calendar_element" />
    <item
            android:state_pressed="true"
            android:drawable="@drawable/transparent_calendar_element" />
    <item android:drawable="@android:color/transparent" />
</selector>

transparent_calendar_element.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
        android:shape="oval">
    <solid android:color="@color/text100"/>
    <stroke android:width="2dp" android:color="@color/gray0"/>
</shape>

calendar_circle_gray.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
        android:shape="oval">
    <solid android:color="@color/transparent"/>
    <stroke android:width="2dp" android:color="@color/gray200"/>
</shape>

이때 stroke를 2dp로 설정해 주는 이유는 stroke를 지정하지 않으면 배경이 되는 원이 범위를 지정할 때 완전 달라붙어 있어 UI적으로 좀 더 자연스럽게 하기 위해 배경색과 동일한 색상의 stroke를 설정해 마치 떨어져 있는 것처럼 보여줍니다. 

 

캘린더의 날짜가 선택되었을 경우, 캘린더에 오늘 날짜를 표시할 drawable은 클래스 파일에서 동적으로 지정하겠습니다.

<string-array name="custom_weekdays">
    <item>월</item>
    <item>화</item>
    <item>수</item>
    <item>목</item>
    <item>금</item>
    <item>토</item>
    <item>일</item>
</string-array>

그리고 요일을 한글로 표현하기 위해 string-array.xml에 custom_weekdays를 작성합니다.


이제 클래스 파일에서 캘린더에서 발생하는 이벤트와 리스너를 목적에 따라 추가하면 되겠습니다.
// 요일을 한글로 보이게 설정 월..일 순서로 배치해서 캘린더에는 일..월 순서로 보이도록 설정
binding.calendarView.setWeekDayFormatter(ArrayWeekDayFormatter(resources.getTextArray(R.array.custom_weekdays)));

// 좌우 화살표 사이 연, 월의 폰트 스타일 설정
binding.calendarView.setHeaderTextAppearance(R.style.CalendarWidgetHeader)

// 시작, 종료 범위가 설정되었을 때 리스너
binding.calendarView.setOnRangeSelectedListener { widget, dates ->
    selectedStartSchedule = dates[0].date.toString()
    selectedEndSchedule = dates[dates.size - 1].date.toString()
}

// 날짜가 단일 선택되었을 때 리스너
binding.calendarView.setOnDateChangedListener { widget, date, selected ->
    selectedStartSchedule = date.date.toString()
    selectedEndSchedule = ""

    if (!selected) {
        selectedStartSchedule = ""
    }
}

주의해야 할 부분은 setOnRangeSelectedListener시작일이 선택된 후 시작일과 다른 종료일이 선택되었을 때 호출되고, 시작일 ~ 종료일 까지의 날짜가 리스트로 반환됩니다.


그에 반해 setOnDateChangedListener시작일을 선택한 경우시작일과 종료일이 같아 선택이 취소된 경우에 콜백이 옵니다. 시작일과 종료일의 범위가 지정된 경우 setOnDateChangedListener가 아닌 setOnRangeSelectedListener이 호출됩니다.

 

val dayDecorator = DayDecorator(requireContext())
val todayDecorator = TodayDecorator(requireContext())
val sundayDecorator = SundayDecorator()
val saturdayDecorator = SaturdayDecorator()
var selectedMonthDecorator = SelectedMonthDecorator(CalendarDay.today().month)

// 캘린더에 Decorator 추가
binding.calendarView.addDecorators(dayDecorator, todayDecorator, sundayDecorator, saturdayDecorator, selectedMonthDecorator)

// 좌우 화살표 가운데의 연/월이 보이는 방식 지정
binding.calendarView.setTitleFormatter { day ->
    val inputText = day.date
    val calendarHeaderElements = inputText.toString().split("-")
    val calendarHeaderBuilder = StringBuilder()

    calendarHeaderBuilder.append(calendarHeaderElements[0]).append("년 ")
        .append(calendarHeaderElements[1]).append("월")

    calendarHeaderBuilder.toString()
}

// 캘린더에 보여지는 Month가 변경된 경우
binding.calendarView.setOnMonthChangedListener { widget, date ->
    // 기존에 설정되어 있던 Decorators 초기화
    binding.calendarView.removeDecorators()
    binding.calendarView.invalidateDecorators()

    // Decorators 추가
    selectedMonthDecorator = SelectedMonthDecorator(date.month)
    binding.calendarView.addDecorators(dayDecorator, todayDecorator, sundayDecorator, saturdayDecorator, selectedMonthDecorator)
}

캘린더의 월(Month)이 변경되어도 캘린더를 구성하는 로직은 동일해야 하는데 이 부분에서 한참 헤맨 것 같아요.

기존에는 여러 Decorators 클래스들을 최초 1회만 추가(addDecorator())하고 setOnMonthChangedListener를 처리해주지 않아 Month가 변경될 때 UI를 갱신하지 못하는 이유였습니다.

 

그래서 기존에 설정되어 있던 Decorators를 초기화하고 해당 월(Month)에 해당하는 Decorators를 다시 추가합니다.

Decorator를 초기화하지 않는다면 월이 변경될 때마다 Decorator가 계속 추가되어 캘린더를 이동하다 보면 매우매우 버벅이는 것처럼 느껴집니다.

 

마지막으로 위에서 사용한 Decorator 클래스를 정의합니다.

/* 선택된 날짜의 background를 설정하는 클래스 */
private inner class DayDecorator(context: Context) : DayViewDecorator {
    private val drawable = ContextCompat.getDrawable(context,R.drawable.calendar_selector)
    // true를 리턴 시 모든 요일에 내가 설정한 드로어블이 적용된다
    override fun shouldDecorate(day: CalendarDay): Boolean {
        return true
    }

    // 일자 선택 시 내가 정의한 드로어블이 적용되도록 한다
    override fun decorate(view: DayViewFacade) {
        view.setSelectionDrawable(drawable!!)
    }
}

/* 오늘 날짜의 background를 설정하는 클래스 */
private class TodayDecorator(context: Context): DayViewDecorator {
    private val drawable = ContextCompat.getDrawable(context,R.drawable.calendar_circle_gray)
    private var date = CalendarDay.today()
    override fun shouldDecorate(day: CalendarDay?): Boolean {
        return day?.equals(date)!!
    }
    override fun decorate(view: DayViewFacade?) {
        view?.setBackgroundDrawable(drawable!!)
    }
}

/* 이번달에 속하지 않지만 캘린더에 보여지는 이전달/다음달의 일부 날짜를 설정하는 클래스 */
private inner class SelectedMonthDecorator(val selectedMonth : Int) : DayViewDecorator {
    override fun shouldDecorate(day: CalendarDay): Boolean {
        return day.month != selectedMonth
    }
    override fun decorate(view: DayViewFacade) {
        view.addSpan(ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.text40)))
    }
}

/* 일요일 날짜의 색상을 설정하는 클래스 */
private class SundayDecorator : DayViewDecorator {
    override fun shouldDecorate(day: CalendarDay): Boolean {
        val sunday = day.date.with(DayOfWeek.SUNDAY).dayOfMonth
        return sunday == day.day
    }

    override fun decorate(view: DayViewFacade) {
        view.addSpan(object:ForegroundColorSpan(Color.RED){})
    }
}

/* 토요일 날짜의 색상을 설정하는 클래스 */
private class SaturdayDecorator : DayViewDecorator {
    override fun shouldDecorate(day: CalendarDay): Boolean {
        val saturday = day.date.with(DayOfWeek.SATURDAY).dayOfMonth
        return saturday == day.day
    }

    override fun decorate(view: DayViewFacade) {
        view.addSpan(object:ForegroundColorSpan(Color.BLUE){})
    }
}

SelectedMonthDecorator의 경우 매월 1일 시작 요일도 다르고 일(Day) 수도 다르기 때문에 월(Month)이 변경될 때마다 갱신해 현재 선택된 월과 day.month가 다른 경우에는 text40(회색)으로 날짜의 색상을 변경해 줍니다.


캘린더 기능을 구현하는 라이브러리가 굉장히 많지만 개발 목적에 맞는 적합한 라이브러리를 찾는데 어려웠고, Material CalenderView를 개발하면서도 관련 레퍼런스를 찾기 힘들었다.
대부분 비교적 간단한 샘플로 해당 캘린더를 소개하고 있지만 본인은 실제 서비스에 캘린더 기능을 붙이는 상황이라 완성하는데 더 오래 걸렸던 것 같다.

 

꽤 오래 삽질하면서 헤맸지만 완성하니 뿌듯하고 나름 잘 만든 것 같아 맘에 든다. 😄

728x90
반응형