관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[안드로이드] 코틀린 리사이클러뷰 무한 스크롤 구현 본문

안드로이드/코틀린 & 아키텍처 & Recent

[안드로이드] 코틀린 리사이클러뷰 무한 스크롤 구현

막무가내막내 2020. 3. 13. 16:53
728x90

 

[2021-04-13 업데이트]

 

영화 불러오는데 무한 스크롤이 필요하여 해당 기능을 구현해봤습니다.

 

 

1. 무한스크롤 리스너 클래스 추가 

import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager


abstract class EndlessRecyclerViewScrollListener : RecyclerView.OnScrollListener {
    // The minimum amount of items to have below your current scroll position
    // before loading more.
    private var visibleThreshold = 5

    // The current offset index of data you have loaded
    private var currentPage = 0

    // The total number of items in the dataset after the last load
    private var previousTotalItemCount = 0

    // True if we are still waiting for the last set of data to load.
    private var loading = true

    // Sets the starting page index
    private val startingPageIndex = 0
    var mLayoutManager: RecyclerView.LayoutManager

    constructor(layoutManager: LinearLayoutManager) {
        mLayoutManager = layoutManager
    }

    constructor(layoutManager: GridLayoutManager) {
        mLayoutManager = layoutManager
        visibleThreshold *= layoutManager.spanCount
    }

    constructor(layoutManager: StaggeredGridLayoutManager) {
        mLayoutManager = layoutManager
        visibleThreshold *= layoutManager.spanCount
    }

    private fun getLastVisibleItem(lastVisibleItemPositions: IntArray): Int {
        var maxSize = 0
        for (i in lastVisibleItemPositions.indices) {
            if (i == 0) {
                maxSize = lastVisibleItemPositions[i]
            } else if (lastVisibleItemPositions[i] > maxSize) {
                maxSize = lastVisibleItemPositions[i]
            }
        }
        return maxSize
    }

    // This happens many times a second during a scroll, so be wary of the code you place here.
    // We are given a few useful parameters to help us work out if we need to load some more data,
    // but first we check if we are waiting for the previous load to finish.
    override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
        var lastVisibleItemPosition = 0
        val totalItemCount = mLayoutManager.itemCount
        if (mLayoutManager is StaggeredGridLayoutManager) {
            val lastVisibleItemPositions =
                (mLayoutManager as StaggeredGridLayoutManager).findLastVisibleItemPositions(null)
            // get maximum element within the list
            lastVisibleItemPosition = getLastVisibleItem(lastVisibleItemPositions)
        } else if (mLayoutManager is GridLayoutManager) {
            lastVisibleItemPosition =
                (mLayoutManager as GridLayoutManager).findLastVisibleItemPosition()
        } else if (mLayoutManager is LinearLayoutManager) {
            lastVisibleItemPosition =
                (mLayoutManager as LinearLayoutManager).findLastVisibleItemPosition()
        }

        // If the total item count is zero and the previous isn't, assume the
        // list is invalidated and should be reset back to initial state
        if (totalItemCount < previousTotalItemCount) {
            currentPage = startingPageIndex
            previousTotalItemCount = totalItemCount
            if (totalItemCount == 0) {
                loading = true
            }
        }
        // If it’s still loading, we check to see if the dataset count has
        // changed, if so we conclude it has finished loading and update the current page
        // number and total item count.
        if (loading && totalItemCount > previousTotalItemCount) {
            loading = false
            previousTotalItemCount = totalItemCount
        }

        // If it isn’t currently loading, we check to see if we have breached
        // the visibleThreshold and need to reload more data.
        // If we do need to reload some more data, we execute onLoadMore to fetch the data.
        // threshold should reflect how many total columns there are too
        if (!loading && lastVisibleItemPosition + visibleThreshold > totalItemCount) {
            currentPage++
            onLoadMore(currentPage, totalItemCount, view)
            loading = true
        }
    }

    // Call this method whenever performing new searches
    fun resetState() {
        currentPage = startingPageIndex
        previousTotalItemCount = 0
        loading = true
    }

    // Defines the process for actually loading more data based on page
    abstract fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?)
}

 

 

 

 

 

2. 어댑터(어댑터에는 따로 해줄건 없습니다.)

