Beeeam

무한 스크롤 본문

Android

무한 스크롤

Beamjun 2023. 9. 18. 21:58

무한 스크롤

Recycler view를 사용하면 여러 개의 데이터를 효율적으로 화면에 보여줄 수 있다. Recycler view를 사용하려면 adapter에 데이터를 전달해야 한다. 만약 보여주고자 하는 데이터의 개수가 적으면 이를 한번에 모두 전달하면 된다. 근데 만약 전달할 데이터가 100개, 1000개이면 이를 한 번에 전달하는 것은 비효율적이다. 그래서 이를 recycler view의 최하단에 도달하는 경우에 추가로 전달 받는 방법을 사용하면 좋다.

이러한 방법을 무한 스크롤이라고 부른다. 무한 스크롤을 구현하는 것을 생각하면 간단하다.

recycler view 최하단 도달? → 데이터 추가

위의 방식으로 구현을 할 것이고 추가로 맨 마지막 아이템에 progress bar를 추가하여 데이터를 불러오는 것을 사용자에게 알려줄 것이다.

 

구현

recycler view를 화면에 보여줄 Activity, recycler view를 구현하기 위한 adapterviewHolder, recycler view의 각 아이템의 뷰를 그려줄 xml, recycler view에 들어갈 데이터를 담을 데이터 클래스를 구현하였다.

 

Adapter

먼저 enum class를 사용하여 아이템의 뷰 타입을 구분한다. 뷰 타입은 로딩 중(progress bar), 아이템을 보여주는 뷰 두 가지로 나눈다.

enum class ItemViewType(val viewType: Int) {
    LOADING(0),
    ITEM(1)
}

다음 getItemViewType 함수를 오버라이드 하여 아이템의 제목에 따라 아이템의 뷰 타입을 구분한다. 이 메서드의 결과로 뷰 타입이 반환되면 이를 바탕으로 올바른 viewHolder를 생성한다.

override fun getItemViewType(position: Int): Int {
        return when (getItem(position).title) {
            "" -> {
                ItemViewType.LOADING.viewType
            }
            else -> {
                ItemViewType.ITEM.viewType
            }
        }
    }

 

전체 코드

enum class ItemViewType(val viewType: Int) {
    LOADING(0),
    ITEM(1)
}

class InfiniteScrollAdapter: ListAdapter<Data, RecyclerView.ViewHolder>(InfiniteScrollDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        when(viewType) {
            ItemViewType.LOADING.viewType -> {
                return InfiniteScrollLoadingViewHoler(
                    ItemRecyclerLoadingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                )
            }
            ItemViewType.ITEM.viewType -> {
                return InfiniteScrollViewHoler(
                    ItemRecyclerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
                )
            }

            else -> {
                throw Exception()
            }
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (getItem(position).title) {
            "" -> {
                (holder as InfiniteScrollLoadingViewHoler).bind(getItem(position))
            }
            else -> {
                (holder as InfiniteScrollViewHoler).bind(getItem(position))
            }
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position).title) {
            "" -> {
                ItemViewType.LOADING.viewType
            }
            else -> {
                ItemViewType.ITEM.viewType
            }
        }
    }
}

class InfiniteScrollDiffCallback : DiffUtil.ItemCallback<Data>() {

    override fun areItemsTheSame(oldItem: Data, newItem: Data): Boolean {
        return oldItem.title == newItem.title
    }

    override fun areContentsTheSame(oldItem: Data, newItem: Data): Boolean {
        return oldItem == newItem
    }
}

 

viewHolder

viewHolder에는 recycler view의 각 아이템의 동작이나 세부 설정을 정의할 수 있다. 본인이 만든 예시에서는 제목, 내용 텍스트만 화면에 보여주면 되기 때문에 bind 함수 내에서 받아온 값을 아이템에 보여주도록 설정하였다.

class InfiniteScrollViewHolder(
    private val binding: ItemRecyclerBinding
): RecyclerView.ViewHolder(binding.root) {

    fun bind(item: Data) {
        binding.apply {
            tvTitle.text = item.title
            tvContent.text = item.content
        }
        binding.executePendingBindings()
    }

}

 

Activity

이 전까지의 과정은 일반적인 recycler view를 구현하는 방법과 동일하다. 무한 스크롤을 구현하기 위해서는 여기 Activity에서 아이템이 마지막인지 판단하는 로직을 작성해야 한다.

recycler view의 아이템이 마지막인지 판단하는 방법은 두 가지가 있다.

 

canScrollVertically() VS findLastCompletelyVisibleItemPosition()

canScrollVertically() 메서드를 사용하는 방법은 recycler view가 밑으로 스크롤이 되는지 여부를 판단하는 방법이다.

findLastCompletelyVisibleItemPosition() 메서드를 사용하는 방법은 화면에 보여지는 마지막 아이템의 위치와 어댑터에 있는 아이템의 개수를 비교해서 최하단인지 여부를 판단하는 방법이다.

 

두 방법 모두 정상적으로 작동해서 어떤 것을 사용하든 상관이 없지만 상황에 맞게 사용할 수 있다.

일단 canScrollVertically() 메서드를 사용하면 스크롤를 할 수 있는지 여부를 판단하기 때문에 무조건 recycler view의 아이템이 최하단에 위치해야 추가 데이터를 요청하는 작업을 실행할 수 있다. 하지만 findLastCompletelyVisibleItemPosition() 메서드를 사용하면 보여지는 마지막 아이템의 개수와 어댑터 내의 아이템 개수를 비교하기 때문에 최하단에 도달하기 전에 미리 데이터를 요청할 수 있다.

 

