Part 5. 要來介紹呈現電影列表的套件 Epoxy,主要是用這個套件來呈現比較複雜的列表,像是我們的首頁,穿插夾雜了橫滑和直滑的列表,用嵌套的 RecyclerView 來實作技術上來說一定做得到,只是你要多花時間和心力,而 Epoxy 套件提供一個更容易的實作方式,讓我們來看 Epoxy 如何簡化我們的列表實作。
完整程式碼 https://github.com/enginebai/MovieHunt 已經釋出 ,可以下載程式碼邊看程式碼邊學習,歡迎給星 :star: 支持。這一系列文章是有連貫性的,如果還沒看過前面文章,建議先去看過前面的章節。傳送門:(Part 1.) (Part 2.) (Part 3.) (Part 4.)
還沒使用 Epoxy 之前
假設我們現在列表第一筆資料希望比較突顯,要放大圖片和文字,和其他地方用不同方式呈現,以我們傳統的 RecyclerView
作法就會是宣告兩個不同的介面檔 item_movie_large.xml
/ item_movie_normal.xml
,然後在 Adapter
宣告不同的 View Type 分開使用:
data class MovieModel( ... val largeSize: Boolean ) class MovieHomeAdapter(private val movieList: List<MovieModel>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { override fun getItemViewType(position: Int): Int { return if (movieList.get(position).largeSize) { R.layout.item_movie_large } else { R.layout.item_movie_normal } } override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerViewHolder { val inflater = LayoutInflater.from(viewGroup.context) return ViewHolder(inflater.inflate(viewType, viewGroup, false)) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { // bind the data to the view holder } class ViewHolder(view: View): RecyclerView.ViewHolder(view) { // find the view by id, display the data } }
view raw MovieHomeAdapter.kt hosted with ❤ by GitHub
如果今天再複雜一點,像是下列需求:
- 列表有分頁載入,當滑到列表最底下需要呈現載入中的
ProgressBar
,像是 MovieHunt 的垂直列表。 - 列表要呈現不同分類的橫滑列表,且橫滑列表要支援分頁載入,載入中要呈現
ProgressBar
,像是 MovieHunt 的首頁。 - 列表一開始要呈現另一個橫滑的列表、或是不同的橫滑項目列表,像是 FunNow。
- 列表要呈現不同分類的橫滑列表,像是 Google Play 呈現方式。
這樣的介面在傳統的 RecyclerView
寫起來肯定是複雜非常多,垂直 RecyclerView
裡面要嵌套橫向 RecyclerView
,要定義不同的 Adapter
,裡面要支援不同的 ViewType … 等,需要很多的元件模板和配置才能達到,以幾個知名的預定 App 來說需求肯定比上述幾個來要來的複雜,Airbnb 的工程團隊推出了 Epoxy 來解決這樣的問題。
Epoxy 是?
Epoxy 簡單來說就是讓你可以用輕鬆的方式來建立複雜的列表,你只需要定義
- Step 1. 各別每一種 item view 長的樣子。
- Step 2. 怎麼用 Step 1. 定義的 item view 在列表中排列出來。
對應到 Epoxy 的話,就是定義下面兩種不同元件:
-
EpoxyModel
:以傳統RecyclerView
的作法來說,這個EpoxyModel
就是RecyclerView.ViewHolder
的角色,在 Model 裡面給定資料、定義資料如何在介面上呈現、要怎麼互動 … 等等。 -
EpoxyController
:把定義好不同的EpoxyModel
在這邊組合起來成為列表的樣子。
以圖來說明這兩個元件:
以我們文章一開始提到「列表第一筆資料希望圖片和文字放大」需求來說,我們會宣告 LargeEpoxyModel
& NormalEpoxyModel
,然後實作 EpoxyController
,程式碼大致是這樣(這邊先著重了解概念就好,等等會講細節怎麼宣告和使用):
class LargeEpoxyModel : EpoxyModel { override fun bind() { // binding implementation } } class NormalEpoxyModel : EpoxyModel { override fun bind() { // binding implementation } } class ModelController(private val movieList: List<MovieModel>): EpoxyController() { override fun buildModels() { movieList.orEachIndexed { index, movie -> if (index == 0) { LargeEpoxyModel_() .addTo(this) } else { NormalEpoxyModel_() .addTo(this) } } } }
view raw MovieController.kt hosted with ❤ by GitHub
對於 Epoxy 有一個大致的了解後,我們就來針對 MovieHunt 專案的使用來細說 EpoxyModel
和 EpoxyController
用法。
Epoxy Model
這就代表了列表中每一個 item 的實作, EpoxyModel
支援三種不同的實作方式:
- Custom View
- Data Binding
- View Holder
用法是你依照這三種不同的方式來實作 Model 類別,然後 建置專案 Make Project 後, EpoxyModel
會幫你自動產生以底線 _ 結尾的類別或者 Kotlin DSL Builder,你就可以在 EpoxyController
裡面使用這些自動產生的類別。
注意:你只要新增一個 EpoxyModel
或者對 EpoxyModel
類別裡面做什麼變動,都要 建置專案 ,Epoxy 才會自動重新幫你建立新的類別,你才可以在 EpoxyController
使用。
我們依序來看怎麼在專案中使用不同的實作:
Custom Views
我們專案比較沒用到這種模式,這種比較多用在客製化的介面上,我提供一個簡單的範例,假設我們要顯示一個客製化下拉選單,選單是使用 RecyclerView
來實作,裡面的 item view 需要客製化,選單項目要可以選取或取消選取,選取起來要顯示特別的圖示,那麼我們會這樣宣告那個 custom view:
@ModelView(autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT) class DropdownItemView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr) { @TextProp var itemText: CharSequence? = null @ModelProp fun selected(selected: Boolean = false) { textMenuItem.isSelected = selected } @CallbackProp var clickListener: OnClickListener? = null private val textMenuItem: TextView init { View.inflate(context, R.layout.item_dropdown_menu, this) orientation = VERTICAL textMenuItem = findViewById(R.id.textMenu) } @AfterPropsSet fun userProps() { textMenuItem.text = itemText textMenuItem.setOnClickListener(clickListener) } }
view raw DropdownItemView.kt hosted with ❤ by GitHub
@ModelView, @TextProp, @ModelProp, @CallbackProp, @AfterPropsSet
都是 Epoxy 針對 Custom View 所提供的標注,分別是用在 1. 定義 Custom View 類別 2. 定義文字屬性 3. 定義一般屬性 3. 定義 Callback 屬性 4. 在屬性設定後呼叫的方法,更多更詳細的用法可以參考 官方 Wiki 。
Data Binding
我們對於資料綁定本身在 Android 的設定不多做說明,我們假設你已經設定好且可以正常使用 Android data binding,這邊我們只講解如何在 Epoxy 使用,以常見一般情況來說,我們可以做一個設定,然後就可以靠 Epoxy 來幫我們自動產生 EpoxyModel
。
以 MovieHunt 專案來解說怎麼設定,我們會到 package 根目錄也就是 com.enginebai.moviehunt
新增一個檔案名稱是 package-info.java
這個檔案裡面要把 R.class
和你想要讓 Epoxy 幫你自動產生 EpoxyModel
的 layout 檔名前綴加進去:
@EpoxyDataBindingPattern(rClass = R.class, layoutPrefix = "item") package com.enginebai.moviehunt; import com.airbnb.epoxy.EpoxyDataBindingPattern;
view raw package-info.java hosted with ❤ by GitHub
注意:這邊 import 是放在 package 宣告之後。
檔案 package-info.java
加入後,之後建置專案時,Epoxy 就會自動幫你抓檔名以 item 為開頭的 layout 檔來產生 EpoxyModel
。首頁列表的 Item View 我們會使用資料綁定的方式來實作 item_movie_home_normal.xml
:
<?xmlversion="1.0"encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="movieId" type="String" /> <variable name="posterImage" type="String" /> <variable name="title" type="String" /> <variable name="rating" type="String" /> <variable name="clickListener" type="com.enginebai.moviehunt.ui.MovieClickListener" /> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:onClick="@{() -> clickListener.onMovieClicked(movieId)}" > <androidx.cardview.widget.CardView android:layout_width="match_parent" android:layout_height="0dp" android:id="@+id/cardPoster" app:cardBackgroundColor="https://www.tuicool.com/articles/iMj2aiF/@color/darkBlue" app:cardCornerRadius="@dimen/corner" app:layout_constraintTop_toTopOf="parent" app:layout_constraintDimensionRatio="1:1.5" > <ImageView android:layout_width="match_parent" android:layout_height="match_parent" android:id="@+id/imagePoster" android:scaleType="centerCrop" android:src="https://www.tuicool.com/articles/iMj2aiF/@color/darkBlue" app:imageUrl="@{posterImage}" app:error="@{@color/darkBlue}" app:placeholder="@{@color/darkBlue}" /> </androidx.cardview.widget.CardView> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/textTitle" style="@style/TitleText.Normal" android:layout_marginTop="@dimen/size_8" android:text="@{title}" app:layout_constraintTop_toBottomOf="@+id/cardPoster" tools:text="Avengers: III" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/textRating" android:layout_marginTop="@dimen/size_4" android:drawableStart="@drawable/thumbs_up" android:text="@{rating}" style="@style/ContextText" tools:text="89.6%" app:layout_constraintTop_toBottomOf="@+id/textTitle" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
view raw item_movie_home_normal.xml hosted with ❤ by GitHub
完成後建置專案,你就可以在 EpoxyController
使用 :
MovieHomeLargeBindingModel_() .id("${movieCategory}${this.id}") .movieId(this.id) .posterImage(this.getPosterUrl()) .title(this.displayTitle()) .rating(this.voteAverage) .voteCount(this.displayVoteCount()) .duration(this.displayDuration()) .clickListener(clickListener)
view raw MovieHomeLargeBindingModel.kt hosted with ❤ by GitHub
View Holder
這個實作方式和原本的 RecyclerView.ViewHolder
非常的接近,你就像平常一樣定義介面檔,然後實作一個繼承 EpoxyModelWithHolder<T>
的抽象類別:
@EpoxyModelClass(layout = R.layout.holder_movie_landscape) abstract class MovieListEpoxyModel : EpoxyModelWithHolder<MovieListEpoxyModel.Holder>() { @EpoxyAttribute var movieId = "" @EpoxyAttribute var imagePoster = "" ... @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var itemClickListener: (String) -> Unit = {} override fun bind(holder: Holder) { Glide.with(holder.imagePoster) .load(imagePoster) .error(R.color.darkBlue) .placeholder(R.color.darkBlue) .into(holder.imagePoster) ... holder.itemView.setOnClickListener { itemClickListener(movieId) } } class Holder : EpoxyHolder() { lateinit var itemView: View lateinit var imagePoster: ImageView ... override fun bindView(itemView: View) { this.itemView = itemView imagePoster = itemView.findViewById(R.id.imagePoster) ... } } }
view raw MovieListEpoxyModel.kt hosted with ❤ by GitHub
- 這邊
@EpoxyModelClass(layout = R.layout.holder_movie_landscape)
指定你的介面 xml 檔 - Model 裡面會用
@EpoxyAttribute
來定義不同的屬性,好讓你在bind()
方法可以用來顯示資料 -
class Holder : EpoxyHolder()
則是定義 Holder 以及 View 元件。
Model 宣告的部份就到這邊,更詳盡的用法可以參考 官方文件 ,文件上介紹更多用法和注意事項(像是宣告 ID,ID 是用來做 Model 的 Diff 和狀態儲存 … 等),接下來我們要來講解如何在 EpoxyController
使用這些 EpoxyModel
來建立你的列表。
Epoxy Controller
這類別控制列表要如何呈現,我們會建立一個類別來繼承 EpoxyController
,然後實作唯一的方法 buildModels()
:
class MovieListController( private val context: Context, private val clickListener: MovieClickListener ) : PagedListEpoxyController<MovieModel>() { private val loadMoreView = LoadMoreView_().apply { id(LoadMoreView::class.java.simpleName) } var loadingMore = false set(value) { field = value requestModelBuild() } override fun buildItemModel(currentPosition: Int, item: MovieModel?): EpoxyModel<*> { return item?.run { MovieListEpoxyModel_() .id(this.id) .movieId(this.id) .imagePoster(this.getPosterUrl()) .textTitle(this.displayTitle()) .rating(this.display5StarsRating()) .voteCount(context.getString(R.string.vote_count, this.displayVoteCount())) .duration(this.displayDuration()) .releaseDate(this.displayReleaseDate()) .itemClickListener { clickListener.onMovieClicked(this.id) } } ?: run { MovieListEpoxyModel_() .id(-currentPosition) } } override fun addModels(models: List<EpoxyModel<*>>) { super.addModels(models) loadMoreView.addIf(loadingMore, this) } }
view raw MovieListController.kt hosted with ❤ by GitHub
我們列表是使用 PagedList
,所以是繼承 PagedListEpoxyController
,概念上是一樣的,最後一個 addModels()
方法則是當我們滑到底部要載入下一頁資料時,可以在底部呈現載入中的介面,這邊 順序 是非常重要的,在 Controller 裡面如何擺放 Model 就 直接決定了最後介面呈現的樣子 ,如果在 Controller.buildModel()
這樣設定:
class MyController : EpoxyController() { override fun buildModels() { HeaderImageModel_() LoadingModel_() MovieModel_() MovieModel_() FooterModel_() } }
view raw MyController.kt hosted with ❤ by GitHub
那麼最後畫面就會呈現這樣:
結語
Epoxy 可以幫助開發者建構出複雜的頁面,降低 RecyclerView 顯示不同介面的實作複雜度,同時可以提高介面元件的重複利用性,大致上的用法就這樣,更多注意事項和選項非常建議到 官方 Wiki 好好看過。
你看完這篇可能會非常好奇首頁是如何實作的?要如何在垂直列表裡面新增不同的水平列表、直的列表裡面還有很多橫的列表該怎麼實作?這部份屬於進階用法超過這章節的深度,礙於篇幅我會在後續章節提及怎麼實作,敬請鎖定這一系列文章。
如果你有任何和此專案相關的疑問,歡迎留言給我交流或討論。完整程式碼: https://github.com/enginebai/MovieHunt 歡迎 Fork + Star :star: 支持。