package com.mtjin.androidarchitecturestudy.ui

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.RatingBar
import android.widget.TextView
import androidx.core.text.HtmlCompat
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.mtjin.androidarchitecturestudy.R
import com.mtjin.androidarchitecturestudy.data.Movie

class MovieAdapter :
    RecyclerView.Adapter<MovieAdapter.ViewHolder>() {
    private lateinit var clickCallBack: (Movie) -> Unit
    private val items: ArrayList<Movie> = ArrayList()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view: View = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_movie, parent, false)
        val viewHolder = ViewHolder(view)
        view.setOnClickListener {
            clickCallBack(items[viewHolder.adapterPosition])
        }

        return viewHolder
    }

    override fun getItemCount(): Int = items.size

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        items[position].let {
            holder.bind(it)
        }
    }

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

        private val ivPoster = itemView.findViewById<ImageView>(R.id.iv_poster)
        private val rvRating = itemView.findViewById<RatingBar>(R.id.rb_rating)
        private val tvTitle = itemView.findViewById<TextView>(R.id.tv_title)
        private val tvReleaseDate = itemView.findViewById<TextView>(R.id.tv_release_date)
        private val tvActor = itemView.findViewById<TextView>(R.id.tv_actor)
        private val tvDirector = itemView.findViewById<TextView>(R.id.tv_director)

        fun bind(movie: Movie) {
            with(movie) {
                Glide.with(itemView).load(image)
                    .placeholder(R.drawable.ic_default)
                    .into(ivPoster!!)
                rvRating.rating = (userRating.toFloatOrNull() ?: 0f) / 2
                tvTitle.text = HtmlCompat.fromHtml(title, HtmlCompat.FROM_HTML_MODE_COMPACT)
                tvReleaseDate.text = pubDate
                tvActor.text = actor
                tvDirector.text = director
            }
        }
    }

    fun setItems(items: List<Movie>) {
        this.items.addAll(items)
        notifyDataSetChanged()
    }

    fun setItemClickListener(clickCallBack: (Movie) -> Unit) {
        this.clickCallBack = clickCallBack
    }

    fun clear() {
        this.items.clear()
        notifyDataSetChanged()
    }
}

 

 

 

 

 

3. 액티비티

저는 서버에 요청할 때 page가 아닌 아이템의 개수가 필요해서 totalItemsCount를 사용했습니다. 스크롤 조건이 된 경우 requestPagingMovie를 호출해줍니다.

private fun initAdapter() {
        movieAdapter = MovieAdapter()
        val linearLayoutManager = LinearLayoutManager(this)
        rvMovies.layoutManager = linearLayoutManager
        scrollListener = object : EndlessRecyclerViewScrollListener(linearLayoutManager) {
            override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
                requestPagingMovie(query, totalItemsCount + 1)
            }
        }
        rvMovies.addOnScrollListener(scrollListener)
        rvMovies.adapter = movieAdapter
    }

 

만약 새로 영화 검색을 해야하는 등 무한스크롤을 초기화 시켜야하는 경우는 

scrooListener.resetState()를 해주면 됩니다. 

private fun requestMovie(query: String) {
        showLoading()
        scrollListener.resetState()
        myApplication.movieRepository.getSearchMovies(query,
            success = {
                if (it.isEmpty()) {
                    onToastMessage("해당 영화는 존재하지 않습니다.")
                } else {
                    movieAdapter.clear()
                    movieAdapter.setItems(it)
                    onToastMessage("영화를 불러왔습니다.")
                }
                hideLoading()
            },
            fail = {
                Log.d(TAG, it.toString())
                when (it) {
                    is HttpException -> onToastMessage("네트워크에 문제가 있습니다.")
                    else -> onToastMessage(it.message.toString())
                }
                hideLoading()
            })
    }

 

영화를 더 불러오는 함수입니다.

fun requestPagingMovie(query: String, offset: Int) {
        showLoading()
        myApplication.movieRepository.getPagingMovies(query, offset,
            success = {
                if (it.isEmpty()) {
                    onToastMessage("마지막 페이지")
                } else {
                    movieAdapter.setItems(it)
                    onToastMessage("영화를 불러왔습니다.")
                }
                hideLoading()
            },
            fail = {
                Log.d(TAG, it.toString())
                when (it) {
                    is HttpException -> onToastMessage("네트워크에 문제가 있습니다.")
                    else -> onToastMessage(it.message.toString())
                }
                hideLoading()
            })
    }

 

 

