[Android/Kotlin] 라이브러리 없이 주간달력 구현하기 (2/2) - 코드 편

코코아의 개발일지·2024년 5월 18일
1

Android-Kotlin

목록 보기
27/31

✍🏻 서론

지난 편이 xml을 다룬 내용이었다면, 이번 편은 로직을 구현한 코드를 말해보도록 하겠다.

👉🏻 지난 편: [Android/Kotlin] 라이브러리 없이 주간달력 구현하기 (1/2) - xml 편

구현해야 하는 기능을 리마인드 하자면,

🗓️ [ 주간달력 요구사항 ]

  • 주간 달력은 좌우로 스크롤되어야 함 (일주일 단위로 끊기도록)
  • 선택한 날짜 표시
    • 처음 들어갔을 때는 현재 날짜를 선택
    • 오늘 이후의 날짜는 파란색 배경으로 표시
    • 오늘 이전의 날짜는 회색 배경으로 표시
  • 선택한 날짜는 상단의 텍스트에 반영됨

이렇게 진행되어야 한다.
그럼 바로 코드 작성을 들어가 보자.

💻 코드 작성

1️⃣ 캘린더 코드 작성

1) 전역 변수 정의

private lateinit var binding: FragmentOneWeekBinding
private lateinit var textViewList: List<TextView> // 일주일 날짜를 넣어줄 TextView의 리스트
private lateinit var dates: List<LocalDate> // 일주일의 날짜를 받아올 dates

private val todayPosition = Int.MAX_VALUE / 2 // 기준 포지션
private var position: Int = 0 // 달력이 스크롤되었을 때 포지션 계산을 위한 변수
private lateinit var onClickListener: IDateClickListener // 선택된 날짜를 넘겨받기 위한 인터페이스

변수에 대한 설명은 달아놓은 주석으로 대체하겠다.

2) 생성자 정의

companion object {
        fun newInstance(position: Int, curDate: LocalDate, onClickListener: IDateClickListener): CalendarOneWeekFragment {
            val fragment = CalendarOneWeekFragment()
            fragment.position = position
            fragment.curDate = curDate
            fragment.onClickListener = onClickListener
            return fragment
        }
        const val MONDAY = 1
        const val SUNDAY = 7
    }

companion object에서 newInstance로 생성자 코드를 작성해 준다. 이런 식으로 작성해주지 않으면

Unable to instantiate fragment com.example.calendar.CalendarOneWeekFragment: could not find Fragment constructor

라는 식의 프래그먼트 생성자와 관련한 오류를 볼 수 있다.
하드 코딩을 막기 위해 월요일을 1, 일요일을 7로 정의해 준다.

3) 스크롤 시의 position 계산하기

private fun calculateNewDate(): LocalDate {
        val curDate = LocalDate.now()
        return if (position < todayPosition){ // 이전 페이지로 스크롤
            curDate.minusDays(((todayPosition - position) * 7).toLong())
        } else if (position > todayPosition) { // 다음 페이지로 스크롤
            curDate.plusDays(((position - todayPosition) * 7).toLong())
        } else {
            curDate
        }
    }

좌, 우로 스크롤했을 때의 기준 date를 반환한다.
여기서 계산한 날짜를 기준으로 일주일의 dates를 얻어올 수 있다.

4) 주간 달력에 넣어줄 일주일의 날짜 계산하기

private fun calculateDatesOfWeek(today: LocalDate): List<LocalDate> { // 최초 주간 달력 날짜들을 표시하기 위함
        val dates = ArrayList<LocalDate>() // 일 ~ 토까지 한 주간의 날짜 리스트 추가하기
        val dayOfToday = today.dayOfWeek.value // 기준 날짜의 요일 구하기
        Log.e("Calendar", "dayOfToday: $dayOfToday")

        if (dayOfToday == SUNDAY) { // 일요일일 경우 다음 리스트를 받아와야 함 (일요일을 가장 먼저 표시하기 때문)
            for (day in MONDAY..SUNDAY) { // 일(오늘) ~ 그 다음주 토
                dates.add(today.plusDays((day - 1).toLong()))
            }
        } else {
            for (day in (MONDAY - 1) until  dayOfToday) { // 일 ~ 오늘
                dates.add(today.minusDays((dayOfToday - day).toLong()))
            }
            for (day in dayOfToday .. (SUNDAY - 1)) { // 오늘 ~ 토
                dates.add(today.plusDays((day - dayOfToday).toLong()))
            }
        }
        this.dates =  dates
    }


