![見出し画像](https://assets.st-note.com/production/uploads/images/120508529/rectangle_large_type_2_3cb956feb4b125debb78f994b8a88906.png?width=1200)
【Kotlin】MVVM + Room + CalendarView でカレンダーアプリを作成してみた
こんにちは!ナディアのエンジニア Sです。
今回は自分自身の技術取得のために、MVVMでカレンダーでスケジュール管理する簡単なアプリを作成しました。
Roomを使ったCRUD(主に作成(Create)、読み出し(Read))の実装方法や、MVVMアーキテクチャのシンプルな実装方法を知ることができる内容となっています。
何をするアプリか
カレンダーに自分が登録したスケジュール、ToDoの確認などを行うためのアプリです。
カレンダーアプリの設計について
アーキテクチャーは概ねAndroid Developersにて推奨されているアーキテクチャーに従っています。
デザインに関しては、さまざまなテーマに関するイメージやデザインのアイデアを共有するプラットフォームのPinterestから検索したdribbbleのデザインを参考にしました。
一部のデザイン作成には、自分でも画像作成が出来るfigmaを利用しました。
Material 3 Design Kitにも対応し、コミュニティーもあるので参考になる画像も見つかります。
カレンダー機能は、サードパーティーのライブラリーを使用しています。
これを選択した理由は、カレンダーの機能を1から全部自前で作成するとコストがかかるため、デザインや機能、ソースなどを見てカスタムしやすいと思い選択しました。
RoomのCRUD(主に作成(Create)、読み出し(Read))、StateFlowを利用したデータ取得から画面表示
DB機能としてRoomライブラリを使用します。
Database、Entity、DAO(Data Access Object)を用いてDB(SQLite)上でデータを管理します。 クラスやインターフェースなどにこれらのアノテーションをつけることで、Roomが提供する各コンポーネントとして振舞うようになります。
Repositoryを利用しているのはDIすることで以下のような利点があります。ただし、このアプリに関してはテーブル、画面数も多くないのでほとんど効果はないです。 DIを実装し、あるデータの処理に関するメソッドを1つのRepositoryに集約することで、そのデータを扱う各ViewModelの肥大化を抑えることができます。
参考サイト
DBのテーブル定義
Entity(エンティティクラス)の作成
@Entity(tableName = "schedule")
data class Schedule(
@PrimaryKey(autoGenerate = true)
@ColumnInfo
var id: Int = 0,
@ColumnInfo
var memo: String = "",
@ColumnInfo
var time: Date = Date(),
//0ー>すべて、1ー>スケジュール、2ー>ToDo
@ColumnInfo
var type: Int = 0,
) : Serializable {
}
参考サイト
DBクエリー
DAO(Data Access Object)の作成
@Dao
interface ScheduleDao {
@Query("select * from schedule")
suspend fun selectScheduleList(): List<Schedule>
@Query("select * from schedule where type = :type order by time desc")
suspend fun selectScheduleListByOrder(type: Int): List<Schedule>
@Query("select * from schedule where type = :type order by time asc")
suspend fun selectScheduleListByOrderAsc(type: Int): List<Schedule>
@Query("SELECT * FROM schedule WHERE time = :targetDate")
suspend fun selectScheduleListOnDate(targetDate: Date): List<Schedule>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertResult(data: Schedule): Long
}
DBの設定
Roomデータベース(Database)の追加
@Database(
//テーブルを追加する場合、entitiesにテーブルクラスを追加する
entities = [
Schedule::class
],
version = AppDatabase.DATABASE_VERSION
)
@TypeConverters(DateConverters::class)
abstract class AppDatabase() : RoomDatabase() {
// DAO
abstract fun ScheduleDao() : ScheduleDao
//クラス内に作成されるシングルトンのことです。
//クラスのインスタンスを生成せずにクラスのメソッドにアクセスすることができ、
//データベースが1つしか存在しないようにできます。
companion object {
const val DATABASE_VERSION: Int = 1
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
// if the INSTANCE is not null, then return it,
// if it is, then create the database
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"calendar_app_database"
)
// Wipes and rebuilds instead of migrating if no Migration object.
// Migration is not part of this codelab.
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
// return instance
instance
}
}
}
}
Repositoryの作成 - DI機能(Dependency Injection)
class ScheduleRepositoryRoom(
private val scheduleDao: ScheduleDao,
) {
//メモ一覧を取得する
suspend fun selectScheduleList(): List<Schedule>{
return scheduleDao.selectScheduleList()
}
//メモの種類を指定しメモ一覧を取得する
suspend fun selectScheduleListByOrder(type: Int): List<Schedule>{
return scheduleDao.selectScheduleListByOrder(type)
}
//メモの種類を指定しメモ一覧を取得する
suspend fun selectScheduleListByOrderAsc(type: Int): List<Schedule>{
return scheduleDao.selectScheduleListByOrderAsc(type)
}
//日付に紐づくメモ一覧を取得する
suspend fun selectScheduleListOnDate(targetDate: Date): List<Schedule>{
return scheduleDao.selectScheduleListOnDate(targetDate)
}
//登録したメモを追加する
suspend fun insertResult(data: Schedule): Long{
return scheduleDao.insertResult(data)
}
}
ViewModelの作成
class DialogViewModel(
private val repository: ScheduleRepositoryRoom,
) : ViewModel() {
//メニュー登録のダイアログからのコールバック
val state = MutableLiveData<DialogState<SimpleDialogFragment>>()
//メニュー登録のダイアログからのコールバック
private val _purchaseState = MutableLiveData<PurchaseState?>()
val purchaseState: LiveData<PurchaseState?> get() = _purchaseState
//一般的に、クラスのカプセル化を意識して、MutableLiveDataプロパティを非公開にし、LiveDataプロパティのみを公開します。
//StateFlow
private val _scheduleList: MutableStateFlow<Resource<List<Schedule>>>
= MutableStateFlow(Resource.Loading())
val scheduleList: StateFlow<Resource<List<Schedule>>>
get() = _scheduleList.asStateFlow()
/**
* DBにメモを追加する
*/
fun insertScheduleData(memo: String, time: String, type: Int){
_purchaseState.value = PurchaseState.PURCHASE_IN_PROGRESS
viewModelScope.launch {
try {
val schedule = Schedule().apply {
this.id = 0
this.memo = memo
val replaceDate: String = time.replace("-", "/")
val formatter: DateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.JAPANESE)
val date: Date = formatter.parse(replaceDate) as Date
this.time = date
val formatter2: DateFormat = SimpleDateFormat("yyyy/MM", Locale.JAPANESE)
val date2: Date = formatter2.parse(replaceDate) as Date
this.time2 = date2
this.type = type
}
val count: Long = repository.insertResult(schedule)
_purchaseState.postValue(PurchaseState.PURCHASE_SUCCESSFUL)
}catch (e: Exception){
_purchaseState.value = PurchaseState.PURCHASE_ERROR
}
}
}
/**
* 日付(年月)に紐づくスケジュール一覧を取得する。
*/
fun selectScheduleListOnDate(targetDate: Date){
viewModelScope.launch {
//データ取得
try {
//これが無いと、呼び出し側の lifecycle.repeatOnLifecycle が呼ばれない
_scheduleList.value = Resource.Success(repository.selectScheduleListOnDate(targetDate))
}catch (e: Exception){
println("### e : " +e.message.toString())
_scheduleList.value = Resource.Error(e.message.toString())
}
}
}
////////////////////////////////////////////////////////////
// inner class
////////////////////////////////////////////////////////////
class DialogViewModelFactory(
private val repository: ScheduleRepositoryRoom,
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(DialogViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return DialogViewModel(repository) as T
}
throw IllegalArgumentException("!!! Unknown DialogViewModel class !!!")
}
}
}
Viewの作成 - 追加機能(一部抜粋)
class SimpleDialogFragment() : DialogFragment() {
private lateinit var viewModel: DialogViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//ViewModelProviderの第一引数にrequireActivityを指定してあげると
// Fragmentを使っているActivityでも同じViewModelのインスタンスをシェアできる。
viewModel = ViewModelProvider(requireActivity(), DialogViewModel.DialogViewModelFactory(
(activity?.application as CalendarAppApplication).scheduleRepositoryRoom,
)).get(DialogViewModel::class.java)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val v = inflater.inflate(R.layout.fragment_simple_dialog, container, false)
purchaseButton.setOnClickListener {
//スケジュール登録を行う。
viewModel.insertScheduleData(memoEditText.text.toString(), date, type)
observePurchaseState()
}
cancelButton.setOnClickListener {
viewModel.state.value = DialogState.Cancel(this@SimpleDialogFragment)
dismissAllowingStateLoss()
}
return v
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
viewModel.state.value = DialogState.Cancel(this@SimpleDialogFragment)
}
/**
* ダイアログのコールバック処理
*/
private fun observePurchaseState(){
viewModel.purchaseState.observe(this) { purchaseState ->
when (purchaseState) {
PurchaseState.PURCHASE_IN_PROGRESS -> {
purchaseButton.isEnabled = false
showProgressBar()
}
PurchaseState.PURCHASE_SUCCESSFUL -> {
hideProgressBar()
// The purchase was successful! Show a message and dismiss the dialog.
Toast.makeText(requireContext(), R.string.purchase_successful, Toast.LENGTH_SHORT).show()
viewModel.state.value = DialogState.Ok(this@SimpleDialogFragment)
dismissAllowingStateLoss()
}
PurchaseState.PURCHASE_ERROR -> {
hideProgressBar()
purchaseButton.isEnabled = true // enable so that the user can try again
Toast.makeText(requireContext(), R.string.purchase_error, Toast.LENGTH_SHORT).show()
}
else -> {
//nothing
}
}
}
}
}
Viewの作成 - 表示機能(一部抜粋)
class CalendarFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//ViewModelProviderの第一引数にrequireActivityを指定してあげると
// Fragmentを使っているActivityでも同じViewModelのインスタンスをシェアできる。
dialogViewModel = ViewModelProvider(requireActivity(), DialogViewModel.DialogViewModelFactory(
(activity?.application as CalendarAppApplication).scheduleRepositoryRoom,
)).get(DialogViewModel::class.java)
//ダイアログからのコールバック処理
dialogViewModel.state.observe(this) {
when (it) {
is DialogState.Ok -> {
//画面に表示するデータを取得する
dialogViewModel.selectScheduleListOnDate2(date)
}
is DialogState.Cancel -> {
}
else -> {
}
}
} //dialogViewModel
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
//監視
viewLifecycleOwner.lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED){
dialogViewModel.scheduleList.collect(){
when(it){
is Resource.Loading -> {
}
is Resource.Success -> {
//ここで取得したデータを画面に表示する
}
is Resource.Error -> {
}
else -> {
}
}
}
}
} //viewLifecycleOwner
return binding.root
}
}
DBに追加、取得時の結果を返す - UI 状態を管理機能
sealed class Resource<T>(
val data: T? = null,
val message: String? = null
) {
/**
* 処理結果が成功時のイベント
*/
class Success<T>(data: T) : Resource<T>(data)
/**
* 処理結果がエラー時のイベント
*/
class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
/**
* 処理中のイベント
*/
class Loading<T> : Resource<T>()
}
参考サイト
ダイアログイベントの結果を返す - UI 状態を管理機能
sealed class DialogState<T : DialogFragment> {
/**
* ダイアログのOKボタンタップイベント
*/
data class Ok<T : DialogFragment>(val dialog: T) : DialogState<T>()
/**
* ダイアログのCancelボタンタップイベント
*/
data class Cancel<T : DialogFragment>(val dialog: T) : DialogState<T>()
/**
* 読み込み処理イベント
*/
data class Loading<T : DialogFragment>(val dialog: T) : DialogState<T>()
}
enum class PurchaseState {
PURCHASE_IN_PROGRESS,
PURCHASE_SUCCESSFUL,
PURCHASE_ERROR
}
参考サイト
実装完成イメージ
![](https://assets.st-note.com/img/1698890497112-NgrHh3KOcP.png?width=1200)
![](https://assets.st-note.com/img/1698829855897-jOMK8NSKYM.png?width=1200)
![](https://assets.st-note.com/img/1698829878337-AhIilutqj8.png?width=1200)
苦労した点、工夫した点など
アプリ作成時、技術的に以下の点で苦慮しました。
1)ダイアログでメモ登録イベント(OKボタン、Cancelボタン)のコールバック実装方法。
2)ダイアログでメモを登録した後、カレンダー画面に戻っても画面が更新されない。
ネットでQiita、ZENNなど技術情報を記事にしているサイトを見ると、いろんな実装方法があり、どの方法がベストプラクティスかひとつずつ確認していきました。
時間はかかりましたが、そのお陰で技術的な知見は広がりました。
1の問題がクリア出来れば、2も同時に解決ができました。
DBに追加、取得時の結果を返す - UI 状態を管理機能を利用することとViewModelProviderの生成方法を変更しました。
//ViewModelProviderの第一引数にrequireActivityを指定してあげると
// Fragmentを使っているActivityでも同じViewModelのインスタンスをシェアできる。
dialogViewModel = ViewModelProvider(requireActivity(), DialogViewModel.DialogViewModelFactory(
(activity?.application as CalendarAppApplication).scheduleRepositoryRoom,
)).get(DialogViewModel::class.java)
3)カレンダー画面の日付に対して、メモを登録した該当日付にドットを表示する。
既存のままだと、該当日付をタップした時にドットが表示される処理になっていたので、既存処理をカスタムする必要がありました。
既存の動作を確認する為、ログなどを仕込みながら処理を理解して行きました。
今度は、カレンダーの該当日付に対して、タップしなくてもドットを表示する為には、どうすれば良いかを考え、DBからメモに紐づく日付の一覧を取得し、 カレンダーの日付とマッチングさせて該当日付にドットを表示するように修正しました。
4)アプリに表示するデザイン、画像を自作で作る。
カレンダーアプリは有象無象にあるので短期間である程度の機能を網羅するデザインをどこから探せば良いか悩みました。
chat GPTに質問したら、以下のサイトをお勧めされたので、そのサイトを参考にしました。(本文のURL参照)
画像も自分のスキルアップの為、figmaを使って画像を作成してみました。 (本文のURL参照)
最後に
このアプリを通して、Android、Kotlinでアーキテクチャーなど使い所を理解する必要があると思いました。 今後もよりAndroid、Kotlinを深掘りして行きます。