[Android] Paging 라이브러리와 SwipeRefreshLayout 적용 가이드(feat. MVV)

2022. 1. 19. 20:57개발을 파헤치다/Android

반응형

Paging SwipeRefreshLayout

안드로이드 앱을 개발할 때 RecyclerView는 정말 많이 쓰이는 요소인데요. 이걸 쓴다고 하면 보통 세트처럼 같이 쓰이는 게 바로 Paging과 SwipeRefreshLayout(당겨서 새로고침 기능)입니다. 이 세 가지를 한꺼번에 적용하려고 하면 조금 머리가 아파지는데요. 이번 아티클에서는 MVVM 아키텍처에서 Paging과 SwipeRefreshLayout을 어떻게 한 번에 적용할 수 있는지 상세한 가이드를 해드리겠습니다.

 

Paging Library 적용하기

Marko Milos의 Paginate 라이브러리는 매우 간단하고 사용하기 편해서 가볍게 적용하기 좋습니다.

설치하기

// App Level build.gradle
dependencies{
   ...
   implementation "com.github.markomilos:paginate:1.0.0"
   ...
}

 

Helper Class 구현

class PagingBuilder(val targetView: RecyclerView): Paginate.Callbacks {
    private var pageNum: Int? = 1
    var loading: Boolean = false
    var threshold: Int = 10
    private var addOption: Boolean = false
    private  lateinit var mCallback: Callback
    lateinit var instance : Paginate

    /*
     *  Paginate 콜백 함수 구현
    */

    // 페이징 데이터를 가져오기 위한 콜백함수
    override fun onLoadMore() {
        // 로딩 상태로 변경
        setLoadingStatus(true)
        mCallback.onLoadMore()
    }

    // 현재 로딩중인지 판단하는 콜백함수
    override fun isLoading(): Boolean {
        return this.loading
    }

    // 페이징이 끝났는지 판단하는 콜백함수
    override fun hasLoadedAllItems(): Boolean {
        return this.pageNum == null
    }

    // 콜백 함수 설정
    fun setCallback(callback: Callback): PagingBuilder{
        this.mCallback = callback
        return this
    }

    // 자동 추가옵셜 설정
    fun addLoadingListItem(flag: Boolean): PagingBuilder{
        this.addOption = flag
        return this
    }
    // 임계값 설정
    fun setLoadingTriggerThreshold(threshold: Int): PagingBuilder{
        this.threshold = threshold
        return this
    }
    // 빌드함수
    fun build(): PagingBuilder{
        instance = Paginate.with(targetView, this) // RecyclerView와 Callback 함수를 설정
            // 몇 개의 아이템을 보고 다음 아이템을 불러올지 임계값을 설정
            .setLoadingTriggerThreshold(this.threshold)
            //  true로 설정하면 연결된 Adapter에 로딩 표시가 추가되고 데이터 변경시 갱신도 처리해준다
            .addLoadingListItem(this.addOption)
            .build()
        return this
    }

    // loading값을 설정하는 메서드
    private fun setLoadingStatus(flag: Boolean){
        this.loading = flag
    }

    // 다음 페이지를 설정하는 메서드
    fun setNext(){
        // 데이터 로드 후 다음 페이지를 설정할 떄 로딩을 해제
        setLoadingStatus(false)
        // 페이지가 null이 아니면 1을 더한다
        this.pageNum = this.pageNum?.plus(1)
    }

    // 불러올 데이터가 없음을 설정하는 메서드
    fun setEnd(){
        // 페이징이 끝나면 로딩을 해제
        setLoadingStatus(false)
        this.pageNum = null
    }

    // 현재 페이지 넘버를 가져온다
    fun getPage():Int?{
        return this.pageNum
    }

    // 페이지 넘버를 초기화한다
    fun refreshPageNum(){
        this.pageNum = 1
    }

    // 콜백 함수 정의
    interface Callback{
        fun onLoadMore()
    }
}

Paginate 라이브러리를 적용하기 위해서는 콜백 함수를 구현해서 페이징이 끝났는지, 현재 로딩 중인지 알려줘야 합니다. 이것을 좀 더 체계적이고 재사용이 가능하게끔 Helper 클래스로 구현한 것입니다.
이 라이브러리를 사용하게 되면 내부적으로 아래와 같은 변수들이 필요합니다.

  • 현재 상태를 나타내는 변수
  • 현재 페이지를 저장하는 변수


또한, Swipe Refresh Layout과 연계해서 페이징이 제대로 작동하려면 아래와 같은 추가 기능들이 필요합니다.

  • 페이징 초기화
  • 현재 페이지 정보 가져오기
  • 다음 페이지 설정하기
  • 더 이상 불러올 데이터가 없음을 설정하기


