캘린더를 직접 만들어서 사용하려 했지만 캘린더가 스크롤 되어 보여지는 월이 달라질 때, 현재의 월이 아닌 날짜를 클릭 했을 때 등 원하는 동작을 구현하는 데 시간이 많이 소요될 것 같아 라이브러리를 찾아보게 되었다.
캘린더 커스텀이나 해당 날짜에 등록 된 일정이 있을 경우 캘린더에 일정을 표시 하는 부분이 가능한 라이브러리를 찾아봤을 때 Material CalendarView가 적절한 것 같아 해당 라이브러리를 사용하여 캘린더를 구현하게 되었다.
app 수준 build.gradle에 라이브러리의 의존성을 추가한다.
dependencies {
...
implementation("com.github.prolificinteractive:material-calendarview:2.0.1")
}
xml에 MaterialCalendarView를 추가한다.
<com.prolificinteractive.materialcalendarview.MaterialCalendarView
android:id="@+id/calendar_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="12dp"
android:theme="@style/CalenderViewCustom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:mcv_dateTextAppearance="@style/CalenderViewDateCustomText"
app:mcv_firstDayOfWeek="sunday"
app:mcv_leftArrow="@drawable/ic_arrow_back"
app:mcv_rightArrow="@drawable/ic_arrow_forward"
app:mcv_selectionMode="single"
app:mcv_showOtherDates="all"
app:mcv_weekDayTextAppearance="@style/CalenderViewWeekCustomText" />
✨ MaterialCalendarView의 속성
- android:theme="@style/CalenderViewCustom"
- MaterialCalendarView의 테마를 설정한다.
- app:mcv_dateTextAppearance="@style/CalenderViewDateCustomText"
- 날짜 텍스트의 스타일을 설정하는 부분으로 MaterialCalendarView 내에서 날짜를 표시할 때 사용된다.
- app:mcv_firstDayOfWeek="sunday"
- 캘린더의 첫 요일을 설정하는 부분으로 일주일의 시작을 월요일로 설정한다.
- app:mcv_leftArrow="@drawable/ic_arrow_back"
- MaterialCalendarView에서 이전 달로 이동하는 화살표의 아이콘을 설정한다.
- app:mcv_rightArrow="@drawable/ic_arrow_forward"
- MaterialCalendarView에서 다음 달로 이동하는 화살표의 아이콘을 설정한다.
- app:mcv_selectionMode="single"
- 날짜 선택 모드를 설정하는 부분으로 단일 선택 모드를 사용하여 사용자가 하나의 날짜만 선택할 수 있게 한다.
- none : 선택이 비활성화된다.
- single : 단일 날짜를 선택할 수 있으며 다른 날짜를 선택하면 이전 선택이 해제된다.
- range : 범위를 지정하여 연속적인 날짜 범위를 선택할 수 있으며 시작 날짜와 끝 날짜를 선택하면 그 사이의 날짜가 선택된다.
- multiple : 여러 개의 날짜를 선택할 수 있다.
- app:mcv_showOtherDates="all"
- MaterialCalendarView에서 현재 월의 이전 달과 다음 달의 날짜를 표시할지의 여부를 설정하는 부분으로 이전 달과 다음 달의 모든 날짜를 표시하도록 설정한다.
- none : 현재 월의 날짜만 표시된다.
- out_of_range : 현재 월에 해당하는 범위를 벗어나는 다른 월의 날짜를 숨긴다.
- all : 모든 달의 날짜가 표시된다.
- app:mcv_weekDayTextAppearance="@style/CalenderViewWeekCustomText"
- 요일 텍스트의 스타일을 정의하는 부분으로 MaterialCalendarView 내에서 요일을 표시할 때 사용된다.
캘린더에 적용 된 스타일
<!-- 캘린더의 날짜(Day)의 스타일 설정 -->
<style name="CalenderViewCustom" parent="Theme.AppCompat">
<item name="android:textColor">@color/black</item>
<item name="android:textStyle">bold</item>
<item name="fontFamily">@font/roboto_regular</item>
</style>
<!-- 캘린더의 날짜(Day)의 스타일 설정 -->
<style name="CalenderViewDateCustomText" parent="android:TextAppearance.DeviceDefault.Small">
<item name="android:textColor">@color/black</item>
<item name="fontFamily">@font/roboto_regular</item>
</style>
<!-- 캘린더의 요일에 적용되는 스타일 -->
<style name="CalenderViewWeekCustomText" parent="android:TextAppearance.DeviceDefault.Small">
<item name="android:textColor">@color/black</item>
</style>
<!-- 연, 월을 표시하는 헤더에 적용되는 스타일 -->
<style name="CalendarWidgetHeader">
<item name="android:textSize">20sp</item>
<item name="android:textColor">@color/main_color</item>
<item name="fontFamily">@font/roboto_regular</item>
</style>
/**
* MaterialCalendarView를 사용하기 위해 다양한 데코레이터를 생성하는 객체
*/
object CalendarDecorators {
/**
* 날짜를 표시하는 데 사용되는 요소를 정의하기 위한 함수
* @param context 리소스에 액세스하기 위해 사용되는 컨텍스트
* @return DayViewDecorator 객체
*/
fun dayDecorator(context: Context): DayViewDecorator {
return object : DayViewDecorator {
private val drawable = ContextCompat.getDrawable(context, R.drawable.calendar_selector)
override fun shouldDecorate(day: CalendarDay): Boolean = true
override fun decorate(view: DayViewFacade) {
view.setSelectionDrawable(drawable!!)
}
}
}
/**
* 현재 날짜를 다른 날짜와 구별하기 위해 스타일이나 색상을 적용하기 위한 함수
* @param context 리소스에 액세스하기 위해 사용되는 컨텍스트
* @return DayViewDecorator 객체
*/
fun todayDecorator(context: Context): DayViewDecorator {
return object : DayViewDecorator {
private val backgroundDrawable =
ContextCompat.getDrawable(context, R.drawable.calendar_circle_today)
private val today = CalendarDay.today()
override fun shouldDecorate(day: CalendarDay?): Boolean = day == today
override fun decorate(view: DayViewFacade?) {
view?.apply {
setBackgroundDrawable(backgroundDrawable!!)
addSpan(
ForegroundColorSpan(
ContextCompat.getColor(
context,
R.color.main_color
)
)
)
}
}
}
}
/**
* 현재 선택된 날 이외의 다른 달의 날짜의 모양을 변경하기 위한 함수
* @param context 리소스에 액세스하기 위해 사용되는 컨텍스트
* @param selectedMonth 현재 선택 된 달
* @return DayViewDecorator 객체
*/
fun selectedMonthDecorator(context: Context, selectedMonth: Int): DayViewDecorator {
return object : DayViewDecorator {
override fun shouldDecorate(day: CalendarDay): Boolean = day.month != selectedMonth
override fun decorate(view: DayViewFacade) {
view.addSpan(
ForegroundColorSpan(
ContextCompat.getColor(
context,
R.color.enabled_date_color
)
)
)
}
}
}
/**
* 일요일을 강조하는 데코레이터를 생성하기 위한 함수
* @return DayViewDecorator 객체
*/
fun sundayDecorator(): DayViewDecorator {
return object : DayViewDecorator {
override fun shouldDecorate(day: CalendarDay): Boolean {
val calendar = Calendar.getInstance()
calendar.set(day.year, day.month - 1, day.day)
return calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY
}
override fun decorate(view: DayViewFacade) {
view.addSpan(ForegroundColorSpan(Color.BLACK))
}
}
}
/**
* 토요일을 강조하는 데코레이터를 생성하기 위한 함수
* @return DayViewDecorator 객체
*/
fun saturdayDecorator(): DayViewDecorator {
return object : DayViewDecorator {
override fun shouldDecorate(day: CalendarDay): Boolean {
val calendar = Calendar.getInstance()
calendar.set(day.year, day.month - 1, day.day)
return calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY
}
override fun decorate(view: DayViewFacade) {
view.addSpan(ForegroundColorSpan(Color.BLACK))
}
}
}
/**
* 이벤트가 있는 날짜를 표시하는 데코레이터를 생성하기 위한 함수
* @param context 리소스에 액세스하기 위해 사용되는 컨텍스트
* @param scheduleList 이벤트 날짜를 포함하는 스케줄 목록
* @return DayViewDecorator 객체
*/
fun eventDecorator(context: Context, scheduleList: List<ScheduleModel>): DayViewDecorator {
return object : DayViewDecorator {
private val eventDates = HashSet<CalendarDay>()
init {
// 스케줄 목록에서 이벤트가 있는 날짜를 파싱하여 이벤트 날짜 목록에 추가한다.
scheduleList.forEach { schedule ->
schedule.startDate?.let { startDate ->
val startDateTime = LocalDate.parse(
startDate,
DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm")
)
val endDateTime = schedule.endDate?.let { endDate ->
LocalDate.parse(
endDate,
DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm")
)
} ?: startDateTime
val datesInRange = getDateRange(startDateTime, endDateTime)
eventDates.addAll(datesInRange)
}
}
}
override fun shouldDecorate(day: CalendarDay?): Boolean {
return eventDates.contains(day)
}
override fun decorate(view: DayViewFacade) {
// 이벤트가 있는 날짜에 점을 추가하여 표시한다.
view.addSpan(DotSpan(10F, ContextCompat.getColor(context, R.color.main_color)))
}
/**
* 시작 날짜와 종료 날짜 사이의 모든 날짜를 가져오는 함수
* @param startDate 시작 날짜
* @param endDate 종료 날짜
* @return 날짜 범위 목록
*/
private fun getDateRange(startDate: LocalDate, endDate: LocalDate): List<CalendarDay> {
val datesInRange = mutableListOf<CalendarDay>()
var currentDate = startDate
while (!currentDate.isAfter(endDate)) {
datesInRange.add(
CalendarDay.from(
currentDate.year,
currentDate.monthValue,
currentDate.dayOfMonth
)
)
currentDate = currentDate.plusDays(1)
}
return datesInRange
}
}
}
}
@AndroidEntryPoint
class MaterialCalendarFragment : Fragment() {
private var _binding: FragmentMaterialCalendarBinding? = null
private val binding get() = _binding!!
private val viewModel: CalendarViewModel by viewModels()
private val sharedViewModel: GroupSharedViewModel by activityViewModels()
private val scheduleListAdapter: ScheduleListAdapter by lazy {
ScheduleListAdapter(
onClickItem = { item ->
onScheduleItemClick(item)
}
)
}
// 데코레이터 변수를 나중에 초기화 하기 위해 lateinit 키워드로 선언한다.
private lateinit var dayDecorator: DayViewDecorator
private lateinit var todayDecorator: DayViewDecorator
private lateinit var selectedMonthDecorator: DayViewDecorator
private lateinit var sundayDecorator: DayViewDecorator
private lateinit var saturdayDecorator: DayViewDecorator
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentMaterialCalendarBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
initViewModel()
}
private fun initView() = with(binding) {
recyclerViewSchedule.adapter = scheduleListAdapter
with(calendarView) {
// 데코레이터 초기화
dayDecorator = CalendarDecorators.dayDecorator(requireContext())
todayDecorator = CalendarDecorators.todayDecorator(requireContext())
sundayDecorator = CalendarDecorators.sundayDecorator()
saturdayDecorator = CalendarDecorators.saturdayDecorator()
selectedMonthDecorator = CalendarDecorators.selectedMonthDecorator(
requireContext(),
CalendarDay.today().month
)
// 캘린더뷰에 데코레이터 추가
addDecorators(
dayDecorator,
todayDecorator,
sundayDecorator,
saturdayDecorator,
selectedMonthDecorator
)
// 월 변경 리스너 설정
setOnMonthChangedListener { widget, date ->
// 캘린더 위젯에서 현재 선택된 날짜를 모두 선택 해제한다.
widget.clearSelection()
// 캘린더 위젯에 적용된 모든 데코레이터를 제거한다.
removeDecorators()
// 데코레이터가 제거되고 위젯이 다시 그려지도록 한다.
invalidateDecorators()
// 새로운 월에 해당하는 데코레이터를 생성하여 selectedMonthDecorator에 할당한다.
selectedMonthDecorator =
CalendarDecorators.selectedMonthDecorator(requireContext(), date.month)
// 새로 생성한 데코레이터를 캘린더 위젯에 추가한다.
addDecorators(
dayDecorator,
todayDecorator,
sundayDecorator,
saturdayDecorator,
selectedMonthDecorator
)
// 현재 월의 첫 번째 날을 나타내는 CalendarDay 객체를 생성한다.
val clickedDay = CalendarDay.from(date.year, date.month, 1)
// 캘린더 위젯에서 clickedDay를 선택하도록 지정한다.
widget.setDateSelected(clickedDay, true)
// 변경 된 일에 해당하는 일정 목록을 필터링하고 업데이트한다.
viewModel.filterScheduleListByDate(date.toLocalDate())
// 변경 된 월에 해당하는 일정 목록을 필터링하고 업데이트한다.
viewModel.filterDataByMonth(date.toLocalDate())
}
// 요일 텍스트 포메터 설정
setWeekDayFormatter(ArrayWeekDayFormatter(resources.getTextArray(R.array.custom_weekdays)))
// 헤더 텍스트 모양 설정
setHeaderTextAppearance(R.style.CalendarWidgetHeader)
// 범위 선택 리스너 설정
setOnRangeSelectedListener { widget, dates -> }
// 날짜 변경 리스너 설정
setOnDateChangedListener { widget, date, selected ->
val localDate = date.toLocalDate()
viewModel.filterScheduleListByDate(localDate)
}
}
}
private fun initViewModel() {
viewModel.apply {
lifecycleScope.launch {
filteredByDate.collect {
scheduleListAdapter.submitList(it.list)
// 선택 된 날짜의 요일 텍스트 설정
val dayOfWeekString = when (it.date?.dayOfWeek) {
DayOfWeek.MONDAY -> "월"
DayOfWeek.TUESDAY -> "화"
DayOfWeek.WEDNESDAY -> "수"
DayOfWeek.THURSDAY -> "목"
DayOfWeek.FRIDAY -> "금"
DayOfWeek.SATURDAY -> "토"
DayOfWeek.SUNDAY -> "일"
else -> ""
}
binding.tvDate.text =
"${it.date?.monthValue}.${it.date?.dayOfMonth}. $dayOfWeekString"
}
}
lifecycleScope.launch {
uiState.collect { uiState ->
}
}
lifecycleScope.launch {
filteredByMonth.collect { uiState ->
// 월이 변경 될 때 이벤트 데코레이터 추가
val eventDecorator =
CalendarDecorators.eventDecorator(requireContext(), uiState)
binding.calendarView.addDecorator(eventDecorator)
}
}
}
sharedViewModel.apply {
lifecycleScope.launch {
key.collect { it?.let { key -> viewModel.setEntity(key) } }
}
}
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
private fun onScheduleItemClick(item: ScheduleModel) {
sharedViewModel.setScheduleKey(item.key!!)
sharedViewModel.setScheduleEntryType(CalendarEntryType.DETAIL)
parentFragmentManager.beginTransaction().apply {
setCustomAnimations(
R.anim.enter_animation,
R.anim.exit_animation,
R.anim.enter_animation,
R.anim.exit_animation
)
replace(
R.id.fg_activity_group,
RegisterScheduleFragment()
)
addToBackStack(null)
commit()
}
}
private fun CalendarDay.toLocalDate(): LocalDate {
return LocalDate.of(year, month, day)
}
}
참조
Material CalendarView - 캘린더 제대로 커스텀하기(with. Range, Select, OtherDays, 주말 설정)
선생님 전체 코드 혹시 올려놓으신 곳 있으시면 보고 싶습니다