디자인으로 위와 같은 달력 구현을 요구받았다.
화면에서는 바텀시트처럼 띄워줘야 했다.
커스텀을 위해 MaterialCalendar
활용 대신 직접 구현하는 방식을 택했다.
PM과 논의한 결과 월, 년도 선택은 나중에 구현하기로 했고, 일 선택 화면만 우선 구현하기로 했다.
그럼, 피그마 화면에서의 요구사항을 한 번 정리해 보자.
[요구사항]
1. 맨 처음에는 오늘 날짜가 세팅되어 있음 (날짜에 초록색 배경 원 표시)
2. 오늘 이전 날짜는 비활성화 처리
3. 상단 화살표를 통해 달을 이동할 수 있음
4. 날짜를 클릭한 후에는 텍스트뷰에 선택 날짜 반영
아이디어는 여느때와 같이 리사이클러뷰를 활용하는 것이다. 한 주는 7일로 고정이니, GridLayoutManager를 사용할 계획이다.
<?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를 동그라미로 해주고, 레이아웃 안에 텍스트뷰를 넣어준다.
배경색을 지정하면 오른쪽 같은 느낌이다.
<?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를 사용해서 해줄 수 있다.
디자인 탭에서 보면 이런 모습이다.
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
에서 리사이클러뷰 날짜를 위한 코드를 작성해 준다. 오늘 이전 날짜는 회색으로 표시하고, 선택 날짜는 초록색 동그라미 배경 + 글자색 흰색 + 볼드 처리를 해준다.
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
DateClickListener
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면 이전 날짜도 그대로 표시할 수 있도록!
맨 처음에는 오늘 날짜로 달력이 설정되고, 화살표를 눌러 월을 이동하는 것, 과거 날짜는 회색 처리가 되어있는 것, 클릭했을 때 텍스트뷰에 반영되는 것까지! 요구사항대로 구현이 모두 끝난 것을 확인할 수 있다.
👉🏻 달력/피커 관련 다른 글 보러가기