디자인을 보면, 날짜가 일요일부터 시작하는 것을 알 수 있는데, 이를 위해 기준 날짜가 무슨 요일인지 dayOfWeek를 사용하여 계산해 준다.
오늘 요일이 일요일이라면 이어지는 한 주를 일~토까지 새로 리스트에 넣어주고,
그렇지 않다면 오늘 요일을 기준으로 오늘 이전과 오늘 이후 날짜를 dates에 넣어준다.

today.dayOfWeek.value 말고 바로 today.dayOfWeek로 사용하여 DayOfWeek.SUNDAY 식으로 요일을 비교할 수도 있다.

5) 날짜 선택 코드 작성

interface IDateClickListener {
    fun onClickDate(date: LocalDate)
}

처음으로는 클릭할 날짜를 넘겨줄 인터페이스를 작성해 준다.

@RequiresApi(Build.VERSION_CODES.O)
    private fun setOneWeekDateIntoTextView() { // 일주일의 날짜를 텍스트뷰에 넣어주기
        for (i in textViewList.indices) {
            setDate(textViewList[i], dates[i])
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun setDate(textView: TextView, date: LocalDate) { // ex. date: 2023-12-23
        val splits = date.toString().split('-')

        textView.text = splits[2].toInt().toString() // 날짜의 뒷 부분(일)만 가져와서 사용
        // 날짜 선택 시의 동작
        textView.setOnClickListener{
            resetUi() // 모든 날짜 선택 해제
            onClickListener.onClickDate(date) // 인터페이스를 통해 클릭한 날짜 전달
            setSelectedDate(textView, date < LocalDate.now())
        }
    }

    private fun resetUi() { // 모든 날짜 선택 해제
        for (i in textViewList.indices) {
            textViewList[i].setTextColor(Color.WHITE)
            textViewList[i].setTypeface(null, Typeface.NORMAL)
            textViewList[i].backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.transparent))
        }
    }

    private fun setSelectedDate(textView: TextView, isPast: Boolean) { // 선택한 날짜 UI
        // 배경색
        if (isPast) textView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.text_alpha_gray))
        else textView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.Jblue))
        // 글자색
        textView.setTextColor(Color.BLACK)
        // 볼드체
        textView.setTypeface(textView.typeface, Typeface.BOLD)
    }
  • 선택한 날짜라면 -> 회색 or 파란색 배경, 볼드체 + 검정 텍스트로,
  • 선택하지 않은 날짜라면 -> 배경 X, 일반 + 흰색 텍스트로

표시해주어야 되기에 날짜 선택 시에 resetUi 함수에서 모든 날짜의 선택을 해제하고,setSelectedDate 함수에서 선택 시의 동작을 정의해 주었다.

CalendarOneWeekFragment 전체 코드

class CalendarOneWeekFragment : Fragment() {
    private lateinit var binding: FragmentOneWeekBinding
    private lateinit var textViewList: List<TextView>
    private lateinit var dates: List<LocalDate>

    private var position: Int = 0
    private lateinit var onClickListener: IDateClickListener

    private val todayPosition = Int.MAX_VALUE / 2

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

        initViews()

