멧돼지 블로그로 공부하고 구현해보았다.
무한 스크롤 방식에는 크게 두 가지 방식이 있다. (jetpack의 paging3이라는 라이브러리 제외)
그 중 이번에 우리가 구현하게 되는 non-offset 방식을 살펴보겠다.
요청할 때 id와 limit을 보내게 되는데, 이 말은 해당 id의 아이템을 기준으로 limit만큼 보내주세요~ 다.
오프셋 기반 페이지네이션은 우리가 원하는 데이터가 '몇 번째'에 있다는 데에 집중하고 있다면, 커서 기반 페이지네이션은 우리가 원하는 데이터가 '어떤 데이터의 다음'에 있다는 데에 집중합니다. n개의 row를 skip 한 다음 10개 주세요가 아니라, 이 row 다음꺼부터 10개 주세요를 요청하는 식이지요.
출처 : minsangk velog
멀티뷰 타입으로 스크롤이 최하단으로가면 아무것도 없는 리스트 아이템을 하나 넣어서 그거 들어왔을때는 로딩 뷰를 최하단에 띄워줬다가 로딩이 완료되면 최하단 로딩하는 아이템을 뜯어내고 새로들어온 리스트를 추가해주는 방식이다.
대충 위의 내용정도만 읽고 따라 치면서 이해해보려고 했다.
따라 치면서 몇몇 부분에서 의문이 들어서 멧돼지에게 물어보니 아주 초보적일 때 만들었던 거라(코딩 3개월차인데 이정도인 것도 대단..ㄷㄷ) 좀 이상한 부분이 있다고 했다.
그래서 몇몇 부분은 그냥 내 방식으로 고쳤다.
위에 블로그 글을 긁어온 것처럼 무한 스크롤은 멀티뷰 타입으로 리사이클러뷰를 만들고 더미 데이터가 들어왔을 경우, 다음 데이터가 들어올 때까지 로딩 화면을 보여주고
다음 데이터가 들어오면 리사이클러뷰의 마지막 아이템(로딩 화면)을 뜯어주고 데이터들을 넣어준다.
그래서 먼저 해주어야 하는 것이 멀티뷰 타입에 들어갈 로딩 화면을 만들어주어야한다.
item_loading.xml
<?xml version="1.0" encoding="utf-8"?>
<com.airbnb.lottie.LottieAnimationView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:lottie_autoPlay="true"
app:lottie_colorFilter="@color/td_main_blue"
app:lottie_loop="true"
app:lottie_rawRes="@raw/loading" />
lottie에서 적당한 로딩 동그라미를 가져와서 로딩화면을 만들었다.
그 후에 리사이클러뷰 어댑터에서 더미 데이터인지를 판별하여 각각의 뷰홀더로 연결해준다.(더미데이터면 로딩화면, 아니면 데이터화면)
class AllPostsAdapter(
private val openPostEvent: (Long) -> Unit,
) : ListAdapter<UiPostOfAll, RecyclerView.ViewHolder>(diffUtil) {
override fun getItemViewType(position: Int): Int {
return if (getItem(position) is UiLoadingItem) {
LOADING_VIEW
} else {
POST_VIEW
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
LOADING_VIEW -> LoadingViewHolder.of(parent)
else -> AllPostsViewHolder.of(parent, openPostEvent)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is AllPostsViewHolder) holder.bind(getItem(position) as UiPostOfAll)
}
companion object {
private const val LOADING_VIEW = 1
private const val POSTS_VIEW = 2
val diffUtil = object : DiffUtil.ItemCallback<UiItemView>() {
override fun areItemsTheSame(
oldItem: UiItemView,
newItem: UiItemView,
): Boolean {
if (oldItem is UiPostOfAll && newItem is UiPostOfAll) {
return oldItem.postId == newItem.postId
}
return false
}
override fun areContentsTheSame(
oldItem: UiItemView,
newItem: UiItemView,
): Boolean {
if (oldItem is UiPostOfAll && newItem is UiPostOfAll) {
return oldItem == newItem
}
return false
}
}
}
class LoadingViewHolder(val binding: ItemLoadingBinding) :
RecyclerView.ViewHolder(binding.root) {
companion object {
fun of(parent: ViewGroup): LoadingViewHolder =
LoadingViewHolder(
ItemLoadingBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false,
),
)
}
}
}
더미 데이터 판별 부분에서 interface UiItemView
를 두고 이를 구현하는 object UiLoadingItem
만들었다.
또한 UiPostOfAll
이 인터페이스를 구현하게 하였다.
말로 하니 어려우니 표로 표현하자면 아래와 같다.
RecyclerView에 들어가는 아이템을 UiItemView 인터페이스를 통해 추상화하고 Adapter 내부에서 분기처리를 해주었다.
로딩중인 경우 UiLoadingItem이 들어가게 된다.
ViewModel
@HiltViewModel
class AllPostsViewModel @Inject constructor(
private val postRepository: PostRepository,
) : ViewModel() {
private val _uiPosts: MutableLiveData<List<UiItemView>> = MutableLiveData(listOf())
val posts: LiveData<UiAllPosts> = Transformations.map(_uiPosts) { post -> UiAllPosts(post) }
private val _openPostDetailEvent = MutableLiveData<Event<Long>>()
val openPostDetailEvent: LiveData<Event<Long>> = _openPostDetailEvent
private var lastId: Long? = null
var hasNextPage = true
private set
var isAddLoading = false
private set
fun fetchPosts() {
checkLoadOrNot()
getPosts()
}
private fun checkLoadOrNot() {
// 불러온 post에 이미 값이 있고 && 다음 페이지가 있다면
if (lastId != null && hasNextPage) {
// 로딩 데이터를 집어넣기
isAddLoading = true // 로딩 중이에요
addLoadingItem()
}
}
// 로딩 데이터 넣는 함수
private fun addLoadingItem() {
_uiPosts.value = requireNotNull(_uiPosts.value).toMutableList().apply { add(UiLoadingItem) }
}
private fun getPosts() {
viewModelScope.launch {
postRepository.getAllPosts(lastViewedId = lastId, limit = PAGE_ITEM_SIZE)
.onSuccess { posts ->
setLastItemId(posts)
setHasNextPage(posts)
isAddLoading = false // 이제 로딩 끝났어요
fetchPosts(posts)
}.onFailure {
TripDrawApplication.logUtil.general.log(it)
}
}
}
private fun setLastItemId(posts: List<PostOfAll>) {
// 마지막 id 설정
if (posts.isNotEmpty()) lastId = posts.last().postId
}
private fun setHasNextPage(posts: List<PostOfAll>) {
// 마지막 페이지 설정
if (posts.size < PAGE_ITEM_SIZE && lastId != null) hasNextPage = false
}
private fun fetchPosts(posts: List<PostOfAll>) {
// 로딩용 데이터 빼고 불러온 값 집어넣기
_uiPosts.value = requireNotNull(_uiPosts.value).toMutableList().apply {
remove(UiLoadingItem)
addAll(posts.map { it.toPresentation() })
}
}
fun openPostDetail(id: Long) {
_openPostDetailEvent.value = Event(id)
}
companion object {
private const val PAGE_ITEM_SIZE = 20
}
}
무한스크롤 부분을 좀 모아보기 위해 일부로 함수분리를 하지 않았다.
@AndroidEntryPoint
class AllPostsFragment : Fragment() {
private var _binding: FragmentAllPostsBinding? = null
private val binding get() = _binding!!
private lateinit var adapter: AllPostsAdapter
private val viewModel: AllPostsViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentAllPostsBinding.inflate(inflater)
binding.lifecycleOwner = viewLifecycleOwner
binding.allPostsViewModel = viewModel
initOpenPostDetailEventObserve()
setAdapter()
// 밑의 두개가 주요함수이다
initPostsObserve()
addScrollListener()
viewModel.fetchPosts()
return binding.root
}
// liveData에 값이 들어오면 submitList로 값을 바꿔준다
private fun initPostsObserve() {
viewModel.posts.observe(viewLifecycleOwner) {
adapter.submitList(it.postItems) {
binding.rvAllPosts.smoothScrollToPosition(INITIAL_POSITION)
}
}
}
// post 클릭하면 일어나는 일(Activity 실행) 옵저버 설정
private fun initOpenPostDetailEventObserve() {
viewModel.openPostDetailEvent.observe(
viewLifecycleOwner,
EventObserver(this@AllPostsFragment::onPostClick),
)
}
private fun onPostClick(postId: Long) {
startActivity(PostDetailActivity.getIntent(requireContext(), postId))
}
// 어댑터 설정해주기
private fun setAdapter() {
adapter = AllPostsAdapter(viewModel::openPostDetail)
binding.rvAllPosts.adapter = adapter
binding.rvAllPosts.itemAnimator = null
}
// recyclerView가 맨 밑으로 내려갔을 때의 작업들을 해준다.
private fun addScrollListener() {
binding.rvAllPosts.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = binding.rvAllPosts.layoutManager as LinearLayoutManager
val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition()
checkFetchPostsCondition(layoutManager, lastPosition)
}
})
}
private fun checkFetchPostsCondition(layoutManager: LinearLayoutManager, lastPosition: Int) {
if (viewModel.hasNextPage &&
viewModel.isAddLoading.not() &&
checkLoadThreshold(layoutManager, lastPosition) &&
binding.rvAllPosts.canScrollVertically(DOWNWARD_DIRECTION).not()
) {
viewModel.fetchPosts()
}
}
private fun checkLoadThreshold(layoutManager: LinearLayoutManager, lastPosition: Int) =
layoutManager.itemCount <= lastPosition + LOAD_THRESHOLD
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val LOAD_THRESHOLD = 3
private const val DOWNWARD_DIRECTION = 1
private const val INITIAL_POSITION = 0
}
}
이 부분만 따로 빼서 하나하나 살펴봐야겠다
private fun addScrollListener() {
binding.rvAllPosts.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = binding.rvAllPosts.layoutManager as LinearLayoutManager
val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition() // 1번
checkFetchPostsCondition(layoutManager, lastPosition)
}
})
}
private fun checkFetchPostsCondition(layoutManager: LinearLayoutManager, lastPosition: Int) {
if (viewModel.hasNextPage &&
viewModel.isAddLoading.not() &&
layoutManager.itemCount <= lastPosition + LOAD_THRESHOLD && // 2번
binding.rvAllPosts.canScrollVertically(DOWNWARD_DIRECTION).not() // 3번
) {
viewModel.fetchPosts()
}
}
findLastCompletelyVisibleItemPosition
Returns the adapter position of the last fully visible view. This position does not include adapter changes that were dispatched after the last layout pass.
Recyclerview에서 보이는 마지막 항목의 아이템 위치를 반환한다. 마지막 화면 갱신 이후에 변경사항이 있어도 해당 변경사항을 반영하지 않고 이전의 상태를 보고한다.
layoutManager.itemCount
: 리스트 전체 항목수
LOAD_THRESHOLD
은 끝에 도달하기전 몇번째 아이템이 보일때 정보를 불러올지 정해주는 상수
즉 lastPosition 은 점점 커지고 마지막에 도달하기 3개전 아이템에서 불러오는 시점으로 설정되어있다.
이 부분이 좀 이해가 안되어서 예시로 생각하니 수월했다.
ex) 총 10개의 아이템이 있는데 현재 화면에 보이는 맨 밑의 아이템 위치는 5다. 내가 설정한 값은 3이니 아직 (새 아이템을 불러오는)서버통신을 하면 안된다.
스크롤을 해서 화면에 보이는 맨 밑의 아이템 위치가 7이 되어야 서버통신이 가능해진다.
= 총 항목수 - LOAD_THRESHOLD
≤ 현재 화면에서 보이는 마지막 항목의 아이템 위치
recyclerView.canScrollVertically(1)
canScrollVertically는 스크롤 가능한상태인지 여부를 boolean 값으로 주기때문에 최하단에 도달하기전까지 true를 배출하다 최하단에 도달하면 false를 반환한다.
그리고 안에 들어가는 매개변수는 Vertically 기준으로 -1이 위쪽, 1이 아래쪽이다.(최상단 최하단 구별용)
if
다음 페이지가 있고
&&
로딩중이지 않고
&&
총 항목수 ≤ 현재 화면에서 보이는 마지막 항목의 아이템 위치 + LOAD_THRESHOLD
(= 바닥 도달 `LOAD_THRESHOLD` 개 전)
&&
더이상 내려갈 수 없을 때(= recyclerView가 맨 밑에 도달했을 때)
post를 서버에서 불러온다.
layoutManager.itemCount
vs layoutManager.findLastCompletelyVisibleItemPosition()
1. layoutManager.itemCount
:
layoutManager.findLastCompletelyVisibleItemPosition()
:요약하면, layoutManager.itemCount
은 RecyclerView 어댑터에 의해 관리되는 총 항목 수를 나타내며, layoutManager.findLastCompletelyVisibleItemPosition()
은 현재 스크롤 위치에서 화면에 완전히 보이는 마지막 항목의 위치를 반환한다. 이 두 가지 속성 및 메서드는 RecyclerView와 그 내용을 다룰 때 유용하게 사용된다.
스크롤해서 정해진 위치에 닿으면 데이터를 불어오는 함수를 호출한다.
로딩중이라면 로딩 화면을 넣어주었다가 로딩이 끝나면 로딩 화면을 뜯어내고 불러온 아이템들을 집어넣어준다.
이를 리사이클러뷰 Adapter에서 멀티뷰 타입으로 구분해서 화면을 넣어준다.