본문 바로가기
Floney

[Floney] 커스텀 기간 설정 캘린더 직접 구현 (라이브러리 X)

by 박매트 2024. 4. 15.

* 직접 구현했는데, Material CalendarView를 사용해야 할 것 같아서 쓰는 글 (Date Range는 커스텀하기 어렵다.)

* 직접 구현하시는 분이 있다면 도움이 될까 싶어 올려보는 글

 

구현 영상

 

 

선택된 날짜에는 회색 표시가 반만 되었으면 좋겠는데, 거기까지는 생각을 못한 것 같다.

recyclerview로 구현을 했고, 코드가 굉장히 별로라고 생각이 되어서ㅎ 좋은 코드는 아니지만 올려봐야겠다.

 

 

 

++ 진짜.. 원초적인 방법으로 

이렇게 반반 나눠서 변수 설정해줬다. .  😂

 

 

얼렁뚱땅 성공한 것 같은

 

 

우선 처음에, viewmodel에서 날짜 정보를 얻는 코드를 추가한다.

fun getInformDateMonth(){
        _calendar.value.set(Calendar.DAY_OF_MONTH, 1)
        generateCalendarDates()
        _getCalendarList.postValue(_getCalendarList.value)
    }

 

캘린더의 현재 날짜를 1일로 설정한 후,

현재 달을 기반으로 calendar에 보여줄 날짜를 생성한다.

 

 private fun generateCalendarDates() {
        // _calendar의 현재 상태를 저장
        val originalCalendar = _calendar.value.clone() as Calendar

        val periodCalendars = mutableListOf<PeriodCalendar>().apply {
            // 이전 달 데이터 리스트
            adjustToStartOfWeek(_calendar.value.clone() as Calendar)?.let { previousCalendars ->
                addAll(previousCalendars)
            }
            // 현재 달 데이터 리스트
            addAll(adjustNowCalendar(_calendar.value))

            // 다음 달 데이터 리스트
            adjustToEndOfWeek(_calendar.value.clone() as Calendar)?.let { nextCalendars ->
                addAll(nextCalendars)
            }
        }

        // 작업 완료 후 _calendar를 원래 상태로 복원
        _calendar.value = originalCalendar
        _getCalendarList.value = periodCalendars

    }

 

이때 현재 날짜를 담고 있는 calendar의 값이 변경되면 안되니까, clone을 해서 따로 calendar를 만들고,

4월 달력 기준으로 3.31(이전달에 해당하는 기간) , 4.1~4.30 (현재달에 해당하는 기간), 5.1~5.4(다음달에 해당하는 기간)이 달력에 보여야 한다. 

 

또한, 이전달, 다음달의 경우는 검정색 글씨색깔이 아니라 회색글씨로 보여야 하기 때문에 함수를 각각 나눠서 날짜를 구했다.. 😂 다 구한 후 calendar 에 보여질 날짜 객체들을 리스트로 담아 그 값을 postvalue로 livedata에 전달을 해서 값이 바뀌는 원리이다.

 

private fun adjustToStartOfWeek(calendar: Calendar): MutableList<PeriodCalendar> {

        val periodCalendars = mutableListOf<PeriodCalendar>()

        while (calendar.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) {
            calendar.add(Calendar.DATE, -1)
            periodCalendars.add(generatePeriodCalendar(calendar, false, false, false))
        }

        return periodCalendars.asReversed()
    }

    private fun adjustToEndOfWeek(calendar: Calendar): MutableList<PeriodCalendar> {
        val periodCalendars = mutableListOf<PeriodCalendar>()
        // 해당 날짜가 속한 주의 토요일까지 이동하지만, 다음 달로 넘어가지 않도록 확인
        while (calendar.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) {
            periodCalendars.add(generatePeriodCalendar(calendar, false, false, false))
            calendar.add(Calendar.DATE, 1)
        }
        return periodCalendars
    }

    private fun adjustNowCalendar(calendar: Calendar): MutableList<PeriodCalendar> {
        val periodCalendars = mutableListOf<PeriodCalendar>()

        val currentMonth = calendar.get(Calendar.MONTH)

        while (calendar.get(Calendar.MONTH) == currentMonth) {
            periodCalendars.add(generatePeriodCalendar(calendar, true, false, false))
            calendar.add(Calendar.DATE, 1)
        }
        return periodCalendars
    }

 

 