        return binding.root
    }

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onViewCreated(view: View, savedInstance: Bundle?){
        super.onViewCreated(view, savedInstance)

        // 달력 페이지 넘겼을 때의 기준 날짜를 받아오기 위함
        val newDate = calculateNewDate()

        calculateDatesOfWeek(newDate)

        setOneWeekDateIntoTextView()
    }

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onResume() {
        super.onResume()

        setPrevSelectedDate()
    }

    override fun onPause() {
        super.onPause()
        resetUi()
    }

    private fun initViews() {
        with(binding) {
            textViewList = listOf( // 텍스트뷰 리스트 초기화
                tv1, tv2, tv3, tv4, tv5, tv6, tv7
            )
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun calculateNewDate(): LocalDate {
        val curDate = LocalDate.now()
        return if (position < todayPosition){ // 이전 페이지로 스크롤
            curDate.minusDays(((todayPosition - position) * 7).toLong())
        } else if (position > todayPosition) { // 다음 페이지로 스크롤
            curDate.plusDays(((position - todayPosition) * 7).toLong())
        } else {
            curDate
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun setPrevSelectedDate() {
        val sharedPreference = context?.getSharedPreferences("CALENDAR-APP", Context.MODE_PRIVATE)
        val selectedDate = sharedPreference?.getString("SELECTED-DATE", "")
        for (i in textViewList.indices) {
            if (selectedDate.toString() == dates[i].toString()) {
                setSelectedDate(textViewList[i], LocalDate.parse(selectedDate) < LocalDate.now())
            }
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun setOneWeekDateIntoTextView() { // 일주일의 날짜를 넣어주기
        for (i in textViewList.indices) {
            setDate(textViewList[i], dates[i])
        }
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun setDate(textView: TextView, date: LocalDate) { // ex. date: 2023-12-23
        val splits = date.toString().split('-')

        textView.text = splits[2].toInt().toString() // 날짜의 뒷 부분(일)만 가져와서 사용
        // 날짜 선택 시의 동작
        textView.setOnClickListener{
            resetUi() // 모든 날짜 선택 해제
            onClickListener.onClickDate(date) // 인터페이스를 통해 클릭한 날짜 전달
            setSelectedDate(textView, date < LocalDate.now())
        }
    }

    private fun resetUi() { // 모든 날짜 선택 해제
        for (i in textViewList.indices) {
            textViewList[i].setTextColor(Color.WHITE)
            textViewList[i].setTypeface(null, Typeface.NORMAL)
            textViewList[i].backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.transparent))
        }
    }

    private fun setSelectedDate(textView: TextView, isPast: Boolean) { // 선택한 날짜 UI
        // 배경색
        if (isPast) textView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.text_alpha_gray))
        else textView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.Jblue))
        // 글자색
        textView.setTextColor(Color.BLACK)
        // 볼드체
        textView.setTypeface(textView.typeface, Typeface.BOLD)
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun calculateDatesOfWeek(today: LocalDate) { // 최초 주간 달력 날짜들을 표시하기 위함
        val dates = ArrayList<LocalDate>() // 일 ~ 토까지 한 주간의 날짜 리스트 추가하기
        val dayOfToday = today.dayOfWeek.value

        if (dayOfToday == SUNDAY) { // 일요일일 경우 다음 리스트를 받아와야 함 (일요일을 가장 먼저 표시하기 때문)
            for (day in MONDAY..SUNDAY) { // 일(오늘) ~ 그 다음주 토
                dates.add(today.plusDays((day - 1).toLong()))
            }
        } else {
            for (day in (MONDAY - 1) until  dayOfToday) { // 일 ~ 오늘
                dates.add(today.minusDays((dayOfToday - day).toLong()))
            }
            for (day in dayOfToday .. (SUNDAY - 1)) { // 오늘 ~ 토
                dates.add(today.plusDays((day - dayOfToday).toLong()))
            }
        }
        this.dates =  dates
    }

    companion object {
        fun newInstance(position: Int, onClickListener: IDateClickListener): CalendarOneWeekFragment {
            val fragment = CalendarOneWeekFragment()
            fragment.position = position
            fragment.onClickListener = onClickListener
            return fragment
        }
        const val MONDAY = 1
        const val SUNDAY = 7
    }
}

각각의 생명주기에 들어가는 함수들을 통해 전체적인 흐름을 파악할 수 있다.

1) onCreateView(), onViewCreated()의 경우에는 새로운 페이지로 달력이 스크롤 될 때만 나타나고,
2) 기존에 한 번 만들어졌던 페이지로 달력이 스크롤 될 때는 기존 페이지의 onPause() 호출 후 onResume()이 호출된다.

onPause()에서 resetUi (선택된 날짜를 해제) 해주지 않으면 다른 페이지에서 날짜를 선택하고 돌아왔을 때 선택되었다는 표시되는, 선택된 날짜 중복 표시가 발생할 수 있다.

이를 방지해주기 위해 onPause()에서 선택 표시를 모두 해제해주고, onResume()에서 저장된 selectedDate 날짜를 가져와 선택 날짜라면 표시를 해준다.
-> '오직 하나의' 날짜만 선택되도록 만들어줄 수 있음

2️⃣ 뷰페이저 어댑터 정의

class CalendarVPAdapter(
    fragmentActivity: FragmentActivity,
    private val onClickListener: IDateClickListener,
): FragmentStateAdapter(fragmentActivity) {
    override fun getItemCount(): Int = Int.MAX_VALUE

    override fun createFragment(position: Int): Fragment {
        return CalendarOneWeekFragment.newInstance(position, onClickListener)
    }
}

FragmentStateAdapter로 뷰페이저 어댑터를 만들어 준다.
달력이 무한으로 스크롤될 수 있도록 ItemCount를 Int.MAX_VALUE로 잡아준다.
달력이 넘어갈 때마다 CalendarOneWeekFragment.newInstance로 새로운 Fragment를 만들어준다.

