# epoxy/kotlinsample
ここでは、Epoxyのkotlinsampleを題材にepoxyを使ったRecyclerViewの構築方法を解説します。
https://github.com/airbnb/epoxy/tree/master/kotlinsample
<img src="https://i.imgur.com/REaizAp.png" width=30%>
## MainActivity
`MainActivity`では、複数のビュータイプをもつRecyclerViewをepoxyを使って構築します。
```xml=
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#80000000"/>
<com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
```
このサンプルでは`EpoxyRecyclerView`を使用しています。`EpoxyRecyclerView`を使用するとepoxyとRecyclerViewを簡単に統合することができます。
`EpoxyRecyclerView`には以下のような特徴があります。
- 全てのEpoxyRecyclerViewインスタンスはViewPoolで共有されます。
- レイアウトマネージャーは、正常なデフォルトで自動的に追加されます。
- このビューのサイズがMATCH_PARENTの場合、setHasFixedSizeがtrueになります
- GridLayoutManagerが使用されている場合、これによりスパンカウントがEpoxyControllerと自動的に同期されます(RecyclerViewへのspanCountの設定が不要になる)。
- EpoxyControllerクラスを作成せずにモデルを設定するためのヘルパーメソッド。
- EpoxyControllerを設定し、1つのステップでモデルを構築します(初回のnotifyが不要になる)
- setItemSpacingPxメソッドによって全てのアイテムの間隔を簡単に設定できます
- ネストされたrecyclerviewとして使用するためのデフォルトは、カルーセルで提供されます。
- setClipToPaddingがデフォルトでfalseに設定されています
### Modelの作成
参考: https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations
RecyclerViewには以下の8種類のモデル(ビュー)を表示します
1. ColoredSquareView
2. databindingItem
3. ItemCustomView
4. ItemEpoxyHolder
5. ItemViewBindingEpoxyHolder
6. CarouselItemCustomView
7. ItemDataClass
8. ItemViewBindingDataClass
#### ColoredSquareView
<img src="https://i.imgur.com/N09mTH9.png" width=30%>
ColoredSquareViewは、カスタムビューからモデルを作成しています。
```kotlin=
// ColoredSquareView.kt
@ModelView(defaultLayout = R.layout.colored_square_view)
class ColoredSquareView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
...
}
```
```xml=
<!-- colored_square_view.xml -->
<?xml version="1.0" encoding="utf-8"?>
<com.airbnb.epoxy.kotlinsample.models.ColoredSquareView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="56dp"
android:layout_height="56dp" />
```
カスタムビューからのモデルの生成には`ModelView`アノテーションを使用します。
`ModelView`アノテーションには、`defaultLayout`としてレイアウトのリソースを渡すことができます。`defaultLayout`にレイアウトを渡すと、epoxyによってinflateされます。
`defaultLayout`に渡すレイアウトは、カスタムビュー自身のみを一つだけ持ったものである必要があります。
```kotlin=
// ColoredSquareView.kt
@JvmOverloads
@ModelProp
fun color(@ColorInt color: Int = Color.RED) {
setBackgroundColor(color)
}
```
動的に値を設定するプロパティには`prop`アノテーションを追加します。`prop`でマークされたプロパティは、epoxyがモデルを自動生成した際にsetterが用意されます。
カスタムビューのカラーを設定する`color`メソッドには、`@ModelProp`アノテーションを付け、コントローラーから呼び出せるようにします。
また、デフォルト引数を設定する場合には`@JvmOverLoads`アノテーションも追加する必要があります。
##### モデルへの値の設定
`@TextProp`や`@ModelProp`などのアノテーションでマークしたプロパティは、Controllerから値を設定することができます。
ColoredSquareViewではcolorを`@ModelProp`でマークしました。
```kotlin=
coloredSquareView {
...
color(Color.DKGRAY)
}
// or
ColoredSquareViewModel_()
.id("uniqueId")
.color(Color.LTGRAY)
.addTo(this)
```
コントローラーからはこのように値を設定できます。
#### databindingItem
<img src="https://i.imgur.com/TlifLez.png" width=30%>
databindingItemは、databindingのレイアウトからモデルを作成しています。
```xml=
<!-- epoxy_layout_data_binding_item.xml -->
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="text"
type="String" />
<variable
name="onClick"
type="android.view.View.OnClickListener" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="20dp"
android:onClick="@{onClick}"
android:text="@{text}"
android:background="?attr/selectableItemBackground"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#80000000"/>
</LinearLayout>
</layout>
```
```kotlin=
// EpoxyDataBindingPatterns.kt
@EpoxyDataBindingPattern(rClass = R::class, layoutPrefix = "epoxy_layout")
object EpoxyDataBindingPatterns
```
databindingのレイアウトからモデルを作成する場合、プロジェクト内の任意の場所に`@EpoxyDataBindingPattern`をつけたobjectを作成します。
`@EpoxyDataBindingPattern`には`layoutPrefix`として、モデル生成の対象とするレイアウトを指定します。
ここでは、`epoxy_layout`をprefixに持つレイアウトをモデル生成の対象としています。
#### ItemCustomView
<img src="https://i.imgur.com/VvopMaX.png" width=30%>
ItemCustomViewは、カスタムビューからモデルを作成しています。
```kotlin=
// ItemCustomView.kt
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT)
class ItemCustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
...
init {
inflate(context, R.layout.custom_view_item, this)
orientation = VERTICAL
textView = (findViewById(R.id.title))
textView.setCompoundDrawables(null, null, onVisibilityEventDrawable, null)
textView.compoundDrawablePadding = (4 * resources.displayMetrics.density).toInt()
}
}
```
```xml=
<!-- custom_view_item.xml -->
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:padding="20dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#80000000" />
</merge>
```
ここでは、`@ModelView`アノテーションの`autoLayout`でビューのサイズを指定しています。
このように、通常のカスタムビューと同じくカスタムビュー内部でレイアウトをinflateすることも可能です。
```kotlin=
var listener: View.OnClickListener? = null
@CallbackProp set
@TextProp
lateinit var title: CharSequence
```
setterやlateinitのプロパティに`@TextProp`や`@CallbackProp`アノテーションを付けることで、コントローラーから値を設定することができます。
```kotlin=
@AfterPropsSet
fun useProps() {
// This is optional, and is called after the annotated properties above are set.
// This is useful for using several properties in one method to guarantee they are all set first.
textView.text = title
textView.setOnClickListener(listener)
}
```
`@AfterPropsSet`アノテーションをつけたメソッドは、ビューのインスタンスがモデルにバインドされ、全てのプロパティが設定された後に実行されます。
```kotlin=
@OnVisibilityStateChanged
fun onVisibilityStateChanged(
@VisibilityState.Visibility visibilityState: Int
) {
...
}
```
`@OnVisibilityStateChanged`アノテーションを付けたメソッドは、ビューのスクリーン上での表示状態が変わった際に実行されます。
表示状態には以下のものがあります。
https://github.com/airbnb/epoxy/wiki/Visibility-Events
- **Visible**:ビューの少なくとも1つのピクセルが表示されているときにトリガーされます。
- **Invisible**:ビューの画面にピクセルがなくなったときにトリガーされます。
- **Focused**:コンポーネントがフォーカス範囲に入ったときに発生するイベント。これは、コンポーネントがビューポートの半分以上を占めるか、コンポーネントがビューポートの半分よりも小さい場合は、コンポーネントが完全に表示されたときに発生します。
- **Unfocused Visible**:コンポーネントがフォーカス範囲を終了したときにトリガーされるイベント。 フォーカス範囲は、ビューポートの少なくとも半分として定義されます。
- **Full Impression Visible**:コンポーネントが全インプレッション範囲に入るとトリガーされるイベント。 これは、たとえば垂直RecyclerViewの場合、コンポーネントの上端と下端の両方が表示されるときに発生します。
#### ItemEpoxyHolder
<img src="https://i.imgur.com/8BOCfg4.png" width=30%>
ItemEpoxyHolderはViewHolderパターンのような実装でモデルを作成しています。
```kotlin=
// ItemEpoxyHolder.kt
@EpoxyModelClass(layout = R.layout.view_holder_item)
abstract class ItemEpoxyHolder : EpoxyModelWithHolder<Holder>() {
@EpoxyAttribute lateinit var listener: () -> Unit
@EpoxyAttribute lateinit var title: String
override fun bind(holder: Holder) {
holder.titleView.setText(title)
holder.titleView.setOnClickListener { listener() }
}
}
class Holder : KotlinEpoxyHolder() {
val titleView by bind<TextView>(R.id.title)
}
```
ViewHolderパターンのようにモデルを作成するには`EpoxyModelWithHolder`を継承したクラスを作成し、`@EpoxyModelClass`アノテーションを付けます。
プロパティには`@EpoxyAttribute`を設定することで、コントローラーから値を設定することができます。
#### ItemViewBindingEpoxyHolder
<img src="https://i.imgur.com/OP5NPGH.png" width=30%>
ItemViewBindingEpoxyHolderはViewBindingを使用したViewHolderパターンでモデルを作成しています。
```kotlin=
// ItemViewBindingEpoxyHolder.kt
@EpoxyModelClass(layout = R.layout.view_binding_holder_item)
abstract class ItemViewBindingEpoxyHolder : ViewBindingEpoxyModelWithHolder<ViewBindingHolderItemBinding>() {
@EpoxyAttribute lateinit var listener: () -> Unit
@EpoxyAttribute lateinit var title: String
override fun ViewBindingHolderItemBinding.bind() {
title.text = this@ItemViewBindingEpoxyHolder.title
title.setOnClickListener { listener() }
}
}
```
ViewBindingを使用したViewHolderパターンでモデルを作成するには`ViewBindingEpoxyModelWithHolder`を継承したクラスを作成し、`@EpoxyModelClass`アノテーションを付けます。
プロパティには`@EpoxyAttribute`を設定することで、コントローラーから値を設定することができます。
#### CarouselItemCustomView
<img src="https://i.imgur.com/u6USQKf.png" width=30%>
CarouselItemCustomViewはItemCustomViewと同様にカスタムビューからモデルを作成しています。
#### ItemDataClass
<img src="https://i.imgur.com/TTc8C6J.png" width=30%>
ItemDataClassはアノテーションを使用せずにモデルを作成しています。
```kotlin=
// ItemDataClass.kt
data class ItemDataClass(
val title: String
) : KotlinModel(R.layout.data_class_item) {
val titleView by bind<TextView>(R.id.title)
override fun bind() {
titleView.text = title
}
}
```
```kotlin=
// KotlinModel.kt
abstract class KotlinModel(
@LayoutRes private val layoutRes: Int
) : EpoxyModel<View>() {
private var view: View? = null
abstract fun bind()
override fun bind(view: View) {
this.view = view
bind()
}
override fun unbind(view: View) {
this.view = null
}
override fun getDefaultLayout() = layoutRes
protected fun <V : View> bind(@IdRes id: Int) = object : ReadOnlyProperty<KotlinModel, V> {
override fun getValue(thisRef: KotlinModel, property: KProperty<*>): V {
// This is not efficient because it looks up the view by id every time (it loses
// the pattern of a "holder" to cache that look up). But it is simple to use and could
// be optimized with a map
@Suppress("UNCHECKED_CAST")
return view?.findViewById(id) as V?
?: throw IllegalStateException("View ID $id for '${property.name}' not found.")
}
}
}
```
この方法では、`EpoxyModel`を継承した`KotlinModel`を作成しています。`KotlinModel`では`EpoxyModel`の`bind`, `unbind`, `getDefaultLayout`をオーバーライドし、ビューのバインド処理を行なっています。また、抽象メソッドとして`bind`を新たに定義し、ビューのバインド時に実行します。
`ItemDataClass`はepoxyの差分更新のためにdata classとして定義する必要があります。
#### ItemViewBindingDataClass
<img src="https://i.imgur.com/5nagiYJ.png" width=30%>
ItemViewBindingDataClassでは、アノテーションを使用せずにViewBinding利用したモデル作成を行なっています。
```kotlin=
// ItemViewBindingDataClass.kt
data class ItemViewBindingDataClass(
val title: String
) : ViewBindingKotlinModel<DataClassViewBindingItemBinding>(R.layout.data_class_view_binding_item) {
override fun DataClassViewBindingItemBinding.bind() {
title.text = this@ItemViewBindingDataClass.title
}
}
```
```kotlin=
// ViewBindingKotlinModel.kt
abstract class ViewBindingKotlinModel<T : ViewBinding>(
@LayoutRes private val layoutRes: Int
) : EpoxyModel<View>() {
// Using reflection to get the static binding method.
// Lazy so it's computed only once by instance, when the 1st ViewHolder is actually created.
private val bindingMethod by lazy { getBindMethodFrom(this::class.java) }
abstract fun T.bind()
@Suppress("UNCHECKED_CAST")
override fun bind(view: View) {
var binding = view.getTag(R.id.epoxy_viewbinding) as? T
if (binding == null) {
binding = bindingMethod.invoke(null, view) as T
view.setTag(R.id.epoxy_viewbinding, binding)
}
binding.bind()
}
override fun getDefaultLayout() = layoutRes
}
// Static cache of a method pointer for each type of item used.
private val sBindingMethodByClass = ConcurrentHashMap<Class<*>, Method>()
@Suppress("UNCHECKED_CAST")
@Synchronized
private fun getBindMethodFrom(javaClass: Class<*>): Method =
sBindingMethodByClass.getOrPut(javaClass) {
val actualTypeOfThis = getSuperclassParameterizedType(javaClass)
val viewBindingClass = actualTypeOfThis.actualTypeArguments[0] as Class<ViewBinding>
viewBindingClass.getDeclaredMethod("bind", View::class.java)
?: error("The binder class ${javaClass.canonicalName} should have a method bind(View)")
}
private fun getSuperclassParameterizedType(klass: Class<*>): ParameterizedType {
val genericSuperclass = klass.genericSuperclass
return (genericSuperclass as? ParameterizedType)
?: getSuperclassParameterizedType(genericSuperclass as Class<*>)
}
```
`EpoxyModel`を継承した`ViewBindingKotlinModel`では、Bindingインスタンスの作成と保存、取り出しを行います。
### Controller
```kotlin=
recyclerView.withModels {
...
}
```
`MainActivity`では、`EpoxyRecyclerView#withModels`を使用してコントローラを作成し、モデルの設定を行なっています。
Epoxyでは、コントローラーにモデルを追加した順がRecyclerViewへのビューの表示順となります。
アノテーションによって自動生成されたモデルやpropsは、Kotlin DSLを使用して設定することが可能です。モデルをコントローラーに追加する際には一意なidを設定する必要があります。
```kotlin=
group {
id("epoxyModelGroupDsl")
layout(R.layout.vertical_linear_group)
coloredSquareView {
id("coloredSquareView 1")
color(Color.DKGRAY)
}
coloredSquareView {
id("coloredSquareView 2")
color(Color.GRAY)
}
coloredSquareView {
id("coloredSquareView 3")
color(Color.LTGRAY)
}
}
```
1,2段目には、`ColoredSquareView`を挿入しています。
`group`は、epoxyで用意されているモデルをグループ化するメソッドです。サンプルでは、3つの`ColoredSquareView`を横に並べグループ化しています。
```kotlin=
for (i in 0 until 100) {
dataBindingItem {
id("data binding $i")
text("this is a data binding model2")
onClick { _ ->
Toast.makeText(this@MainActivity, "clicked", Toast.LENGTH_LONG).show()
}
onVisibilityStateChanged { model, view, visibilityState ->
Log.d(TAG, "$model -> $visibilityState")
}
}
itemCustomView {
id("custom view $i")
color(Color.GREEN)
title("Open sticky header activity")
listener { _ ->
Toast.makeText(this@MainActivity, "clicked", Toast.LENGTH_LONG).show()
startActivity(Intent(this@MainActivity, StickyHeaderActivity::class.java))
}
...
}
```
3段目以降は
1. dataBindingItem
2. ItemCustomView
3. ItemEpoxyHolder
4. ItemViewBindingEpoxyHolder
5. CarouselItemCustomView
6. ItemDataClass
7. ItemViewBindingDataClass
の順に並べられたブロックを、forで指定した回数分追加しています。
## StickyHeaderActivity
`MainActivity`に表示されるItemCustomViewをタップすると`StickyHeaderActivity`に遷移することができます。
### Controller
`StickyHeaderActivity`ではコントローラークラスを作成しています。
```kotlin=
class StickyHeaderController(
private val context: Context
) : EpoxyController(), StickyHeaderCallbacks {
override fun buildModels() {
for (i in 0 until 100) {
when {
i % 5 == 0 -> stickyItemEpoxyHolder {
id("sticky-header $i")
title("Sticky header $i")
listener {
Toast.makeText(context, "clicked", Toast.LENGTH_LONG).show()
}
}
else -> itemEpoxyHolder {
id("view holder $i")
title("this is a View Holder item")
listener {
Toast.makeText(context, "clicked", Toast.LENGTH_LONG)
.show()
}
}
}
}
}
// Feel feel to use any logic here to determine if the [position] is sticky view or not
override fun isStickyHeader(position: Int) = adapter.getModelAtPosition(position) is StickyItemEpoxyHolder
}
```
`EpoxyController`を継承した`StickyHeaderController`では`buildModels`を実装します。`buildModels`はモデルの設定を行い、 `reuestModelBuild`をトリガーに実行されます。
さらに、`StickyHeaderController`では、StickyHeaderの実現のため`StickyHeaderCallbacks#isStickyHeader`を実装しています。
### RecyclerViewの設定
```kotlin=
class StickyHeaderActivity : AppCompatActivity() {
private lateinit var recyclerView: EpoxyRecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity)
recyclerView = findViewById(R.id.recycler_view)
recyclerView.layoutManager = StickyHeaderLinearLayoutManager(this)
recyclerView.setController(StickyHeaderController(this))
recyclerView.requestModelBuild()
}
}
```
`StickyHeaderActivity`では、`EpoxyRecyclerView`に対し`StickyHeaderLinearLayoutManager`をLayoutManagerとして設定し。コントローラーは`StickyHeaderController`を設定しています。
コントローラーの設定後、`requestModelBuild`を実行して`StickyHeaderController`の`buildModels`をトリガーし、RecyclerViewへのアイテムの表示を行っています。
## リストの更新
参考: https://github.com/airbnb/epoxy/wiki/Diffing
モデルはImmutableとなっており、ビューからのモデルの更新はできません。コールバックを用いてビューのイベントを取得し、新たなモデルを作成する必要があります。
```kotlin=
class SampleController : EpoxyController() {
var data: SampleData? = null
override fun buildModels() {
data?.messages?.forEachIndexed { i, v ->
sample {
id(i)
message(v)
}
}
}
}
```
```kotlin=
// MainActivity.kt
sampleController.data = data
sampleController.requestModelBuild()
```
EpoxyControllerを使用している場合は、データの更新後、controllerの`requestModelBuild`を実行することで自動で差分更新されます。
```kotlin=
class TypedSampleController : TypedEpoxyController<SampleData>() {
override fun buildModels(data: SampleData) {
data.messages.forEachIndexed { i, v ->
sample {
id(i)
message(v)
}
}
}
}
```
```kotlin=
// MainActivity.kt
typedSampleController.setData(data)
```
TypedEpoxyControllerを使用している場合は、データの更新後、controllerの`setData`にデータを渡すことで自動で差分更新されます(`requestModelBuild`は実行しないでください)
## おまけ
`withModels`の実装
```kotlin=
fun withModels(buildModels: EpoxyController.() -> Unit) {
val controller = (epoxyController as? WithModelsController)
?: WithModelsController().also { setController(it) }
controller.callback = buildModels
controller.requestModelBuild()
}
private class WithModelsController : EpoxyController() {
var callback: EpoxyController.() -> Unit = {}
override fun buildModels() {
callback(this)
}
}
```