[Android] Scroll Custom Calendar

KIMGEUNTAE·2023년 8월 23일
1

Android

목록 보기
4/8

월별 달력

스크롤이 되는 커스텀 달력을 작성해야 했기 때문에 연수를 하면서 작성하였던 커스텀 달력을 참고하면서 새롭게 달력을 작성 하였습니다.
스크롤 시 달이 변해야 하기 때문에 ViewPager2를 활용 하였고 ViewPager2 어댑터 아이템으로 RecyclerView를 넣어서 활용 했습니다.


object

날짜관련 작업은 오브젝트를 만들어서 수행하도록 하였습니다.

object Dates {

  fun generateDates(calendar: Calendar): List<Date> {
  
      val dates = mutableListOf<Date>()
      val cal = calendar.clone() as Calendar
      cal.set(Calendar.DAY_OF_MONTH, 1)

      // 달의 첫 번째 날의 요일 계산
      val firstDayOfWeek = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7

      // 전월의 마지막 일로 채우기
      cal.add(Calendar.DAY_OF_MONTH, -firstDayOfWeek)
      for (i in 0 until firstDayOfWeek) {
          dates.add(cal.time)
          cal.add(Calendar.DAY_OF_MONTH, 1)
      }

      // 현재 달의 일자 추가
      val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
      for (i in 0 until daysInMonth) {
          dates.add(cal.time)
          cal.add(Calendar.DAY_OF_MONTH, 1)
      }

      // 다음 달의 처음 일로 채우기
      val lastDayOfWeek = (cal.get(Calendar.DAY_OF_WEEK) - Calendar.MONDAY + 6) % 7
      val remainingDays = 6 - lastDayOfWeek

      // 마지막 날이 일요일이 아니거나, 마지막 날이 일요일이지만 다음 달 1일이 월요일이 아닌 경우에만 채우기
      if (lastDayOfWeek != 6 || (remainingDays == 0 && cal.get(Calendar.DAY_OF_MONTH) != 1)) {
          for (i in 0 until remainingDays) {
              dates.add(cal.time)
              cal.add(Calendar.DAY_OF_MONTH, 1)
          }
      }

      return dates
  }
  
}
  • calendar 객체를 기반으로 해당 달의 날짜 목록을 생성을 하며 현재 달의 첫 날이 무슨 요일인지 계산 하며 월요일을 시작일로 설정

  • 전월의 마지막 일로 채우기: 달력에서 현재 달의 첫 날이 시작하는 위치 전까지는 전월의 마지막 일자로 채우며 이 부분은 해당 월의 첫 주에서 비어 있는 일자를 채우는 역할을 함

  • 현재 달의 일자 추가: 현재 달의 모든 일자를 리스트에 추가

  • 다음 달의 처음 일로 채우기: 현재 달의 마지막 날이 일요일이 아니거나, 마지막 날이 일요일이지만 다음 달 1일이 월요일이 아닌 경우에 다음 달의 처음 일로 채우며 이 부분은 해당 월의 마지막 주에서 비어 있는 일자를 채우는 역할



날짜

우선 요일과 일별을 각각의 리사이클러뷰로 따로 만들어서 사용을 했습니다.
요일을 월요일부터 일요일까지 순서로 정해야 했기 때문에 각각의 리사이클러뷰로 하여 설정하였습니다.

그리고 이렇게하면 오버스크롤 때문에 동작이 이상 할 수 있는데 뷰페이저로 한번에 묶어서 사용 하기에 일별 부분의 리사이클러뷰를 오버스크롤 동작을 off하면 스크롤 시 자연스럽게 가능하도록 하였습니다.

RecyclerViewAdapter.kt

  • 요일을 표시하는 RecyclerView
class DayOfTheWeekAdapter(private val days: List<String>) : RecyclerView.Adapter<DayOfTheWeekAdapter.DayViewHolder>() {

    class DayViewHolder(private val binding: ItemDayoftheweekBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(day: String) {
            binding.dayTextOfWeek.text = day
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DayViewHolder {
        val binding = ItemDayoftheweekBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return DayViewHolder(binding)
    }


    override fun onBindViewHolder(holder: DayViewHolder, position: Int) {
        holder.bind(days[position])
    }

    override fun getItemCount(): Int = days.size

	}
}


  • 일별 날짜를 표시하는 RecyclerView
class CalendarAdapter(private val dates: List<Date?>, currentMonth: Int) : RecyclerView.Adapter<CalendarAdapter.ViewHolder>() {

    private val thisMonth = currentMonth

    inner class ViewHolder(private val binding: CalendarItemBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(date: Date) {
            val calendar = Calendar.getInstance()
            calendar.time = date
            val month = calendar.get(Calendar.MONTH)
            val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK)

            if (month != thisMonth) {
                binding.dayText.setTextColor(Color.LTGRAY)
            } else {
                when (dayOfWeek) {
                    Calendar.SATURDAY -> {
                        binding.dayText.setTextColor(Color.BLUE)
                    }
                    Calendar.SUNDAY -> {
                        binding.dayText.setTextColor(Color.RED)
                    }
                    else -> {
                        binding.dayText.setTextColor(Color.BLACK)
                    }
                }
            }

            binding.dayText.text = SimpleDateFormat("d", Locale.getDefault()).format(date)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding =
            CalendarItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(dates[position]!!)
    }

