본문 바로가기
앱 제작/키워드 알림 앱

[코틀린] infinite/endless scroll(무한 스크롤)과 recyclerView

by Kim Juhwan 2021. 1. 8.

가수 인피니트 로고 이뻐서 씀...

1. infinite/endless scroll
   1-1. 개념
   1-2. Progress Bar
   1-3. 아이템 뷰(ItemView)
   1-4. 홀더(Holder)
   1-5. 스크롤 리스너(Scroll Listener)
   1-6. 다른 최하단 도달 감지 방법
2. Adapter
   1-1. 전체 소스
   1-2. 코드 설명
3. Activity
   3-1. 전체 소스
   3-2. 코드 설명
4. 예제 다운로드

 

 


 

1. infinite/endless scroll

1-1. 개념

무한 스크롤을 이용해 다음 게시물을 업데이트 함

 

게시물 리스트를 쭉 내리다가 어느 지점에 도착하면

그다음 게시물 리스트를 가져오는 방법이 있다.

이것을 infinite scroll 혹은 endless scroll이라고 부른다.

(심지어 어떤 외국 블로거는 ultimate scroll이라고 부르던데 딱히 정해진 이름은 없는 것 같다)

 

이 방법의 이점은 게시물들을 한꺼번에 다 가져오지 않아도 된다는 것이다.

그때그때 사용자가 원할 때 다음 게시물을 가져오니

시간 절약, 메모리 절약, 데이터 절약(?)...

그리고 페이지 별로 나눠서 게시물을 보는 것보다 더 쉬워서 요새 많이 쓰이는 듯하다.

 

 

 

1-2. Progress Bar

profressBar(프로그레스 바)

 

우선 프로그레스 바가 무엇인지 알아야 한다.

프로그레스 바는 이렇게 로딩 중임을 사용자에게 시각적으로 표현해주는 뷰이다.

이게 없다면 사용자가 이놈이 동작을 멈춘 건지, 진행 중인지 뭔지 알 수가 없으니 사용자에게 알려주는 것이다.

 

 

 

1-3. 아이템 뷰(itemView)

카카오톡 대화목록을 리사이클러뷰로 만들었다고 해보자.

(실제로도 그러지 않았을까? 다른 리스트뷰를 사용했는지는 모르겠다만)

그러면 저 대화창 하나를 아이템 뷰라고 한다.

아이템 뷰가 여러 개가 쌓여쌓여 리스트 형태를 한 게 리사이클러뷰이다.

 

 

 

1-4. 홀더(Holder)

메신저 대화창

 

이번엔 카카오톡 대화방을 리사이클러뷰로 만들었다고 해보자

대화 메시지 종류는 2가지로 나뉠 수 있을 것이다.

내가 보낸 메시지, 그리고 상대방이 보낸 메시지

 

이렇게 리사이클러뷰에 들어가는 아이템 뷰의 모양을 2개로 나누려면

홀더도 2개로 나누어서 만들어야 한다.

 

 

 

 

홀더 이야기를 한 이유는

사실 저 프로그레스 바 모양은 floating button처럼 뷰 위에 떠있는 게 아니라

그냥 리사이클러뷰의 맨 마지막 아이템으로 넣은 뿐이기 때문이다.

로딩이 다 끝나고 나면 프로그레스 바가 들어있는 아이템 뷰를 삭제하고

그다음 게시물들을 이어서 보여주는 뿐이다.

 

고로 게시물을 보여줄 때 사용할 홀더와

프로그레스 바를 보여줄 때 사용할 홀더 2개가 필요하다는 뜻이다.

 

 

 

1-5. 스크롤 리스너(Scroll Listener)

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

                // 스크롤이 끝에 도달했는지 확인
                if (!recyclerView_notices.canScrollVertically(1)) {

                }
            }
        })

스크롤 리스너는 사용자가 스크롤을 감지하는 리스너이다.

리스너 안에 있는 onScrolled 메서드는 사용자가 스크롤을 움직이는 동안 계속 반복해서 호출이 된다.

 

스크롤이 끝에 도달하여 더 이상 움직일 수 없는지 알고 싶을 때는

canScrollVertically 메서드를 사용하면 된다.

 

 

수평/수직스크롤이 끝이 도달했는지 알려주는 메서드

 

스크롤이 최상단에 도달했는지 알고 싶다면 '-1'을 값으로 주고

최하단에 도달했는지 알고 싶다면 '1'을 값으로 주면 된다.

 

endless scroll을 이번에 공부하면서 몇몇 글들을 둘러보았는데

최하단에 도달했는지를 아이템의 인덱스와 개수를 더하는 등의 방법을 통해 구하는 코드들이 있었다.