이런 식으로 이전 달 부분, 다음 달 부분, 현재 달 부분에 해당하는 날짜 객체들을 리스트로 만들어서 합쳐주었다.

 

여기까지 하면, 초기 캘린더 날짜 불러오기까지는 가능.

 

이제 recyclerview의 item 클릭에 따라서, 시작, 끝 지점 인 부분에는 클릭된 동그라미 형태가 보여져야 하고, 시작 끝 지점 사이에 있는 날짜들은 회색배경으로 표시가 되어야 한다.

 

1. 첫번째로 클릭한 item이 시작 지점이냐, 끝 지점이냐를 구분하기 위해 startDate, endDate를 변수로 두어 관리하였다.

2. 첫번째로 클릭한 item은 startDate가 되는 것이고, 두번째로 클릭한 아이템은 endDate가 되는 것이다.

3. 그리고 첫번째 item, 두번째 item이 모두 값이 있는 경우, 다른 item을 또다시 눌렀다면 모든 게 초기화가 되고 그 item이 또 startDate가 되어야 한다는 가설을 세웠다.

 

fun updateAdjustPeriod(item: PeriodCalendar) {
        val clickItem = Calendar.getInstance().apply {
            set(item.year, item.month - 1, item.day) // 월은 0부터 시작하므로 -1 해줍니다.
        }

        if (_startDate.value != null && _endDate.value != null) {
            // startDate와 endDate가 모두 존재하는 경우
            _startDate.value = clickItem
            _endDate.value = null // endDate를 리셋합니다.
        } else if (_startDate.value != null && clickItem.before(_startDate.value)) {
            // startDate가 존재하고, item이 startDate보다 이전 날짜인 경우
            _endDate.value = _startDate.value // startDate를 endDate로 설정합니다.
            _startDate.value = clickItem
        } else if (_startDate.value != null && clickItem.after(_startDate.value)) {
            // startDate가 존재하고, item이 startDate보다 이후 날짜인 경우
            _endDate.value = clickItem
        } else if (_startDate.value != null && areCalendarsEqual(clickItem,_startDate.value!!)) {
            // startDate가 존재하고, 클릭된 item이 같은 startDate인 경우.
            _startDate.value = null
        } else {
            // startDate가 존재하지 않는 경우
            _startDate.value = clickItem
        }
        updateAdjustCalendar()

    }

 

이렇게 가설을 세우다 보니 매우,, 코드 가독성이 떨어지는 코드가 탄생하고 말았다.

 

이제 recyclerview 각각 들어가는 item 객체의 변수 값에 따라, 보여지는 걸 달리하기로 생각했다.

 

data class PeriodCalendar(
    val year: Int,
    val month: Int,
    val day: Int,
    val isMonth : Boolean,
    val isClick: Boolean,
    val isRange: Boolean
)

 

이런식으로 설정을 하였다.

  • item의 년도, 달, 일 -> 쉽게 날짜별로 이전 날짜인지, 이후 날짜인지 구분하고자
  • 이번달 item여부(검정색 글씨, 아니면 회색 글씨)
  • 클릭된 여부(동그라미 표시)
  • 시작지점과 끝지점 사이에 해당되는 지 여부(회색 배경)

 

fun updateAdjustCalendar() {
        val updatedList = _getCalendarList.value?.map { calendarItem ->
            val check = Calendar.getInstance().apply {
                set(calendarItem.year, calendarItem.month - 1, calendarItem.day) // 월은 0부터 시작하므로 -1 해줍니다.
            }
            if (_startDate.value != null && _endDate.value != null) {
                if (areCalendarsEqual(check, _startDate.value!!) || areCalendarsEqual(check, _endDate.value!!)) {
                    calendarItem.copy(isClick = true, isRange = true)
                } else if (check.after(_startDate.value) && check.before(_endDate.value)) {
                    calendarItem.copy(isRange = true, isClick = false)
                } else {
                    calendarItem.copy(isRange = false, isClick = false)
                }
            } else if (_startDate.value != null && areCalendarsEqual(check, _startDate.value!!)) {
                calendarItem.copy(isClick = true)
            } else if (_startDate.value != null && !areCalendarsEqual(check, _startDate.value!!)) {
                calendarItem.copy(isClick = false, isRange = false)
            }   else {
                calendarItem.copy(isClick = false, isRange = false)
            }
        }
        return _getCalendarList.postValue(updatedList!!)
    }

 

