[Android] Custom Calendar 만들기 - Infinite ViewPager 구현
Android

[Android] Custom Calendar 만들기 - Infinite ViewPager 구현

728x90

 

 

 지난 프로젝트에서 Calendar를 만들게 되었는데, 만드는 과정에서 까다로웠던 부분이 있어서 블로그에 공유하려고 한다. 월간 달력 부분을 만들어야 했고, 처음에는 간단하게 생각했다. 월간 달력은 그리드 형태기 때문에 GridView나 RecyclerView를 사용하려 했고, 좌우로 스와이프가 되어야 했기에 ViewPager2를 사용하여 구현하면 금방 할 줄 알았다. 하지만...

 

1. Infinite ViewPager

 월간 달력은 좌우로 무제한으로 스와이프 되어야 한다는 조건이 있었다. 이걸 어떻게 구현해야 할까 고민하다가 찾아보니 스와이프 할 때마다 페이지를 추가, 삭제하면서 현재 position 값을 고정적으로 유지하는 방법이 있었다. 

 위의 사진과 같이 전체 item 개수를 3개로 고정한 뒤, 오른쪽으로 스와이프 하면 오른쪽에 페이지를 하나 추가한 뒤, 뒤에 있는 페이지를 삭제한다. 이런 방법을 사용하면 무제한 스와이프는 가능해지지만, 스와이프가 일어날 때마다 계산해야 하는 동작이 너무 많아서 느리다는 문제가 있었다.

 

 따라서 다른 방법을 찾아야만 했고, 완벽하게 무한 스와이프는 아니지만 거의 근접한 방법을 찾을 수 있었다.

class CalendarAdapter(fm: FragmentActivity) : FragmentStateAdapter(fm) {

    /* 달의 첫 번째 Day timeInMillis*/
    private var start: Long = DateTime().withDayOfMonth(1).withTimeAtStartOfDay().millis

    override fun getItemCount(): Int = Int.MAX_VALUE

    override fun createFragment(position: Int): CalendarFragment {
        val millis = getItemId(position)
        return CalendarFragment.newInstance(millis)
    }

    override fun getItemId(position: Int): Long
            = DateTime(start).plusMonths(position - START_POSITION).millis

    override fun containsItem(itemId: Long): Boolean {
        val date = DateTime(itemId)
        return date.dayOfMonth == 1 && date.millisOfDay == 0
    }

    companion object {
        const val START_POSITION = Int.MAX_VALUE / 2
    }
}

 우선 전체 item의 개수는 Int.MAX_VALUE로 잡아준다. 이 때문에 완전하게 무한 스와이프가 가능한 달력은 아니다. 어차피 한 방향으로 10억 번 이상 스와이프 할 사람은 없을 거라 생각하기 때문에 상관없다고 본다. 그리고 뷰페이저에서 맨 처음 보여줄 화면은 오늘을 기준으로 잡고 position은 Int.MAX_VALUE의 절반으로 잡는다.

 일반적인 뷰페이저는 list를 private으로 하나 선언한 뒤, 해당 list의 item을 페이지로 반환한다. 하지만 이 코드에서는 list를 선언하지 않고, getItemId() 메서드를 사용해서 해당 position에 해당하는 페이지를 반환한다.

 그러려면 position을 비교할 기준점이 필요하다. 그걸 오늘 날짜로 잡고 start라는 변수를 오늘 날짜의 첫 번째 일의 millis 값으로 잡는다. 또한 불필요한 값은 버그를 발생할 수 있기 때문에 시, 분, 초는 0으로 초기화해준다. 예를 들어 오늘 날짜가 11월 27일이라고 하면 11월 1일 00시 00분 00초의 millis 값이다.

 

override fun getItemId(position: Int): Long
        = DateTime(start).plusMonths(position - START_POSITION).millis

 getItemId() 메서드는 해당 position의 고유 id를 반환하는 메서드다. 서로 다른 달의 millis 값이 같은 일은 없기 때문에 이를 활용해서 해당 position의 millis 값을 반환한다.

 

override fun containsItem(itemId: Long): Boolean {
    val date = DateTime(itemId)
    return date.dayOfMonth == 1 && date.millisOfDay == 0
}

 containsItem() 메서드는 해당 itemId가 유효한 id인지를 판별한다. getItemId()를 오버라이드 했기 때문에 containsItem() 메서드도 함께 오버라이드 해줘야 한다. itemId를 만들 때 매달 1일에 0시 0분 0초를 기준으로 했기 때문에 이를 기준으로 하여 판별해주면 된다.

 

 ViewPager2의 어댑터를 설정한 뒤, Activity에서 currentPosition을 START_POSITION으로 설정해준다.

