본문 바로가기
KUIT-앱 개발 프로젝트 동아리

10주차 실습(2)-직접 calendar 만들기

by 농농씨 2023. 12. 9.

지난 글에서는 CalendarView와 Material 라이브러리의 MaterialCalendarView를 이용하여 캘린더를 만들어보았다.

이런 라이브러리를 이용하여도 커스텀에 한계가 있을 수 있기 때문에 직접 캘린더를 만드는 것을 연습해 볼 것이다.

 

1. 액티비티 만들기

액티비티 있는 폴더 우클릭(또는 Ctrl+N)-New-Activity

이름은 CalendarActivity로 액티비티를 하나 새로 만들어주자.

2. 뷰바인딩

lateinit~

binding = ~

setContentView(binding.root)

세줄을 추가 및 수정해주자.

package com.iyr.a10thweek2

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.iyr.a10thweek2.databinding.ActivityCalendarBinding

class CalendarActivity : AppCompatActivity() {
    lateinit var binding: ActivityCalendarBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityCalendarBinding.inflate(layoutInflater)
        setContentView(binding.root)
    }
}

 

3. 레이아웃 추가

3-1. 연/월을 위한 LinearLayout 추가

3-2. 요일(weekday)을 위한 Recycler View 추가

List View도 가능

3-3. 일(date)을 위한 recycler view 추가

나중에 grid 형태로 설정할 것임

 

// activity_calendar.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".CalendarActivity">

    <androidx.appcompat.widget.LinearLayoutCompat
        android:id="@+id/ll_ym"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        android:gravity="center">
        <TextView
            android:id="@+id/tv_year"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="23년"/>
        <TextView
            android:id="@+id/tv_month"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="11월"/>
    </androidx.appcompat.widget.LinearLayoutCompat>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_week"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/ll_ym"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_date"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@id/rv_week"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

4. 요일(Week) 리사이클러 뷰 만들어주기

4-1. 바인딩 후 LayoutManager 설정

date를 위한 바인딩도 미리 해줬다.

class CalendarActivity : AppCompatActivity() {
    lateinit var binding: ActivityCalendarBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        binding.rvWeek.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
        // 요일이니까 LinearLayoutManager-Horizontal 로 설정
        binding.rvDate.layoutManager = GridLayoutManager(this, 7)
        // GridLayoutManager 두번째 인자에 한줄에 몇개까지 넣을건지 설정
    }
}

 

4-2. 리사이클러뷰 어댑터 만들기

(어댑터코드는 이전에 썼던 거 복붙해도 됨)

// WeekAdapter.kt에서
class WeekAdapter(${val week (요일 데이터 받아오기)}) : RecyclerView.Adapter<WeekAdapter.ViewHolder>() {

    inner class ViewHolder(val binding : ${아이템 레이아웃 바인딩}) : RecyclerView.ViewHolder(binding.root) {
        fun bind() {

        }
    }
    // + Adapter에 필요한 3가지 메서드들
}

 

4-3. item 레이아웃 만들고, 어댑터 수정하고, 적용하기

 

// item_calendar.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="50dp"
    android:layout_height="50dp"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <TextView
        android:id="@+id/tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="1"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
// WeekAdapter.kt
package com.iyr.a10thweek2

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.iyr.a10thweek2.databinding.ItemCalendarBinding

class WeekAdapter(val week : ArrayList<String>) : RecyclerView.Adapter<WeekAdapter.ViewHolder>() {

