본문 바로가기

개발 이야기/Android (안드로이드)

ViewModel, DataBinding 으로 ViewPager2/RecyclerView 제대로 쓰는 방법 정리

320x100

ViewModel, DataBinding 으로 ViewPager2/RecyclerView 제대로 쓰는 방법 정리

ViewModel + DataBinding + ViewPager2 + RecyclerView 조합을 한 화면에 쓰다 보면,
클릭 한 번에 로그가 여러 번 찍히거나, 콜백이 중복 호출되는 문제가 자주 나온다.

이 글에서는

  • ViewModel 에서 상태와 콜백을 관리하고
  • XML(DataBinding) 으로 ViewPager2 와 묶고
  • @BindingAdapter 와 어댑터에서 중복 등록 없이 구현하는 전체 패턴

을 예제 코드와 함께 정리한다.

 


1. 전체 구조 한 번에 보기

구성은 이렇게 나뉜다.

  1. ViewModel
    • 아이템 리스트 상태
    • 선택된 아이템 상태
    • 아이템 선택 콜백
  2. XML 레이아웃
    • ViewPager2
    • DataBinding 으로 ViewModel 주입
    • 커스텀 속성(app:xxx) 으로 리스트와 콜백 전달
  3. @BindingAdapter
    • XML 커스텀 속성을 받아서 ViewPager2 를 초기화
    • 어댑터를 한 번만 생성하고, 데이터/리스너만 갱신
  4. ViewPager2 어댑터
    • 내부적으로 RecyclerView.Adapter 사용
    • 각 페이지(카드)와 ViewModel 상태를 바인딩
    • 클릭 시 ViewModel 콜백 호출
  5. Activity/Fragment
    • DataBinding + ViewModel 연결

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.prtModelList
  • app:onItemSelect => ViewModel.onItemSelect
  • selectedModel 로 버튼 배경 색까지 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 = viewModelViewModel 을 XML 변수에 연결.
  • 버튼 클릭은 ViewClickListener 로 받아서 ViewModel 상태를 보고 처리.

8. 정리 및 팁

  • ViewModel
    • 화면 상태(리스트, 선택 상태, 버튼 활성화)를 전부 가지고 간다.
    • 콜백(onItemSelect) 도 ViewModel 안에 두고, 뷰는 참조만 한다.
  • XML
    • app:prtModelList, app:onItemSelect 등으로 ViewModel 의 데이터를 전달.
    • 버튼 색, 텍스트 등 단순 UI 상태도 웬만하면 DataBinding 으로 처리.
  • @BindingAdapter
    • ViewPager2.adapter한 번만 생성하고 재사용.
    • 데이터가 바뀌어 BindingAdapter가 여러 번 호출되더라도
      어댑터를 새로 만들지 않고 submitList 만 호출.
  • 어댑터
    • 클릭 시 단순히 listener?.onItemSelect() 만 호출하고
      실제 상태 변경은 ViewModel 에 위임.
  • Activity/Fragment
    • DataBinding + ViewModel 연결만 담당하고,
      비즈니스 로직은 최대한 ViewModel 에 몰아넣는다.

이 패턴을 지키면

  • 클릭 1회에 로그가 여러 번 찍히는 문제(콜백 중복 등록)를 피할 수 있고,
  • 화면 상태는 전부 ViewModel 에 모여 있어서 테스트/유지보수가 쉬워진다.
반응형