일단 코드가 너무 복잡하고, 스마트폰 화면 크기에 따라 한 번에 볼 수 있는 아이템의 개수가 다르므로

문제가 될 거라고 생각했다. (실제로 시도해보진 않았다)

아무튼 구글에서 편하게 사용하라고 이렇게 메서드를 만들어 놓았으니 이걸 사용하면 된다.

 

 

 

1-6. 다른 최하단 도달 감지 방법 (2021.02.01 내용 추가)

약 한 달 전, 이 게시글을 작성할 때 나는 endless scroll에 대해 찾아보다가

최하단에 도달했음을 감지하는 여러 가지 방법을 보게 되었고

비교적 옛날 글에서 복잡한 방법을 사용하길래 "아 이때는 canScrollVerically 메서드가 없었나 보다"라고 생각했다.

그렇게 1-5를 작성했었는데 오늘 오픈 채팅방에서 이런 대화를 보게 됐다.

 

 

"저 조건을 달성했다고 하단이라는 보장은 안되죠"라는 말이 아직 잘 이해가 안 가긴 한다.

하단에 도달하면 호출되는 메서드인데 무언가 예외 상황이 있는 걸까??

일단 저분 말대로 '데이터의 마지막 아이템이 화면에 뿌려졌는지'를 이용해 보기로 했다.

 

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

                val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager?)!!.findLastCompletelyVisibleItemPosition() // 화면에 보이는 마지막 아이템의 position
                val itemTotalCount = recyclerView.adapter!!.itemCount - 1 // 어댑터에 등록된 아이템의 총 개수 -1
                
                // 스크롤이 끝에 도달했는지 확인
                if (lastVisibleItemPosition == itemTotalCount) {

                }
            }
        })

lastVisibleItemPosition을 로그로 찍어보면 스크롤을 내리는 순간순간마다 보이는

맨 마지막 아이템의 position을 알 수 있다.

예를 들어 아이템이 총 10개라면 6.. 7.. 8.. 이렇게 찍히다가

맨 마지막 아이템이 비로소 모습을 다 드러내면 9가 찍히는 것이다.

이를 통해 스크롤이 최하단에 도달했는지 알 수 있다.

 

 

두 가지 방법을 동시에 사용해보았다.

position이 계속 찍히는 것이 lastVisibleItemPosition을 사용한 방법이고

끝에 도달했어요 메시지를 띄운 것이 canScrollVertically 메서드를 사용한 것이다.

결론부터 말하자면 난 둘 다 정상적으로 작동한다.

하지만 조심해서 나쁠 것 없기 때문에 if문에서 두 조건 다 검사를 해주기로 했다.

 

    public boolean canScrollVertically(int direction) {
        final int offset = computeVerticalScrollOffset();
        final int range = computeVerticalScrollRange() - computeVerticalScrollExtent();
        if (range == 0) return false;
        if (direction < 0) {
            return offset > 0;
        } else {
            return offset < range - 1;
        }
    }

궁금해서 canScrollVertically 메서드가 어떻게 생겼는지 찾아보았다.

흠.. 스크롤의 범위랑.. 이것저것 계산해서 알아내는 거 같은데

대체 사이드 이펙트가 생기는 상황은 무엇일까??

알아내면 바로 내용을 또 추가할 예정이다.

 

 

 

2. Adapter

2-1. 전체 소스

class NoticeAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    private val VIEW_TYPE_ITEM = 0
    private val VIEW_TYPE_LOADING = 1
    private val items = ArrayList<Content>()

    // 아이템뷰에 게시물이 들어가는 경우
    inner class NoticeViewHolder(private val binding: ItemNoticeBinding):RecyclerView.ViewHolder(binding.root){
        fun bind(notice: Content){
            binding.tvTitle.text = notice.title
            binding.tvDate.text = notice.created.substring(0, 10)
        }
    }
    
    // 아이템뷰에 프로그레스바가 들어가는 경우
    inner class LoadingViewHolder(private val binding: ItemLoadingBinding) :
        RecyclerView.ViewHolder(binding.root) {

    }

    // 뷰의 타입을 정해주는 곳이다.
    override fun getItemViewType(position: Int): Int {
        // 게시물과 프로그레스바 아이템뷰를 구분할 기준이 필요하다.
        return when (items[position].title) {
            " " -> VIEW_TYPE_LOADING
            else -> VIEW_TYPE_ITEM
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) : RecyclerView.ViewHolder {
        return when (viewType) {
            VIEW_TYPE_ITEM -> {
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = ItemNoticeBinding.inflate(layoutInflater, parent, false)
                NoticeViewHolder(binding)
            }
            else -> {
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = ItemLoadingBinding.inflate(layoutInflater, parent, false)
                LoadingViewHolder(binding)
            }
        }
    }

    override fun getItemCount(): Int {
        return items.size
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if(holder is NoticeViewHolder){
            holder.bind(items[position])
        }else{

        }
    }

    fun setList(notice: MutableList<Content>) {
        items.addAll(notice)
        items.add(Content(" ", " ")) // progress bar 넣을 자리
    }

    fun deleteLoading(){
        items.removeAt(items.lastIndex) // 로딩이 완료되면 프로그레스바를 지움
    }
}

 

 

 

