[Android/Kotlin] 달력 직접 구현하기 (DatePicker)

코코아의 개발일지·2024년 11월 9일
0

Android-Kotlin

목록 보기
36/36
post-thumbnail

✍🏻 요구사항 분석

디자인으로 위와 같은 달력 구현을 요구받았다.

화면에서는 바텀시트처럼 띄워줘야 했다.
커스텀을 위해 MaterialCalendar 활용 대신 직접 구현하는 방식을 택했다.

PM과 논의한 결과 월, 년도 선택은 나중에 구현하기로 했고, 일 선택 화면만 우선 구현하기로 했다.
그럼, 피그마 화면에서의 요구사항을 한 번 정리해 보자.

[요구사항]
1. 맨 처음에는 오늘 날짜가 세팅되어 있음 (날짜에 초록색 배경 원 표시)
2. 오늘 이전 날짜는 비활성화 처리
3. 상단 화살표를 통해 달을 이동할 수 있음
4. 날짜를 클릭한 후에는 텍스트뷰에 선택 날짜 반영

아이디어는 여느때와 같이 리사이클러뷰를 활용하는 것이다. 한 주는 7일로 고정이니, GridLayoutManager를 사용할 계획이다.


💻 코드 작성

1️⃣ 날짜 아이템

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="6dp">

        <LinearLayout
            android:id="@+id/item_calendar_date_bg"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintDimensionRatio="1:1"
            android:padding="11dp"
            android:backgroundTint="@color/transparent"
            android:background="@drawable/bg_circle_fill"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent">

            <TextView
                android:id="@+id/item_calendar_date_tv"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="1"
                android:textColor="@color/title_black"
                style="@style/calendar_date_tv"/>

        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

달력에 들어갈 날짜의 디자인을 잡아준다. 선택된 아이템의 경우는 배경이 초록색 원으로 되어야 하므로, LinearLayout의 background를 동그라미로 해주고, 레이아웃 안에 텍스트뷰를 넣어준다.

배경색을 지정하면 오른쪽 같은 느낌이다.

2️⃣ 바텀시트 레이아웃 구현

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

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

        <ImageView
            android:id="@+id/calendar_close_iv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="24dp"
            android:layout_marginEnd="22dp"
            android:padding="5dp"
            android:src="@drawable/ic_close"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>

        <!-- 상단 날짜 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:paddingHorizontal="22dp"
            app:layout_constraintTop_toBottomOf="@id/calendar_close_iv"
            app:layout_constraintStart_toStartOf="parent">

            <LinearLayout
                android:id="@+id/calendar_top_ll"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:layout_gravity="center_vertical">

                <ImageView
                    android:id="@+id/calendar_previous_month_iv"
                    android:layout_width="26dp"
                    android:layout_height="26dp"
                    android:padding="3dp"
                    android:layout_gravity="center_vertical"
                    android:src="@drawable/ic_arrow_left" />

                <TextView
                    android:id="@+id/calendar_year_month_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:minWidth="110dp"
                    android:paddingVertical="3dp"
                    android:layout_marginHorizontal="10dp"
                    tools:text="2023년 1월"
                    style="@style/title_l"/>

                <ImageView
                    android:id="@+id/calendar_next_month_iv"
                    android:layout_width="26dp"
                    android:layout_height="26dp"
                    android:padding="3dp"
                    android:layout_gravity="center_vertical"
                    android:rotation="180"
                    android:src="@drawable/ic_arrow_left"/>

            </LinearLayout>

            <!-- 달력 -->
            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="42dp"
                android:layout_marginTop="18dp"
                android:orientation="horizontal"
                android:gravity="center_vertical"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/full_cal_change_month_arrows_ll">

                <TextView
                    android:text=""
                    style="@style/calendar_date_tv" />
                <TextView
                    android:text=""
                    style="@style/calendar_date_tv" />
                <TextView
                    android:text=""
                    style="@style/calendar_date_tv" />
                <TextView
                    android:text=""
                    style="@style/calendar_date_tv" />
                <TextView
                    android:text=""
                    style="@style/calendar_date_tv" />
                <TextView
                    android:text=""
                    style="@style/calendar_date_tv" />
                <TextView
                    android:text=""
                    style="@style/calendar_date_tv" />
            </LinearLayout>

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/calendar_date_rv"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
                app:layout_constraintVertical_weight="1"
                app:layout_constraintHorizontal_weight="1"
                app:layout_constraintTop_toBottomOf="@+id/full_cal_day_of_month_ll"
                app:layout_constraintStart_toStartOf="parent"
                app:spanCount="7"
                tools:listitem="@layout/item_calendar_date"
                tools:itemCount="31"/>

        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

