[안드로이드] BindingAdapter!! 두둥등장!!
최근에 Koin을 다시 공부하면서 샘플 코드에 RecyclerView를 사용했었다. 그 때 DataBinding도 사용했는데 "DataBinding과 RecyclerView를 모두 사용할 것이면 BindingAdapter도 사용하는게 더 낫지 않을까?"라는 생각을 했었다. (물론 RecyclerView와 BindingAdapter는 직접적으로 관계는 없다.) 그래서... BindingAdapter 두둥 등장!!! (아래 샘플 코드는 필자의 github를 참고바란다.)
🤔 BindingAdapter ?
BindingAdapter는 속성값을 설정하거나 이벤트리스너를 설정하는 것처럼 View(xml 레이아웃)의 attribute를 정의하고 로직을 작성하는데 사용된다. Android의 기본 UI들은 대부분 이미 정의된 BindingAdapter가 있다. 정의로 이해하기 어려울 수 있으니 바로 실습을 해 보자.
🛠 Setting
// build.gradle (app)
// 예전에는 이렇게 사용했는데
dataBinding {
enabled = true
}
// 요즘은 이렇게 사용한다
buildFeatures {
dataBinding = true
}
혹시나 아래와 같은 에러 메시지가 나타난다면 위 부분을 수정해보길 바란다.
DSL element 'android.dataBinding.enabled' is obsolete and has been replaced with 'android.buildFeatures.dataBinding'.
Data Binding layout으로 전환을 해보자. DataBinding 사용시, xml에는 최상위에 반드시 <layout> 태그를 사용해야 한다.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
// 생략
</LinearLayout>
</layout>
필자의 샘플에는 BaseKotlinActivity라는 Base 코드에 선언한 binding 객체를 초기화하는 코드가 있다. 하지만 Base 코드가 별로도 없는 경우에는 아래 두번째 코드를 참고하자.
// Base 코드가 있는 경우 - BaseKotlinActivity.kt
abstract class BaseKotlinActivity<T : ViewDataBinding, R : BaseKotlinViewModel> :
AppCompatActivity() {
lateinit var binding: T
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, layoutResourceId)
}
// 일부 생략
}
// Base 코드가 없는 경우 - MainActivity.kt
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
// 일부 생략
}
여기까지 DataBinding을 사용하기 위한 기본적인 세팅이다. BindingAdapter를 사용하려면 DataBinding을 사용하기 위한 세팅이 되어 있어야 한다.
🚗 DataBinding on View
binding은 레이아웃을 가리키는 변수이다. 이 변수를 통해 두가지 초기화를 진행한다. 첫번째는 LifecycleOwner이다. binding 변수의 생명주기를 액티비티나 프래그먼트의 생명주기에 종속시키기 위해 binding 선언 후 LifecycleOwner도 초기화를 해줄 필요가 있다. binding 변수가 View의 생명주기에 맞게 생성 및 해제가 되기 때문에 메모리 누수의 문제가 적어진다. 두번째는 ViewModel이다. 레이아웃에서 ViewModel을 선언하여 사용한다. 그러므로 액티비티에서는 초기화를 해줄 필요가 있다.
// activity_main.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="model"
type="what.the.mvvm.viewmodel.MainViewModel" />
</data>
// 생략
// MainActivity.kt
override val viewModel: MainViewModel by viewModel()
override fun initStartView() {
binding.lifecycleOwner = this
binding.model = viewModel
// 이하 생략
}
만약 LifecycleOwner 초기화가 제대로 진행되지 않을 경우, BindingAdapter 사용할 때 제대로 접근하지 못할 수 있으니 주의하자. (MutableLiveData with BindingAdapter not updating visibility of view)
BindingAdapter를 사용할 준비는 완료되었다. 이제 레이아웃의 뷰를 끌어다 사용해보자. 접근하고자 하는 뷰의 ID는 snake_case에서 camelCase로 바뀐다는 점에 유의하자.
// 레이아웃
<EditText
android:id="@+id/main_activity_search_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ems="10"
android:hint="Query"
android:inputType="textPersonName" />
// 액티비티
binding.mainActivitySearchTextView.setText("first preview of Android 11")
DataBinding과 관계는 없지만... 뒤에서 BindingAdapter를 사용하기 전에 RecyclerView와 관련된 세팅이 완료되어야 한다. 바로 MainActivity에서 Adapter가 초기화.
private val mainSearchRecyclerViewAdapter: MainSearchRecyclerViewAdapter by inject()
lateinit var imm: InputMethodManager
override fun initStartView() {
// 일부 생략
imm = getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as InputMethodManager
binding.mainActivitySearchRecyclerView.run {
adapter = mainSearchRecyclerViewAdapter
layoutManager = StaggeredGridLayoutManager(3, 1).apply {
gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
orientation = StaggeredGridLayoutManager.VERTICAL
}
setHasFixedSize(true)
}
}
🚀 BindingAdapter 적용
BindingAdapter 함수를 만들자. RecyclerView와 ArrayList를 인자로 받아서 RecyclerView에 데이터를 넣고 갱신하는 함수다. 그리고 레이아웃의 RecyclerView에서 이 함수를 사용하자.
@BindingAdapter("bindData")
fun bindRecyclerView(
recyclerView: RecyclerView?,
data: ArrayList<Document>?
) {
val adapter = recyclerView?.adapter as MainSearchRecyclerViewAdapter
data?.forEach {
adapter.addPersonItem(it.doc_url)
}
adapter.notifyDataSetChanged()
}
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/main_activity_search_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="12dp"
android:paddingBottom="12dp"
bindData="@{model.personData}"/>
리스트로 나타날 데이터를 레이아웃에 출력하기 위해 해당 데이터(여기서는 Document)를 ViewModel에 담는다. 뷰모델(MainViewModel)의 LiveData가 데이터 변경을 감지하면 bindData가 작동한다. 감지하기 위한 LiveData는 아래와 같이 작성한다.
// MainViewModel.kt
private val _personData = MutableLiveData<ArrayList<Document>>()
val personData: LiveData<ArrayList<Document>>
get() = _personData
🤪 부록 : Required DataBindingComponent is null in class ActivityMainBindingImpl
java.lang.RuntimeException: Unable to start activity ComponentInfo{what.the.mvvm/what.the.mvvm.MainActivity}: java.lang.IllegalStateException: Required DataBindingComponent is null in class ActivityMainBindingImpl. A BindingAdapter in what.the.mvvm.MainActivity is not static and requires an object to use, retrieved from the DataBindingComponent. If you don't use an inflation method taking a DataBindingComponent, use DataBindingUtil.setDefaultComponent or make all BindingAdapter methods static.
위와 같은 에러가 나타날 경우가 있다. BindingAdapter 어노테이션을 사용한 함수의 경우 static으로 접근이 가능해야 한다. @JvmStatic을 사용하거나 아래와 같이 사용하면 된다. 코틀린에서는 클래스 내부가 아닌 바로 함수를 사용하게 될 경우, static으로 선언한 것이 된다.
@BindingAdapter("bindData")
fun bindRecyclerView(
recyclerView: RecyclerView?,
data: ArrayList<Document>?
) {
// 생략
}
이로써 Model(ArrayList<Document>)의 변화는 오직 ViewModel이 감지하여 정의하고, ViewModel의 변화는 View에 반영하는 모습을 볼 수 있다.
참고자료
- 뷰모델에서 XML로 데이터 바로 꽂기
- LiveData + ViewModel 사용해보기
- 안드로이드 BindingAdapter를 사용해보자
- 데이터바인딩 Recyclerview
- [데이터바인딩] RecyclerView와 BindingAdapter