전체 액티비티 코드입니다.

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.mtjin.androidarchitecturestudy.R
import com.mtjin.androidarchitecturestudy.utils.MyApplication
import retrofit2.HttpException


class MovieSearchActivity : AppCompatActivity() {

    private lateinit var etInput: EditText
    private lateinit var btnSearch: Button
    private lateinit var rvMovies: RecyclerView
    private lateinit var pbLoading: ProgressBar
    private lateinit var movieAdapter: MovieAdapter
    private lateinit var myApplication: MyApplication
    private lateinit var query: String
    private lateinit var scrollListener: EndlessRecyclerViewScrollListener

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_movie_search)

        initView()
        initAdapter()
        initListener()
    }

    private fun initView() {
        myApplication = application as MyApplication
        etInput = findViewById(R.id.et_input)
        btnSearch = findViewById(R.id.btn_search)
        rvMovies = findViewById(R.id.rv_movies)
        pbLoading = findViewById(R.id.pb_loading)
    }

    private fun initAdapter() {
        movieAdapter = MovieAdapter()
        val linearLayoutManager = LinearLayoutManager(this)
        rvMovies.layoutManager = linearLayoutManager
        scrollListener = object : EndlessRecyclerViewScrollListener(linearLayoutManager) {
            override fun onLoadMore(page: Int, totalItemsCount: Int, view: RecyclerView?) {
                requestPagingMovie(query, totalItemsCount + 1)
            }
        }
        rvMovies.addOnScrollListener(scrollListener)
        rvMovies.adapter = movieAdapter
    }

    private fun initListener() {
        //어댑터 아이템 클릭리스너
        movieAdapter.setItemClickListener { movie ->
            Intent(Intent.ACTION_VIEW, Uri.parse(movie.link)).takeIf {
                it.resolveActivity(packageManager) != null
            }?.run(this::startActivity)
        }
        //검색버튼
        btnSearch.setOnClickListener {
            query = etInput.text.toString().trim()
            if (query.isEmpty()) {
                onToastMessage("검색어를 입력해주세요.")
            } else {
                onToastMessage("잠시만 기다려주세요.")
                requestMovie(query)
            }
        }
    }

    private fun requestMovie(query: String) {
        showLoading()
        scrollListener.resetState()
        myApplication.movieRepository.getSearchMovies(query,
            success = {
                if (it.isEmpty()) {
                    onToastMessage("해당 영화는 존재하지 않습니다.")
                } else {
                    movieAdapter.clear()
                    movieAdapter.setItems(it)
                    onToastMessage("영화를 불러왔습니다.")
                }
                hideLoading()
            },
            fail = {
                Log.d(TAG, it.toString())
                when (it) {
                    is HttpException -> onToastMessage("네트워크에 문제가 있습니다.")
                    else -> onToastMessage(it.message.toString())
                }
                hideLoading()
            })
    }

    fun requestPagingMovie(query: String, offset: Int) {
        showLoading()
        myApplication.movieRepository.getPagingMovies(query, offset,
            success = {
                if (it.isEmpty()) {
                    onToastMessage("마지막 페이지")
                } else {
                    movieAdapter.setItems(it)
                    onToastMessage("영화를 불러왔습니다.")
                }
                hideLoading()
            },
            fail = {
                Log.d(TAG, it.toString())
                when (it) {
                    is HttpException -> onToastMessage("네트워크에 문제가 있습니다.")
                    else -> onToastMessage(it.message.toString())
                }
                hideLoading()
            })
    }

    private fun onToastMessage(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }

    private fun showLoading() {
        pbLoading.visibility = View.VISIBLE
    }

    private fun hideLoading() {
        pbLoading.visibility = View.GONE
    }

    companion object {
        const val TAG = "MovieSearchActivity"
    }

}

 

 

 

 

 

 

참고 및 출처 :

https://github.com/codepath/android_guides/wiki/Endless-Scrolling-with-AdapterViews-and-RecyclerView

 

codepath/android_guides

Extensive Open-Source Guides for Android Developers - codepath/android_guides

github.com

 

 

댓글과 공감은 큰 힘이 됩니다. 감사합니다. !!

 

 

 

 

 

 

 

728x90
Comments