카테고리 없음

[Android] Paging3 - infinite scroll

Jun.LEE 2024. 5. 23. 16:30

Retrofit API 통신을 통해 데이터를 가져와 RecyclerView에 

데이터를 노출하는 작업을 하면 무한 스크롤을 구현해야 하는 경우가 있다.

 

구현방법은 크게 2가지가 있는 듯하다.

 

첫 번째로는 API에서 가져온 데이터를 RecyclerView Adapter에 반복적으로 삽입해 주는 방법으로

RecyclerView의 스크롤이 하단에 도달하게 되면 다음 페이지의 API 호출을 하는 방법이다.

 

오늘 살펴볼 방법은 두번째 방법인 Paging을 사용하는 것이다.

시작하기에 앞서 앱수준 Gradle에 paging 라이브러리를 추가하자.

implementation("androidx.paging:paging-runtime:3.3.0")

 

사용할 API는 kakao daum search api로 dto는 다음과 같다.

data class SearchResponse(
    val documents: List<ImageResponse>,
    val meta: Meta,
)

data class ImageResponse(
    val collection: String,
    val datetime: String,
    val display_sitename: String,
    val doc_url: String,
    val image_url: String,
    val thumbnail_url: String,
    val height: Int,
    val width: Int,
)

data class Meta(
    val is_end: Boolean,
    val pageable_count: Int,
    val total_count: Int,
)

 

Retrofit을 사용하였고 코드는 다음과 같다.

object RetrofitClient {

    private const val BASE_URL = "https://dapi.kakao.com"
    private val okHttpClient by lazy {
        OkHttpClient.Builder()
            .addInterceptor(AppInterceptor())
            .build()
    }
    private val retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    val search: RetrofitService by lazy {
        retrofit.create(RetrofitService::class.java)
    }
}
interface RetrofitService {
    @GET("/v2/search/image")
    suspend fun getSearchImage(
        @Query("query") query: String,
        @Query("sort") sort: String,
        @Query("page") page: Int,
        @Query("size") size: Int,
    ): SearchResponse
}

 

 

본격적으로 Paging을 구성하자.

먼저 PagingSource를 만들자.

class MyPagingSource(
    private val service: RetrofitService,
    private val query: String
): PagingSource<Int, ImageResponse>() {

    override fun getRefreshKey(state: PagingState<Int, ImageResponse>): Int? {
        return state.anchorPosition?.let {
            state.closestPageToPosition(it)?.prevKey
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ImageResponse> {
        val page = params.key ?: 1
        return try {
            val response = service.getSearchImage(
                query = query,
                sort = "accuracy",
                page = page,
                size = 20
            )
            val items = response.documents
            LoadResult.Page(
                data = items,
                prevKey = if(page == 0) null else page - 1,
                nextKey = page + 1
            )
        } catch(e: Exception) {
            LoadResult.Error(e)
        }
    }
}

 

위 코드에서 getRefreshKey는 데이터가 첫 로드 후

새로고침되거나 무호화되었을 때 키를 반환하여 load에 전달하게 됩니다.

 

load는 Retrofit의 getSearchImage에서 response를 받아 

ImageResponse List를 Page에 담아 리턴하는 역할을 하는 것을 확인할 수 있다.

 

이후 Repository를 만들어주고 

class PagingRepository {
    fun pagingSource(query: String) = MyPagingSource(
        RetrofitClient.search, query
    )
}

 

ViewModel에 Pager를 만들어주자.

class NewsViewModel(
    private val pagingRepository: PagingRepository
): ViewModel() {

    val items: Flow<PagingData<ImageResponse>> = Pager(
        config = PagingConfig(pageSize = 20, enablePlaceholders = false),
        pagingSourceFactory = { pagingRepository.pagingSource("dog") }
    ).flow.cachedIn(viewModelScope)
}

 

데이터를 RecyclerView에 노출하도록 하기 위해 Adapter를 만들어주자.

DiffUtil.ItemCallback 정의하여 생성자에 넣어주었다.

class PagingRecyclerViewAdapter:
    PagingDataAdapter<ImageResponse, PagingRecyclerViewAdapter.SearchViewHolder>(DiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder {
        val binding = ItemSearchBinding
            .inflate(LayoutInflater.from(parent.context), parent, false)
        return SearchViewHolder(binding)
    }

    override fun onBindViewHolder(holder: SearchViewHolder, position: Int) {
        val current = getItem(position)
        with(holder.binding) {
            current?.let {
                iv.load(it.thumbnail_url)
            }
        }
    }

    inner class SearchViewHolder(
        val binding: ItemSearchBinding
    ): ViewHolder(binding.root)
}

 

 

이후 RecyclerView를 정의한 Fragment를 다음과 같이 작성해 주었다.

class SearchFragment : Fragment() {

    private var _binding: FragmentSearchBinding? = null
    private val binding get() = _binding!!

    private var _adapter: PagingRecyclerViewAdapter? = null
    private val adapter get() = _adapter!!

    private val viewModel: NewsViewModel by activityViewModels {
        NewsViewModelFactory()
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View {
        _binding = FragmentSearchBinding.inflate(inflater)
        return binding.root
    }

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

        _adapter = PagingRecyclerViewAdapter()

        with(binding) {
            rv.layoutManager = GridLayoutManager(activity, 4)
            rv.adapter = adapter
        }

        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.items.collectLatest {
                    adapter.submitData(it)
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
        _adapter = null
    }
}

 

Pager를 collect하여 emit이 생기면 PagingDataAdapter에 정의된 함수 submitData 함수를

사용하여 데이터를 전달하는 모습을 볼 수 있다.

 

Compose로도 적용해 볼 예정이다.