[Android / Kotlin] Material Calendar

Subeen·2024년 3월 22일
0

Android

목록 보기
71/73

캘린더를 직접 만들어서 사용하려 했지만 캘린더가 스크롤 되어 보여지는 월이 달라질 때, 현재의 월이 아닌 날짜를 클릭 했을 때 등 원하는 동작을 구현하는 데 시간이 많이 소요될 것 같아 라이브러리를 찾아보게 되었다.
캘린더 커스텀이나 해당 날짜에 등록 된 일정이 있을 경우 캘린더에 일정을 표시 하는 부분이 가능한 라이브러리를 찾아봤을 때 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를 사용하기 위해 데코레이터를 생성하는 객체

/**
 * 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
            }
        }
    }
}

MaterialCalendarFragment

@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, 주말 설정)

Github 주소
전체 코드가 포함된 깃허브 주소 입니다.
포트폴리오 용도로 개발한거라 부족한 필요한 부분이 많지만 조금이나마 도움이 되길 바라며 깃허브 링크 공유 합니다.
참고용으로 봐주세요 🐣

profile
개발 공부 기록 🌱

8개의 댓글

comment-user-thumbnail
2024년 8월 1일

선생님 전체 코드 혹시 올려놓으신 곳 있으시면 보고 싶습니다

1개의 답글
comment-user-thumbnail
2024년 8월 12일

메일보냈는데 확인부탁드립니다

답글 달기
comment-user-thumbnail
2024년 10월 5일

저역시 전체코드 혹시 볼수있을까요? 좋은 예제 감사합니다.

답글 달기
comment-user-thumbnail
2024년 11월 20일

본 캘린더들 중에 너무 이쁩니다... 전체코드가 궁금한데 저도 볼 수있을까요?
조심스레 이메일남겨봅니다.. ladyliki@naver.com

답글 달기
comment-user-thumbnail
2024년 11월 25일

혹시 저도 전체 코드 받아볼 수 있을까요.... MaterialCalendarView 독학하는 데에 너무 어려워서 참고하고싶습니다..ㅠㅠ bbk990721@gmail.com입니다...!

답글 달기
comment-user-thumbnail
3일 전

저도 전체코드 한번 부탁드립니다. 깃허브에 내용을 보고 하려는데 activity_main.xml 에 추가하는 부분에서 계속 오류가 발생하네요. 공부하는데 큰 도움이 될 것 같습니다. tgwj123@gmail.com

1개의 답글