날짜 표시는 리사이클러뷰 GridLayout를 사용해서 해줄 수 있다.

디자인 탭에서 보면 이런 모습이다.

3️⃣ 리사이클러뷰 어댑터

class CalendarRVAdapter(private val selectedDatePosition: Int, private val selectedMonth: Int) : RecyclerView.Adapter<CalendarRVAdapter.ViewHolder>() {

    private var dateList = listOf<LocalDate?>() // 달력에 표시될 날짜 목록
    private var selectedItemPosition = -1 // 달이 넘어가더라도 선택한 날짜는 유일하게 표시해주기 위함
    private lateinit var mItemClickListener: MyDateClickListener

    private lateinit var context: Context

    interface MyDateClickListener {
        fun onDateClick(selectedDate: LocalDate)
    }

    fun setMyDateClickListener(itemClickListener: MyDateClickListener) {
        mItemClickListener = itemClickListener
    }

    @SuppressLint("NotifyDataSetChanged")
    fun addDateList(dateList: List<LocalDate?>) {
        this.dateList = dateList
        this.selectedItemPosition = if (dateList[10]!!.monthValue == selectedMonth) selectedDatePosition else -1
        notifyDataSetChanged()
    }

    // 보여지는 화면 설정
    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
        val binding: ItemCalendarDateBinding = ItemCalendarDateBinding.inflate(
            LayoutInflater.from(viewGroup.context), viewGroup, false
        )
        context = viewGroup.context
        return ViewHolder(binding)
    }

    // 내부 데이터 설정
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if (dateList[position] == null) { // 날짜 데이터가 없을 경우 캘린더에 표시하지 않음
            holder.dateText.text = null
            return
        }

        // 날짜의 date만 표시
        holder.dateText.text = dateList[position]!!.dayOfMonth.toString()

        if (dateList[position]!! < TODAY) { // 오늘 이전 날짜 회색 처리
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.gray5))
            holder.dateText.setOnClickListener { null } // 클릭 불가 처리
            return
        }
        if (selectedItemPosition == position) { // 선택 날짜 표시
            holder.bg.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.main))
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.white))
            holder.dateText.setTypeface(null, Typeface.BOLD) // 볼드 처리
        } else { // 선택하지 않은 날짜 표시
            holder.bg.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.transparent))
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.title_black))
            holder.dateText.setTypeface(null, Typeface.NORMAL)
        }

        // 날짜 클릭 이벤트
        holder.bg.setOnClickListener {
            notifyItemChanged(selectedItemPosition) // 이전에 선택한 아이템 notify
            selectedItemPosition = position // 선택한 날짜 position 업데이트
            notifyItemChanged(selectedItemPosition) // 새로 선택한 아이템 notify
            mItemClickListener.onDateClick(dateList[selectedItemPosition]!!) // 클릭 이벤트 처리
        }
    }

    override fun getItemCount(): Int = dateList.size

    inner class ViewHolder(val binding: ItemCalendarDateBinding): RecyclerView.ViewHolder(binding.root){
        val bg: LinearLayout = binding.itemCalendarDateBg
        var dateText: TextView = binding.itemCalendarDateTv
    }
}

달력에 표시할 날짜 목록은 LocalDate 리스트로 받아온다. 이전에 선택한 날짜는 그대로 선택된 채 나타내기 위해 selectedItemPosition를 사용한다.
onBindViewHolder에서 리사이클러뷰 날짜를 위한 코드를 작성해 준다. 오늘 이전 날짜는 회색으로 표시하고, 선택 날짜는 초록색 동그라미 배경 + 글자색 흰색 + 볼드 처리를 해준다.

4️⃣ CalendarBottomSheet

interface DateClickListener {
    fun onDateReceived(isStartDate: Boolean, date: LocalDate)
}

@RequiresApi(Build.VERSION_CODES.O)
class CalendarBottomSheet(private var listner: DateClickListener, var isStartDate: Boolean, private var initialDate: LocalDate) : BottomSheetDialogFragment() {
    private lateinit var binding: BottomSheetCalendarBinding
    private var criteriaDate = this.initialDate // 캘린더 날짜를 가져오는 기준 일자

