지난 편이 xml을 다룬 내용이었다면, 이번 편은 로직을 구현한 코드를 말해보도록 하겠다.
구현해야 하는 기능을 리마인드 하자면,
🗓️ [ 주간달력 요구사항 ]
- 주간 달력은 좌우로 스크롤되어야 함 (일주일 단위로 끊기도록)
- 선택한 날짜 표시
- 처음 들어갔을 때는 현재 날짜를 선택
- 오늘 이후의 날짜는 파란색 배경으로 표시
- 오늘 이전의 날짜는 회색 배경으로 표시
- 선택한 날짜는 상단의 텍스트에 반영됨
이렇게 진행되어야 한다.
그럼 바로 코드 작성을 들어가 보자.
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 // 선택된 날짜를 넘겨받기 위한 인터페이스
변수에 대한 설명은 달아놓은 주석으로 대체하겠다.
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로 정의해 준다.
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를 얻어올 수 있다.
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
식으로 요일을 비교할 수도 있다.
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)
}
표시해주어야 되기에 날짜 선택 시에 resetUi
함수에서 모든 날짜의 선택을 해제하고,setSelectedDate
함수에서 선택 시의 동작을 정의해 주었다.
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 날짜를 가져와 선택 날짜라면 표시를 해준다.
-> '오직 하나의' 날짜만 선택되도록 만들어줄 수 있음
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를 만들어준다.
달력이 실질적으로 나타날 프래그먼트이다.
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에 직접 기여해보고 싶다는 생각도 하게 됐다.