2-2. 코드 설명

    private val VIEW_TYPE_ITEM = 0
    private val VIEW_TYPE_LOADING = 1

아이템 뷰의 타입을 2가지로 나누기 위해 임의로 0과 1을 변수에 저장해 주었다.

 

 

    // 아이템뷰에 게시물이 들어가는 경우
    inner class NoticeViewHolder(private val binding: ItemNoticeBinding):RecyclerView.ViewHolder(binding.root){
        fun bind(notice: Content){
            binding.tvTitle.text = notice.title
            binding.tvDate.text = notice.created.substring(0, 10)
        }
    }
    
    // 아이템뷰에 프로그레스바가 들어가는 경우
    inner class LoadingViewHolder(private val binding: ItemLoadingBinding) :
        RecyclerView.ViewHolder(binding.root) {

    }

리사이클러뷰 사용할 때랑 똑같이

홀더 안에서 아이템 뷰 내에 있는 뷰들을 변수에 저장하는 작업을 한다.

다만 2개의 홀더를 정의해줄 뿐이다.

 

 

    // 뷰의 타입을 정해주는 곳이다.
    override fun getItemViewType(position: Int): Int {
        // 게시물과 프로그레스바 아이템뷰를 구분할 기준이 필요하다.
        return when (items[position].title) {
            " " -> VIEW_TYPE_LOADING
            else -> VIEW_TYPE_ITEM
        }
    }

홀더 1개짜리 리사이클러뷰를 만들 때는 사용할 필요가 없는 메서드이다.

왜냐하면 이 메서드는 뷰의 타입을 구분하기 위해 사용되기 때문이다.

뷰를 구분하는 방법은 정하기 나름이다.

나는 공지사항 10개를 넣은 뒤 맨 마지막으로 progressbar를 넣기위해

마지막에 비어있는 아이템을 하나 더 넣었다. (공지사항 10개 + 빈 아이템 1개)

그래서 위와 같이 뷰 타입을 구분해주었다.

 

 

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) : RecyclerView.ViewHolder {
        return when (viewType) {
            VIEW_TYPE_ITEM -> {
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = ItemNoticeBinding.inflate(layoutInflater, parent, false)
                NoticeViewHolder(binding)
            }
            else -> {
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = ItemLoadingBinding.inflate(layoutInflater, parent, false)
                LoadingViewHolder(binding)
            }
        }
    }

getItemViewType에서 리턴한 값이 onCreateViewHolder의 viewType 변수로 들어오게 된다.

그러면 이제 아이템 뷰의 타입에 따라 홀더를 리턴하면 된다.

 

item_notice은 게시물 아이템 뷰를 어떻게 보여줄지 구성한 xml 파일이고

item_loading은 프로그레스 바 아이템 뷰를 어떻게 보여줄지 구성한 xml 파일이다.

 

 

3. Activity

3-1. 전체 소스

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var model: MainViewModel
    private lateinit var noticeAdapter: NoticeAdapter
    private var page = 1 // 현재 페이지

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        model = ViewModelProvider(this).get(MainViewModel::class.java)

        model.loadBaeminNotice(page)

        binding.rvBaeminNotice.apply {
            binding.rvBaeminNotice.layoutManager = LinearLayoutManager(context)
            noticeAdapter = NoticeAdapter()
            binding.rvBaeminNotice.adapter = noticeAdapter
        }

        model.getAll().observe(this, Observer{
            noticeAdapter.setList(it.content)
            
            // 한 페이지당 게시물이 10개씩 들어있음.
            // 새로운 게시물이 추가되었다는 것을 알려줌 (추가된 부분만 새로고침)
            noticeAdapter.notifyItemRangeInserted((page - 1) * 10, 10)
        })

        // 스크롤 리스너
        binding.rvBaeminNotice.addOnScrollListener(object : RecyclerView.OnScrollListener(){
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)

                val lastVisibleItemPosition =
                    (recyclerView.layoutManager as LinearLayoutManager?)!!.findLastCompletelyVisibleItemPosition()
                val itemTotalCount = recyclerView.adapter!!.itemCount-1

                // 스크롤이 끝에 도달했는지 확인
                if (!binding.rvBaeminNotice.canScrollVertically(1) && lastVisibleItemPosition == itemTotalCount) {
                    noticeAdapter.deleteLoading()
                    model.loadBaeminNotice(++page)
                }
            }
        })
    }
}

 

 