class MainActivity : AppCompatActivity() {

    private lateinit var calendarAdapter: CalendarAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        calendarAdapter = CalendarAdapter(this)

        calendar.adapter = calendarAdapter
        calendar.setCurrentItem(CalendarAdapter.START_POSITION, false)
    }
}

 

 

2. RecyclerView 대신 CustomView

 월간 달력에서 달 하나에는 42개의 날짜가 있다. 이를 구현하기 위해 처음엔 RecyclerView를 Grid 방식으로 해서 42개의 item을 넣어줬었다. 하지만 문제는 너무 느렸다. 좌우로 스와이프 할 때마다 보고 있는 날짜를 색칠해주는 기능이 있었는데 색칠해주는 시간이 1초에서 2초 정도 걸렸다. 나중에 일정들이 들어오면 달력에 그려줘야 하는데, 일정들이 많아지면 더 느려질 것이 눈에 훤했다.

 느려지는 원인을 찾아보다가 같은 문제를 겪고 CustomView로 바꿨다는 글을 보게 되었고, 바로 갈아타게 되었다. ViewGroup을 오버라이드 한 CalendarView에서 42개의 DayItemView를 추가해준 뒤, 각각 item에서 요일을 그려준다.

class CalendarView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    @AttrRes defStyleAttr: Int = R.attr.calendarViewStyle,
    @StyleRes defStyleRes: Int = R.style.Calendar_CalendarViewStyle
) : ViewGroup(ContextThemeWrapper(context, defStyleRes), attrs, defStyleAttr) {

    private var _height: Float = 0f

    init {
        context.withStyledAttributes(attrs, R.styleable.CalendarView, defStyleAttr, defStyleRes) {
            _height = getDimension(R.styleable.CalendarView_dayHeight, 0f)
        }
    }

    /**
     * Measure
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val h = paddingTop + paddingBottom + max(suggestedMinimumHeight, (_height * WEEKS_PER_MONTH).toInt())
        setMeasuredDimension(getDefaultSize(suggestedMinimumWidth, widthMeasureSpec), h)
    }

    /**
     * Layout
     */
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val iWidth = (width / DAYS_PER_WEEK).toFloat()
        val iHeight = (height / WEEKS_PER_MONTH).toFloat()

        var index = 0
        children.forEach { view ->
            val left = (index % DAYS_PER_WEEK) * iWidth
            val top = (index / DAYS_PER_WEEK) * iHeight

            view.layout(left.toInt(), top.toInt(), (left + iWidth).toInt(), (top + iHeight).toInt())

            index++
        }
    }

    /**
     * 달력 그리기 시작한다.
     * @param firstDayOfMonth   한 달의 시작 요일
     * @param list              달력이 가지고 있는 요일과 이벤트 목록 (총 42개)
     */
    fun initCalendar(firstDayOfMonth: DateTime, list: List<DateTime>) {
        list.forEach {
            addView(DayItemView(
                context = context,
                date = it,
                firstDayOfMonth = firstDayOfMonth
            ))
        }
    }
}
class DayItemView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    @AttrRes private val defStyleAttr: Int = R.attr.itemViewStyle,
    @StyleRes private val defStyleRes: Int = R.style.Calendar_ItemViewStyle,
    private val date: DateTime = DateTime(),
    private val firstDayOfMonth: DateTime = DateTime()
) : View(ContextThemeWrapper(context, defStyleRes), attrs, defStyleAttr) {

    private val bounds = Rect()

    private var paint: Paint = Paint()

    init {
        /* Attributes */
        context.withStyledAttributes(attrs, R.styleable.CalendarView, defStyleAttr, defStyleRes) {
            val dayTextSize = getDimensionPixelSize(R.styleable.CalendarView_dayTextSize, 0).toFloat()

            /* 흰색 배경에 유색 글씨 */
            paint = TextPaint().apply {
                isAntiAlias = true
                textSize = dayTextSize
                color = getDateColor(date.dayOfWeek)
                if (!isSameMonth(date, firstDayOfMonth)) {
                    alpha = 50
                }
            }
        }
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        if (canvas == null) return

        val date = date.dayOfMonth.toString()
        paint.getTextBounds(date, 0, date.length, bounds)
        canvas.drawText(
            date,
            (width / 2 - bounds.width() / 2).toFloat() - 2,
            (height / 2 + bounds.height() / 2).toFloat(),
            paint
        )
    }
}

 

 

  전체 코드 : github.com/tkdgusl94/CustomCalendar

 

tkdgusl94/CustomCalendar

Contribute to tkdgusl94/CustomCalendar development by creating an account on GitHub.

github.com

 

728x90