    inner class ViewHolder(val binding : ItemCalendarBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item : String) {
            binding.tv.text = item
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeekAdapter.ViewHolder {
        val binding = ItemCalendarBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun getItemCount(): Int {
        return week.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position : Int) {
        holder.bind(week[position])
    }
}

/*

<리사이클러 뷰 복습>

-어댑터는 RecyclerView의 Adapter를 상속받도록 한다.

-그 Adapter의 파라미터는 내부에 inner class로 ViewHolder를 선언해서 전달해준다.

-inner class인 ViewHolder는 파라미터로 아이템 레이아웃을 바인딩해오고, RecyclerView의 ViewHolder에 binding.root를 넣은 것을 상속받는다.

-ViewHolder 내부의 함수인 fun bind는 fun getItemCount를 통해, position으로 array의 아이템을 전달받아서 이를 텍스트뷰에 세팅한다.

-fun onCreateViewHolder는 아이템 레이아웃을 inflate해서 객체화해서 이를 inner class인 ViewHolder에 넣어 반환한다.

-fun getItemSize는 데이터의 개수를 반환한다.

-fun onBindViewHolder는 스크롤이 발생할 때마다 이를 감지해서 inner class ViewHolder의 bind에 item을 전달하며 호출한다. 

*/

 

그리고 CalendarActivity에 다음과 같은 코드를 추가해준다

class CalendarActivity : AppCompatActivity() {
    lateinit var binding: ActivityCalendarBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        binding.rvWeek.adapter = WeekAdapter(arrayListOf("M", "T", "W", "T", "F", "S", "S"))
    }
}

 

// 내가 만든 어댑터 클래스(WeekAdapter)에 arrayList 데이터(요일)를 전달하여,

이를 요일 리사이클러뷰(rvWeek)의 자체 어댑터에 적용하는 것이다.

 

실행해보면 다음과 같다.

요일이 잘 출력된 것을 알 수 있다.

(어플 이름은 갑자기 왜 출력된 거지?

참조: https://withthisclue.tistory.com/entry/Android-안드로이드-프로젝트-이름-없애기-타이틀바-없애기

)

상단의 프로젝트 이름을 지워주고 카메라에 뷰가 가려져서 약간의 margin까지 설정해주면 다음과 같이 실행된다.

 

 

5. 일(Date) 리사이클러 뷰 만들어주기

순서는 

recyclerView binding-> 어댑터만들기->아이템레이아웃 만들고, 어댑터 수정하고 데이터 전달해서 뷰에 적용하기

인데 아까 바인딩은 미리 해줬으므로 어댑터부터 만들자.

5-1. Week 어댑터 복붙

복붙하고 일부만 수정해준다.

package com.iyr.a10thweek2

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.iyr.a10thweek2.databinding.ItemCalendarBinding

class DateAdapter(val day : ArrayList<String>) : RecyclerView.Adapter<DateAdapter.ViewHolder>() {

