2023.02.28 - [안드로이드] - [ 안드로이드 ] 구글 캘린더 구현하기 (2) - CalendarView 라이브러리 속성 정리
기본 레이아웃
메인 레이아웃
FrameLayout 내에 WeekCalendarView와 CalendarView를 선언했다.
각 캘린더뷰의 visibilty 속성과 애니메이션을 활용하여 자연스럽게 전환되도록 구현하였다. 현재는 테스트이기에 체크 박스로 전환을 구현해놨지만, 실제 프로젝트에선 스크롤로 전환하는 방식으로 구현할 예정이다.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
android:orientation="vertical"
android:paddingStart="20dp"
android:paddingEnd="20dp"
tools:context=".CollapsibleCalendarViewActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="2023.02"
android:textSize="16sp"
android:textColor="#051B44"
android:fontFamily="@font/pretendard_regular"
/>
</LinearLayout>
<include
android:id="@+id/titlesContainer"
layout="@layout/calendar_day_titles_container"/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.kizitonwose.calendar.view.CalendarView
android:id="@+id/calendar_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:cv_dayViewResource="@layout/sub_calendar_day_layout"
app:cv_scrollPaged="true"/>
<com.kizitonwose.calendar.view.WeekCalendarView
android:id="@+id/week_calendar_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cv_dayViewResource="@layout/sub_calendar_day_layout"/>
<CheckBox
android:id="@+id/weekModeCheckBox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="20dp"
android:buttonTint="@color/black_20"
android:padding="2dp"
android:text="Week Mode"
android:textColor="@color/black_20"
android:textSize="18sp" />
</FrameLayout>
</LinearLayout>
달력 날짜 셀 레이아웃
달력의 날짜 셀 레이아웃이다. 현재 프로젝트에선 하나의 셀에 날짜와 도트로 단계를 표시해줄 예정이기 때문에 아래와 같이 선언해주었다.
도트는 가로로 최대 4개까지 생성되는데, 아직은 테스트 단계이므로 하나만 생성했다. ConstraintLayout을 이용해서 TextView의 Height가 match_parent여도 도트가 숨겨지지 않도록 구현했다.
<?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"
android:id="@+id/dayCellLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/dayTextView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textSize="14sp"
android:textColor="#1D1C2B"
android:fontFamily="@font/pretendard_regular"
android:layout_gravity="center_horizontal"
android:layout_margin="10dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:gravity="center"
/>
<View
android:id="@+id/dot"
android:layout_width="5dp"
android:layout_height="5dp"
android:background="@color/blue"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_gravity="center_horizontal"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
달력 날짜 선택 레이아웃
날짜를 선택했을 때, 배경에 표시될 레이아웃이다. res/drawable에 생성한다.
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/example_1_selection_color" />
</shape>
구현
WeekCalendarView와 CalendarView를 둘 다 사용하기 때문에, 캘린더 각각에 대한 세팅 코드를 작성해야한다.
WeekCalendarView와 CalendarView 둘 다 거의 코드가 유사하다. 동일하게 쓰이는 메소드들은 아래에 따로 작성하였다.
타이틀 세팅
타이틀은 동일하므로, 아래의 코드만 있으면 된다. 타이틀을 다르게 하고 싶으면 타이틀을 따로 선언한 뒤, 각 캘린더 뷰와 같이 visibilty 속성을 관리하면 된다.
/**
* 캘린더뷰 타이틀 세팅
*/
private fun settingTitlesContainer(){
titlesContainer.children
.map { it as TextView }
.forEachIndexed{ idx, textView ->
val dayOfWeek = daysOfWeek()[idx]
val title = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault())
textView.text = title
}
}
CalendarView 세팅
전체 코드
private fun setupMonthCalendar(
startMonth: YearMonth,
endMonth: YearMonth,
currentMonth: YearMonth,
daysOfWeek: List<DayOfWeek>,
){
/**
* 날짜 셀의 뷰 홀더 역할을 하는 클래스
*/
class DayViewContainer(view: View) : ViewContainer(view){
// MonthDayBinder에서 해당 변수에 data(CalendarDay)를 넣어줄 예정
lateinit var day: CalendarDay
var textView: TextView = view.findViewById(R.id.dayTextView)
init {
view.setOnClickListener {
// 날짜를 클릭했을 때 이벤트
if(day.position == DayPosition.MonthDate){
dateClicked(date = day.date)
Log.d("CollapisbleActivity", day.toString())
}
}
}
}
calendarView.dayBinder = object : MonthDayBinder<DayViewContainer> {
override fun create(view: View) = DayViewContainer(view)
/** container = 아래에서 생성한 DayViewContainer를 여기서 사용 */
override fun bind(container: DayViewContainer, data: CalendarDay) {
// DayViewContainer의 day 변수에 data 할당
container.day = data
bindDate(data.date, container.textView, data.position == DayPosition.MonthDate)
}
}
calendarView.apply {
monthScrollListener = { updateTitle() }
setup(startMonth, endMonth, daysOfWeek.first())
scrollToMonth(currentMonth)
}
}
CalendarView에 대한 세팅 작업은 setupCalendarView() 메소드에서 수행한다. 먼저 날짜 셀의 ViewHolder 역할을 하는 DayViewContainer 클래스를 생성한다.
DayViewContainer 클래스 생성
/**
* 날짜 셀의 뷰 홀더 역할을 하는 클래스
*/
class DayViewContainer(view: View) : ViewContainer(view){
// MonthDayBinder에서 해당 변수에 data(CalendarDay)를 넣어줄 예정
lateinit var day: CalendarDay
var textView: TextView = view.findViewById(R.id.dayTextView)
init {
// 날짜를 클릭했을 때 이벤트
view.setOnClickListener {
// 이번 달의 날짜여야 클릭 가능
if(day.position == DayPosition.MonthDate){
dateClicked(date = day.date)
Log.d("CollapisbleActivity", day.toString())
}
}
}
}
DayBinder 세팅
DayViewContainer 클래스를 작성했으면, 이를 이용해서 DayBinder를 세팅 해주어야한다. 전체 달력인 CalendarView의 경우 MonthDayBinder 인터페이스를 구현한 클래스를 넣어주면 된다.
calendarView.dayBinder = object : MonthDayBinder<DayViewContainer> {
override fun create(view: View) = DayViewContainer(view)
/** container = 아래에서 생성한 DayViewContainer를 여기서 사용 */
override fun bind(container: DayViewContainer, data: CalendarDay) {
// DayViewContainer의 day 변수에 data 할당
container.day = data
bindDate(data.date, container.textView, data.position == DayPosition.MonthDate)
}
}
CalendarView 초기 상태 설정
monthScrollListener는 스크롤하여 달이 바뀌었을 때의 이벤트를 캐치하는 리스너이다.
달이 바뀔 때마다 타이틀에 현재 달을 표시하도록 지정한다. 날짜 범위, 시작 날짜를 선택하고, 현재 달로 스크롤 되도록 설정한다.
calendarView.apply {
monthScrollListener = { updateTitle() }
setup(startMonth, endMonth, daysOfWeek.first())
scrollToMonth(currentMonth)
}
WeekCalendarView 세팅
전체 코드
private fun setupWeekCalendar(
startMonth: YearMonth,
endMonth: YearMonth,
currentMonth: YearMonth,
daysOfWeek: List<DayOfWeek>,
){
class WeekDayViewContainer(view: View) : ViewContainer(view) {
lateinit var day: WeekDay
var textView: TextView = view.findViewById(R.id.dayTextView)
init {
view.setOnClickListener {
if(day.position == WeekDayPosition.RangeDate){
dateClicked(date = day.date)
Log.d("CollapisbleActivity", day.toString())
}
}
}
}
weekCalendarView.apply {
dayBinder = object : WeekDayBinder<WeekDayViewContainer> {
override fun create(view: View): WeekDayViewContainer = WeekDayViewContainer(view)
override fun bind(container: WeekDayViewContainer, data: WeekDay) {
container.day = data
bindDate(data.date, container.textView, data.position == WeekDayPosition.RangeDate)
}
}
weekScrollListener = { updateTitle() }
setup(
startMonth.atStartOfMonth(),
endMonth.atEndOfMonth(),
daysOfWeek.first(),
)
scrollToWeek(currentMonth.atStartOfMonth())
WeekDayViewContainer 클래스 생성
CalendarView와 동일하게 먼저 날짜 셀의 ViewHolder 역할을 하는 WeekDayViewContainer 클래스를 생성한다.
동일한 날짜 셀을 사용하고 있으므로, 거의 코드가 동일하다. WeekCalendarView의 날짜 셀 모양은 다르게 하고 싶다면, 그에 맞춰서 작성해주면 된다.
class WeekDayViewContainer(view: View) : ViewContainer(view) {
lateinit var day: WeekDay
var textView: TextView = view.findViewById(R.id.dayTextView)
init {
view.setOnClickListener {
if(day.position == WeekDayPosition.RangeDate){
dateClicked(date = day.date)
Log.d("CollapisbleActivity", day.toString())
}
}
}
}
DayBinder 세팅
WeekDayBinder 인터페이스를 구현하여 할당해준다.
weekCalendarView.apply {
dayBinder = object : WeekDayBinder<WeekDayViewContainer> {
override fun create(view: View): WeekDayViewContainer = WeekDayViewContainer(view)
override fun bind(container: WeekDayViewContainer, data: WeekDay) {
container.day = data
bindDate(data.date, container.textView, data.position == WeekDayPosition.RangeDate)
}
}
weekScrollListener = { updateTitle() }
setup(
startMonth.atStartOfMonth(),
endMonth.atEndOfMonth(),
daysOfWeek.first(),
)
scrollToWeek(currentMonth.atStartOfMonth())
}
CalendarView와 거의 똑같은 방식으로 초기화할 수 있다. 다른 점이라면 setup() 메소드에서 startMonth.atStartOfMonth()와 endMonth.atEndOfMonth()를 넣어준다는 점이다.
기타 메소드
bindDate 메소드
날짜 셀 안의 데이터를 세팅하고, 선택 유무, inDates, outDates에 따라 각각 텍스트 색상과 배경 등을 지정하는 메소드이다.
private fun bindDate(date: LocalDate, textView: TextView, isSelectable: Boolean){
textView.text = date.dayOfMonth.toString()
if(isSelectable){
when{
selectedDates.contains(date) -> { // 선택한 날짜인 경우
textView.setTextColor(resources.getColor(R.color.white))
textView.setBackgroundResource(R.drawable.calendar_selection_bg)
}
today == date -> { // 오늘 날짜는 별도로 표시
textView.setTextColor(resources.getColor(R.color.black_20))
textView.setBackgroundResource(R.drawable.calendar_today_bg)
}
else -> { // 선택 해제된 날짜인 경우
textView.setTextColor(resources.getColor(R.color.black_20))
textView.background = null
}
}
} else { // 해당 달에 포함되지 않는 날짜들은 gray 처리
textView.setTextColor(resources.getColor(R.color.gray))
textView.background = null
}
}
현재는 여러 날짜를 선택할 수 있게 되어 있어서, selectedDates라는 Set으로 선택된 날짜를 관리하고 있다. 하나의 날짜만 선택할 수 있는 경우라면 Set이 아닌 단일 변수로 관리해주면 될 듯하다.
dateClicked 메소드
마찬가지로 선택된 날짜들을 Set으로 관리하고 있어서 해당 날짜가 클릭 되었을 때 이미 Set에 존재한다면 선택을 해제한 경우이므로 remove, 반대의 경우는 선택한 것이므로 add를 해주고 있다.
그리고 각 캘린더 뷰에 변경사항을 알려준다.
private fun dateClicked(date: LocalDate) {
if (selectedDates.contains(date)) {
selectedDates.remove(date)
} else {
selectedDates.add(date)
}
// Refresh both calendar views..
calendarView.notifyDateChanged(date)
weekCalendarView.notifyDateChanged(date)
}
weekModeToggled 클래스
체크 박스를 눌렀을 때, WeekCalendarView <-> CalendarView로 전환이 되도록 토글 클래스를 작성한다.
CompoundButton.OnCheckedChangeListener를 구현하는 익명 클래스로 작성하였다.
private val weekModeToggled = object : CompoundButton.OnCheckedChangeListener {
override fun onCheckedChanged(buttonView: CompoundButton, monthToWeek: Boolean) {
// Month 모드에서 첫 번째 날이 Week 모드의 시작점이 되도록 설정
/** 이 부분을 수정하면, 선택된 날에 맞게 변할 수 있도록 할 수 있을 듯 */
if (monthToWeek) { // Week 모드로 변경
val targetDate = calendarView.findFirstVisibleDay()?.date ?: return
weekCalendarView.scrollToWeek(selectedDates.lastOrNull()?:targetDate)
} else { // Month 모드로 변경
// Week 모드에서 보이는 마지막 날의 달과 다음 달이 보이도록 설정
val targetMonth = weekCalendarView.findLastVisibleDay()?.date?.yearMonth ?: return
calendarView.scrollToMonth(targetMonth)
}
val weekHeight = weekCalendarView.height
// If OutDateStyle is EndOfGrid, you could simply multiply weekHeight by 6.
val visibleMonthHeight = weekHeight *
calendarView.findFirstVisibleMonth()?.weekDays.orEmpty().count()
val oldHeight = if (monthToWeek) visibleMonthHeight else weekHeight
val newHeight = if (monthToWeek) weekHeight else visibleMonthHeight
// 캘린더 높이 변경 애니메이션 (oldHeight -> newHeight)
val animator = ValueAnimator.ofInt(oldHeight, newHeight)
animator.addUpdateListener { anim ->
calendarView.updateLayoutParams {
height = anim.animatedValue as Int
}
// A bug is causing the month calendar to not redraw its children
// with the updated height during animation, this is a workaround.
calendarView.children.forEach { child ->
child.requestLayout()
}
}
animator.apply {
doOnStart {
if (!monthToWeek) { // Week -> Month로 변경하는 경우
weekCalendarView.isInvisible = true // Week뷰 숨기기
calendarView.isVisible = true // Month뷰 보이기
}
}
doOnEnd {
if (monthToWeek) { // Month -> Week로 변경하는 경우
weekCalendarView.isVisible = true // Week뷰 보이기
calendarView.isInvisible = true // Month뷰 숨기기
} else {
// Allow the month calendar to be able to expand to 6-week months
// in case we animated using the height of a visible 5-week month.
// Not needed if OutDateStyle is EndOfGrid.
calendarView.updateLayoutParams { height = WRAP_CONTENT }
}
updateTitle()
}
duration = 250
start()
}
}
}
중요한 부분
CalendarViwe에서 WeekCalendarView로 전환될 때, 선택한 날짜가 있는 줄이 보이게 달력이 접히는 것을 원했다.
Week 모드로 변경할 때, 선택한 날짜로 스크롤 되도록하여 선택된 날짜의 주가 보이도록 접히게 구현했다.
아무것도 선택하지 않은 경우는 첫 번째 줄이 보이게 된다.
if (monthToWeek) { // Week 모드로 변경
val targetDate = calendarView.findFirstVisibleDay()?.date ?: return
weekCalendarView.scrollToWeek(selectedDates.lastOrNull()?:targetDate)
} else { // Month 모드로 변경
// Week 모드에서 보이는 마지막 날의 달과 다음 달이 보이도록 설정
val targetMonth = weekCalendarView.findLastVisibleDay()?.date?.yearMonth ?: return
calendarView.scrollToMonth(targetMonth)
}
실행 결과
'안드로이드' 카테고리의 다른 글
[ 안드로이드 ] 구글 캘린더 구현하기 (2) - CalendarView 라이브러리 속성 정리 (0) | 2023.02.28 |
---|---|
[ 안드로이드 ] 구글 캘린더 구현하기 (1) - CalendarView 라이브러리 추천 / 예제 (0) | 2023.02.27 |
[ 안드로이드 ] 멘토링 과제 1. (1) | 2023.02.13 |