3️⃣ 프래그먼트에 달력 나타내기

달력이 실질적으로 나타날 프래그먼트이다.

class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::bind, R.layout.fragment_home),
     IDateClickListener {
     
     var today = LocalDate.now()
     lateinit var selectedDate: LocalDate
    
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        selectedDate = today // 최초에는 선택 날짜를 오늘로 초기화

        // 주간 달력 뷰페이저
        setOneWeekViewPager()
    }
    
	/** 주간 달력 */
    private fun setOneWeekViewPager() {
        saveSelectedDate(LocalDate.now())
        val calenarAdapter = CalendarVPAdapter(requireActivity(), this)
        binding.homeWeeklyCalendarWeekVp.adapter = calenarAdapter
        binding.homeWeeklyCalendarWeekVp.setCurrentItem(Int.MAX_VALUE / 2, false)
    }
}

이게 HomeFragment의 코드인데, 위에 보이는 것처럼 선택한 날짜를 'yyyy년 MM월 dd일' 형식으로 표시해 주어야 한다. 앞서 작성해 주었던 IDateClickListener 인터페이스가 활약해 줄 상황이다.

override fun onClickDate(date: LocalDate) {
        selectedDate = date
        // 선택 날짜 저장
        saveSelectedDate(date)
        // 선택한 날짜 표시
        binding.homeTopNavTv.text = dateFormat(date)
        // API 호출 - 오늘 날짜 목표 받아오기
        GoalService(this).tryGetGoals(date.toString())
        GoalService(this).tryGetCheckGoalList(date.toString())
    }

private fun saveSelectedDate(date: LocalDate) {
        val sharedPreference = requireActivity().getSharedPreferences("CALENDAR-APP", AppCompatActivity.MODE_PRIVATE)
        val editor : SharedPreferences.Editor = sharedPreference.edit()
        editor.putString("SELECTED-DATE", date.toString())
        editor.apply()
}
    
private fun dateFormat(date: LocalDate): String{
        val formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")
        return date.format(formatter)
}

날짜 선택 시의 동작을 정의해 준다.


📱 완성 모습


1.주간 달력은 좌우로 스크롤되어야 함 (일주일 단위로 끊기도록)
2.선택한 날짜 표시

  • 처음 들어갔을 때는 현재 날짜를 선택
  • 오늘 이후의 날짜는 파란색 배경으로 표시
  • 오늘 이전의 날짜는 회색 배경으로 표시

3.선택한 날짜는 상단의 텍스트에 반영됨

위 기존 요구사항을 모두 만족하는 모습을 볼 수 있다.
다른 액티비티가 표시된 이후에도 선택한 날짜가 유지되며, 선택된 날짜가 중복되어 표시되지도 않는다.

일주일 단위로 스크롤되는 주간 달력을 요구사항 대로 잘 구현한 것이다!


🫠 마치며

기존에 멘토 님께서 작성해 주셨던 베이스 코드를 우리 앱에 맞게 고치면서 '코드를 이렇게 작성할 수 있구나'라는 것을 알게 되었는데,
이번에 블로그를 쓰려고 주석을 달고 코드를 더 정리하는 과정에서 전체적인 흐름과 로직을 정확히 이해할 수 있게 되었다. 기존에 작성해 놓았던 코드를 이번 기회에 함수로 분리하고, 하드 코딩으로 된 부분 대체하거나 중복되는 코드를 없애면서 훨씬 더 깔끔해진 코드를 만든 것 같아 뿌듯하다. (필요 없는 변수들도 하나씩 지워보며 정말 많이 없앴다.)
비록 지금은 Android 네이티브가 아니라 Flutter로 이전하고 있는 프로젝트이지만, 역시 나는 네이티브가 더 좋기에 이렇게 혼자서 코드를 정리해 보는 과정도 의미있게 느껴진다.
(+ 주석의 중요성도 뭔가 느끼게 되었던 작업)

다른 사람의 코드를 프로젝트에 적용하고, 내 방식대로 바꾸는 작업도 재미있어서 앞으로 라이브러리를 커스텀 해보면서 라이브러리 contribute에 직접 기여해보고 싶다는 생각도 하게 됐다.

🔗 전체 코드: https://github.com/nahy-512/OneWeekCalendar.git

profile
우당탕탕 성장하는 개발자

0개의 댓글