MVVM+DataBinding+RecyclerViewでカレンダーを作る - 1
2021年7月12日 追記
RecyclerView.AdapterのアイテムにDataBindingを用いるため、コンストラクタで LifecycleOwner (FragmentのviewLifecycleOwner) を渡すのは誤った実装でした。
発見に至った経緯
まったく別のアプリ開発でLifecycleRegistry.java
内のObserverWithState.dispatchEvent
を確認していたところ、newState = event.getTargetState
にて、本来生きているはずの親FragmentのON_DESTROY
が渡ってきていました。
static class ObserverWithState { State mState; LifecycleEventObserver mLifecycleObserver; ObserverWithState(LifecycleObserver observer, State initialState) { mLifecycleObserver = Lifecycling.lifecycleEventObserver(observer); mState = initialState; } void dispatchEvent(LifecycleOwner owner, Event event) { State newState = event.getTargetState(); mState = min(mState, newState); mLifecycleObserver.onStateChanged(owner, event); mState = newState; } }
ハッと、binding.lifecycleOwner = parentLifecycleOwner (実体はviewLifecycleOwner)
を渡していたことを思い出しググってみたところ、上記の記事を発見したところです。
RecyclerView+DataBindingはどうすればいいんでしょうかね……。
以下、ご参考まで。
RecyclerViewでカレンダー風の表示を作ったので、備忘録的なまとめその1。 MVVM+DataBinding でViewに表示させるまでです。
- 発見に至った経緯
- できるもの
- 使用した技術的なモノ
- 項目を sealed class で用意する
- データバインディングレイアウトファイルの作成
- CalendarActivity の定義
- CalendarActivityViewModel の定義
- CalendarAdapter の定義
- RecyclerView の拡張関数の定義
- CalendarFragment の定義
- GitHub
できるもの
月ヘッダーと日付をこんな感じで表示させます。
LayoutManagerにはGridLayoutManager
を拡張したCalendarLayoutManager
を使用していますが、それらの実装は次回にします。
使用した技術的なモノ
- MVVMアーキテクチャ
- ViewModelにてRecyclerViewのリストを保持
LiveData#observe
は記述しないように実装する
- Data Binding
- レイアウトファイル(xml)にバインド
- notify系メソッドを叩くのもバインド
サードパーティ製のライブラリは使用しません。 また、今回のキモとなるRecyclerViewと sealed class のテクニックはこちらのブログをご参照ください。
項目を sealed class で用意する
今回作るカレンダーは、「x年x月」と表示したヘッダーと「x」と表示した日にちを混在させる必要があります。
そのため、愚直にやるならList<Any>
として、あるいはインターフェース等を継承しリストを保持しなければなりません。
いずれにしても、RecyclerView#getItemViewType
やRecyclerView#onBindViewHolder
への対応など、大変面倒な実装となってしまいます。
しかし、Kotlinでは sealed class という仕様があり、今回はこちらを活用していきます。
sealed class Content
と定義することで、List<Content>
として扱うことができ、さらにキャスト可否もコンパイラ側で判断できるようになります。
sealed class Content の定義
sealed class Content
を定義し、その中にヘッダーと日にち用の data class を定義します。
これらの data class に Content を継承させます。
sealed class Content { data class CalendarHeader( private val _date: Calendar ) : Content() { // バインド用のプロパティ val date: String get() { val d = _date.time val dateFormat = SimpleDateFormat("yyyy年M月", Locale.JAPAN) return dateFormat.format(d) } } data class CalendarItem( private val _date: Calendar ) : Content() { // バインド用のプロパティ val date: String get() { val d = _date.time val dateFormat = SimpleDateFormat("d", Locale.JAPAN) return dateFormat.format(d) } } }
データバインディングレイアウトファイルの作成
次に項目ごとのレイアウトファイルを作ります。
上で定義したdate
プロパティをTextView
にバインドさせてやります。
<!-- ヘッダー用レイアウトファイル --> <layout ...> <data> <variable name="header" type="...CalendarHeader" /> </data> <androidx.constraintlayout.widget.ConstraintLayout ...> <TextView android:id="@+id/header_date" android:text="@{header.date}" ... /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
<!-- 日にち用レイアウトファイル --> <layout ...> <data> <variable name="item" type="...CalendarItem" /> </data> <androidx.constraintlayout.widget.ConstraintLayout ...> <TextView android:id="@+id/calendar_date" android:text="@{item.date}" ... /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
CalendarActivity の定義
ここは特にありません。
とりあえずonCreate
にてViewModelを生成してやりましょう。
class CalendarActivity : AppCompatActivity() { private lateinit var viewModel: CalendarActivityViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel = obtainViewModel() val binding = DataBindingUtil.setContentView<CalendarActivityBinding>( this, R.layout.calendar_activity ).also { it.viewModel = viewModel it.lifecycleOwner = this } setSupportActionBar(binding.calendarToolbar) // フラグメントの展開とか } fun obtainViewModel() = ViewModelProviders.of(this).get(CalendarActivityViewModel::class.java) }
CalendarActivityViewModel の定義
次にViewModelを定義します。
contextが必要なければ、ただのViewModel
を継承しましょう。
class CalendarActivityViewModel(...) : AndroidViewModel(app) { // リストの保持はViewModel。Adapterにはリストを持たせない // 空のリストで初期化しておく private val _contentsLiveData = MutableLiveData<List<Content>>(listOf()) val contentsLiveData: LiveData<List<Content>> get() = _contentsLiveData // RecyclerViewの拡張関数でnotify系メソッドを叩くために定義 // レイアウトファイルにバインドする val onCalendarDataSetChanged = LiveEvent<Unit>() // Adapterへの参照を持たせたくないためData Bindingで通知する // 今のところ外部から呼ぶ必要がないため private private fun updateCalendarDataSet() { onCalendarDataSetChanged.call(Unit) } // リストの中身取得用のメソッド fun getCalendarContent(position: Int): Content = _contentsLiveData.value?.get(position) ?: throw IllegalStateException("ContentList is not Initialized") // リストのセット fun setCalendarContents(contents: List<Content>) { _contentsLiveData.value = contents updateCalendarDataSet() } // RecyclerView#getItemCount から呼ぶために定義 fun getCalendarContentsCount(): Int = _contentsLiveData.value?.size ?: 0 ... }
リスト更新時に必要なAdapter#notify
系のメソッドを叩く部分は、あとでRecyclerViewを拡張して定義します。
ここで、LiveEvent<T>
がViewModel中にありますが、これはクリックイベント等で一度だけイベントを発生させるために必要となります。
詳細は下記の投稿にて。
CalendarAdapter の定義
次にRecyclerViewにセットするアダプタを定義します。 リストをViewModelで保持しているため、コンストラクタで渡してやります。
また、Data Binding によりLifecycleOwnerが必要となるため、こちらもコンストラクタで渡してやります(Fragment#viewLifecycleOwner
)。
class CalendarAdapter( // コンストラクタで ViewModel を参照 // Fragment#viewLifecycleOwner を渡す val viewModel: CalendarActivityViewModel, private val parentLifecycleOwner: LifecycleOwner ) : RecyclerView.Adapter<CalendarAdapter.ContentViewHolder>() { // ViewHodler の生成 override fun onCreateViewHolder(...): ContentViewHolder = ContentViewHolder( LayoutInflater.from(parent.context).inflate( getLayoutRes(viewType), parent, false ) ) // ViewHolder へのバインド // Data Bindingにて自動生成された「~Binding」は ViewDataBinding を継承している // contentを無い型にキャストしようとしてもコンパイラで弾く override fun onBindViewHolder(...) { // ViewModel からアイテム(Content)を取得、 // Contentの型により処理分岐 when (val content = viewModel.getCalendarContent(position)) { is Content.CalendarHeader -> { (holder.binding as CalendarHeaderBinding) .also { it.header = content it.lifecycleOwner = parentLifecycleOwner } } is Content.CalendarItem -> { (holder.binding as CalendarItemBinding) .also { it.item = content it.lifecycleOwner = parentLifecycleOwner } } } holder.binding.executePendingBindings() } // Contentの型によりヘッダーかそうでないか判断 // 別の型にキャストしようとしてもコンパイラで弾く override fun getItemViewType(position: Int): Int { return when (viewModel.getCalendarContent(position)) { is Content.CalendarHeader -> VIEW_TYPE_HEADER is Content.CalendarItem -> VIEW_TYPE_ITEM } } // リスト件数を ViewModel から取得 override fun getItemCount(): Int = viewModel.getCalendarContentsCount() // Exception は原則呼ばれない private fun getLayoutRes(viewType: Int) = when (viewType) { VIEW_TYPE_HEADER -> R.layout.calendar_header VIEW_TYPE_ITEM -> R.layout.calendar_item else -> throw IllegalArgumentException("Unknown viewType $viewType") } companion object { // ViewType用の定数を定義 // LayoutManagerにて必要になるため public で宣言 const val VIEW_TYPE_HEADER = 1 const val VIEW_TYPE_ITEM = 2 } // ViewDataBinding として保持 // 原則 View が null になることはない(はず)なので強制的にバインド!! class ContentViewHolder(v: View) : RecyclerView.ViewHolder(v) { val binding: ViewDataBinding = DataBindingUtil.bind(v)!! } }
RecyclerView の拡張関数の定義
RecyclerViewを拡張し、Adapter#notify
系メソッドを叩けるようにします。
// ViewExt.kt 等、別ファイルで作って定義 @BindingAdapter("bind:calendarUpdate") fun RecyclerView.onCalendarDataSetChanged(unit: Unit?) { adapter?.notifyDataSetChanged() }
notifyDataSetChanged だと激重になる場合、きちんと引数を取って分岐させるようにすればいいと思います。
その場合、val onCalendarDataSetChanged = LiveEvent<String>
等にして引数を持てるようにしましょう。
CalendarFragment の定義
次にRecyclerViewを乗っけるためのFragmentを定義します。
レイアウトファイルとバインド
上で定義したbind:calendarUpdate
にLiveEventをバインドします。
コンパイルエラーになる場合は Rebuild すればいいかも。
<layout ... xmlns:bind="http://schemas.android.com/apk/res-auto"> <data> <variable name="viewModel" type="...CalendarActivityViewModel" /> </data> <androidx.constraintlayout.widget.ConstraintLayout > <androidx.recyclerview.widget.RecyclerView android:id="@+id/calendar_contents" bind:calendarUpdate="@{viewModel.onCalendarDataSetChanged}" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
これで、ViewModelからAdapterへの参照なしにLiveEvent#call
→RecyclerView.onCalendarDataSetChanged
と処理が流れるようになりました。
Fragment の定義
ここまでで準備が整ったので、あとはAdapterオブジェクトを生成してセットするだけです。 LayoutManagerはGridLayoutMangerを拡張したCalendarLayoutManagerを使用していますが、MVVMとData Bindingによる動作確認だけならLinearLayoutManagerで十分です。
class CalendarFragment : Fragment() { private lateinit var binding: CalendarFragmentBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // activity 決め打ち val viewModel = (activity as CalendarActivity).obtainViewModel() val binding = ... // inflate // Adapterのオブジェクトの作成 val adapter = CalendarAdapter(viewModel, this.viewLifecycleOwner) binding.calendarContents.adapter = adapter // GridView を拡張した LayoutManager をセット // 今回は詳細省略 binding.calendarContents.layoutManager = CalendarLayoutManager(context!!, 7, adapter) launch(coroutineContext) { // List<Content>の生成と ViewModel へのセット // 今回は省略 ... } return binding.root } // 蛇足:RecyclerViewのスクロール位置の記憶と復元 override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val calendarState = binding.calendarContents.layoutManager?.onSaveInstanceState() outState.putParcelable(CALENDAR_STATE, calendarState) } override fun onViewStateRestored(savedInstanceState: Bundle?) { super.onViewStateRestored(savedInstanceState) val calendarState = savedInstanceState?.getParcelable<Parcelable>(CALENDAR_STATE) binding.calendarContents.layoutManager?.onRestoreInstanceState(calendarState) } companion object { private const val CALENDAR_STATE = "calendar_state" fun getInstance(...): CalendarFragment {...} } }
GitHub
「だらだらろぐ」(β版公開中)というAndroidアプリの中で使用しています。 19/1/13時点では公開版には実装されていませんが……(:3」∠)