iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 18
0
自我挑戰組

Android Architecture 及 Unit Test系列 第 18

[Day 18] DataBinding

  • 分享至 

  • xImage
  •  

今天我想要先拉一個顯示工作事項的列表,這時就要提到常常跟 MVVM 一起提到的 Data Binding 了 。

Data Binding 是一個幫助我們把資料綁定在畫面上的 library ,這樣當資料變更時就可以在畫面上看到變化。

新的 Data Binding 已經可以搭配 LiveData 實現資料綁定,讓 Data Binding 的資料也支援 life-aware 的效果。

接下來就開始實現 Data Binding 吧。

Gradle

build.gradle 加入以下程式碼:

android {
    ...
    dataBinding {
        enabled = true
        enabledForTests = true // 未來寫測試會用到
    }
}

然後重新編譯即可。

重構 layout

昨天在 ViewModel 建立了一個 LiveData dataLoading ,可以表示資料 loading 中的狀態,現在就把它拿來控制我們的 SwipeRefreshLayout 的 loading 圈圈。

先在 TasksViewModel 創建 dataLoading

class tasksViewModel @Inject constructor(
    ......
) : ViewModel() {
    private val _dataLoading = MutableLiveData<Boolean>()
    val dataLoading: LiveData<Boolean> = _dataLoading
    
    ......
}

接著要來調整一下 TasksFragment 的 xml layout ,打開我的 TasksFragment 的 xml tasks_frag.xml ,並在 root layout 外包上一層 layout 標籤,當然像是 xmlns:android 之類的也要移到這裡:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    
    <data>

        <import type="android.view.View" />

        <variable
            name="viewmodel"
            type="com.example.android.architecture.blueprints.todoapp.tasks.TasksViewModel" />

    </data>
    
    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:id="@+id/coordinatorLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        
        <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:id="@+id/refresh_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:refreshing="@{viewmodel.dataLoading}">
    
        
        </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

稍微解釋一下意思:

data 描述所綁定的 variable ,這邊我綁定了 TasksViewModel,如果後續寫 Data Binding 時會用到 Android API ,可以用 import 的方式 import 進來,像是 View.VISIBLE 或是 Context 之類的 Android API 。

最後將 Activity / Fragment 使用的 layout 改成 Data Binding 幫我們生成的 layout ,以tasks_frag.xml 為例, Data Binding 會自動生成一個 Binding class TasksFragBinding

class TasksFragment : DaggerFragment() {

    private lateinit var viewDataBinding: TasksFragBinding
    
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        viewDataBinding = TasksFragBinding.inflate(inflater, container, false).apply {
        
            // 綁定資料到 layout 上
            viewmodel = viewModel
        }
        return viewDataBinding.root
    }
    
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        // 別忘了把 Fragment 的 lifecycle owner 交給 binding class
        viewDataBinding.lifecycleOwner = this.viewLifecycleOwner
    }
    
    ......
}

這樣 TasksFragment 的 UI 就完成 loading 狀態的 Data binding 了。

RecyclerView Data Binding

剛剛示範了簡單的 Data Binding ,可以開始完成今天的目標了。

今天我的目標是完成工作事項的畫面綁定, RecyclerView 具體如何實作我就不提了,直接開始改造 RecyclerView 吧。

一樣先修改 xml 檔:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <import type="android.widget.CompoundButton" />

        <variable
            name="task"
            type="com.example.android.architecture.blueprints.todoapp.data.Task" />

        <variable
            name="viewmodel"
            type="com.example.android.architecture.blueprints.todoapp.tasks.TasksViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="?android:attr/listPreferredItemHeight"
        android:orientation="horizontal"
        android:paddingLeft="@dimen/activity_horizontal_margin"
        android:paddingRight="@dimen/activity_horizontal_margin"
        android:paddingBottom="@dimen/list_item_padding"
        android:paddingTop="@dimen/list_item_padding">

        <CheckBox
            android:id="@+id/complete"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:onClick="@{(view) -> viewmodel.completeTask(task, ((CompoundButton)view).isChecked())}"
            android:checked="@{task.completed}" />

        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:layout_marginLeft="@dimen/activity_horizontal_margin"
            android:layout_marginStart="@dimen/activity_horizontal_margin"
            android:textAppearance="@style/TextAppearance.AppCompat.Title"
            android:text="@{task.titleForList}" />
    </LinearLayout>
</layout>