canScrollVertically() 사용

recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                    super.onScrolled(recyclerView, dx, dy)

                    if (!recycler.canScrollVertically(1)) {
                        data.removeAt(data.lastIndex)
                        setItem(data)
                        rvAdapter.submitList(data)
                        rvAdapter.notifyItemRangeInserted(data.lastIndex, 2)
                    }
                }
            })

if문으로 스크롤을 할 수 없는 경우를 판단해서 최하단에 도달했다고 판단되는 경우에 추가 데이터를 요청하는 작업을 할 수 있게 했다. 이렇게 구현하게 되면 무조건 최하단에 도달해야 다음 데이터를 받아올 수 있다.

 

findLastCompletelyVisibleItemPosition() 사용

recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                    super.onScrolled(recyclerView, dx, dy)

                    val lastVisiblePosition = (recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()
                    val isBottom = lastVisiblePosition + 1 == recyclerView.adapter?.itemCount

                    if (isBottom) {
                        data.removeAt(data.lastIndex)
                        setItem(data)
                        rvAdapter.submitList(data)
                        rvAdapter.notifyItemRangeInserted(data.lastIndex, 2)
                    }
                }
            })

먼저 findLastCompletelyVisibleItemPosition() 메서드를 사용하여 화면에 보여지는 마지막 아이템의 위치를 찾는다. 다음 찾은 위치를 어댑터 내의 아이템의 개수와 비교하여 같으면 최하단에 도달했다고 판단, 데이터를 받아오는 작업을 하도록 했다.

여기서 isBottom에 화면에 보여지는 마지막 아이템의 위치와 어댑터 내의 아이템의 개수를 비교하는데 마지막 위치에 + 숫자를 하면 숫자 만큼의 위치 전에 추가 데이터를 요청할 수 있다. 이 방법을 사용하면 사용자에게 화면이 끊기지 않고 부드럽게 연결되는 느낌을 줄 수 있다.

 

전체 코드

private lateinit var binding: ActivityMainBinding

var cnt = 1

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val rvAdapter = InfiniteScrollAdapter()
        val data = mutableListOf<Data>()
        val handler = Handler()

        setItem(data)

        binding.apply {
            binding.recycler.adapter = rvAdapter
            rvAdapter.submitList(data)

//            TODO("아이템 개수 vs 마지막 아이템 위치 비교 + 스크롤 확인 방법")
            recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                    super.onScrolled(recyclerView, dx, dy)

                    val lastVisiblePosition = (recyclerView.layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()
                    val isBottom = lastVisiblePosition + 5 == recyclerView.adapter?.itemCount
                    val isDownScroll = dy > 0

                    if (isBottom and isDownScroll) {
                        val delayMillis = 1000

                        handler.postDelayed({
                            Log.d("last", "최하단")
                            data.removeAt(data.lastIndex)
                            setItem(data)
                            rvAdapter.submitList(data)
                            rvAdapter.notifyItemRangeInserted(data.lastIndex, 10)
                        }, delayMillis.toLong())

                    }
                }
            })

//            TODO("canScrollVertically(1) 사용 방법")
//            recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
//                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
//                    super.onScrolled(recyclerView, dx, dy)
//
//                    if (!recycler.canScrollVertically(1)) {
//                        val delayMillis = 1000
//
//                        handler.postDelayed({
//                            Log.d("last", "최하단")
//                            data.removeAt(data.lastIndex)
//                            setItem(data)
//                            rvAdapter.submitList(data)
//                            rvAdapter.notifyItemRangeInserted(data.lastIndex, 2)
//                        }, delayMillis.toLong())
//                    }
//                }
//            })
        }
    }
}

fun setItem(data: MutableList<Data>) {
    for (i in 0 until  10) {
        data.add(Data("제목 $cnt", "내용 $cnt"))
        cnt ++
    }
    data.add(Data("", ""))
}

본인은 추가 데이터를 받아오고 있음을 사용자에게 보여주기 위해서 progress bar를 딜레이를 사용하여 보여주게 했다. 만약 서버에서 데이터를 받아오는 작업이면 딜레이가 필요 없다. 그 작업을 하면서 딜레이가 생기기 때문이다.

progress bar를 보여주게 하기 위해서 어댑터에 아이템을 전달할 때 빈 값을 전달했다. 이렇게 전달된 빈 값은 어댑터의 getItemViewType() 메서드를 통해서 로딩 중인 뷰 타입으로 구분되어 progress bar가 있는 viewHolder를 생성하여 보여주게 된다.

 

canScrollVertically() 사용 구현 결과 findLastCompletelyVisibleItemPosition() 사용 구현
최하단에 도달하면 추가 데이터가 화면에 나타나는 것을 확인할 수 있다. 적당한 속도로 내리면 화면 끊김 없이 부드럽게 추가 데이터를 화면에 보여줄 수 있다.

 

 

 

 

 

 

'Android' 카테고리의 다른 글

Android CountDownTimer  (0) 2023.11.07
Android Email Intent  (0) 2023.11.07
RecyclerView 오류 해결 과정..  (0) 2023.08.08
Power Menu  (0) 2023.06.27
Bottom Navigation View  (0) 2023.06.27