# 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) } } ```