그렇게 startDate, endDate 값에 따라 값들을 각자 다르게 주어 날짜 배열을 만들어서, postValue로 livedate에 전달해서 날짜 디자인이 바뀌는 형태이다.

 

 

전체 코드를 올려보자면 이렇다

init {
        getInformDateMonth()
        getFormatDateMonth()
    }
    // 날짜 정보 얻기
    fun getInformDateMonth(){
        _calendar.value.set(Calendar.DAY_OF_MONTH, 1)
        generateCalendarDates()
        _getCalendarList.postValue(_getCalendarList.value)
    }

    // 이전 월 클릭
    fun onClickPreviousMonth() {
        viewModelScope.launch {
            updateCalendarMonth(-1)
            generateCalendarDates() // 날짜 생성
            updateAdjustCalendar() // 값 조정
            _clickedPreviousMonth.emit(getFormatDateMonth())

        }
    }

    // 다음 월 클릭
    fun onClickNextMonth() {
        viewModelScope.launch {
            updateCalendarMonth(1)
            generateCalendarDates()
            updateAdjustCalendar()
            _clickedPreviousMonth.emit(getFormatDateMonth())
        }
    }

    // 캘린더 값 변경
    private fun updateCalendarMonth(value: Int) {
        _calendar.value.set(Calendar.DAY_OF_MONTH, 1)
        _calendar.value.add(Calendar.MONTH, value)
    }

    private fun generateCalendarDates() {
        // _calendar의 현재 상태를 저장
        val originalCalendar = _calendar.value.clone() as Calendar

        val periodCalendars = mutableListOf<PeriodCalendar>().apply {
            // 이전 달 데이터 리스트
            adjustToStartOfWeek(_calendar.value.clone() as Calendar)?.let { previousCalendars ->
                addAll(previousCalendars)
            }
            // 현재 달 데이터 리스트
            addAll(adjustNowCalendar(_calendar.value))

            // 다음 달 데이터 리스트
            adjustToEndOfWeek(_calendar.value.clone() as Calendar)?.let { nextCalendars ->
                addAll(nextCalendars)
            }
        }

        // 작업 완료 후 _calendar를 원래 상태로 복원
        _calendar.value = originalCalendar
        _getCalendarList.value = periodCalendars

    }
    private fun generatePeriodCalendar(calendar: Calendar, isMonth: Boolean, isClick: Boolean, isRange : Boolean): PeriodCalendar {
        return PeriodCalendar(
            year = calendar.get(Calendar.YEAR),
            month = calendar.get(Calendar.MONTH) + 1, // Calendar.MONTH는 0부터 시작하므로 +1
            day = calendar.get(Calendar.DAY_OF_MONTH),
            isMonth = isMonth,
            isClick = isClick,
            isRange = isRange
        )
    }

    private fun adjustToStartOfWeek(calendar: Calendar): MutableList<PeriodCalendar> {

        val periodCalendars = mutableListOf<PeriodCalendar>()

        while (calendar.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) {
            calendar.add(Calendar.DATE, -1)
            periodCalendars.add(generatePeriodCalendar(calendar, false, false, false))
        }

        return periodCalendars.asReversed()
    }

    private fun adjustToEndOfWeek(calendar: Calendar): MutableList<PeriodCalendar> {
        val periodCalendars = mutableListOf<PeriodCalendar>()
        // 해당 날짜가 속한 주의 토요일까지 이동하지만, 다음 달로 넘어가지 않도록 확인
        while (calendar.get(Calendar.DAY_OF_WEEK) != Calendar.SUNDAY) {
            periodCalendars.add(generatePeriodCalendar(calendar, false, false, false))
            calendar.add(Calendar.DATE, 1)
        }
        return periodCalendars
    }

    private fun adjustNowCalendar(calendar: Calendar): MutableList<PeriodCalendar> {
        val periodCalendars = mutableListOf<PeriodCalendar>()

        val currentMonth = calendar.get(Calendar.MONTH)

        while (calendar.get(Calendar.MONTH) == currentMonth) {
            periodCalendars.add(generatePeriodCalendar(calendar, true, false, false))
            calendar.add(Calendar.DATE, 1)
        }
        return periodCalendars
    }

    // 날짜 포멧 결과 가져오기
    fun getFormatDateMonth(): String {
        val date = SimpleDateFormat("yyyy-MM", Locale.getDefault()).format(_calendar.value.time)
        val showDate = date.substring(0, 7).replace("-", ".")
        _showDate.postValue(showDate)
        return date
    }
    fun updateAdjustPeriod(item: PeriodCalendar) {
        val clickItem = Calendar.getInstance().apply {
            set(item.year, item.month - 1, item.day) // 월은 0부터 시작하므로 -1 해줍니다.
        }

        if (_startDate.value != null && _endDate.value != null) {
            // startDate와 endDate가 모두 존재하는 경우
            _startDate.value = clickItem
            _endDate.value = null // endDate를 리셋합니다.
        } else if (_startDate.value != null && clickItem.before(_startDate.value)) {
            // startDate가 존재하고, item이 startDate보다 이전 날짜인 경우
            _endDate.value = _startDate.value // startDate를 endDate로 설정합니다.
            _startDate.value = clickItem
        } else if (_startDate.value != null && clickItem.after(_startDate.value)) {
            // startDate가 존재하고, item이 startDate보다 이후 날짜인 경우
            _endDate.value = clickItem
        } else if (_startDate.value != null && areCalendarsEqual(clickItem,_startDate.value!!)) {
            // startDate가 존재하고, 클릭된 item이 같은 startDate인 경우.
            _startDate.value = null
        } else {
            // startDate가 존재하지 않는 경우
            _startDate.value = clickItem
        }
        updateAdjustCalendar()

    }
    fun updateAdjustCalendar() {
        val updatedList = _getCalendarList.value?.map { calendarItem ->
            val check = Calendar.getInstance().apply {
                set(calendarItem.year, calendarItem.month - 1, calendarItem.day) // 월은 0부터 시작하므로 -1 해줍니다.
            }
            if (_startDate.value != null && _endDate.value != null) {
                if (areCalendarsEqual(check, _startDate.value!!) || areCalendarsEqual(check, _endDate.value!!)) {
                    calendarItem.copy(isClick = true, isRange = true)
                } else if (check.after(_startDate.value) && check.before(_endDate.value)) {
                    calendarItem.copy(isRange = true, isClick = false)
                } else {
                    calendarItem.copy(isRange = false, isClick = false)
                }
            } else if (_startDate.value != null && areCalendarsEqual(check, _startDate.value!!)) {
                calendarItem.copy(isClick = true)
            } else if (_startDate.value != null && !areCalendarsEqual(check, _startDate.value!!)) {
                calendarItem.copy(isClick = false, isRange = false)
            }   else {
                calendarItem.copy(isClick = false, isRange = false)
            }
        }
        return _getCalendarList.postValue(updatedList!!)
    }
    fun areCalendarsEqual(cal1: Calendar, cal2: Calendar): Boolean {
        return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
                cal1.get(Calendar.MONTH) == cal2.get(Calendar.MONTH) &&
                cal1.get(Calendar.DAY_OF_MONTH) == cal2.get(Calendar.DAY_OF_MONTH)
    }
    fun calendarToDateString(calendar: Calendar): String {
        val year = calendar.get(Calendar.YEAR)
        val month = calendar.get(Calendar.MONTH) + 1
        val day = calendar.get(Calendar.DAY_OF_MONTH)
        return String.format("%d.%02d.%02d", year, month, day)
    }

    fun getFormatPeriodDay(calendar: Calendar): String {
        val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time)
        return date
    }

    // 날짜 선택 완료
    fun onClickPeriodSelect(){
        settingFormatDate()
        viewModelScope.launch {
            val startDateString = startDate.value?.let { calendarToDateString(it) } ?: ""
            val endDateString = endDate.value?.let { calendarToDateString(it) } ?: ""

            val displayString = when {
                startDateString.isNotEmpty() && endDateString.isNotEmpty() -> {
                    if (startDate.value!!.get(Calendar.YEAR) == endDate.value!!.get(Calendar.YEAR)) {
                        "$startDateString - ${endDateString.substring(5)}"
                    } else {
                        "$startDateString - $endDateString"
                    }
                }
                startDateString.isNotEmpty() && endDateString.isEmpty() -> {
                    startDateString
                }
                else -> {
                    ""
                }
            }
            Timber.e("haha ${displayString}")
            _selectButton.emit(displayString)
        }
    }
    fun settingFormatDate()
    {
        val startDateFormatted = startDate.value?.let { getFormatPeriodDay(it) } ?: ""
        val endDateFormatted = endDate.value?.let { getFormatPeriodDay(it) } ?: startDateFormatted

        _startDateFormat.value = startDateFormatted
        _endDateFormat.value = endDateFormatted
    }

 

