[Android] Scroll Custom Calendar


![untitled.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3639815/e49943fa-a391-2a9d-82b3-a0f6c603b745.gif)


# *月別 カレンダー*

スクロールができるカスタムカレンダーを作成する必要があったため、研修で作成したカスタムカレンダーを参考にし、新しくカレンダーを作成しました。
スクロール時に月が変わる必要があるため、```ViewPager2```を活用し、```ViewPager2```のアダプターアイテムとして```RecyclerView```を入れて活用しました。

</br>

## *object*
日付関連の作業はオブジェクトを作成して実行するようにしました。


  ```kotlin
object Dates {

    fun generateDates(calendar: Calendar): List<Date> {
    
        val dates = mutableListOf<Date>()
        val cal = calendar.clone() as Calendar
        cal.set(Calendar.DAY_OF_MONTH, 1)

        // 月の最初の日の曜日を計算
        val firstDayOfWeek = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7

        // 前月の最後の日で埋める
        cal.add(Calendar.DAY_OF_MONTH, -firstDayOfWeek)
        for (i in 0 until firstDayOfWeek) {
            dates.add(cal.time)
            cal.add(Calendar.DAY_OF_MONTH, 1)
        }

        // 現在の月の日付を追加
        val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
        for (i in 0 until daysInMonth) {
            dates.add(cal.time)
            cal.add(Calendar.DAY_OF_MONTH, 1)
        }

        // 次の月の最初の日で埋める
        val lastDayOfWeek = (cal.get(Calendar.DAY_OF_WEEK) - Calendar.MONDAY + 6) % 7
        val remainingDays = 6 - lastDayOfWeek

        // 最後の日が日曜日ではないか、最後の日が日曜日でも次の月の1日が月曜日ではない場合にのみ埋める
        if (lastDayOfWeek != 6 || (remainingDays == 0 && cal.get(Calendar.DAY_OF_MONTH) != 1)) {
            for (i in 0 until remainingDays) {
                dates.add(cal.time)
                cal.add(Calendar.DAY_OF_MONTH, 1)
            }
        }

        return dates
    }
    
}

  ```
  
* calendarオブジェクトに基づいて、該当月の日付リストを生成し、現在の月の最初の日が何曜日か計算し、月曜日を開始日と設定
</br>

* ```前月の最後の日で埋める```: カレンダーで現在の月の最初の日が開始する位置までを前月の最後の日で埋め、この部分は該当月の最初の週で空いている日付を埋める役割を果たす
</br>

* ```現在の月の日付を追加```: 現在の月の全ての日付をリストに追加
</br>

* ```次の月の最初の日で埋める```: 現在の月の最後の日が日曜日ではないか、最後の日が日曜日でも次の月の1日が月曜日ではない場合に、次の月の最初の日で埋め、この部分は該当月の最後の週で空いている日付を埋める役割を果たす


## *日付*

まず、曜日と日別をそれぞれのRecyclerViewとして別々に作成して使用しました。
曜日を月曜日から日曜日まで順に設定する必要があったため、それぞれのRecyclerViewとして設定しました。

そして、これによりオーバースクロールのために動作が異常になることがありますが、ViewPagerで一括して使用するため、日別部分のRecyclerViewのオーバースクロール動作をoffにすると、スクロール時に自然に動作するようにしました。
 
</br>

---

RecyclerViewAdapter.kt
* 曜日を表示する RecyclerView


```kotlin
class DayOfTheWeekAdapter(private val days: List<String>) : RecyclerView.Adapter<DayOfTheWeekAdapter.DayViewHolder>() {

    class DayViewHolder(private val binding: ItemDayoftheweekBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(day: String) {
            binding.dayTextOfWeek.text = day
        }
    }

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


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

    override fun getItemCount(): Int = days.size

	}
}
``` 

</br>
</br>


* 日別の日付を表示する RecyclerView

```kotlin
class CalendarAdapter(private val dates: List<Date?>, currentMonth: Int) : RecyclerView.Adapter<CalendarAdapter.ViewHolder>() {

    private val thisMonth = currentMonth

    inner class ViewHolder(private val binding: CalendarItemBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(date: Date) {
            val calendar = Calendar.getInstance()
            calendar.time = date
            val month = calendar.get(Calendar.MONTH)
            val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK)

            if (month != thisMonth) {
                binding.dayText.setTextColor(Color.LTGRAY)
            } else {
                when (dayOfWeek) {
                    Calendar.SATURDAY -> {
                        binding.dayText.setTextColor(Color.BLUE)
                    }
                    Calendar.SUNDAY -> {
                        binding.dayText.setTextColor(Color.RED)
                    }
                    else -> {
                        binding.dayText.setTextColor(Color.BLACK)
                    }
                }
            }

            binding.dayText.text = SimpleDateFormat("d", Locale.getDefault()).format(date)
        }
    }

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

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

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

``` 

- 曜日に応じてテキストの色を変更
土曜日は青色、日曜日は赤色、現在の月ではない日付は灰色で表示


</br>