    inner class ViewHolder(val binding : ItemCalendarBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item : String) {
            binding.tv.text = item
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DateAdapter.ViewHolder {
        val binding = ItemCalendarBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun getItemCount(): Int {
        return day.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position : Int) {
        holder.bind(day[position])
    }
}

 

5-2. 날짜 구성

앞뒤로 이전 달과 다음 달의 날짜를 미리 보여주는 것을 구상해볼 것이다. 다소 까다롭다.

차근차근 해보자.

 

5-2-1. 이번 달 설정

var cal = Calendar.getInstance() // 캘린더 객체 가져옴

캘린더 객체를 가져와서

 

cal.set(Calendar.DATE, 1) // 일(date)을 1일로 설정

1일로 설정해준다.

 

var startWeekday = cal.get(Calendar.DAY_OF_WEEK)
// 숫자로 현재 날짜의 요일 가져옴(일요일이면 1, 토요일이면 6)
// 그리고 1일의 요일을 달력의 시작 요일로 설정함

cal.get(Calendar.DAY_OF_WEEK) 함수로 현재 날짜의 요일을 가져오면, 일요일부터 토요일까지 숫자로 1부터 7까지 대응된다.

그리고 그걸 변수에 저장한다. 이번 달의 시작 요일을 저장하는 것이다.

 

var lastDay = cal.getActualMaximum(Calendar.DATE)

그리고 getActualMaximum 함수를 이용하여 현재 캘린더 객체의 날짜가 속한 달이 최대 며칠까지인지(마지막날)을 얻어와서 변수에 저장해준다.

 

 

5-2-2. 지난 달 설정

if (cal.get(Calendar.MONTH) == 1) { // 1월이면
    cal.set(cal.get(Calendar.YEAR) - 1, 12, 1)
    // 이전 해의 12월 1일을 캘린더 객체에 세팅
} else { // 1월이 아닌 나머지는
    cal.set(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) - 1, 1)
    // 이전 달의 1일을 세팅
}

현재 캘린더 객체의 Month 정보에서 -1 해서 지난 달을 세팅할 것인데, 1월이면 0이 되므로

if 문을 이용하여1월인 경우만 '지난 해의 12월'로 세팅해준다.

이때

cal.set(Calendar.YEAR - 1, 12, 1)

이런 식으로 하면 안되고 꼭 get 함수를 얻어서 현재의 캘린더 정보를 얻어야 한다.

 

var preMonthLastDay = cal.getActualMaximum(Calendar.DATE)
// 캘린더 객체의 마지막 일을 변수로 저장

그리고 이번 달의 마지막 정보를 저장했던 것처럼

지난 달이 몇달까지 있는지를 얻어와서 마지막 날짜를 구해 변수에 저장해준다.

 

 

5-2-3. 지난달, 이번달 리스트에 넣어주기

var dayList = ArrayList<String>() // 일 list로 빈 리스트 만들기

ArrayList로 빈 리스트를 만들어주고

// 이전 달 넣기
for (i in startWeekday - 2 downTo 0) {
    // 이전 달의 마지막 일부터 차례로 -5, -4, -3, -2, -1, -0 해줄거니까
    // 현재 요일이 토요일(7)이면 i는 5부터 0까지.
    dayList.add((preMonthLastDay - i).toString())
}

예를 들어 오늘이 12월 9일이고 12월 1일은 토요일이라서 시작요일 정보로 7을 얻었다.

그러면 일요일부터 금요일까지는 지난 달의 정보를 채워야 하므로

11월이 30일까지 있으면

30-5 30-4 30-3 30-2 30-1 30-0 1

이런 식으로 나머지 6개의 요일을 채워줘야 한다.

for 문을 이용해 i가 현재 요일 정보인 7을 토대로 7(startWeekday)-2=5부터 0까지 줄어들도록 했다.

그리고 dayList에는 지난 달의 마지막 날짜인 preMonthLastDay에서 이 i를 빼서 총 6개의 날짜가 입력되도록 했다.

 

// 이번 달 넣기
for (i in 1..lastDay)
    dayList.add(i.toString()) // 현재 달의 1일부터 마지막날까지 list에 추가

 

이번달을 넣어주는 것은 쉽다.

for 문을 이용하여 1부터 이번달의 마지막 날짜까지 i를 모두 넣어주면 된다.

 

5-2-4. 다음달 설정하고 넣기

// 다음 달 넣기
var dayCount = 1
while (dayList.size < 42) { // 다음달의 날짜는
    // 달력이 이전달, 다음달 포함해서 6줄 될때까지만 채운다.
    dayList.add(dayCount.toString())
    dayCount++ // 리스트에 1넣고 2되고, 다음 반복에서 리스트에 2넣고 3되고.
    // 리스트 사이즈가 43되면 더이상 반복 안함.
}

다음 달을 넣어주는 것도 쉽다.

달력의 한 면에 들어갈 수 있는 날짜는 일정하게 제한되어 있으므로

dayList의 크기가 (예를 들면) 7*6=42를 넘어가기 전까지 1부터 또 넣어주면 된다.

while문 밖에 변수를 1로 선언해주고

리스트에 그 변수를 넣은 다음 1씩 추가되게 했다.

리스트의 크기가 43이 되면 더이상 while 문을 반복하지 않는다.

 

5-2-4. 함수로 묶고 어댑터 적용하기

필수는 아니지만 가독성을 위해 달력의 리스트를 작성하는 코드는 따로 함수로 작성해주자

코드들을 드래그하고 우클릭-Refactor-Function을 누르면(또는 Command+option+M) 함수가 외부로 추출된다.

그리고 함수가 날짜 데이터를 담은 dayList를 반환 하도록 하고

이를 onCreate에서 dayList라는 변수에 담았다.

 

마지막으로 어댑터를 적용해주자(내가 만든 어댑터에 리스트 전달해서 뷰의 자체 어댑터에 넣어주기)

 

코드는 다음과 같다.

package com.iyr.a10thweek2

import ...

class CalendarActivity : AppCompatActivity() {
    lateinit var binding: ActivityCalendarBinding
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        var dayList = setDayList()
        binding.rvDate.adapter = DateAdapter(dayList)
    }