위의 기능들을 구현해 놓으면 RecyclerView 및 Swipe Refresh Layout과 연동해서 사용할 때 매우 편리하고 재사용이 가능하다는 점에서 좋습니다. 또, 유지 보수할 때에도 페이징 관련된 부분은 위의 Helper 클래스에서 수정하면 된다는 점이 편리하죠.

 

반응형

MVVM  아키텍처에 적용

 private fun initEventList(){
        // 이벤트 탭 리스트 초기화
        val eventListView: RecyclerView = binding.mainEventTapRecyclerView
        val eventAdapter = EventListAdapter(requireContext())
        eventListView.adapter = eventAdapter
        eventListView.layoutManager = LinearLayoutManager(requireContext())

        // 페이징을 설정한다
        paging = PagingBuilder(eventListView)
            paging
            .setCallback(object : PagingBuilder.Callback{
                override fun onLoadMore() {
                    // 페이지가  null이 아닌 경우에만 실행한다
                    paging.getPage()?.let {
                        // refreshing 하지 않는 경우만 페이징 메서드를 호출한다
                        if(!binding.refreshLayout.isRefreshing){
                            // 데이터를 가져와서 기존 목록에 추가하는 메서드 호출
                            viewModel.next(it)
                        }
                    }
                }

            })
            .build()

        // Live Data를 observing해서 이벤트 리스트를 업데이트한다
        viewModel.dataList.observe(viewLifecycleOwner, Observer {
            if(it.isEmpty()){
                // 이벤트가 없음을 표시한다
                binding.isEmpty = true

            }else{
                // 데이터를 갱신한다
                val dataList = it.filterIsInstance<Event>()
                eventAdapter.setDataList(dataList)

                // 다음 페이징을 위해 세팅한다
                paging.setNext()
            }

            // Refresh Layout 로딩아이콘 완료표시
            binding.refreshLayout.isRefreshing = false
        })

        // 페이징이 끝났음을 알리는 이벤트를 observing
        viewModel.pagingEnd.observe(viewLifecycleOwner, Observer {
            // 페이징이 끝났음을 설정한다
            paging.setEnd()
        })
    }
    

페이징을 위해 꼭 사용해줘야 하는 메서드는 아래와 같습니다.

  • onLoadMore 콜백 함수 : 다음 페이지를 불러오기 위해 호출할 콜백 함수를 반드시 등록해주어야 합니다.
  • setNext 함수 : 다음 페이징 데이터를 불러오기 위해 페이지 값을 세팅해주어야 합니다.
  • setEnd 함수 : 더 이상 불러올 데이터가 없을 때 페이징 클래스에 onLoadMore를 더 이상 호출하지 않도록 설정해주기 위해 사용합니다.


이 함수들을 사용하는 위치를 잘 살펴보아야 합니다. View Model과도 연관성이 매우 큽니다. View Model에서 페이징 데이터가 추가됐을 때, 더 불러올 데이터가 없을 때 이벤트를 받아서 처리해줘야 합니다.

RecyclerView에 특화된 View Model을 설계 및 구현하는 방법은 여기를 참고해주세요!

 

Swipe Refresh Layout과 함께 사용하기

당겨서 새로고침은 이제 Recycler View를 사용한다면 거의 필수적인 요소가 되어버렸습니다.
페이징과 더불어서 함께 적용해주면 됩니다.

설치하기

dependencies {
    implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
    }

App 단위의 build.gradle에 라이브러리를 추가해줍니다.

Layout 적용하기

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    >


    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        >
        <!-- 이벤트 Label -->
        <TextView
            android:id="@+id/main_event_tap_label"
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:text="@string/event_tap_label"
            android:fontFamily="@font/nanum_gothic"
            android:textStyle="normal"
            android:textSize="18.3sp"
            android:textColor="#000000"
            android:lineSpacingExtra="3.7sp"

            app:layout_constraintBottom_toTopOf="@id/refresh_layout"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintHorizontal_bias="0"
            app:layout_constraintVertical_bias="0"

            android:layout_marginStart="11.5dp"
            android:layout_marginTop="30.8dp"/>

        <!-- Swipe Refresh Layout -->
        <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
            android:id="@+id/refresh_layout"
            android:layout_height="0dp"
            android:layout_width="match_parent"

            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/main_event_tap_label"
            app:layout_constraintBottom_toBottomOf="parent"

            android:layout_marginTop="36.2dp"
            android:layout_marginStart="12dp"
            android:layout_marginEnd="11.3dp"
            android:layout_marginBottom="14dp"
            >

        <!-- 이벤트 목록 Recycler View -->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/main_event_tap_recycler_view"
            android:layout_height="match_parent"
            android:layout_width="match_parent"
            />
        </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

