Nuke Olaf - Log Store

[Android] - 안드로이드 리사이클러뷰 무한 스크롤 (Infinite/Endless Scroll) 본문

Android

[Android] - 안드로이드 리사이클러뷰 무한 스크롤 (Infinite/Endless Scroll)

NukeOlaf 2020. 5. 6. 08:12

서버에서 데이터를 가져와 리사이클러뷰로 보여줘야 하는 경우,

많은 데이터를 전부다 한번에 가져오는 것은 무리가 있다.

그래서 사용자가 리사이클러뷰 스크롤을 하여 아이템을 보여지게 하는 데 필요한 경우에

무한 스크롤 기능을 구현하여 데이터를 가져올 수 있다.

무한 스크롤링도 어찌 보면 페이징 기능의 한 방식이다.

 

무한 스크롤 구현방법

0. 레트로핏 인터페이스 작성

레트로핏으로 서버와 통신하여 데이터를 가져올 것이다.

사용할 무한 스크롤 API 는
request 로 페이지와 아이템 개수를 요청하면,
response 로 전체 게시글 총 개수, 다음 페이지 유무, 아이템 리스트를 반환한다.

@GET("/posts")
fun getPosts(
    @Query("page") page: Int,
    @Query("limit") limit: Int
): Call<Posts>
data class Posts(
    val total_count: Int,
    val is_Next: Boolean,
    val posts: List<Post>
)

 

1. activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/infiniteRv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

2. MainActivity.kt

리사이클러뷰에 addOnScrollListener 를 달아준다

class MainActivity : AppCompatActivity() {

    private var totalCount = 0 // 전체 아이템 개수
    private var isNext = false // 다음 페이지 유무
    private var page = 0       // 현재 페이지
    private var limit = 10     // 한 번에 가져올 아이템 수
    
    private lateinit var adapter: PostAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        adapter = PostAdapter()
        infiniteRv.adapter = adapter
        
        loadPosts()
        initScrollListener()
    }

    // 리사이클러뷰에 최초로 넣어줄 데이터를 load 한다
    private fun loadPosts() {
        retrofitClient.getPosts(getPage(), limit)
            .enqueue(object : Callback<Posts> {
                override fun onResponse(call: Call<Posts>, response: Response<Posts>) {
                    val body = response.body()
                    if (body != null && response.isSuccessful) {
                        totalCount = body.total_count
                        isNext = body.is_Next
                        adapter.setPosts(body.posts)
                    } else {
                        // 통신 에러
                    }
                }

                override fun onFailure(call: Call<Posts>, t: Throwable) {
                    // 통신 에러
                }
            })
    }
    
    // 리사이클러뷰에 더 보여줄 데이터를 로드하는 경우
    private fun loadMorePosts() {
        adapter.setLoadingView(true)
        
        // 너무 빨리 데이터가 로드되면 스크롤 되는 Ui 를 확인하기 어려우므로,
        // Handler 를 사용하여 1초간 postDelayed 시켰다
        val handler = android.os.Handler()
        handler.postDelayed({
            retrofitClient.getPosts(getPage(), limit)
            .enqueue(object : Callback<Posts> {
                override fun onResponse(call: Call<Posts>, response: Response<Posts>) {
                    val body = response.body()
                    if (body != null && response.isSuccessful) {
                        totalCount = body.total_count
                        isNext = body.is_Next
                        adapter.run {
                            setLoadingView(false)
                            addPosts(body.posts)
                        }
                    } else {
                        // 통신 에러
                    }
                }

                override fun onFailure(call: Call<Posts>, t: Throwable) {
                    // 통신 에러
                }
            })
        }, 1000)
    }
     
    private fun initScrollListener() {
        infiniteRv.addOnScrollListener(object : RecyclerView.OnScrollListener() {

            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)

                val layoutManager = infiniteRv.layoutManager

                // hasNextPage() -> 다음 페이지가 있는 경우
                if (hasNextPage()) {
                    val lastVisibleItem = (layoutManager as LinearLayoutManager)
                        .findLastCompletelyVisibleItemPosition()

                    // 마지막으로 보여진 아이템 position 이
                    // 전체 아이템 개수보다 5개 모자란 경우, 데이터를 loadMore 한다
                    if (layoutManager.itemCount <= lastVisibleItem + 5) {
                        loadMorePosts()
                        setHasNextPage(false)
                    }
                }
            }
        })
    }
    
    private fun getPage(): Int {
        page++
        return page
    }

    private fun hasNextPage(): Boolean {
        return isNext
    }

    private fun setHasNextPage(b: Boolean) {
        isNext = b
    }

}

 

3. PostAdpater.kt

PostViewHolder 와 LoadingViewHolder 는 생략

adapter 의 setLoadingView(true) 함수를 호출할 경우, posts 리스트의 맨 마지막에 null 값을 추가하게 된다.
그래서 posts[position] 값이 null 인경우, ViewType 이 loadingView 가 된다.

그래서 loadMore() 함수를 호출할때, setLoadingView(true) 로 마지막 아이템을 로딩뷰로 바꿨다가,
아이템 load 가 끝나면 setLoadingView(false) 로 마지막 null 값 아이템을 제거한다

PostAdapter() : RecyclerView.Adapter<RecyclerView.ViewHolder> {

    companion object {
        private const val TYPE_POST = 0
        private const val TYPE_LOADING = 1
    }
    
    private val posts = mutableListOf<Post?>()

    fun setPosts(posts: List<Post>) {
        this.posts.apply {
            clear()
            addAll(posts)
        }
        notifyDataSetChanged()
    }

    fun addPosts(posts: List<Post>) {
        this.posts.addAll(posts)
        notifyDataSetChanged()
    }
    
    fun setLoadingView(b: Boolean) {
        if (b) {
            android.os.Handler().post {
                this.posts.add(null)
                notifyItemInserted(comments.size - 1)
            }
        } else {
            if (this.comments[comments.size - 1] == null) {
                this.comments.removeAt(comments.size - 1)
                notifyItemRemoved(comments.size)
            }
        }
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        when (viewType) {           
            TYPE_POST -> {
                val inflatedView = LayoutInflater
                    .from(parent.context)
                    .inflate(R.layout.item_post, parent, false)
                return PostViewHolder(inflatedView)
            }    
            else -> {
                val inflatedView = LayoutInflater
                    .from(parent.context)
                    .inflate(R.layout.item_loading, parent, false)
                return LoadingViewHolder(inflatedView)
            }
        }
    }
    
    override fun getItemCount(): Int {
        return posts.size
    }
    
    override fun getItemViewType(position: Int): Int {
        return when (posts[position]) {          
            null -> TYPE_POST
            else -> TYPE_LOADING
        }
    }
    
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (holder.itemViewType) {
            TYPE_POST -> {
                val postViewHolder = holder as PostViewHolder
                postViewHolder.bind(posts[position])
            }           
        }
    }

}
Comments