    private fun setDayList() : ArrayList<String> {
        var cal = Calendar.getInstance() // 캘린더 객체 가져옴
        cal.set(Calendar.DATE, 1) // 일(date)을 1일로 설정
        var startWeekday = cal.get(Calendar.DAY_OF_WEEK)
        // 숫자로 현재 날짜의 요일 가져옴(일요일이면 1, 토요일이면 6)
        // 그리고 1일의 요일을 달력의 시작 요일로 설정함
        var lastDay = cal.getActualMaximum(Calendar.DATE)
        // 현재 달의 가장 마지막 날짜를 얻어서 변수에 저장함

        // 이전 달 설정
        if (cal.get(Calendar.MONTH) == 1) { // 1월이면
            cal.set(cal.get(Calendar.YEAR) - 1, 12, 1)
            // 이전 해의 12월 1일을 캘린더 객체에 세팅
        } else { // 1월이 아닌 나머지는
            cal.set(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) - 1, 1)
            // 이전 달의 1일을 세팅
        }
        Log.d("qwerty1", cal.toString())

        var preMonthLastDay = cal.getActualMaximum(Calendar.DATE)
        // 캘린더 객체의 마지막 일을 변수로 저장
        Log.d("qwerty2", preMonthLastDay.toString())

        var dayList = ArrayList<String>() // 일 list로 빈 리스트 만들기

        // 이전 달 넣기
        for (i in startWeekday - 2 downTo 0) {
            // 이전 달의 마지막 일부터 차례로 -5, -4, -3, -2, -1, -0 해줄거니까
            // 현재 요일이 토요일(7)이면 i는 5부터 0까지.
            dayList.add((preMonthLastDay - i).toString())
        }

        // 이번 달 넣기
        for (i in 1..lastDay)
            dayList.add(i.toString()) // 현재 달의 1일부터 마지막날까지 list에 추가

        // 다음 달 넣기
        var dayCount = 1
        while (dayList.size < 42) { // 다음달의 날짜는
            // 달력이 이전달, 다음달 포함해서 6줄 될때까지만 채운다.
            dayList.add(dayCount.toString())
            dayCount++ // 리스트에 1넣고 2되고, 다음 반복에서 리스트에 2넣고 3되고.
            // 리스트 사이즈가 43되면 더이상 반복 안함.
        }
        return dayList
    }
}

 

실행해보면 다음과 같은 화면이 나온다.(요일 리사이클러뷰는 일요일부터 시작하도록 수정했다)

 

 

만약 이번 달이 아닌 지난 달과 다음 달의 날짜는 연하게 한다든지 커스텀을 하고 싶다면?

Day 객체를 만들어서 구분을 위한 flag 값을 객체에 삽입해서 

DateAdapter에서 String이 아닌 Day 객체가 왔을 때 flag값에 따라 글씨체나 색 등을 바꿀 수 있을 것이다.

 

캘린더 기초 실습을 해보았다. 이렇게 직접 만들면 커스텀이 쉽다는 장점이 있다.