定義一個 Task 資料的 variable ,讓 CheckBox 的狀態及文字皆依據 Task 的內容,同時寫一個 click event 在點擊 checkbox 時觸發 ViewModel 的 completeTask() 方法。

接著修改 RecyclerView Adapter :

class TasksAdapter(private val viewModel: TasksViewModel) :
    ListAdapter<Task, ViewHolder>(TaskDiffCallback()) {

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position)

        holder.bind(viewModel, item)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder.from(parent)
    }

    class ViewHolder private constructor(val binding: TaskItemBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(viewModel: TasksViewModel, item: Task) {

            // 綁定資料到 layout 上
            binding.viewmodel = viewModel
            binding.task = item
            binding.executePendingBindings()
        }

        companion object {
            fun from(parent: ViewGroup): ViewHolder {
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = TaskItemBinding.inflate(layoutInflater, parent, false)

                return ViewHolder(binding)
            }
        }
    }
}

/**
 * 使用了 DiffUtil ,有興趣可以自行理解
 */
class TaskDiffCallback : DiffUtil.ItemCallback<Task>() {
    override fun areItemsTheSame(oldItem: Task, newItem: Task): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Task, newItem: Task): Boolean {
        return oldItem == newItem
    }
}

好了, RecyclerView 的 Data Binding 完成!

可能有人已經發現我沒有實作把資料傳進來的方法,接下來讓我們看看具體實作的方式。

自定義的 Binding

Data Binding 還提供了一些 API 讓我們處理一些狀況,像是有時候我們想要實作綁定資料後具體的畫面行為或是資料變化,像是把要顯示的資料綁在 RecyclerView 上,那麼要怎麼做呢?

這時候就會需要使用 @BindingAdapter 了 ,BindingAdapter 可以讓我們自定義 xml 上一個 attribute 的具體實現,以這個例子的話可以用來定義 RecyclerView 傳值進去的方法。

首先建立一個 extension TasksListBindings ,完成方法如下:

// TasksListBindings.kt

@BindingAdapter("app:items")
fun setItems(listView: RecyclerView, items: List<Task>) {
    (listView.adapter as TasksAdapter).submitList(items)
}

我定義了一個 attribute app:items ,用來把要顯示的資料傳給 RecyclerView ,接下來再看看 tasks_frag 內的 RecyclerView 如何使用:

......
<androidx.recyclerview.widget.RecyclerView
                        android:id="@+id/tasksList"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:fadingEdge="vertical"
                        android:requiresFadingEdge="vertical"
                        android:fadingEdgeLength="4dp"
                        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
                        app:items="@{viewmodel.items}" />
......

這樣就成功把值傳給 RecyclerView 了,同樣的,如果想要在勾選工作事項時讓被勾選的項目加上一條刪除線,就可以這麼寫:

// TasksListBindings.kt

@BindingAdapter("app:items")
fun setItems(listView: RecyclerView, items: List<Task>) {
    (listView.adapter as TasksAdapter).submitList(items)
}

@BindingAdapter("app:completedTask")
fun setStyle(textView: TextView, enable: Boolean) {
    if (enable) {
        textView.paintFlags = textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
    } else {
        textView.paintFlags = textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
    }
}

然後在 item 的 TextView 上:

<TextView
    android:id="@+id/itemTitle"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_vertical"
    android:layout_marginStart="@dimen/activity_horizontal_margin"
    android:layout_marginEnd="@dimen/activity_horizontal_margin"
    android:textAppearance="@style/TextAppearance.AppCompat.Title"
    android:text="@{task.titleForList}"
    app:completedTask="@{task.completed}" />

透過 Taskcompleted 狀態來判斷勾選時要在 item 上畫刪除線。

總結

我簡單介紹了 Data Binding 的使用方式,其實對於 MVVM 模式需不需要使用 Data Binding 有很多看法,個人是抱持推薦的態度,也有人覺得這樣做一個不好就會在 layout 寫了太多畫面邏輯,讓 View 與 ViewModel 之間的耦合性大大增加。

因此要不要用 Data Binding 就見仁見智,我是覺得再如何完美的 library 一旦亂用一樣會有災難性的後果,也不必太過於抗拒,只要用法正確,並了解其優缺點,一樣能好好地使用它。


上一篇
[Day 17] Domain layer:UseCase
下一篇
[Day 19] Test:Part 1 Datebase Dao
系列文
Android Architecture 及 Unit Test30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言