material calendar가 구현이 쉽지 않다면? 다시 돌아올지도 모르는 써두는 글이다. 파이티잉..

 

 

 

++ 우선.. 지금 Material View로 구현해봤는데, 선택한 시작, 끝 사이에 있는 디자인이 적용이 잘 안되어가지고 ^_^ 원래대로 우선 바꿔둘 예정이다.

@AndroidEntryPoint
class SettleUpPeriodRangeSelectBottomSheetFragment(private val onSelect: (String, String, String?) -> Unit) :
    BaseBottomSheetFragment<BottomSheetSettleUpPeriodSelectBinding, SettleUpPeriodRangeSelectViewModel>(R.layout.bottom_sheet_settle_up_period_select), UiPeriodSelectModel.OnItemClickListener {
    override fun onItemClick(item: PeriodCalendar) {
        viewModel.updateAdjustPeriod(item)
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        setUpUi()
        setUpCustomCalendar()
        setUpViewModelObserver()
    }

    private fun setUpUi() {
        binding.setVariable(BR.eventHolder, this@SettleUpPeriodRangeSelectBottomSheetFragment)
    }
    private fun setUpCustomCalendar()
    {
        // 요일을 한글로 보이게 설정 월..일 순서로 배치해서 캘린더에는 일..월 순서로 보이도록 설정
        binding.periodCalendarView.setWeekDayFormatter(ArrayWeekDayFormatter(resources.getTextArray(R.array.custom_weekdays)));

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

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

        // 날짜가 단일 선택되었을 때 리스너
        binding.periodCalendarView.setOnDateChangedListener { widget, date, selected ->

            if (!selected) {
                viewModel.settingDate( "", "")
            }
            else {
                viewModel.settingDate( date.date.toString(), "")
            }

        }

        val dayDecorator = DayDecorator(requireContext())
        var selectedMonthDecorator = SelectedMonthDecorator(CalendarDay.today().month)

        // 캘린더에 Decorator 추가
        binding.periodCalendarView.addDecorators(dayDecorator, selectedMonthDecorator)

        // 좌우 화살표 가운데의 연/월이 보이는 방식 지정
        binding.periodCalendarView.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.periodCalendarView.setOnMonthChangedListener { widget, date ->
            // 기존에 설정되어 있던 Decorators 초기화
            binding.periodCalendarView.removeDecorators()
            binding.periodCalendarView.invalidateDecorators()

            // Decorators 추가
            selectedMonthDecorator = SelectedMonthDecorator(date.month)
            binding.periodCalendarView.addDecorators(dayDecorator, selectedMonthDecorator)
        }
    }
    private fun setUpViewModelObserver() {
        repeatOnStarted {
            // 선택하기 buttonClick
            viewModel.selectButton.collect {
                Timber.e("nextPage $it")
                if(it != "") {
                    onSelect(viewModel.startDateFormat.value!!, viewModel.endDateFormat.value!!, it)
                    dismiss()
                }
                else{
                    dismiss()
                }
            }
        }
    }
    /* 선택된 날짜의 background를 설정하는 클래스 */
    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!!)
        }
    }

    /* 이번달에 속하지 않지만 캘린더에 보여지는 이전달/다음달의 일부 날짜를 설정하는 클래스 */
    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.grayscale6)))
        }
    }
}