스크롤이 되는 커스텀 달력을 작성해야 했기 때문에 연수를 하면서 작성하였던 커스텀 달력을 참고하면서 새롭게 달력을 작성 하였습니다.
스크롤 시 달이 변해야 하기 때문에 ViewPager2
를 활용 하였고 ViewPager2 어댑터 아이템으로 RecyclerView를 넣어서 활용 했습니다.
날짜관련 작업은 오브젝트를 만들어서 수행하도록 하였습니다.
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
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
}
}
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
}
}
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
}
}
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해서 스크롤시 자연스러운 동작을 연출 할 수가 있음
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
}
}
calendar
: 현재 선택된 달력 정보를 담은 인스턴스
startCalendar
: 초기 달력 상태를 저장하는 데 사용
ViewPager2 설정
: 수평 방향으로 스크롤되는 달력 페이지를 위한 ViewPager2를 설정, 페이지가 바뀔 때마다 선택된 월을 업데이트하고 달력을 갱신
updateCalendar 메서드
:현재 선택된 연도와 월을 텍스트 뷰에 표시하며 generateMonths를 호출하여 달력의 여러 페이지를 생성하고, 이를 CalendarPagerAdapter에 연결합니다.
generateMonths 메서드
:
주어진 달력을 기준으로 이전 12개월과 다음 12개월의 달력 데이터를 생성 (총 25개월의 달력 데이터)
각 월별 달력 데이터는 Dates.generateDates를 통해 생성되며 결과적으로 달력 뷰를 제공하며 사용자가 월별로 탐색하고 특정 달을 선택하게 할 수 있는 기능
스크롤을 하지 않고 버튼으로 월이 바뀌는 커스텀 달력은 연수 때 해봐서 어렵지 않게 간단하게 할 줄 알았으나 스크롤 기능을 추가 해야 했기 때문에 각 요일과 날짜를 분리해서 각각의 리사이클러뷰를 만들어 연결하게 했고 뷰페이저2 어댑터의 아이템을 리사이클러뷰로 하면서 스크롤이 가능하도록 하였습니다.
깃허브 : https://github.com/GEUN-TAE-KIM/CustomCalendar_Sample.git