[Android] 이제는 더 이상 물러날 곳이 없는 무한스크롤 공부

핑구·2023년 10월 27일
2

Android

목록 보기
6/8
post-thumbnail

무한스크롤 대략적인 공부

멧돼지 블로그로 공부하고 구현해보았다.

무한 스크롤 방식에는 크게 두 가지 방식이 있다. (jetpack의 paging3이라는 라이브러리 제외)

그 중 이번에 우리가 구현하게 되는 non-offset 방식을 살펴보겠다.

요청할 때 id와 limit을 보내게 되는데, 이 말은 해당 id의 아이템을 기준으로 limit만큼 보내주세요~ 다.

오프셋 기반 페이지네이션은 우리가 원하는 데이터가 '몇 번째'에 있다는 데에 집중하고 있다면, 커서 기반 페이지네이션은 우리가 원하는 데이터가 '어떤 데이터의 다음'에 있다는 데에 집중합니다. n개의 row를 skip 한 다음 10개 주세요가 아니라, 이 row 다음꺼부터 10개 주세요를 요청하는 식이지요.

출처 : minsangk velog

  1. 로딩할때 밑에다 붙여줄 로딩 아이템을 하나 만들어준다.
  2. 어댑터에서 뷰홀더 하나 더 만들어주고 getItemVIewType, onCreateViewHolder 에서 로딩 관련 들어가는 것을 분기처리해준다.
  3. viewModel에서 로딩관련 더미 데이터 하나 만들어주고 기타 변수 선언하고 관련 분기처리한다.
  4. fragment에서 불러줄때 onScrollListener에 붙여주고 리스폰스가 들어오는 라이브데이터에 옵저버 붙여서 listadapter 리스트 최신화 시키는 코드를 만들어준다.

멀티뷰 타입으로 스크롤이 최하단으로가면 아무것도 없는 리스트 아이템을 하나 넣어서 그거 들어왔을때는 로딩 뷰를 최하단에 띄워줬다가 로딩이 완료되면 최하단 로딩하는 아이템을 뜯어내고 새로들어온 리스트를 추가해주는 방식이다.


코드로 이해하기

대충 위의 내용정도만 읽고 따라 치면서 이해해보려고 했다.

따라 치면서 몇몇 부분에서 의문이 들어서 멧돼지에게 물어보니 아주 초보적일 때 만들었던 거라(코딩 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에서 적당한 로딩 동그라미를 가져와서 로딩화면을 만들었다.

Adapter.kt

그 후에 리사이클러뷰 어댑터에서 더미 데이터인지를 판별하여 각각의 뷰홀더로 연결해준다.(더미데이터면 로딩화면, 아니면 데이터화면)

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
    }
}

Fragment

무한스크롤 부분을 좀 모아보기 위해 일부로 함수분리를 하지 않았다.

@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()
        }
    }
  1. 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에서 보이는 마지막 항목의 아이템 위치를 반환한다. 마지막 화면 갱신 이후에 변경사항이 있어도 해당 변경사항을 반영하지 않고 이전의 상태를 보고한다.

  2. layoutManager.itemCount : 리스트 전체 항목수

    LOAD_THRESHOLD은 끝에 도달하기전 몇번째 아이템이 보일때 정보를 불러올지 정해주는 상수

    즉 lastPosition 은 점점 커지고 마지막에 도달하기 3개전 아이템에서 불러오는 시점으로 설정되어있다.

    이 부분이 좀 이해가 안되어서 예시로 생각하니 수월했다.

    ex) 총 10개의 아이템이 있는데 현재 화면에 보이는 맨 밑의 아이템 위치는 5다. 내가 설정한 값은 3이니 아직 (새 아이템을 불러오는)서버통신을 하면 안된다.

    스크롤을 해서 화면에 보이는 맨 밑의 아이템 위치가 7이 되어야 서버통신이 가능해진다.

    = 총 항목수 - LOAD_THRESHOLD ≤ 현재 화면에서 보이는 마지막 항목의 아이템 위치

  3. 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:

  • RecyclerView의 어댑터에 의해 관리되는 총 항목(item)의 수
  • 이 값은 항목이 화면에 표시되지 않더라도 모두 포함
  1. layoutManager.findLastCompletelyVisibleItemPosition():
  • RecyclerView에서 가장 마지막에 완전히 보이는 항목의 위치를 반환
  • "완전히 보이는 항목"은 화면에 완전히 표시되고 다른 항목에 의해 가려지지 않는 항목을 의미
  • 이 메서드는 스크롤 위치에 따라 계산되며, 현재 화면에 표시되는 마지막 항목의 위치를 알려준다

요약하면, layoutManager.itemCount은 RecyclerView 어댑터에 의해 관리되는 총 항목 수를 나타내며, layoutManager.findLastCompletelyVisibleItemPosition()은 현재 스크롤 위치에서 화면에 완전히 보이는 마지막 항목의 위치를 반환한다. 이 두 가지 속성 및 메서드는 RecyclerView와 그 내용을 다룰 때 유용하게 사용된다.

정리

흐름은 위와 같다.

스크롤해서 정해진 위치에 닿으면 데이터를 불어오는 함수를 호출한다.

로딩중이라면 로딩 화면을 넣어주었다가 로딩이 끝나면 로딩 화면을 뜯어내고 불러온 아이템들을 집어넣어준다.

이를 리사이클러뷰 Adapter에서 멀티뷰 타입으로 구분해서 화면을 넣어준다.

출처

https://mccoy-devloper.tistory.com/49

profile
발전중

0개의 댓글