💡 왜 사용 하는가?
검색하기 기능을 구현하면서 화면에 많은 검색결과를 리스트로 보여줘야 했다. 보여줘야 할 결과가 많기 때문에 일반 리스트로 구현하는 것보다는 리사이클러뷰로 재활용성을 높이는게 중요하다고 생각했다.
그리고 기본적으로 데이터바인딩을 사용해 MVVM 패턴을 구현하고자 했는데 외부 서버로부터 받아온 검색 결과를 어떻게 동적으로 리사이클러뷰에 표시해줘야 할지 고민이 되었다.
💡 코드 설명
✔️ SearchViewModel
MVVM 패턴을 사용하게 되면 모델로부터 데이터를 요청해 뷰모델에서 livedata나 flow 변수에 데이터를 홀드하고 있을 것이다. 또한 안드로이드 아키텍쳐를 사용하면 domain, data 레이어를 통해 데이터를 가져올텐데 이런 로직 까지 다 쓰면 글이 너무 길어지니 생략하고 그냥 바로 리스트를 리턴해온다고 가정하고 코드를 작성하겠다.
나는 검색 기능을 구현하고 싶었기 때문에 자세한 코드에는 없지만 EditText 같은 뷰에 검색어를 입력하고 엔터를 치면 getSearchPlacesList() 가 실행되면서 검색결과를 _SearchedPlaceList 로 반환하게 된다.
@HiltViewModel
class SearchViewModel @Inject constructor(
private val searchRestaurantUseCase: SearchRestaurantUseCase,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _SearchedPlaceList = MutableLiveData<List<SearchedRestaurantItem>>() // 가게 리스트
var SearchedPlaceList : LiveData<List<SearchedRestaurantItem>> = _SearchedPlaceList
// 검색어가 입력되면 호출됩니다
fun getSearchPlacesList(keyword : String) {
// 가게데이터 불러오기!!
_SearchedPlaceList.value = searchRestaurantUseCase()
}
}
searchRestaurantUseCase() 를 호출했을 때 불러오는 가게데이터의 응답 양식이다. 이 데이터들을 리사이클러뷰의 하나의 리스트 아이템으로 뿌려줘야 한다.
data class SearchedRestaurantItem(
var placeId : String?,
val placeName : String,
val placeAddress : String,
val distance : String,
val x : String,
val y : String,
val veganType :VeganOptions
)
data class VeganOptions(
var allVegan: Boolean,
var someMenuVegan: Boolean,
var ifRequestVegan: Boolean
)
✔️ activity_search.xml (RecyclerView XML)
리사이클러뷰를 설명하는 블로그를 보면 대부분 뷰모델이나 액티비티 코드에서 데이터 리스트를 직접 만들어서 어댑터에 바로 셋팅을 해준다. 하지만 실무에서 그렇게 사용하지는 않겠지??….
즉, 리사이클러뷰에 표시할 데이터는 변할 수 있고, 이 데이터를 관찰하고 있다가 ui 업데이트를 해줘야한다.
따라서 리사이클러뷰 xml에서도 뷰모델의 SearchedPlaceList LiveData로 들어오게 될 데이터를 바인딩해서 관찰하고 있어야 한다.
리사이클러뷰 어댑터를 통해 뷰에 표시할 데이터를 셋팅해줘야 한다는 것은 기본적으로 알고 있을 것이다. 하지만 xml 코드로 이걸 바로 해줄 수는 없기 때문에 바인딩 어댑터를 사용해 app:items="@{viewmodel.SearchedPlaceList}" 코드를 넣어줬다.
액티비티 코드에서 SearchedPlaceList 데이터를 observe 하고 있다가 어댑터에 설정해주는 방식을 왜 사용하지 않느냐고 물어볼 수 도 있다!!! 하지만 나는 데이터바인딩 방식을 사용하고 싶고, 최대한 액티비티에서 라이브데이터를 observe하는 방식은 지양하려고 한다. (액티비티 코드가 너무 지저분해지고 observe 하는 방식은 뷰모델-뷰 간의 의존성을 더욱 확고하게 만들기 때문에 MVVM 패턴임을 고려했을 때 지양해야 할 것 같다)
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewmodel"
type="com.android.aviro.presentation.search.SearchViewModel" />
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
app:layout_constraintTop_toBottomOf="@+id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<!-- 검색 리스트 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/searchRecyclerview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:items="@{viewmodel.SearchedPlaceList}"
app:visibilityChanged="@{viewmodel.isSearching}"/>
</ScrollView>
</layout>
✔️ Binding Adapter
item 이 null 일 수도 있으니! null 이 아닐 때만 다음과 같이 셋팅해준다.
위에서 말햇듯이 리사이클러뷰는 어댑터를 통해 데이터를 셋팅한다. 아래에서 설명하겠지만 어댑터에 서버에서 받아온 리스트 데이터를 받을 변수를 선언해놓고 셋팅해준다.
나는 검색기능을 구현하고 싶기 때문에 검색어가 바뀔 경우에는 기존에 리사이클러뷰에 셋팅되었던 아이템들을 모두 제거 해줘야 한다. 그래서 새로운 검색어가 들어올 경우 removeAllViews() 를 호출하도록 했다.
@JvmStatic
@BindingAdapter("app:items")
fun setList(recyclerView: RecyclerView, items:List<SearchedRestaurantItem>?) {
items?.let{
recyclerView.removeAllViews() // 기존 검색 리스트 삭제
(recyclerView.adapteras SearchAdapter).searchedList =
items as MutableList<SearchedRestaurantItem>
}
}
✔️ search_restaurnt_item.xml (Item XML)
- 리사이클러뷰에 표시할 데이터 아이템 하나에 대한 xml 코드
- 실무에서는 단순히 텍스트뷰 하나 띄워주고 이러려고 쓰기 보다는 이쁜 디자인이 들어간 아이템이 사용되는 경우가 많고, 그 안에 들어가는 데이터도 여러개인 경우가 많다
사진에는 리스트에 3개의 아이템이 존재하는 것이다. 하나의 아이템에만 해도 이미지뷰, 가게명, 주소, 거리 같은 여러 데이터 값들을 갖고 있다. 위에서 서버로부터 가져온 리스트 데이터 중 하나의 데이터에 저런 정보들이 담겨져 있는 것이다.
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="search_item"
type="com.android.aviro.domain.entity.SearchedRestaurantItem" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="15dp"
android:paddingBottom="15dp">
<LinearLayout
android:id="@+id/placeVeganTypeIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:orientation="horizontal"
android:layout_marginTop="3dp"
app:placeVeganTypeIcon="@{search_item.veganType}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<LinearLayout
android:layout_width="255dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@+id/placeVeganTypeIcon">
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{search_item.placeName}"
android:textColor="@color/Gray0"
android:textSize = "18sp"
android:fontFamily="@font/pretendard_medium"/>
<TextView
android:id="@+id/location"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="11dp"
android:text="@{search_item.placeAddress}"
android:textColor="@color/Gray3"
android:textSize = "15sp"
android:fontFamily="@font/pretendard_regular"/>
</LinearLayout>
<TextView
android:id="@+id/distance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:fontFamily="@font/pretendard_regular"
android:text="@{search_item.distance}"
android:textColor="@color/Gray2"
android:textSize="14sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<!-- </LinearLayout>-->
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
✔️ SearchAdapter (RecyclerView의 Adapter)
- 리사이클러뷰에 표시할 데이터 아이템들을 셋팅해줌
- 뷰홀더가 리사이클러뷰에 데이터를 표시하고 홀드 해주는 역할
- 검색 결과 아이템 하나에 데이터를 셋팅해줘야 하는데 이 부분을 데이터바인딩으로 구현하도록 로직을 짜주는 곳
onCreateViewHolder() 가 호출되면서 뷰홀더가 생성된다. 메서드를 따라가 보면 SearchViewHolder.from() 이 호출되는데 이는 리스트에 표시할 아이템 하나에 대한 레이아웃을 inflate 해주는 과정이 들어가 있다. 즉, 아이템에 대한 뷰바인딩을 하기 위한 과정이라고 보면 될 것 같다. 그래서 이때 바로 데이터가 화면에 나타나는 것은 아니다.
onBindViewHolder() 가 호출될 때 비로소 대강 화면에 보여지는 만큼 searchedList 의 데이터를 하나씩 화면에 나타내게 된다.
이전 프로젝트에서는 이 메서드 안에서 바로 binding.(item의 어떤 뷰) = searchedList!![position].(표시할 데이터) 이런식으로 코드를 짰었다. 하지만 이번엔 표시해줘야 할 데이터도 많고, 데이터바인딩을 적극 활용해보고 싶기 때문에 SearchViewHolder.bind 메서드를 따라가 binding.searchItem = item 로데이터바인딩을 해준다.
searchedList 를 null 처리 해준 이유는 나는 검색하기 기능을 구현하는데 사용하기 때문에 검색어가 입력되지 않으면 리사이클러뷰에 표시할 데이터가 null 일 것이고, SearchAdapter 객체가 생성된 후 검색어가 입력되지 않은 경우에는 당연히 초기화 에러가 날 것이기 때문이다.
class SearchAdapter(): RecyclerView.Adapter<SearchAdapter.SearchViewHolder>() {
var searchedList : MutableList<SearchedRestaurantItem>? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {
return SearchViewHolder.from(parent) // 가게 데이터 하나를 홀더에 셋팅해줄 때마다 그 가게 데이터에 대한 레이아웃을 뷰바인딩 해주고
}
override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
// 뷰홀더가 아이템을 리사이클러뷰에 표시 해주는 곳
if(searchedList != null) {
holder.bind(searchedList!![position]) // 데이터 바인딩해주러 고고
}
}
override fun getItemCount() = searchedList?.size ?: 0
// 뷰홀더 클래스
class SearchViewHolder private constructor(val binding: SearchRestaurantItemBinding) : RecyclerView.ViewHolder(binding.root) {
// 데이터바인딩으로 아이템에 표시될 데이터를 셋팅
fun bind(item: SearchedRestaurantItem) {
binding.searchItem = item
}
companion object {
fun from(parent: ViewGroup) : SearchViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = SearchRestaurantItemBinding.inflate(layoutInflater, parent, false)
return SearchViewHolder(binding)
}
}
}
}
✔️ Search (Activity)
가장 마지막에 해주는 작업이 액티비티에서 리사이클러뷰 어댑터 생성해서 어댑터 설정해주기이다. 위 설명들을 쭉 따라왔다면 알겠지만…. 데이터바인딩을 사용하기 때문에 액티비티에서 어댑터에 데이터를 셋팅해주는 작업이 없어 여기선 달랑 이 코드가 전부다.
다만 주의해야 할 것은! SearchAdapter 객체가 생성될 때 객체 전역 변수로 리사이클러뷰에 표시할 데이터를 갖고 있는데 null 값을 가질 수 있도록 선언해주지 않으면 에러가 난다.
왜냐하면 나의 경우, 검색하기 기능을 구현하기 때문에 검색어가 입력되지 않으면 리사이클러뷰에 표시할 데이터가 null 인데 SearchAdapter 객체가 생성되자마자 이 변수를 초기화 해야 한다면 당근 에러가 날 것 이다.
@AndroidEntryPoint
class Search : AppCompatActivity() {
private lateinit var binding: ActivitySearchBinding
private val viewmodel: SearchViewModel byviewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySearchBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.viewmodel= viewmodel
binding.lifecycleOwner= this
binding.searchRecyclerview.adapter = SearchAdapter()
}
}
💡 전체 로직 & 플로우
리사이클러뷰에 표시할 아이템 리스트 서버에서 불러옴 → 뷰모델에서 아이템 리스트 보관 → 리사이클러뷰에서 데이터바인딩으로 아이템 리스트 변경된것을 관찰(바인딩어댑터) → 리사이클러뷰에 아이템 표시하려면 어댑터를 통해 셋팅해줘야 하는데 뷰(xml)의 데이터바인딩 만으로는 해결할 수 없으니 바인딩 어댑터 사용 → 바인딩어댑터에서 리사이클러뷰 어댑터에 아이템 리스트 셋팅 → 어댑터에서 뷰홀더를 통해 아이템 셋팅 → 각 아이템에는 여러 데이터들이 존재(가게명, 주소, 거리 등) → 데이터바인딩으로 각 아이템의 뷰들에 데이터를 셋팅
**아래 블로그가 없었다면 나는 이 문제를 해결하지 못했을 것이다....
그리고 이걸 구현하면서 내가 그동안 데이터바인딩 = 뷰모델 데이바인딩 로 알고 있었다는 것을 깨닫고 다시 데이터바인딩을 공부할 수 있었다,,,,,;;;;; 지금이라도 알아서 다행이다,,,,,
[참고자료]
https://algosketch.tistory.com/148
'Android' 카테고리의 다른 글
[안드로이드/BottomSheet] Persistent Bottom Sheet 외부 화면 클릭 기능 구현 (0) | 2024.02.28 |
---|---|
[안드로이드/아키텍쳐] 클린아키텍쳐 총정리 & 적용 중간 점검 (1) | 2024.02.10 |
[안드로이드/소셜로그인&Oauth] 안드로이드에서 애플(Apple Sign-In) 로그인 구현하기 (1) | 2024.01.29 |
[안드로이드/DI] 의존성 주입(DI) & Hilt 시작해보기! (0) | 2024.01.09 |
[안드로이드/AI] 앱에서 Segmentation 모델을 사용하기 위한 다양한 시도 (0) | 2023.08.17 |