3-2. 코드 설명

        model.getAll().observe(this, Observer{
            noticeAdapter.setList(it.content)
            
            // 한 페이지당 게시물이 10개씩 들어있음.
            // 새로운 게시물이 추가되었다는 것을 알려줌 (추가된 부분만 새로고침)
            noticeAdapter.notifyItemRangeInserted((page - 1) * 10, 10)
        })

LiveData를 사용하면 페이지 요청이 끝났을 때 이 부분이 자동으로 호출된다.

setList 메서드를 이용해 새로 가져온 공지사항 목록을 설정한다.

 

notifyDataSetChanged()는 전체 목록을 새로고침 하는 메서드이다.

즉, 어댑터에게 리사이클러뷰를 처음부터 끝까지 다시 새로 그리라고 명령하는 메서드이다.

notifyItemRangeInserted()는 인덱스의 범위를 알려주면서

"여기서부터 여기까지 새로운 값을 추가했으니 거기만 새로 그려"라고 명령하는 메서드이다.

 

귀찮다면 전체 새로고침을 해도 되지만

굳이 새로고침 하지 않아도 되는 부분을 다시 그리는 건 불필요하다.

notifyItemRangeInserted를 사용하는 것이 바람직 할 것이다.

 

 

        // 스크롤 리스너
        binding.rvBaeminNotice.addOnScrollListener(object : RecyclerView.OnScrollListener(){
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)

                val lastVisibleItemPosition =
                    (recyclerView.layoutManager as LinearLayoutManager?)!!.findLastCompletelyVisibleItemPosition()
                val itemTotalCount = recyclerView.adapter!!.itemCount-1

                // 스크롤이 끝에 도달했는지 확인
                if (!binding.rvBaeminNotice.canScrollVertically(1) && lastVisibleItemPosition == itemTotalCount) {
                    noticeAdapter.deleteLoading()
                    model.loadBaeminNotice(++page)
                }
            }
        })

이제 스크롤 리스너를 등록해준다.

1-5에서 설명했듯이 스크롤이 끝에 도달했는지 알아내기 위해 canScrollVertically를 사용했다.

 

지금부터 infinite/endless scroll의 핵심이라 할 수 있다.

우선 스크롤이 끝에 다 도착했다면 이제 progress bar는 필요 없으므로 삭제를 해준다.

리스트의 마지막 인덱스를 삭제해주면 된다.

(deleteLoading 메서드로 구현해두었다)

 

그다음 다음 페이지 공지사항을 요청하면

요청이 끝났을 때 결과를 가지고 다시 Observer가 이를 감지하게 된다.

 

 

4. 예제 다운로드

 

juhwankim-dev/SelfStudy

코틀린으로 공부한 것들을 올리는 공간입니다. Contribute to juhwankim-dev/SelfStudy development by creating an account on GitHub.

github.com

배달의 민족 공지사항을 가져와서 infinite scroll로 다음 페이지를 보여주는 예제이다.

 

 


 

 

▼ 당겨서 새로고침도 배워보면 어떨까요? ▼

 

 

SwipeRefreshLayout :: 당겨서 새로고침 하기

1. SwipeRefreshLayout란? 2. xml 소스 코드 3. activity 소스 코드 1. SwipeRefreshLayout란? 앱에서 게시물 목록을 당겨서 새로고침 할 때 사용하는 것이 바로 SwipeRefreshLayout이다. 화면을 당기면 빙글빙..

todaycode.tistory.com

 

▼ viewPager 무한 스크롤도 배워봐요! ▼

 

 

[Kotlin] 뷰페이저2 활용 예제 : tabLayout, indicator, fragment, 자동 스크롤, 무한스크롤, 배너 등

0. 시작하기 앞서.. 1. viewPager 활용  1-1. Indicator와 같이 사용  1-2. Fragment와 같이 사용  1-3. tabLayout과 같이 사용 2. 광고 배너 만들기  2-1. 현재 배너 위치 표시하기  2-2. 무한 뷰페이저  ..

todaycode.tistory.com

 

 


 

 

저도 오늘 처음 공부한 내용이라 부족한 부분이 있을 수 있습니다.

의견이나 궁금하신 점 댓글 남겨주세요! :D

 

참고한 링크

 

Android RecyclerView Load More, Endless Scrolling - JournalDev

Android RecyclerView Load More and Endless Scrolling example tutorial. How to create infinite scrolling RecyclerView items in android app example.

www.journaldev.com

 

반응형

댓글