## *ViewPager2*
ViewPagerAdapter
```kotlin
class CalendarPagerAdapter(
    private val datesList: List<List<Date>>,
    private val currentMonth: Int
) : RecyclerView.Adapter<CalendarPagerAdapter.ViewHolder>() {

    inner class ViewHolder(val binding: CalendarPageBinding) : RecyclerView.ViewHolder(binding.root) {
        val calendarRecyclerView: RecyclerView = binding.calendarViewPager
        val dayOfTheWeekRecyclerView: RecyclerView = binding.dayOfTheWeekRecyclerView
    }

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

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {

        val dates = datesList[position]
        val adapter = CalendarAdapter(dates, currentMonth)

        holder.calendarRecyclerView.adapter = adapter
        holder.calendarRecyclerView.layoutManager = GridLayoutManager(holder.itemView.context, 7)


        val daysOfWeek = listOf("月", "火", "水", "木", "金", "土", "日")
        val dayOfWeekAdapter = DayOfTheWeekAdapter(daysOfWeek)
        holder.dayOfTheWeekRecyclerView.adapter = dayOfWeekAdapter
        holder.dayOfTheWeekRecyclerView.layoutManager = GridLayoutManager(holder.itemView.context, 7)

    }

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

``` 
  
* 複数のカレンダーページを管理するRecyclerViewのアダプター
各ページには日付を表示するカレンダーと曜日を表示する部分があり、CalendarAdapterとDayOfTheWeekAdapterを使用して実装

* ```datesList```: 各ページに表示する日付のリストを含むリスト
```currentMonth```: 現在表示している月を示す値

* ```ViewHolder```: 各ページで日付と曜日を含む

* ```override 메서드```: 各RecyclerViewにAdapterを設定し、該当月の日付データを接続し、GridLayoutManagerを使用してカレンダーをグリッド形式で表示


複数のページを表示できるアダプターが作成され、各ページは該当月の日付と曜日を表示します。これにより、ユーザーは複数のカレンダーページをスワイプして移動できるようになります


</br>

そして、

calendar_page.xml

```xml
<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/dayOfTheWeek_recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/calendarViewPager"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:overScrollMode="never"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/dayOfTheWeek_recyclerView" />

    </androidx.constraintlayout.widget.ConstraintLayout>
```
android:overScrollMode="never"でオーバースクロールをoffにして、スクロール時に自然な動作を演出することができます


## *View*
Fragment
```kotlin
class HomeFragment : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {

    private var calendar = Calendar.getInstance()
    private val startCalendar: Calendar = Calendar.getInstance().apply {
        time = Date()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        startCalendar.time = calendar.time

        binding.calendarViewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL
        binding.calendarViewPager.registerOnPageChangeCallback(object :
            ViewPager2.OnPageChangeCallback() {
            override fun onPageSelected(position: Int) {
                calendar.set(Calendar.MONTH, calendar.get(Calendar.MONTH) - 12 + position)
                updateCalendar()
            }
        })

        updateCalendar() // 初期カレンダーの更新

    }

    private fun updateCalendar() {
        val year = calendar.get(Calendar.YEAR)
        val month = calendar.get(Calendar.MONTH)
        binding.yearMonthTextView.text = "${year}年 ${month + 1}月"

        val months = generateMonths(calendar)
        val pagerAdapter = CalendarPagerAdapter(months, month)
        binding.calendarViewPager.adapter = pagerAdapter
        binding.calendarViewPager.setCurrentItem(months.size / 2, false)
    }


    private fun generateMonths(calendar: Calendar): List<List<Date>> {

        val months = mutableListOf<List<Date>>()

        for (i in -12..12) {
            val cal = calendar.clone() as Calendar
            cal.add(Calendar.MONTH, i)
            months.add(Dates.generateDates(cal))
        }

        return months
    }


}
``` 
  
1.
``` calendar``` : 現在選択されたカレンダー情報を含むインスタンス
``` startCalendar``` : 初期カレンダーの状態を保存するために使用
</br>
2. ```ViewPager2の設定```: 水平方向にスクロールするカレンダーページのためにViewPager2を設定し、ページが変更されるたびに選択された月を更新してカレンダーを更新
</br>
3. ```updateCalendarメソッド```: 現在選択された年と月をテキストビューに表示し、generateMonthsを呼び出してカレンダーの複数のページを生成し、それをCalendarPagerAdapterに接続します。
</br>
4. ```generateMonthsメソッド```:
与えられたカレンダーに基づいて、前12ヶ月と次12ヶ月のカレンダーデータを生成（合計25ヶ月のカレンダーデータ）
各月別のカレンダーデータはDates.generateDatesを通じて生成され、結果的にカレンダービューを提供し、ユーザーが月別に探索し特定の月を選択できる機能を提供

</br>

## *まとめ*
 スクロールせずにボタンで月が変わるカスタムカレンダーは研修時に行ったため、簡単にできると思いましたが、スクロール機能を追加する必要があったため、各曜日と日付を分離してそれぞれのRecyclerViewを作成し接続し、ViewPager2アダプターのアイテムをRecyclerViewにすることでスクロールが可能になるようにしました。



GitHub : https://github.com/GEUN-TAE-KIM/CustomCalendar_Sample.git