日々是好日

プログラミングについてのあれこれ、ムダ知識など

MVVM+DataBinding+RecyclerViewでカレンダーを作る - 1

RecyclerViewでカレンダー風の表示を作ったので、備忘録的なまとめその1。 MVVM+DataBinding でViewに表示させるまでです。

できるもの

月ヘッダーと日付をこんな感じで表示させます。 LayoutManagerにはGridLayoutManagerを拡張したCalendarLayoutManagerを使用していますが、それらの実装は次回にします。

f:id:kcpoipoi:20200113185429g:plain:w200

使用した技術的なモノ

  • MVVMアーキテクチャ
    • ViewModelにてRecyclerViewのリストを保持
    • LiveData#observeは記述しないように実装する
  • Data Binding
    • レイアウトファイル(xml)にバインド
    • notify系メソッドを叩くのもバインド

サードパーティ製のライブラリは使用しません。 また、今回のキモとなるRecyclerViewと sealed class のテクニックはこちらのブログをご参照ください。

firespeed.org

項目を sealed class で用意する

今回作るカレンダーは、「x年x月」と表示したヘッダーと「x」と表示した日にちを混在させる必要があります。 そのため、愚直にやるならList<Any>として、あるいはインターフェース等を継承しリストを保持しなければなりません。

いずれにしても、RecyclerView#getItemViewTypeRecyclerView#onBindViewHolderへの対応など、大変面倒な実装となってしまいます。

しかし、Kotlinでは sealed class という仕様があり、今回はこちらを活用していきます。 sealed class Contentと定義することで、List<Content>として扱うことができ、さらにキャスト可否もコンパイラ側で判断できるようになります。

taro.hatenablog.jp

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中にありますが、これはクリックイベント等で一度だけイベントを発生させるために必要となります。 詳細は下記の投稿にて。

qiita.com

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#callRecyclerView.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」∠)

github.com