✨ 구현 내용 요약
- Horizontal 타입의 RecyclerView를 사용하여 각 월을 표시하며 해당 월 안에서 일수를 나타내기 위한 Grid 타입의 RecyclerView를 사용하여 달력을 표시한다.
- PagerSnapHelper()를 사용하여 RecyclerView를 페이징 가능한 뷰로 변환하며 스크롤 할 때 한 번에 하나의 항목만 보이게 한다.
- 월을 표시하는 MonthListAdapter에서 getItemCount()의 반환 값을 Integer의 최대값으로 반환하여 무한 스크롤 효과를 구현한다.
- scrollToPosition을 Int.MAX_VALUE/2에서 항목이 시작되도록 설정하여 좌우로 실제로는 끝이 있지만 무한 스크롤이 가능한 것처럼 구현한다.
class MonthListAdapter : ListAdapter<Int, MonthListAdapter.MonthViewHolder>(
object : DiffUtil.ItemCallback<Int>() {
override fun areItemsTheSame(oldItem: Int, newItem: Int): Boolean =
oldItem == newItem
override fun areContentsTheSame(oldItem: Int, newItem: Int): Boolean =
oldItem == newItem
}
) {
val center = Int.MAX_VALUE / 2
abstract class MonthViewHolder(view: View) : RecyclerView.ViewHolder(view) {
abstract fun bind(month: Int, position: Int)
}
/*
* Integer의 최대값을 반환하여 무한 스크롤 효과를 구현한다.
* 무한대로 스크롤 할 수 있지만 실제 메모리에는 현재 표시되는 아이템만 로드 된다.
*/
override fun getItemCount(): Int = Int.MAX_VALUE
/*
* 새로운 뷰 홀더를 생성한다.
* month 정보를 표시하는 아이템의 레이아웃과 바인딩 된다.
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MonthViewHolder {
val binding = ItemListMonthBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return MonthView(binding)
}
/*
* ViewHolder를 바인딩하고 해당 month 정보를 표시한다.
* 해당 달의 day 목록을 생성하고 DayListAdapter를 사용하여 각 달의 day를 표시한다.
*/
override fun onBindViewHolder(holder: MonthViewHolder, position: Int) {
val month = position - center
holder.bind(month, position)
}
inner class MonthView(
private val binding: ItemListMonthBinding
) : MonthViewHolder(binding.root) {
/*
* bind 메서드를 통해 해당 월에 대한 데이터를 뷰에 바인딩한다.
* 해당 월을 텍스트로 표시하고 그에 해당하는 일을 DayListAdapter로 설정하여 하위 RecyclerView를 초기화한다.
*/
override fun bind(month: Int, position: Int) {
calendar.time = Date() // Calendar 객체를 현재 날짜로 초기화
calendar.set(Calendar.DAY_OF_MONTH, 1) // 현재 날짜의 월의 첫 번째 날로 설정
calendar.add(Calendar.MONTH, position - center) // position을 기준으로 center 값을 뺀 만큼의 month를 추가한다.
binding.itemMonthText.text =
"${calendar.get(Calendar.YEAR)}년 ${calendar.get(Calendar.MONTH) + 1}월"
val tempMonth = calendar.get(Calendar.MONTH) + 1 // 현재 표시된 month를 임시 변수에 저장한다.
var dayList: MutableList<LocalDate> = MutableList(6 * 7) { LocalDate.now() } // 각 날짜를 저장할 리스트로 현재 날짜로 초기화
for (i in 0..5) { // 주차
for (k in 0..6) { // 주 내의 요일
calendar.add(
Calendar.DAY_OF_MONTH,
(1 - calendar.get(Calendar.DAY_OF_WEEK)) + k // 각 주의 첫 번째 날(일요일)로 이동한다.
)
dayList[i * 7 + k] =
calendar.time.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() // 날짜를 리스트에 추가한다.
}
calendar.add(Calendar.WEEK_OF_MONTH, 1)
}
val dayListManager = GridLayoutManager(binding.root.context, 7) // 행에 7개의 열을 표시
val dayListAdapter =
DayListAdapter(tempMonth, dayList, onItemClick = { position, month -> // 계산된 날짜 목록과 현재 표시된 월을 사용하여 DayListAdapter를 생성한다.
})
binding.itemMonthDayList.apply {
layoutManager = dayListManager
adapter = dayListAdapter
}
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:orientation="vertical">
<TextView
android:id="@+id/item_month_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="5dp"
android:text="2022년 6월"
android:textColor="@color/black"
android:textSize="22sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="일"
android:textColor="#ff0000"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="월"
android:textColor="@color/black"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="화"
android:textColor="@color/black"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="수"
android:textColor="@color/black"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="목"
android:textColor="@color/black"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="금"
android:textColor="@color/black"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="토"
android:textColor="@color/forth_color"
android:textSize="18sp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="3dp"
android:background="@color/border_line" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/item_month_day_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white" />
</LinearLayout>
/*
* tempMonth : 현재 표시되는 month
* dayList : 표시할 day 목록을 포함하는 MutableList
* onItemClick : day를 클릭할 때 호출 되는 콜백 함수
*/
class DayListAdapter(
private val tempMonth: Int,
private val dayList: MutableList<LocalDate>,
private val onItemClick: (position: Int) -> Unit
) :
ListAdapter<LocalDate, DayListAdapter.DayView>(
object : DiffUtil.ItemCallback<LocalDate>() {
override fun areItemsTheSame(oldItem: LocalDate, newItem: LocalDate): Boolean =
oldItem == newItem
override fun areContentsTheSame(oldItem: LocalDate, newItem: LocalDate): Boolean =
oldItem == newItem
}
) {
private var selectedPosition = RecyclerView.NO_POSITION
abstract class DayViewHolder(view: View) : RecyclerView.ViewHolder(view) {
abstract fun bind(date: LocalDate, position: Int, isSelected: Boolean)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DayView {
val binding = ItemListDayBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return DayView(binding, tempMonth, onItemClick)
}
override fun onBindViewHolder(holder: DayView, position: Int) {
val date = dayList[position]
holder.bind(date, position, position == selectedPosition)
}
inner class DayView(
val binding: ItemListDayBinding,
private val tempMonth: Int,
private val onItemClick: (position: Int) -> Unit
) : DayViewHolder(binding.root) {
/*
* date : 표시 할 LocalDate 객체
* position : 해당 항목의 위치
* isSelected : 항목이 현재 선택 된 상태인지의 상태
*/
override fun bind(date: LocalDate, position: Int, isSelected: Boolean) {
val context = binding.root.context
binding.itemDayText.text = date.dayOfMonth.toString()
if (date.getCurrentDate()) {
binding.itemDayText.backgroundTintList =
ContextCompat.getColorStateList(context, R.color.main_color)
binding.itemDayText.setTextColor(context.getColor(R.color.white))
} else {
/* 전달 받은 dayList를 이용하여 position % 7의 값이 0일 경우 일요일로서 빨강색으로 표시
* 6일 경우 토요일로서 파랑색으로 표시
* 그 외는 검정색으로 텍스트 색상을 표시한다.
*/
binding.itemDayText.setTextColor(
when (position % 7) {
0 -> context.getColor(R.color.red)
6 -> context.getColor(R.color.forth_color)
else -> context.getColor(R.color.black)
}
)
binding.itemDayText.backgroundTintList =
ContextCompat.getColorStateList(context, if (isSelected) R.color.selected_color else R.color.white)
}
// 현재 month가 아닌 경우 텍스트의 투명도를 0.4f로 설정하여 비활성화된 날짜임을 표현한다.
if (tempMonth != date.monthValue) {
binding.itemDayText.alpha = 0.4f
} else {
binding.itemDayText.alpha = 1.0f
}
// day를 클릭했을 때 onItemClick 콜백 함수를 호출하고 선택 된 위치를 업데이트 한 후 해당 위치의 뷰를 다시 그리도록 알린다.
binding.root.setOnClickListener {
onItemClick(adapterPosition)
notifyItemChanged(selectedPosition)
selectedPosition = adapterPosition
notifyItemChanged(selectedPosition)
}
}
// 해당 LocalDate가 현재 날짜와 일치하는지 확인하기 위한 메서드
private fun LocalDate.getCurrentDate(): Boolean {
val today = LocalDate.now()
val date = LocalDate.parse(this.toString())
return today.isEqual(date)
}
}
// 목록에 표시 될 항목 수
override fun getItemCount(): Int {
return dayList.size
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/item_day_layout"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginTop="5dp"
android:layout_marginEnd="5dp"
android:background="@color/white"
android:gravity="end">
<TextView
android:id="@+id/item_day_text"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@drawable/bg_background_circle"
android:fontFamily="@font/roboto_regular"
android:gravity="center"
android:padding="4dp"
android:text="9"
android:textSize="12sp" />
</LinearLayout>
class CalendarFragment : Fragment() {
private var _binding: FragmentCalendarBinding? = null
private val binding: FragmentCalendarBinding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentCalendarBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initView()
}
private fun initView() = with(binding) {
val monthListManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
val monthListAdapter = MonthListAdapter()
recyclerViewCalendar.apply {
layoutManager = monthListManager
adapter = monthListAdapter
/*
* 무한히 스크롤 할 수 있도록 보이기 위해 Int.MAX_VALUE / 2 로 설정한다.
* Int.MAX_VALUE / 2 를 사용하면 스크롤 위치가 매우 큰 값으로 설정되어 아이템을 거의 무한대로 반복해서 스크롤 할 수 있게 된다.
*/
scrollToPosition(Int.MAX_VALUE / 2)
}
/*
* PagerSnapHelper는 RecyclerView를 페이징 가능한 뷰로 변환한다.
* 스크롤이 멈췄을 때 가장 가까운 뷰가 전체 화면에 표시된다.
* RecyclerView를 스크롤 할 때 한 번에 하나의 항목만이 보인다.
*/
val snap = PagerSnapHelper()
snap.attachToRecyclerView(recyclerViewCalendar)
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.group.calendar.CalendarFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view_calendar"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>