이번 2018년 11월 25일에 마루 180에서 진행한 안드로이드 컨퍼런스에서 발표한 내용 중에 BaseRecyclerView에 대해 글로 정리해보려고 이번 포스팅을 준비해봤습니다.
안드로이드 앱을 개발하면서 서버에서 데이터를 가져와서 리스트로 보여주는 기능을 자주 개발 했었는데 이런 리스트를 만드는 작업을 많이 하다 보니 공통으로 처리 할 수 있는 부분이 보이기 시작했고 그러면서 BaseRecyclerView를 만들어야겠다고 느꼈습니다.
이 글을 이해하기 위해서는 아래와 같은 지식이 필요하니 꼭 선행학습을 하고 보시면 좋을 것 같습니다.
1. RecyclerView
- RecyclerView를 어떻게 구현하는지
- RecyclerView.Adapter에서 필수로 override해야하는 함수들의 역할과 구현 방법
- RecyclerView.ViewHolder 구현 방법
2. DataBinding
- DataBinding 사용 방법
- BindingAdapter 사용 방법
언어 코드 리스트(http://help.bingads.microsoft.com/apex/index/18/ko/10004)
위에 주소에 있는 언어 코드 리스트를 RecyclerView를 통해서 보여주는 기능을 구현해보도록 하겠습니다.
일반적인 구현 방법
class Step1Adapter : RecyclerView.Adapter<Step1Adapter.ViewHolder>() {
private val items = mutableListOf<LanguageCode>()
fun replaceAll(newItems: List<LanguageCode>?) {
newItems?.let {
items.clear()
items.addAll(it)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent)
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.onBindViewHolder(items[position])
}
inner class ViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.language_code_item, parent, false)
) {
fun onBindViewHolder(item: LanguageCode) {
with(itemView) {
tvCode.text = item.code
tvLanguage.text = item.language
}
}
}
}
class Step1Activity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.step1_activity)
rvContent.adapter = Step1Adapter().apply { replaceAll(getLanguageCodeList()) }
}
}
제가 처음에 많이 하던 RecyclerView의 Adapter를 구현하는 방법으로 RecyclerView.Adatper를 상속받는 CustomAdapter를 구현하고 RecyclerView.ViewHolder를 상속받는 CustomViewHolder를 구현하는 방식입니다.
CustomAdapter
- Adapter내부에 List를 가지고 있는다.
- replaceAll()과 같은 함수를 통해서 데이터를 교체한다.
- onCreateViewHolder()에서 CustomViewHolder를 생성한다.
- getItemCount()에서 list의 사이즈를 리턴한다.
- onBindViewHolder()에서 holder에 position에 맞는 데이터를 전달한다.
CustomViewHolder
- Adapter의 onCreateViewHolder에서 인자로 넘어오는 ViewGroup으로 View를 생성하여 RecyclerView.ViewHolder의 생성자에 전달한다.
- Adapter의 onBindViewHolder에서 넘어오는 데이터를 set한다.
이렇게 CustomAdapter를 따로 만들게 되면 새로운 화면에서 새로운 리스트를 보여줘야 할 때마다 계속 만들어 줘야 하는 번거로움이 있습니다.
조금 다르게 말하자면 ArrayList를 Generic을 사용하지 않고 Int를 저장하는 IntArrayList, String을 저장하는 StringArrayList를 만드는 것과 동일합니다.(조금 억지인가..)
그러면 위에 Step1Adpater와 비슷한 형태로 다른 데이터를 다른 layout으로 보여주는 CustomAdapter를 만들어본다고 가정해보겠습니다.
class CustomAdapter : RecyclerView.Adapter<CustomAdapter.ViewHolder>() {
private val items = mutableListOf<CustomItem>()
fun replaceAll(newItems: List<CustomItem>?) {
newItems?.let {
items.clear()
items.addAll(it)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent)
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.onBindViewHolder(items[position])
}
inner class ViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.custom_item, parent, false)
) {
fun onBindViewHolder(item: CustomItem) {
with(itemView) {
tvTitle.text = item.title
tvContent.text = item.content
tvTail.text = item.tail
}
}
}
}
여기서 Step1Adapter와 CustomAdapter의 차이점은
1. items의 Class
private val items = mutableListOf<LanguageCode>()
2. ViewHolder 생성자에 인자로 들어가는 layout res id
LayoutInflater.from(parent.context).inflate(R.layout.language_code_item, parent, false)
3. ViewHolder에 onBindViewHolder()의 처리
fun onBindViewHolder(item: LanguageCode) {
with(itemView) {
tvCode.text = item.code
tvLanguage.text = item.language
}
}
입니다.
그러면 어떻게 해야 위 3가지의 차이점을 공통처리할 수 있게 만들 수 있을까요?
1. Generic으로 처리
2. 매개변수로 처리
3. DataBinding으로 처리
제가 생각한 방식으로 구현한 BaseRecyclerView입니다.
abstract class BaseRecyclerView {
abstract class Adapter<ITEM : Any, B : ViewDataBinding>(
@LayoutRes private val layoutResId: Int,
private val bindingVariableId: Int? = null
) : RecyclerView.Adapter<ViewHolder<B>>() {
private val items = mutableListOf<ITEM>()
fun replaceAll(items: List<ITEM>?) {
items?.let {
this.items.run {
clear()
addAll(it)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
object : ViewHolder<B>(
layoutResId = layoutResId,
parent = parent,
bindingVariableId = bindingVariableId
) {}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: ViewHolder<B>, position: Int) {
holder.onBindViewHolder(items[position])
}
}
abstract class ViewHolder<B : ViewDataBinding>(
@LayoutRes layoutResId: Int,
parent: ViewGroup,
private val bindingVariableId: Int?
) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context).inflate(layoutResId, parent, false)
) {
protected val binding: B = DataBindingUtil.bind(itemView)!!
fun onBindViewHolder(item: Any?) {
try {
bindingVariableId?.let {
binding.setVariable(it, item)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
BaseRecyclerView.Adapter는
Generic으로
1. Item Class
ITEM : Any
2. ViewHolder의 Layout에 해당하는 ViewDataBinding Class
B : ViewDataBinding
매개변수로
1. ViewHolder의 layout res id
@LayoutRes private val layoutResId: Int
2. ViewHolder의 layout에 variable로 정의한 Item의 변수명 id
private val bindingVariableId: Int? = null
받게 됩니다.
BaseRecyclerView.ViewHolder는
Generic으로
1. ViewHolder의 Layout에 해당하는 ViewDataBinding Class
B : ViewDataBinding
매개변수로
1. ViewHolder의 layout res id
@LayoutRes layoutResId: Int
2. ViewGroup
parent: ViewGroup
3. ViewHolder의 layout에 variable로 정의한 Item의 변수명 id
private val bindingVariableId: Int
받게 됩니다.
ViewHolder에서 itemView를 통해 binding객체를 생성하고
protected val binding: B = DataBindingUtil.bind(itemView)!!
onBindViewHolder()에서 binding.setVariable(bindingVariableId, item)으로 layout에 variable로 선언한 변수에 데이터를 set하게 됩니다.
fun onBindViewHolder(item: Any?) {
try {
bindingVariableId?.let {
binding.setVariable(it, item)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
그러면 layout xml에서 처리한 방식대로 data를 binding 시켜주게 됩니다.
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="languageCode"
type="com.googry.googrybaserecyclerview.LanguageCode"/>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="60dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tvCode"
android:layout_width="120dp"
android:layout_height="match_parent"
android:gravity="center"
android:text="@{languageCode.code}"
android:textSize="20sp"
tools:text="ko"/>
<TextView
android:id="@+id/tvLanguage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@{languageCode.language}"
android:textSize="20sp"
tools:text="한국어"/>
</LinearLayout>
</layout>
class Step2Activity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.step2_activity)
val adapter = object : BaseRecyclerView.Adapter<LanguageCode, LanguageCodeItemBinding>(
layoutResId = R.layout.language_code_item,
bindingVariableId = BR.languageCode
) {}
adapter.replaceAll(getLanguageCodeList())
rvContent.adapter = adapter
}
}
Activity에서 RecyclerView에 adapter를 set 할 때는 위와 같이만 처리해주면 됩니다.
이렇게 하면 매번 단순한 RecyclerView를 만들 때마다 CustomAdapter를 따로 만들지 않아도 되는 장점이 있습니다.
그리고 저는 DataBinding과 MVVM구조를 사용해서 프로젝트를 만드는데 그 부분에 대한 코드는 아래와 같습니다.
Activity
class Step3Activity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = DataBindingUtil.setContentView<Step3ActivityBinding>(this, R.layout.step3_activity)
binding.run {
setLifecycleOwner(this@Step3Activity)
vm = ViewModelProviders.of(this@Step3Activity)[LanguageCodeViewModel::class.java]
rvContent.adapter = object : BaseRecyclerView.Adapter<LanguageCode, LanguageCodeItemBinding>(
layoutResId = R.layout.language_code_item,
bindingVariableId = BR.languageCode
) {}
}
}
}
Activity는
1. binding 객체 생성
2. binding 객체에 LifecycleOwner 전달
3. layout.xml에 선언한 ViewModel 전달
4. adapter 셋팅
View(layout)
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="vm"
type="com.googry.googrybaserecyclerview.step3.LanguageCodeViewModel"/>
</data>
<android.support.v7.widget.RecyclerView
android:id="@+id/rvContent"
replaceAll="@{vm.liveLanguageCodeList}"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"/>
</layout>
layout에서는 activity에서 전달 받은 ViewModel을 variable로 가지고 있고
ViewModel에 있는 data를 RecyclerView의 BindingAdapter로 선언한 replaceAll에 전달합니다.
ViewModel
class LanguageCodeViewModel : ViewModel() {
val liveLanguageCodeList = MutableLiveData<List<LanguageCode>>()
init {
liveLanguageCodeList.postValue(getLanguageCodeList())
}
}
ViewModel에는 data를 LiveData에 감싸서 가지고 있습니다.
RecyclerViewExt
@BindingAdapter("replaceAll")
fun RecyclerView.replaceAll(list: List<Any>?) {
(this.adapter as? BaseRecyclerView.Adapter<Any, *>)?.run {
replaceAll(list)
notifyDataSetChanged()
}
}
replaceAll함수는 List<Any>? 타입으로 list를 받고 RecyclerView의 adapter를 safe type cast를 하여 BaseRecyclerView.Adapter에 list를 전달하고 notifyDataSetChanged()를 호출합니다.
데이터의 흐름으로 보면 ViewModel -> layout.xml -> BindingAdapter -> BaseRecyclerView.Adapter.replaceAll() 입니다.
예제 프로젝트는 아래 링크에서 확인 할 수 있습니다.
https://github.com/sjjeong/GoogryBaseRecyclerView
아래의 링크를 참고하여 만들었습니다.
https://thdev.tech/android/2018/01/31/Recycler-Adapter-Distinguish/
https://github.com/googlesamples/android-architecture/tree/todo-mvvm-databinding
의견이 있거나 문의점이 있다면 댓글로 남겨주시거나 카카오톡ID googry로 톡주세요.
'Android > Android' 카테고리의 다른 글
(Android) 키보드가 보여질 때 화면 스크롤하기 (2) | 2018.07.24 |
---|---|
(Android) DataBinding Two Way Binding (0) | 2017.10.24 |
(Android) 간단한 selection popup 만들기 (0) | 2017.08.25 |
(Android) Android 8.0 Oreo 미리보기 (0) | 2017.08.24 |
(Android) 앱 초기 로딩화면으로 스플래시 화면 만들기 (2) | 2017.08.20 |