스크롤 밀림 현상을 비롯해 Focus 관련 이슈가 발생하지 않으려면 RecyclerView 바로 상단에 SwipeRefreshLayout을 적용해주는 것이 좋습니다.


Activity or Fragment에 적용하기

class EventFragment: Fragment(), SwipeRefreshLayout.OnRefreshListener, LifecycleObserver {

    lateinit var binding: FragmentEventBinding
    lateinit var viewModel : RecyclerViewModel
    lateinit var paging : PagingBuilder


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // RecyclerViewModel 초기화하고 데이터 로드
        viewModel = RecyclerViewModel(RecyclerViewModel.requestEventBannerList)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = DataBindingUtil.inflate(inflater, R.layout.fragment_event, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initEventList()

        // Refresh Layout listener 설정
        binding.refreshLayout.setOnRefreshListener(this)

        // 데이터 로드 실패 이벤트를 observing한다
        viewModel.navigateToLoadFail.observe(viewLifecycleOwner, Observer {
            showErrorMessage()
        })

    }




    private fun initEventList(){
        // 이벤트 탭 리스트 초기화
        val eventListView: RecyclerView = binding.mainEventTapRecyclerView
        val eventAdapter = EventListAdapter(requireContext())
        eventListView.adapter = eventAdapter
        eventListView.layoutManager = LinearLayoutManager(requireContext())

        // 페이징을 설정한다
        paging = PagingBuilder(eventListView)
            paging
            .setCallback(object : PagingBuilder.Callback{
                override fun onLoadMore() {
                    paging.getPage()?.let {
                        // refreshing 하지 않는 경우만 페이징 메서드를 호출한다
                        if(!binding.refreshLayout.isRefreshing){
                            viewModel.next(it)
                        }
                    }
                }

            })
            .build()

        // Live Data를 observing해서 이벤트 리스트를 업데이트한다
        viewModel.dataList.observe(viewLifecycleOwner, Observer {
            if(it.isEmpty()){
                // 이벤트가 없음을 표시한다
                binding.isEmpty = true

            }else{
                // 데이터를 갱신한다
                val dataList = it.filterIsInstance<Event>()
                eventAdapter.setDataList(dataList)

                // 다음 페이징을 위해 세팅한다
                paging.setNext()
            }

            // Refresh Layout 로딩아이콘 완료표시
            binding.refreshLayout.isRefreshing = false
        })

        // 페이징이 끝났음을 알리는 이벤트를 observing
        viewModel.pagingEnd.observe(viewLifecycleOwner, Observer {
            // 페이징이 끝났음을 설정한다
            paging.setEnd()
        })


    }

    // 데이터 로드 실패시 메시지를 보여준다
    private fun showErrorMessage(){
        Toast.makeText(
            requireContext(),
            AppUtils.getString(R.string.dialog_error_msg),
            Toast.LENGTH_SHORT
        ).show()
    }


    override fun onRefresh() {
        // 페이징 넘버를 초기화한다
        paging.refreshPageNum()

       // 데이터목록 초기화
        viewModel.init()

    }
}

적용하는 방법은 꽤 간단합니다. 먼저 Activity나 Fragment에 onRefreshListener를 설정해서 당겼을 때 onRefresh 메서드가 호출될 수 있도록 구현합니다.
페이징 라이브러리와 함께 사용할 때 주의점이 있는데요. 페이징을 통해 불러온 데이터는 기존 데이터 목록에 추가가 되어야 하지만, onRefresh에서는 데이터를 초기화를 해줘야 합니다. 따라서 View Model에서 데이터를 추가하는 메서드와 초기화하는 메서드 모두 구현하고 알맞게 호출해주는 것이 중요합니다.

페이징 라이브러리의 onLoadMore는 RecyclerView의 scroll listener를 기반으로 실행됩니다. 따라서 당겨서 새로고침 상황에서도 실행이 될 수 있습니다. 이때에는 페이징 데이터를 가져오는 메서드가 실행되면 안 되기 때문에 새로고침이 아닌 경우에만 페이징 메서드를 호출할 수 있게 구현합니다.

데이터를 가져와서 Adapter에 설정한 뒤에는 반드시 isRefreshing을 false로 변경해주어야 합니다. 그렇지 않으면 상단에 로딩 아이콘이 사라지지 않습니다.
이렇게 구현하면 Recycler View에 페이징과 당겨서 새로고침 기능까지 모두 적용할 수 있습니다.

반응형