    private lateinit var calendarAdapter: CalendarRVAdapter

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = BottomSheetCalendarBinding.inflate(inflater, container, false)

        initClickListeners()
        setAdapter()

        return binding.root
    }


    private fun initClickListeners() {
        /* 화살표 눌러서 월 이동 */
        binding.calendarPreviousMonthIv.setOnClickListener { // 이전 달
            setCalendarDate(-1)
        }
        binding.calendarNextMonthIv.setOnClickListener { // 다음 달
            setCalendarDate(+1)
        }

        // 닫기 버튼 클릭
        binding.calendarCloseIv.setOnClickListener {
            dismiss() // 창 닫기
        }
    }

    // 날짜 적용 함수
    private fun setAdapter() {
        // 어댑터 초기화
        calendarAdapter = CalendarRVAdapter(getSelectedDatePosition(), initialDate.monthValue)
        binding.calendarDateRv.apply {
            layoutManager = GridLayoutManager(requireContext(), DAY_OF_WEEK)
            adapter = calendarAdapter
        }
        setCalendarDate(0)
        // 날짜 클릭 이벤트
        calendarAdapter.setMyDateClickListener(object: CalendarRVAdapter.MyDateClickListener{
            override fun onDateClick(selectedDate: LocalDate) {
                listner.onDateReceived(isStartDate, selectedDate) // 날짜 전달
                dismiss() // 뒤로가기
            }
        })
    }

    private fun setCalendarDate(direct: Long) {
        criteriaDate = criteriaDate.plusMonths(direct)
        // 상단 날짜 세팅
        binding.calendarYearMonthTv.text = DateConverter.getFormattedYearMonth(criteriaDate)
        calendarAdapter.addDateList(dayInMonthArr(criteriaDate))
    }

    // 날짜 생성
    private fun dayInMonthArr(date: LocalDate): ArrayList<LocalDate?> {
        val dateList = ArrayList<LocalDate?>()
        val yearMonth = YearMonth.from(date)

        // 월의 시작일
        val monthFirstDate = criteriaDate.withDayOfMonth(1)
        // 월 첫 날의 요일 (일요일=0, ... ,월요일=6)
        val dayOfMonthFirstDate = monthFirstDate.dayOfWeek.value % DAY_OF_WEEK
        // 월의 종료일
        val monthLastDate = yearMonth.lengthOfMonth()

        for (i in 1..DAY_OF_WEEK * 6) { // 6줄짜리 달력
            if (dayOfMonthFirstDate == SUNDAY) { // 일~토 달력에서 1일이 일요일일 때, 첫째주가 비는 현상 제거
                if (i <= monthLastDate){
                    dateList.add(LocalDate.of(date.year, date.monthValue, i))
                }
                else {
                    dateList.add(null)
                }
            } else {
                if (i > dayOfMonthFirstDate && i < (monthLastDate + dayOfMonthFirstDate)) {
                    dateList.add(LocalDate.of(date.year, date.monthValue, i - dayOfMonthFirstDate))
                } else {
                    dateList.add(null)
                }
            }
        }

        return dateList
    }

    private fun getSelectedDatePosition(): Int {
        // 월 첫 날의 요일 구하기
        val dayOfWeek = initialDate.withDayOfMonth(1).dayOfWeek.value % DAY_OF_WEEK
        // 초기 날짜의 포지션 계산
        return initialDate.dayOfMonth + dayOfWeek - 1
    }

    companion object {
        const val DAY_OF_WEEK = 7 // 일주일
        const val SUNDAY = 0
    }
}

일주일은 7일이니까 DAY_OF_WEEK = 7로 상수 처리를 해주고, 특별한 상황인 SUNDAY = 0도 미리 추가했다.

  • dayInMonthArr
    달력에는 기본적으로 일요일부터 표시를 시작해서, 7일 * 6주 = 42개의 날짜를 dateList에 넣어줄 것이다. 하지만 디자인을 봤을 때 이번 달에 벗어나는 날짜는 표시해 줄 필요가 없으므로 이전/다음 달의 날짜인 경우에는 null를 추가해 준다. 이 null인 date는 어댑터에서 null일 경우는 표시해주지 않게끔 미리 구현했다.
  • DateClickListener
    날짜를 클릭하면 선택한 날짜를 이전 화면에 넘겨줘야 하기에 인터페이스를 구현했다.

