320x100
ViewModel, DataBinding 으로 ViewPager2/RecyclerView 제대로 쓰는 방법 정리
ViewModel + DataBinding + ViewPager2 + RecyclerView 조합을 한 화면에 쓰다 보면,
클릭 한 번에 로그가 여러 번 찍히거나, 콜백이 중복 호출되는 문제가 자주 나온다.
이 글에서는
ViewModel에서 상태와 콜백을 관리하고- XML(DataBinding) 으로
ViewPager2와 묶고 @BindingAdapter와 어댑터에서 중복 등록 없이 구현하는 전체 패턴
을 예제 코드와 함께 정리한다.

1. 전체 구조 한 번에 보기
구성은 이렇게 나뉜다.
ViewModel- 아이템 리스트 상태
- 선택된 아이템 상태
- 아이템 선택 콜백
- XML 레이아웃
ViewPager2- DataBinding 으로
ViewModel주입 - 커스텀 속성(
app:xxx) 으로 리스트와 콜백 전달
@BindingAdapter- XML 커스텀 속성을 받아서
ViewPager2를 초기화 - 어댑터를 한 번만 생성하고, 데이터/리스너만 갱신
- XML 커스텀 속성을 받아서
ViewPager2어댑터- 내부적으로
RecyclerView.Adapter사용 - 각 페이지(카드)와
ViewModel상태를 바인딩 - 클릭 시
ViewModel콜백 호출
- 내부적으로
- Activity/Fragment
- DataBinding +
ViewModel연결
- DataBinding +
2. ViewModel 설계
- 프린터 리스트
- 선택된 프린터
- 선택 가능 여부
- 선택 콜백
까지 한 번에 관리하는 예제다.
// kotlin
package com.example.printer.ui.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
data class PrinterModel(
val name: String,
val description: String,
var isSelected: Boolean = false
)
interface OnItemSelectListener<T> {
fun onItemSelect(item: T, position: Int)
}
class DeviceSelectViewModel : ViewModel() {
private val _prtModelList = MutableLiveData<List<PrinterModel>>()
val prtModelList: LiveData<List<PrinterModel>> get() = _prtModelList
private val _selectedModel = MutableLiveData<PrinterModel?>()
val selectedModel: LiveData<PrinterModel?> get() = _selectedModel
private val _isMoveable = MutableLiveData(false)
val isMoveable: LiveData<Boolean> get() = _isMoveable
// ViewPager2 아이템 선택 콜백
val onItemSelect = object : OnItemSelectListener<PrinterModel> {
override fun onItemSelect(item: PrinterModel, position: Int) {
val current = _prtModelList.value?.toMutableList() ?: return
// 하나만 선택되도록 처리
current.forEachIndexed { index, model ->
model.isSelected = (index == position)
}
_prtModelList.value = current
_selectedModel.value = item
_isMoveable.value = true
}
}
init {
// 예시 데이터
_prtModelList.value = listOf(
PrinterModel("A8R POS Printer", "매장용 프린터 1"),
PrinterModel("B10 POS Printer", "매장용 프린터 2"),
PrinterModel("C5 POS Printer", "매장용 프린터 3")
)
}
}
포인트
- UI 에서 필요한 상태는 전부
ViewModel이 들고 있다. onItemSelect콜백도ViewModel내부에 구현해서,
뷰는 이 콜백을 참조만 해서 사용한다.- 선택 로직, 활성화 여부 등은 뷰가 아니라
ViewModel책임.
3. Activity XML: ViewPager2 + DataBinding
Activity 레이아웃 안에 ViewPager2 를 두고,ViewModel 을 DataBinding 으로 주입한다.
<!-- xml -->
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="dsVm"
type="com.example.printer.ui.viewmodel.DeviceSelectViewModel" />
<variable
name="click"
type="com.example.printer.base.listener.ViewClickListener" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 제품 선택 ViewPager2 -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp_select_printer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:orientation="horizontal"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/btn_printer_select"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:prtModelList="@{dsVm.prtModelList}"
app:onItemSelect="@{dsVm.onItemSelect}"
app:deviceVm="@{dsVm}" />
<!-- 선택 버튼 -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_printer_select"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="프린터 선택"
android:backgroundTint="@{dsVm.selectedModel != null ? @color/blue700 : @color/mono100}"
android:onClick="@{(v) -> click.onViewClick(v)}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
포인트
app:prtModelList=>ViewModel.prtModelListapp:onItemSelect=>ViewModel.onItemSelectselectedModel로 버튼 배경 색까지 DataBinding 으로 제어
이 커스텀 속성들은 @BindingAdapter 에서 처리하게 된다.
4. BindingAdapter: ViewPager2 초기화
커스텀 속성을 받아서 ViewPager2 에 어댑터를 붙이는 코드다.
중복 등록을 막으려면 어댑터를 한 번만 만들고 재사용해야 한다.
// kotlin
package com.example.printer.ui.binding
import androidx.databinding.BindingAdapter
import androidx.lifecycle.LiveData
import androidx.viewpager2.widget.ViewPager2
import com.example.printer.ui.viewmodel.DeviceSelectViewModel
import com.example.printer.ui.viewmodel.OnItemSelectListener
import com.example.printer.ui.viewmodel.PrinterModel
import com.example.printer.ui.widget.PrinterPagerAdapter
@BindingAdapter("prtModelList", "onItemSelect", "deviceVm", requireAll = false)
fun bindPrinterViewPager(
viewPager: ViewPager2,
list: LiveData<List<PrinterModel>>?,
listener: OnItemSelectListener<PrinterModel>?,
viewModel: DeviceSelectViewModel?
) {
// 1\) 어댑터 재사용
val adapter = (viewPager.adapter as? PrinterPagerAdapter)
?: PrinterPagerAdapter(listener).also {
viewPager.adapter = it
}
// 2\) 데이터만 갈아끼우기
val items = list?.value ?: emptyList()
adapter.submitList(items)
// 필요하다면 viewModel 을 어댑터에 넘겨도 됨
}
포인트
viewPager.adapter를 캐스팅해서 있으면 재사용, 없으면also로 한 번만 생성.- 새 데이터가 들어올 때마다 BindingAdapter는 다시 호출되지만,
어댑터는 그대로 두고submitList로 데이터만 교체한다. - 이렇게 해야 클릭 리스너가 중복으로 붙지 않아
로그 7회같은 문제가 안 생긴다.
프로젝트 구조에 따라
prtModelList타입을LiveData<List\<T\>>대신List<PrinterModel>로 받을 수도 있다. 그 경우 파라미터 타입만 바꾸면 된다.
5. ViewPager2 어댑터: RecyclerView.Adapter 구현
ViewPager2 는 내부적으로 RecyclerView 를 쓰므로,
우리는 일반 RecyclerView.Adapter 를 만들면 된다.
// kotlin
package com.example.printer.ui.widget
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.printer.databinding.ItemPrinterPageBinding
import com.example.printer.ui.viewmodel.OnItemSelectListener
import com.example.printer.ui.viewmodel.PrinterModel
class PrinterPagerAdapter(
private val listener: OnItemSelectListener<PrinterModel>?
) : RecyclerView.Adapter<PrinterPagerAdapter.PrinterViewHolder>() {
private val items = mutableListOf<PrinterModel>()
fun submitList(list: List<PrinterModel>) {
items.clear()
items.addAll(list)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PrinterViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = ItemPrinterPageBinding.inflate(inflater, parent, false)
return PrinterViewHolder(binding)
}
override fun onBindViewHolder(holder: PrinterViewHolder, position: Int) {
val item = items[position]
holder.bind(item, position)
}
override fun getItemCount(): Int = items.size
inner class PrinterViewHolder(
private val binding: ItemPrinterPageBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: PrinterModel, position: Int) {
binding.model = item
binding.executePendingBindings()
binding.root.setOnClickListener {
listener?.onItemSelect(item, position)
}
}
}
}
포인트
ItemPrinterPageBinding은 아이템 레이아웃의 DataBinding 클래스.- 클릭 시
listener?.onItemSelect(item, position)호출 =>ViewModel의 콜백 실행. - 클릭 로직은 어댑터에서 처리하지만, 실제 상태 변경은
ViewModel에서만 일어난다.
6. ViewPager2 아이템 레이아웃
각 페이지(프린터 카드)에 대한 레이아웃이다.
// xml
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="model"
type="com.example.printer.ui.viewmodel.PrinterModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{model.name}"
android:textSize="20sp" />
<TextView
android:id="@+id/tv_desc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{model.description}"
android:layout_marginTop="8dp" />
<!-- 선택 여부에 따른 인디케이터 -->
<View
android:id="@+id/v_selected_indicator"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_marginTop="8dp"
android:background="@{model.isSelected ? @color/blue700 : @color/mono100}" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
포인트
model.isSelected값에 따라 색상 변경 가능.- 어댑터에서
binding.model = item만 해주면 XML 이 상태를 자동 반영한다.
7. Activity/Fragment: ViewModel + DataBinding 연결
마지막으로 Activity (또는 Fragment) 에서ViewModel 과 DataBinding 을 연결해 준다.
// kotlin
package com.example.printer.ui
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import com.example.printer.R
import com.example.printer.base.listener.ViewClickListener
import com.example.printer.databinding.ActivityStartBinding
import com.example.printer.ui.viewmodel.DeviceSelectViewModel
class StartActivity : AppCompatActivity(), ViewClickListener {
private lateinit var binding: ActivityStartBinding
private val viewModel: DeviceSelectViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_start)
binding.lifecycleOwner = this
binding.dsVm = viewModel
binding.click = this
}
override fun onViewClick(v: android.view.View) {
// 선택된 프린터가 있을 때만 다음 액션 수행
val selected = viewModel.selectedModel.value ?: return
// TODO: 다음 화면 이동 등 처리
}
}
포인트
binding.lifecycleOwner = this를 꼭 설정해야LiveData변경이 XML에 자동 반영된다.binding.dsVm = viewModel로ViewModel을 XML 변수에 연결.- 버튼 클릭은
ViewClickListener로 받아서ViewModel상태를 보고 처리.
8. 정리 및 팁
ViewModel- 화면 상태(리스트, 선택 상태, 버튼 활성화)를 전부 가지고 간다.
- 콜백(
onItemSelect) 도ViewModel안에 두고, 뷰는 참조만 한다.
- XML
app:prtModelList,app:onItemSelect등으로ViewModel의 데이터를 전달.- 버튼 색, 텍스트 등 단순 UI 상태도 웬만하면 DataBinding 으로 처리.
@BindingAdapterViewPager2.adapter는 한 번만 생성하고 재사용.- 데이터가 바뀌어 BindingAdapter가 여러 번 호출되더라도
어댑터를 새로 만들지 않고submitList만 호출.
- 어댑터
- 클릭 시 단순히
listener?.onItemSelect()만 호출하고
실제 상태 변경은ViewModel에 위임.
- 클릭 시 단순히
- Activity/Fragment
- DataBinding +
ViewModel연결만 담당하고,
비즈니스 로직은 최대한ViewModel에 몰아넣는다.
- DataBinding +
이 패턴을 지키면
- 클릭 1회에 로그가 여러 번 찍히는 문제(콜백 중복 등록)를 피할 수 있고,
- 화면 상태는 전부
ViewModel에 모여 있어서 테스트/유지보수가 쉬워진다.
반응형
'개발 이야기 > Android (안드로이드)' 카테고리의 다른 글
| [Android] 확 바뀐 안드로이드 스튜디오 프로파일러로 메모리 릭 & CPU 점유율 잡는 법 (0) | 2026.01.22 |
|---|---|
| Gemini가 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
| GitHub Copilot 이 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
| ChatGpt가 말아주는 MVVM 시작 가이드 (0) | 2025.12.08 |
| [Android] MVVM 다음 스텝: Flow, StateFlow, Hilt 초보자 완벽 가이드 (1) | 2025.11.18 |
| [Android] 3년 만에 복귀한 개발자를 위한 MVVM, ViewModel, DataBinding 완벽 정복 가이드 (0) | 2025.11.18 |
| Jetpack Compose 초보자 가이드 (0) | 2024.12.23 |
| Android DataBinding과 ViewModel 적용하기 (6) | 2024.12.12 |