    override fun getItemCount(): Int {
        return dates.size
    }
}
  • 요일에 따라 텍스트 색상을 변경
    토요일은 파란색, 일요일은 빨간색, 현재 달이 아닌 날짜는 회색으로 표시


ViewPager2

ViewPagerAdapter

class CalendarPagerAdapter(
    private val datesList: List<List<Date>>,
    private val currentMonth: Int
) : RecyclerView.Adapter<CalendarPagerAdapter.ViewHolder>() {

    inner class ViewHolder(val binding: CalendarPageBinding) : RecyclerView.ViewHolder(binding.root) {
        val calendarRecyclerView: RecyclerView = binding.calendarViewPager
        val dayOfTheWeekRecyclerView: RecyclerView = binding.dayOfTheWeekRecyclerView
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = CalendarPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {

        val dates = datesList[position]
        val adapter = CalendarAdapter(dates, currentMonth)

        holder.calendarRecyclerView.adapter = adapter
        holder.calendarRecyclerView.layoutManager = GridLayoutManager(holder.itemView.context, 7)


        val daysOfWeek = listOf("월", "화", "수", "목", "금", "토", "일")
        val dayOfWeekAdapter = DayOfTheWeekAdapter(daysOfWeek)
        holder.dayOfTheWeekRecyclerView.adapter = dayOfWeekAdapter
        holder.dayOfTheWeekRecyclerView.layoutManager = GridLayoutManager(holder.itemView.context, 7)

    }

    override fun getItemCount(): Int {
        return datesList.size
    }
}


  • 여러 달력 페이지를 관리하는 RecyclerView의 어댑터
    각 페이지에는 일자를 표시하는 달력과 요일을 표시하는 부분이 있으며, CalendarAdapter와 DayOfTheWeekAdapter를 사용하여 구현
  • datesList: 각 페이지에 표시할 날짜의 리스트들을 담은 리스트
    currentMonth: 현재 표시하고 있는 달을 나타내는 값

  • ViewHolder: 각 페이지에서 일자와 요일을 담음

  • override 메서드: 각 RecyclerView에 Adapter를 설정하고, 해당 월의 날짜 데이터를 연결하고 GridLayoutManager를 사용하여 달력을 그리드 형식으로 표시

여러 개의 페이지를 표시할 수 있는 어댑터가 만들어지며, 각 페이지는 해당 월의 날짜와 요일을 표시합니다. 이를 통해 사용자는 여러 개의 달력 페이지를 스와이프하여 넘길 수 있게 됨


그리고
calendar_page.xml

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/dayOfTheWeek_recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/calendarViewPager"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:overScrollMode="never"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/dayOfTheWeek_recyclerView" />

    </androidx.constraintlayout.widget.ConstraintLayout>

android:overScrollMode="never"에 오버스크롤을 off해서 스크롤시 자연스러운 동작을 연출 할 수가 있음



View

Fragment

class HomeFragment : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {

    private var calendar = Calendar.getInstance()
    private val startCalendar: Calendar = Calendar.getInstance().apply {
        time = Date()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        startCalendar.time = calendar.time

        binding.calendarViewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL
        binding.calendarViewPager.registerOnPageChangeCallback(object :
            ViewPager2.OnPageChangeCallback() {
            override fun onPageSelected(position: Int) {
                calendar.set(Calendar.MONTH, calendar.get(Calendar.MONTH) - 12 + position)
                updateCalendar()
            }
        })

        updateCalendar() // 초기 달력 업데이트

    }

    private fun updateCalendar() {
        val year = calendar.get(Calendar.YEAR)
        val month = calendar.get(Calendar.MONTH)
        binding.yearMonthTextView.text = "${year}${month + 1}월"

        val months = generateMonths(calendar)
        val pagerAdapter = CalendarPagerAdapter(months, month)
        binding.calendarViewPager.adapter = pagerAdapter
        binding.calendarViewPager.setCurrentItem(months.size / 2, false)
    }


    private fun generateMonths(calendar: Calendar): List<List<Date>> {

        val months = mutableListOf<List<Date>>()

        for (i in -12..12) {
            val cal = calendar.clone() as Calendar
            cal.add(Calendar.MONTH, i)
            months.add(Dates.generateDates(cal))
        }

        return months
    }


}
  1. calendar : 현재 선택된 달력 정보를 담은 인스턴스
    startCalendar : 초기 달력 상태를 저장하는 데 사용

  2. ViewPager2 설정: 수평 방향으로 스크롤되는 달력 페이지를 위한 ViewPager2를 설정, 페이지가 바뀔 때마다 선택된 월을 업데이트하고 달력을 갱신

  3. updateCalendar 메서드:현재 선택된 연도와 월을 텍스트 뷰에 표시하며 generateMonths를 호출하여 달력의 여러 페이지를 생성하고, 이를 CalendarPagerAdapter에 연결합니다.

  4. generateMonths 메서드:
    주어진 달력을 기준으로 이전 12개월과 다음 12개월의 달력 데이터를 생성 (총 25개월의 달력 데이터)
    각 월별 달력 데이터는 Dates.generateDates를 통해 생성되며 결과적으로 달력 뷰를 제공하며 사용자가 월별로 탐색하고 특정 달을 선택하게 할 수 있는 기능



정리

스크롤을 하지 않고 버튼으로 월이 바뀌는 커스텀 달력은 연수 때 해봐서 어렵지 않게 간단하게 할 줄 알았으나 스크롤 기능을 추가 해야 했기 때문에 각 요일과 날짜를 분리해서 각각의 리사이클러뷰를 만들어 연결하게 했고 뷰페이저2 어댑터의 아이템을 리사이클러뷰로 하면서 스크롤이 가능하도록 하였습니다.




깃허브 : https://github.com/GEUN-TAE-KIM/CustomCalendar_Sample.git


profile
Study Note

0개의 댓글