5️⃣ 달력 바텀시트 띄우기

class RouteCreateActivity : AppCompatActivity(), DateClickListener {
    private lateinit var binding: ActivityRouteCreateBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_route_create)

        // ,..

        initClickListeners()
    }

    private fun initClickListeners() {
    	// ...
        // 시작 날짜
        binding.routeCreateStartDateTv.setOnClickListener {
            showCalendarBottomSheet(true, viewModel.startDate.value!!)
        }

        // 종료 날짜
        binding.routeCreateEndDateTv.setOnClickListener {
            showCalendarBottomSheet(false, viewModel.endDate.value!!)
        }
    }

    private fun showCalendarBottomSheet(isStartDate: Boolean, date: LocalDate) {
        val calendarBottomSheet = CalendarBottomSheet(this, isStartDate, date)
        calendarBottomSheet.run {
            setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialogStyle)
        }
        calendarBottomSheet.show(this.supportFragmentManager, calendarBottomSheet.tag)
    }
    
    override fun onDateReceived(isStartDate: Boolean, date: LocalDate) {
        viewModel.updateDate(isStartDate, date)
    }

    companion object {
        @RequiresApi(Build.VERSION_CODES.O)
        val TODAY: LocalDate = LocalDate.now()
    }
}

showCalendarBottomSheet에서 앞서 구현한 캘린더 바텀시트를 띄우는 코드를 작성해 준다.
바텀시트에서 날짜를 클릭하면 뷰모델로 선택한 날짜가 업데이트되었다고 알려준다.

(번외) 이전 날짜 회색 처리 여부를 선택하는 옵션 추가하기

맨 처음 나온 화면에서는 달력에서 오늘 이후 날짜만 선택할 수 있게끔 이전 날짜들은 아예 회색으로 처리하고, 클릭도 불가능하게 했었다. 이건 기획상으로 과거 날짜는 선택하지 못했기 때문인데, 수정 시에는 지나간 날짜를 아예 수정하지 못하면 날짜를 선택하는 의미가 없어진다. 때문에 이전 날짜도 선택할 수 있게끔 옵션을 제공해야 했다.
바로 어댑터의 코드를 수정해주면 된다!

class CalendarRVAdapter(private val setPrevDateDisable: Boolean, ...) : RecyclerView.Adapter<CalendarRVAdapter.ViewHolder>() {
	// 내부 데이터 설정
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if (dateList[position] == null) { // 날짜 데이터가 없을 경우 캘린더에 표시하지 않음
            holder.dateText.text = null
            return
        }

        // 날짜의 date만 표시
        holder.dateText.text = dateList[position]!!.dayOfMonth.toString()

        if (setPrevDateDisable && dateList[position]!! < TODAY) { // 오늘 이전 날짜 회색 처리
            holder.dateText.setTextColor(ContextCompat.getColor(context, R.color.gray5))
            holder.dateText.setOnClickListener { null } // 클릭 불가 처리
            return
        }
        
        // 선택 날짜, 선택하지 않은 날짜 처리

        // 날짜 클릭 이벤트
        holder.bg.setOnClickListener {
            notifyItemChanged(selectedItemPosition) // 이전에 선택한 아이템 notify
            selectedItemPosition = position // 선택한 날짜 position 업데이트
            notifyItemChanged(selectedItemPosition) // 새로 선택한 아이템 notify
            mItemClickListener.onDateClick(dateList[selectedItemPosition]!!) // 클릭 이벤트 처리
        }
    }
}

CalendarRVAdapter의 생성자로 이전 날짜를 비활성화 할지를 관리하는 setPrevDateDisaable를 추가하고, 기존에 오늘 이전 날짜 회색 처리를 하던 코드에 setPrevDateDisable를 달아준다. 이 값이 false면 이전 날짜도 그대로 표시할 수 있도록!

📱 완성 화면

맨 처음에는 오늘 날짜로 달력이 설정되고, 화살표를 눌러 월을 이동하는 것, 과거 날짜는 회색 처리가 되어있는 것, 클릭했을 때 텍스트뷰에 반영되는 것까지! 요구사항대로 구현이 모두 끝난 것을 확인할 수 있다.

👉🏻 달력/피커 관련 다른 글 보러가기

profile
안드로이드 개발자를 꿈꾸는 학생입니다

0개의 댓글