[Android / Kotlin] Material Calendar

Subeen·2024년 3월 22일
0

Android

목록 보기
71/71

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

profile
개발 공부 기록 🌱

0개의 댓글