[Android / Kotlin] Custom Calendar

Subeen·2024년 3월 13일
0

Android

목록 보기
68/73

결과 화면

✨ 구현 내용 요약

  • Horizontal 타입의 RecyclerView를 사용하여 각 월을 표시하며 해당 월 안에서 일수를 나타내기 위한 Grid 타입의 RecyclerView를 사용하여 달력을 표시한다.
  • PagerSnapHelper()를 사용하여 RecyclerView를 페이징 가능한 뷰로 변환하며 스크롤 할 때 한 번에 하나의 항목만 보이게 한다.
  • 월을 표시하는 MonthListAdapter에서 getItemCount()의 반환 값을 Integer의 최대값으로 반환하여 무한 스크롤 효과를 구현한다.
  • scrollToPosition을 Int.MAX_VALUE/2에서 항목이 시작되도록 설정하여 좌우로 실제로는 끝이 있지만 무한 스크롤이 가능한 것처럼 구현한다.

MonthAdapter

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
            }
        }
    }
}

item_list_month

<?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>

DayAdapter

/*
 * 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
    }
}

item_list_day

<?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>

CalendarFragment

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()
    }
}

fragment_calendar

<?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>

참조
Android Kotlin Custom Calendar - 커스텀 달력

profile
개발 공부 기록 🌱

0개의 댓글