【Android】カレンダーを自作する
IT技術
はじめに
カレンダー機能を実装する場合、ライブラリを使用するとうまくデザインを実現できないことがあると思います
そこで今回、RecyclerView と ViewPager2 を使ってカレンダーを自作してみました
カレンダーの実装
RecyclerView の GridLayoutManager を使用してカレンダーの見た目、ViewPager2 を使用してカレンダーの横スクロールを実装していきます
View の作成
メインとなる画面に、年月とボタンを表示する View と カレンダーを表示する ViewPager2 を配置し、 View を作成する
また、年月とボタンの View に罫線を追加するため、drawable で枠線を作成し、backgound に設定します
1// res/drawable/header_border.xml
2<?xml version="1.0" encoding="utf-8"?>
3<shape xmlns:android="http://schemas.android.com/apk/res/android">
4 <stroke android:width="1dp" android:color="@color/gray" />
5</shape>
1// res/layout/activity_main.xml
2<?xml version="1.0" encoding="utf-8"?>
3<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
4 xmlns:app="http://schemas.android.com/apk/res-auto"
5 xmlns:tools="http://schemas.android.com/tools"
6 android:id="@+id/main"
7 android:layout_width="match_parent"
8 android:layout_height="match_parent"
9 tools:context=".MainActivity"
10 android:fitsSystemWindows="true">
11
12 <LinearLayout
13 android:id="@+id/llHeader"
14 android:layout_width="match_parent"
15 android:layout_height="wrap_content"
16 android:background="@drawable/header_border"
17 android:baselineAligned="false"
18 android:orientation="horizontal"
19 app:layout_constraintEnd_toEndOf="parent"
20 app:layout_constraintStart_toStartOf="parent"
21 app:layout_constraintTop_toTopOf="parent">
22
23 <ImageButton
24 android:id="@+id/btPreviousMonth"
25 style="?android:attr/borderlessButtonStyle"
26 android:layout_width="wrap_content"
27 android:layout_height="wrap_content"
28 android:contentDescription="@string/previous_month_button"
29 android:src="@drawable/baseline_arrow_back_ios_new_24"
30 app:tint="#ff000000" />
31
32 <TextView
33 android:id="@+id/tvMonth"
34 android:layout_width="0dp"
35 android:layout_height="wrap_content"
36 android:layout_gravity="center"
37 android:layout_weight="1"
38 android:textAlignment="center" />
39
40 <ImageButton
41 android:id="@+id/btNextMonth"
42 style="?android:attr/borderlessButtonStyle"
43 android:layout_width="wrap_content"
44 android:layout_height="wrap_content"
45 android:contentDescription="@string/next_month_button"
46 android:src="@drawable/baseline_arrow_forward_ios_24"
47 app:tint="#ff000000" />
48
49 </LinearLayout>
50
51 <androidx.viewpager2.widget.ViewPager2
52 android:id="@+id/calendarPager"
53 android:layout_width="match_parent"
54 android:layout_height="0dp"
55 app:layout_constraintBottom_toBottomOf="parent"
56 app:layout_constraintTop_toBottomOf="@+id/llHeader" />
57
58</androidx.constraintlayout.widget.ConstraintLayout>
次に、カレンダーを表示するため、 RecyclerView を配置した View を作成する
1// res/layout/fragment_calendar.xml
2<?xml version="1.0" encoding="utf-8"?>
3<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
4 xmlns:app="http://schemas.android.com/apk/res-auto"
5 xmlns:tools="http://schemas.android.com/tools"
6 android:id="@+id/main"
7 android:layout_width="match_parent"
8 android:layout_height="match_parent"
9 tools:context=".MainActivity">
10
11 <androidx.recyclerview.widget.RecyclerView
12 android:id="@+id/rvCalendar"
13 android:layout_width="0dp"
14 android:layout_height="0dp"
15 app:layout_constraintBottom_toBottomOf="parent"
16 app:layout_constraintEnd_toEndOf="parent"
17 app:layout_constraintStart_toStartOf="parent"
18 app:layout_constraintTop_toTopOf="parent" />
19
20</androidx.constraintlayout.widget.ConstraintLayout>
最後に、日付を表示するための View を作成する
また、View に罫線を追加するため、drawable で枠線を作成し、backgound に設定します
1// res/drawable/day_item_border.xml
2<?xml version="1.0" encoding="utf-8"?>
3<shape xmlns:android="http://schemas.android.com/apk/res/android">
4 <stroke android:width="0.5dp" android:color="@color/gray" />
5</shape>
1// res/layout/day_item.xml
2<?xml version="1.0" encoding="utf-8"?>
3<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
4 xmlns:app="http://schemas.android.com/apk/res-auto"
5 xmlns:tools="http://schemas.android.com/tools"
6 android:layout_width="match_parent"
7 android:layout_height="match_parent"
8 android:background="@drawable/day_item_border">
9
10 <TextView
11 android:id="@+id/tvDay"
12 android:layout_width="wrap_content"
13 android:layout_height="wrap_content"
14 android:layout_marginTop="8dp"
15 app:layout_constraintEnd_toEndOf="parent"
16 app:layout_constraintStart_toStartOf="parent"
17 app:layout_constraintTop_toTopOf="parent" />
18</androidx.constraintlayout.widget.ConstraintLayout>
カレンダーの日付部を実装
RecyclerView にカレンダーを表示するための処理を実装する
1// kotlin+java/com.example.calendarapp/CalendarManager.kt
2package com.example.calendarapp
3
4import android.icu.util.Calendar
5
6class CalendarManager(private val calendar: Calendar) {
7 fun getDays(): IntArray {
8 // 日付配列の作成用にカレンダーを複製
9 val temporaryCalendar = calendar.clone() as Calendar
10
11 // 曜日の数
12 val dayOfWeekCount = 7
13 // カレンダーに表示する日付の数
14 val calendarDaysCount = getWeeksCount() * dayOfWeekCount
15 // カレンダーの日付を1日にする
16 temporaryCalendar.set(Calendar.DATE, 1)
17 // 曜日を取得(日曜日の1から土曜日の7までの数字)
18 val dayOfWeek = temporaryCalendar.get(Calendar.DAY_OF_WEEK)
19 // カレンダーに表示する最初の日付にする
20 temporaryCalendar.add(Calendar.DATE, -dayOfWeek + 1)
21
22 // カレンダーの日付リストを作成
23 val days: MutableList<Int> = mutableListOf()
24 repeat(calendarDaysCount) {
25 days.add(temporaryCalendar.get(Calendar.DATE))
26 temporaryCalendar.add(Calendar.DATE, 1)
27 }
28
29 return days.toIntArray()
30 }
31
32 // カレンダーに表示される週の数
33 fun getWeeksCount(): Int {
34 return calendar.getActualMaximum(Calendar.WEEK_OF_MONTH)
35 }
36}
このコードでは、カレンダーの日付配列を返す getDays() と カレンダーの週の数を返す getWeeksCount() を実装しています
getDays() では、カレンダーに表示する最初の日付 と カレンダーに表示する日数から日付配列を作成します
getWeeksCount() は、日付配列の作成と日付セルの高さの計算に使用します
次に、RecyclerView に日付を表示する処理を実装する
1// kotlin+java/com.example.calendarapp/CalendarFragment.kt
2package com.example.calendarapp
3
4import android.icu.util.Calendar
5import android.os.Build
6import android.os.Bundle
7import androidx.fragment.app.Fragment
8import android.view.LayoutInflater
9import android.view.View
10import android.view.ViewGroup
11import android.widget.TextView
12import androidx.annotation.RequiresApi
13import androidx.recyclerview.widget.GridLayoutManager
14import androidx.recyclerview.widget.RecyclerView
15import com.example.calendarapp.databinding.DayItemBinding
16import com.example.calendarapp.databinding.FragmentCalendarBinding
17
18class CalendarFragment : Fragment() {
19 private lateinit var fragmentCalendarBinding: FragmentCalendarBinding
20 private lateinit var calendarManager: CalendarManager
21
22 companion object {
23 private const val CALENDAR = "calendar"
24
25 // newInstanceメソッドで引数を設定
26 fun newInstance(calendar: Calendar): CalendarFragment {
27 val fragment = CalendarFragment()
28 val args = Bundle()
29 args.putSerializable(CALENDAR, calendar)
30 fragment.arguments = args
31 return fragment
32 }
33 }
34
35 @RequiresApi(Build.VERSION_CODES.TIRAMISU)
36 override fun onCreateView(
37 inflater: LayoutInflater, container: ViewGroup?,
38 savedInstanceState: Bundle?
39 ): View {
40 arguments?.let {
41 val calendar = it.getSerializable(CALENDAR, Calendar::class.java) as Calendar
42 calendarManager = CalendarManager(calendar)
43 }
44
45 fragmentCalendarBinding = FragmentCalendarBinding.inflate(inflater, container, false)
46
47 val rvCalendar = fragmentCalendarBinding.rvCalendar
48 val numberOfColumns = 7
49 val layout = GridLayoutManager(this@CalendarFragment.context, numberOfColumns)
50 rvCalendar.layoutManager = layout
51 val adapter = RecyclerListAdapter(calendarManager.getDays())
52 rvCalendar.adapter = adapter
53
54 // RecyclerViewのスクロールをOFFにする
55 rvCalendar.suppressLayout(true)
56
57 return fragmentCalendarBinding.root
58 }
59
60 private inner class RecyclerListViewHolder(binding: DayItemBinding): RecyclerView.ViewHolder(binding.root) {
61 var tvDay: TextView
62
63 init {
64 val weeksCount = calendarManager.getWeeksCount()
65 // 日付Viewの高さを指定
66 itemView.layoutParams.height = fragmentCalendarBinding.rvCalendar.height / weeksCount
67 tvDay = binding.tvDay
68 }
69 }
70
71 private inner class RecyclerListAdapter(private val listData: IntArray): RecyclerView.Adapter<RecyclerListViewHolder>() {
72 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerListViewHolder {
73 val binding = DayItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
74 return RecyclerListViewHolder(binding)
75 }
76
77 override fun onBindViewHolder(holder: RecyclerListViewHolder, position: Int) {
78 val day = listData[position].toString()
79 holder.tvDay.text = day
80 }
81
82 override fun getItemCount(): Int {
83 return listData.size
84 }
85 }
86}
このコードでは、RecyclerView の ViewHolder と Adapter を定義し、RecyclerView に日付を表示するよう実装しています
ViewHolder では、週の数から View の高さを計算して指定します
Adapter では、ViewHolder の繋ぎ込みと RecyclerView のアイテム数を指定します
RecyclerView には、カレンダーを表示するために GridLayoutManager を設定し、定義した Adapter を設定します
ViewPager に日付部を表示
ViewPager にカレンダーの日付部を表示する
1// kotlin+java/com.example.calendarapp/MainActivity.kt
2package com.example.calendarapp
3
4import android.icu.util.Calendar
5import android.os.Bundle
6import android.view.View
7import androidx.activity.enableEdgeToEdge
8import androidx.appcompat.app.AppCompatActivity
9import androidx.fragment.app.Fragment
10import androidx.fragment.app.FragmentActivity
11import androidx.viewpager2.adapter.FragmentStateAdapter
12import androidx.viewpager2.widget.ViewPager2
13import com.example.calendarapp.databinding.ActivityMainBinding
14import java.text.SimpleDateFormat
15import java.util.Locale
16
17class MainActivity : AppCompatActivity() {
18 companion object {
19 // 今月から前後1年分のページ数を設定
20 const val PAGE_COUNT = 25
21 }
22
23 private val calendar = Calendar.getInstance()
24
25 private lateinit var activityMainBinding: ActivityMainBinding
26 private lateinit var calendarPagerAdapter: CalendarPagerAdapter
27
28 override fun onCreate(savedInstanceState: Bundle?) {
29 super.onCreate(savedInstanceState)
30 enableEdgeToEdge()
31 activityMainBinding = ActivityMainBinding.inflate(layoutInflater)
32 val activityMainView = activityMainBinding.root
33 setContentView(activityMainView)
34
35 calendarPagerAdapter = CalendarPagerAdapter(this@MainActivity, calendar)
36 activityMainBinding.calendarPager.adapter = calendarPagerAdapter
37
38 // 初期表示をPagerの中央にする
39 val centerPosition = (PAGE_COUNT - 1) / 2
40 activityMainBinding.calendarPager.setCurrentItem(centerPosition, false)
41 }
42
43 private inner class CalendarPagerAdapter(
44 activity: FragmentActivity,
45 private val calendar: Calendar
46 ) : FragmentStateAdapter(activity) {
47 override fun getItemCount(): Int {
48 return PAGE_COUNT
49 }
50
51 override fun createFragment(position: Int): Fragment {
52 val calendar = calendar.clone() as Calendar
53 // Pagerの中央に今月が表示されることを前提にamontの値を計算
54 val calendarAddAmount = position - ((PAGE_COUNT - 1) / 2)
55 calendar.add(Calendar.MONTH, calendarAddAmount)
56 return CalendarFragment.newInstance(calendar)
57 }
58 }
59}
このコードでは、FragmentStateAdapter を定義し、ViewPager に日付部を表示しています
FragmentStateAdapter では、今月から前後1年間分の25ページを指定し、今月が ViewPager の中央にくるよう設定します
ViewPager には、作成した FragmentStateAdapter を設定し、初期表示が ViewPager の中央になるよう指定します
カレンダーに年月を表示
TextView にカレンダーの年月を表示する
1// kotlin+java/com.example.calendarapp/MainActivity.kt
2〜省略
3
4 override fun onCreate(savedInstanceState: Bundle?) {
5 super.onCreate(savedInstanceState)
6
7 〜省略
8
9 val calendarOnPageChanger = CalendarOnPageChanger()
10 activityMainBinding.calendarPager.registerOnPageChangeCallback(calendarOnPageChanger)
11
12 〜省略
13 }
14
15
16 // カレンダーのタイトル
17 fun getTitle(position: Int): String {
18 // タイトル作成用にカレンダーを複製
19 val temporaryCalendar = calendar.clone() as Calendar
20 // Pagerの中央に今月が表示されることを前提にamontの値を計算
21 val calendarAddAmount = position - ((PAGE_COUNT - 1) / 2)
22 temporaryCalendar.add(Calendar.MONTH, calendarAddAmount)
23
24 // 年月にフォーマットして返す
25 val locale = Locale("ja", "JP", "JP")
26 val dateFormat = SimpleDateFormat("yyyy年M月", locale)
27 return dateFormat.format(temporaryCalendar.time)
28 }
29
30 private inner class CalendarOnPageChanger : ViewPager2.OnPageChangeCallback() {
31 override fun onPageSelected(position: Int) {
32 super.onPageSelected(position)
33 activityMainBinding.tvMonth.text = getTitle(position)
34 }
35 }
36
37 〜省略
このコードでは、ViewPager2.OnPageChangeCallback() を定義し、カレンダーに年月を表示しています
ViewPager2.OnPageChangeCallback() では、onPageSelected の時に、そのページの年月を TextView に表示するようにします
そして、定義した ViewPager2.OnPageChangeCallback() を ViewPager に設定します
先月・翌月ボタンのハンドラー実装
先月・翌月ボタンが押された時に、ViewPager がスクロールするようにする
1// kotlin+java/com.example.calendarapp/MainActivity.kt
2〜省略
3
4 override fun onCreate(savedInstanceState: Bundle?) {
5 super.onCreate(savedInstanceState)
6
7 〜省略
8
9 val calendarHeaderListener = CalendarHeaderListener()
10 activityMainBinding.btPreviousMonth.setOnClickListener(calendarHeaderListener)
11 activityMainBinding.btNextMonth.setOnClickListener(calendarHeaderListener)
12
13 〜省略
14 }
15
16 private inner class CalendarHeaderListener : View.OnClickListener {
17 override fun onClick(v: View?) {
18 when (v?.id) {
19 // < ボタン
20 activityMainBinding.btPreviousMonth.id -> {
21 val previousPosition = activityMainBinding.calendarPager.currentItem - 1
22 activityMainBinding.calendarPager.setCurrentItem(previousPosition, true)
23 }
24 // > ボタン
25 activityMainBinding.btNextMonth.id -> {
26 val nextPosition = activityMainBinding.calendarPager.currentItem + 1
27 activityMainBinding.calendarPager.setCurrentItem(nextPosition, true)
28 }
29 }
30 }
31 }
32
33 〜省略
このコードでは、View.OnClickListener を定義し、ボタンが押された時に ViewPager がスクロールするようにしています
View.OnClickListener では、ViewPager の setCurrentItem を使って ViewPager をスクロールさせます
そして、定義した View.OnClickListener を ViewPager に設定します
無限スクロールの実装
ViewPager を無限にスクロールできるようにする
1// kotlin+java/com.example.calendarapp/MainActivity.kt
2〜省略
3
4 private inner class CalendarOnPageChanger : ViewPager2.OnPageChangeCallback() {
5
6 〜省略
7
8 override fun onPageScrollStateChanged(state: Int) {
9 super.onPageScrollStateChanged(state)
10 // Page移動が完了した時
11 if (ViewPager2.SCROLL_STATE_IDLE == state) {
12 val currentPosition = activityMainBinding.calendarPager.currentItem
13 val centerPosition = (PAGE_COUNT - 1) / 2
14 when (currentPosition) {
15 // 表示がPagerの左端まできた時
16 0 -> {
17 calendar.add(Calendar.MONTH, -centerPosition)
18 activityMainBinding.calendarPager.setCurrentItem(centerPosition, false)
19 }
20 // 表示がPagerの右端まできた時
21 PAGE_COUNT - 1 -> {
22 calendar.add(Calendar.MONTH, centerPosition)
23 activityMainBinding.calendarPager.setCurrentItem(centerPosition, false)
24 }
25 }
26 }
27 }
28 }
29
30 〜省略
このコードでは、onPageScrollStateChanged でPagerを無限にスクロールできるようにしています
onPageScrollStateChanged では、最初と最後のページに移動が完了した時、そのページを ViewPager の中央に表示し、表示を中央に切り替えます
おわりに
実装は以上です
ビルドすると以下のようなカレンダーが表示され、ボタンとスワイプでカレンダーを切り替えることができます
まだ、日付の色を変えたり、日付の View に何か表示するなどはできていないので、これから機能を追加していこうと思います
最後までご覧いただきありがとうございました
こちらの記事が少しでも参考になれば幸いです
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
業務ではiOS開発に携わらせていただいています。 まだまだ分からないことだらけで、日々分からないことと戦いながら仕事をしている者です。 ブログ記事は暖かい目で見ていただけるとありがたいです。