Finut을 개발하면서 여러 종류의 캘린더를 만들어야 했다.
기존에 만든 Calendar는 단순히 RecyclerView를 활용했었는데, View가 재활용되지도 않고 성능도 별로 좋지 않아 의미가 없었다.
Calendar UI가 새로 디자인 되어서 이번 기회에 새롭게 Custom 하였다.
해당 포스팅은 하단의 결과물과는 달리 스케줄을 표시하는 기능을 제외하고 기본적인 Calendar를 만드는 과정만 다루고 있습니다...!!
1️⃣ fragment_calendar.xml
- linearlayout_days: 월화수목금토일을 나타내주는 레이아웃
- viewpager_calendar: 월별 날짜를 나타내주는 레이아웃
- linearlayout_days에는 동적으로 요일을 넣어주었다.
- 추후에 요일을 나타내는 부분도 새롭게 컴포넌트로 만들 예정이다!
<LinearLayout
android:id="@+id/linearlayout_days"
android:layout_width="0dp"
android:layout_marginTop="24dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@id/constraintlayout_month"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewpager_calendar"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintTop_toBottomOf="@id/linearlayout_days"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
</androidx.viewpager2.widget.ViewPager2>
2️⃣ CalendarDayView.kt
-요일을 나타내주는 역할을 하며 View를 상속받아 만들었다.
-width는 마진 없이 화면을 채울 예정이라 화면의 가로 길이를 7로 나누어주었다.
class CalendarDayView @JvmOverloads constructor(
private val day: String,
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = -1
): AppCompatTextView(context,attrs,defStyle) {
init {
typeface = ResourcesCompat.getFont(context, R.font.spoqa_han_sans_neo_light)
text = day
textSize = 13.toFloat()
gravity = Gravity.CENTER
background = ContextCompat.getDrawable(context, R.drawable.shape_top_stroke_c4_30_border)
setPadding(0, 15,0,20)
setTextColor(context.getColor(if (day == "일") R.color.sub_color_red else R.color.black))
width = context.getDeviceWidth() / 7
}
}
3️⃣ CalendarFragment.kt
- 예전에 요일을 편하게 사용하려고 DaysEnum을 만들어 놨었는데, 이 값들을 활용해서 요일을 추가해주었다.
private fun setDays() {
DayEnum.values().forEach {
binding.linearlayoutDays.addView(CalendarDayView(it.day, requireContext()))
}
}
enum class DayEnum(val dayCode: Int, val day: String) {
SUNDAY(Calendar.SUNDAY, "일"),
MONDAY(Calendar.MONDAY, "월"),
TUESDAY(Calendar.TUESDAY, "화"),
WEDNESDAY(Calendar.WEDNESDAY, "수"),
THURSDAY(Calendar.THURSDAY, "목"),
FRIDAY(Calendar.FRIDAY, "금"),
SATURDAY(Calendar.SATURDAY, "토");
}
4️⃣ CalendarDateFragment.kt
- 각 월별 날짜를 나타내주는 Fragment이다.
- 해당 프래그먼트에는 Custom으로 만든 CalendarLayout을 추가해주고, PagerAdapter를 CalendarFragment에 붙여주어야한다.
- CalendarLayout만드는 과정은 뒤에서 설명하도록 하겠다.
<layout>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<son.peace.com.CalendarLayout
android:id="@+id/calendar"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>
</layout>
5️⃣ CalendarDateView.kt
- 각 날짜 하나하나의 아이템 View이다. 날짜 정보를 갖고 있는 DateEntity를 생성자에 추가하였다.
- 이번 달이 아닌 날짜는 회색으로 날짜를 보여줄 예정이기 때문에 해당 아이템이 이번달이 아닌지를 판별할 수 있는 isThisMonth도 추가해주었다.
class CalendarDateView @JvmOverloads constructor(
context: Context,
private val date: DateEntity,
private val isThisMonth: Boolean,
attrs: AttributeSet? = null
): View(context, attrs)
- 전역 변수로 Canvas, Paint, Rect를 추가해주었다.
- Rect()는 Canvas를 이용하여 Text를 그릴 때 정확한 위치에 그리기 위해 추가해주었다.
private val bounds = Rect()
private lateinit var canvas: Canvas
var paint = Paint()
✅ onDraw()
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (canvas == null) return
this.canvas = canvas
paint.getTextBounds(date.calendarDate, 0, date.calendarDate.length, bounds)
with(canvas) {
drawTodayCircle()
drawDate()
drawRecordCircle(recordPerDate)
}
}
- View에서 화면을 다시 그릴 때 자동적으로 호출 되는 메서드이다.
- 이 메소드에서 우리가 그려야할 날짜나 오늘인지를 표시해주는 마킹을 그리면 된다.
- Canvas에 Text를 출력하기 전에 getTextBounds()메서드와 Rect를 이용하여 정확한 Text의 크기를 알아낼 수 있다.
✅ DrawText()
-상단은 각 날짜별 TextColor를 다르게 하기 위해 paint의 color를 설정해주는 코드이다.
-drawText() 함수에 Text와 x,y위치값, paint를 추가해주면 Canvas에 Text를 그릴 수 있다. ( x,y값은 좌하단의 좌표이다. )
private fun Canvas.drawDate() {
paint.color = ContextCompat.getColor(
context,
when {
!isThisMonth -> R.color.black_gray3
date.calendarDayIndex == 0 -> R.color.sub_color_red
date.isTodayDate -> R.color.white
isThisMonth -> R.color.black
else -> R.color.black_gray3
}
)
drawText(
date.calendarDate,
((width - bounds.width()) / 2).toFloat(),
(height / 3.5).toFloat(),
paint
)
}
✅ drawCircle()
원을 그리는 방법도 Text를 그리는 법과 비슷하다.
paint를 이용하여 색을 지정해주고 중심점(x,y)를 drawCircle()메서드에 추가해주면 된다.
private fun Canvas.drawTodayCircle() {
paint.color = ContextCompat.getColor(context, R.color.black)
if (date.isTodayDate) drawCircle(
(width / 2).toFloat(),
(height / 3.5 - bounds.height()/2).toFloat(),
bounds.height().toFloat() + 3,
paint
)
}
6️⃣ CalendarLayout.kt
CalendarLayout은 View들을 담아야하기 때문에 ViewGroup을 상속하여 만들어준다.
class CalendarLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
): ViewGroup(context, attrs)
앞서 만들었던 CalendarDateView들을 추가해야한다.
fun initCalendar(selectedMonth: String?, list: List<DateEntity>) {
list.forEach {
val view = CalendarDateView(
context = context,
date = it,
isThisMonth = selectedMonth == it.month
)
view.id = it.hashCode()
addView(view)
}
}
이후 ViewGroup의 onLayout() 함수에서 각 view들이 그려질 위치들을 설정하면 된다.
override fun onLayout(p0: Boolean, p1: Int, p2: Int, p3: Int, p4: Int) {
val dayWidth = width / DAYS_PER_WEEK
val dayHeight = ((height * 0.7)/ WEEKS_PER_MONTH).toInt()
var idx = 0
children.forEach { view ->
val left = (idx % DAYS_PER_WEEK) * dayWidth
val top = (idx / DAYS_PER_WEEK) * dayHeight
view.layout(left, top, left + dayWidth, top + dayHeight)
idx++
}
}
7️⃣ PagerAdapter
이제 ViewPager에 어댑터를 만들어 달아주면 된다.
FragmentStateAdapter를 상속받아 어댑터를 만들어준다.
좌우로 넘겨도 무한히 넘어가는 Pager를 만들 예정이니 getItemCount에는 Int.MAX_VALUE를 설정해준다.
createFragment에는 CalendarDateFragment를 만들어 각 월에 대한 time을 bundle로 넘겨준다.
getItemId에서는 각 아이템에 대한 id를 지정해주면 되는데 해당 월의 time을 id로 설정해주었다.
(START_POSITION은 Item 갯수의 중간 값 Int.MAX_VALUE / 2이다.)
class ClientCalendarPagerAdapter(
fragment: Fragment,
val calendarUtil: CalendarUtil,
): FragmentStateAdapter(fragment) {
private var initialTimeMills = calendarUtil.getFirstDateOfCurrentMonth(Date().time)
override fun getItemCount(): Int = Int.MAX_VALUE
override fun createFragment(position: Int): Fragment {
val calendarDatesFragment = CalendarDateFragment()
val bundle = Bundle()
bundle.putLong(BUNDLE_TIME_MILLS, getItemId(position))
calendarDatesFragment.arguments = bundle
return calendarDatesFragment
}
override fun getItemId(position: Int): Long {
return calendarUtil.changeMonth(initialTimeMills, position - START_POSITION)
}
}
8️⃣ CalendarFragment.kt
viewPager에 어댑터를 붙여주고 currentItem은 초기에 Int.MAX_VALUE로 설정해준다.
이후 page가 앞뒤로 넘어갈 때마다 각 month또한 변경시켜주면 된다.
binding.viewpagerCalendar.run {
datesPagerAdapter = ClientCalendarPagerAdapter(this@ClientCalendarFragment, calendarUtil)
adapter = datesPagerAdapter
setCurrentItem(START_POSITION,false)
registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
val timeMills = datesPagerAdapter.getItemId(position)
clientCalendarViewModel.changeSelectedMonthTime(timeMills)
}
})
clientCalendarViewModel.changeSelectedMonthTime(datesPagerAdapter.getItemId(START_POSITION))
}
'Android' 카테고리의 다른 글
| MVI( Model - View - Intent ) (0) | 2022.08.16 |
|---|---|
| Architecture Patterns (MVC, MVP, MVVM) (0) | 2022.08.16 |
| [Jetpack Compose] State (0) | 2022.04.22 |
| Jetpack Compose로 마이그레이션하기 (1) (0) | 2022.04.20 |
| [Android] Fragment간 데이터 공유(Navigation, SafeArgs) (0) | 2021.05.07 |