[안드로이드] RecyclerView로 StickyScrollView만들기

dada·2022년 8월 26일
5

Android

목록 보기
16/16
post-thumbnail
post-custom-banner

✅문제 상황

  • spoonfeed를 리팩토링하면서 RecyclerView가 NestedScrollView안에 중첩되어 있을 경우, ViewHolder가 재활용되지 않는다는 문제점을 알게되었고, 문제의 원인/해결방안을 포스팅했었습니다. 이후 바통 1차 릴리즈 이후 아이템이 많아지면 로딩이 버벅거리는 현상이 있었습니다.

  • 문제의 원인을 고민했는데, 해당 화면을 StickyScrollView(amarjain07:StickyScrollView라이브러리 사용) 안에 RecyclerView를 중첩해서 구현했었던 게 떠올랐습니다. 혹시 해당 라이브러리가 NestedScrollView를 상속받아 만든 CustomView라서 한번에 ViewHolder를 그리느라 버벅거리나..?🤔라는 생각이 들었습니다. 확인해보니 제가 사용한 라이브러리는 정말 NestedScrollView의 하위 클래스로 구현되었고, 이 때문에 아이템 개수만큼 ViewHolder를 한번에 Create하고 있었습니다😲

  • 현재 item수가 많지 않아서 서비스 사용엔 문제가 없었지만 이후 item수가 많아지면 사용자 관점에서 로딩 속도에 불편함을 느낄 것 같아서 문제를 해결하기로 했습니다!

✅마주한 어려움과 고민한 방법

  • 마주한 어려움1) RecyclerView의 높이를 고정dp로 주고 테스트 해봤었습니다. ViewHolder 재활용 매커니즘은 동작했지만 ScrollView를 사용했을때와 마찬가지로 중첩 스크롤 문제가 발생했습니다.

  • 마주한 어려움2) 위 해결 방법을 살짝 바꿔서 동적으로 recyclerview의 높이값을 알아내서 뷰의 높이값을 지정하면 문제가 해결되지 않을까?라고 생각했고, NestedScrollView의 height값을 알아낸 후 동적으로 RecyclerView의 height값을 NestedScrollView의 height값으로 지정해 실행해보았습니다. 그 결과 ViewHolder가 재활용되지만 고정 dp를 주었을때처럼 중첩 스크롤 문제는 해결되지 않았습니다.

  • 그래서 아예 외부 라이브러리를 걷어내고 RecyclerView 컴포넌트를 응용하는 방법을 생각했습니다.

  • 일단 RecyclerView의 ViewType을 여러개 만들거나, ConcatAdapter를 사용할 수 있겠다고 생각했습니다.

✔ ConcatAdapter에서 Header역할 Adapter를 커스텀해보자!

  • Top(헤더뷰의 상단뷰), Header(화면 상단에 고정될 헤더뷰), Bottom(헤더뷰의 하단뷰) 의 역할을 하는 View들을 FirstAdapter, SecondAdapter, ThirdAdapter로 나눈 후 ConcatAdapter로 병합하고 SecondAdapter가 화면 상단으로 올때 콜백을 보내도록 구현하는 방식을 생각해봤습니다. 하지만 SecondAdapter가 상단에 고정될 때 ThirdAdapter는 스크롤 되기 위해선 RecyclerView.Adater자체를 커스텀해야했습니다. RecyclerView, LayoutManager와 함께 동작하는 RecyclerView.Adapter 내부 구조를 모두 이해하고 응용하기엔 한계가 있겠다고 생각했습니다

✔ ViewType을 나누고 itemDecoration을 커스텀해보자!

  • view들을 type별로 나눠 그린 후 현재 포지션에 해당하는 view가 헤더가 필요한 뷰인지 아닌지를 판단해서 만약 헤더가 필요한 뷰라면 현재 뷰 위에 header를 그리는 방식을 생각했습니다. 즉 실제로 헤더뷰가 최상단에 달라붙는게 아니라 현재 뷰 위에 헤더뷰를 중첩해서 그리는 눈속임 방법을 이용하는 것입니다!

    1. RecyclerView를 여러 ViewType으로 나누고 헤더뷰 역할을하는 itemView을 얻은 후
    1. 현재 RecyclerView의 최상단 포지션에 해당하는 view가 헤더가 필요한 뷰라면 위에서 얻은 itemView를 그린다
    1. 헤더가 필요하지 않은 뷰라면 그리지 않는다
  • 일단 RecyclerView위에 새로운 view를 그리는 것이기 때문에 itemDecoration을 커스텀해보기로 했습니다 막연히 itemDecoration의 onDraw()로 itemView를 RecyclerView의 캔버스 위에 그리면 가능할 것 같았지만 canvas, view에 대한 깊은 지식이 없었서 막막했습니다. 비슷하게 구현한 소스를 검색해본 결과 해당 프로젝트에서 itemDecoration의 onDraw()메소드를 사용해 header역할의 view를 itemView 위에 그리는 방식으로 적용한 걸 발견했습니다!! 해당 소스를 참고해 문제를 해결해보기로 했습니다.

✅1. 라이브러리를 걷어내고 ViewType을 나누자

  • 가장먼저 NestedScrollView의 하위 클래스로 구현된 라이브러리를 사용하지 않고 해당 화면을 여러 ViewType을 가진 RecyclerView로 구현해야합니다.

  • 고정영역인 Appbar 아래부터 상단에 고정되어야할 view인 Header를 기준으로 ViewType을 나눴습니다. Top(헤더뷰의 상단뷰), Header(화면 상단에 고정될 헤더뷰), Bottom(헤더뷰의 하단뷰) 으로 view를 만들었습니다

  • 중첩된 RecyclerView가 viewHolder를 생성, 재활용하는 부분을 로그로 남겨보니 원하던대로 viewHolder가 재활용되어 onCreateViewHolder가 호출되는 횟수가 감소되었습니다. itemView가 그려지는 속도도 매우 빨라졌습니다

  • 하지만 저는 Header부분이 위에 달라붙는 StickyScrollView를 구현하고 싶었기 때문에 ItemDecoration을 커스텀하는 단계가 남아있습니다!!

✅2. ItemDecoration 커스텀

  • ItemDecoration이 해줘야 하는 일은 아래와 같습니다

  • 현재 RecyclerView에서 맨 위에 보이는 view를 얻는다

  • 얻은 view의 position을 얻는다

  • position에 해당하는 view가 헤더가 필요한 뷰인지 아닌지를 판단한다

  • 헤더가 필요한 뷰라면 헤더뷰를 그린다

  • 필요하지 않은 뷰라면 그리지 않는다

👉 RecyclerView의 Adapter로부터 헤더뷰 가져오기

  • 가장먼저 ItemDecoration은 Adapter에게 현재 최상단 position을 전달해서 그에맞는 itemView를 얻어와야 합니다
    interface SectionCallback {
        fun getHeaderLayoutView(list: RecyclerView, position: Int): View? //position주면 View반환하기 위함
    }
  • 이를 위해 getHeaderLayoutView 역할을 하는 인터페이스를 ItemDecoration 클래스 안에 만들어줬습니다. 인터페이스로 만든 이유는 ItemDecoration이 Adapter에 직접 접근하는걸 막기 위해서입니다

👉 ItemDecoration 구현


class StickyHeaderItemDecoration(
    private val sectionCallback: SectionCallback //SectionCallback 상속한 익명객체를 아이템데코레이션 만들떄 넣기
) : RecyclerView.ItemDecoration() {


    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
         super.onDraw(c, parent, state)

        //현재 맨 위에 있는 view를 얻는다 현재 recyclerView에 보이는 첫번째 뷰
        val topChild = parent.getChildAt(0) ?: return

        //맨 위에있는 view의 position을 얻는다
        val topChildPosition = parent.getChildAdapterPosition(topChild)

        //현재 맨 위에 있는 position을 이용해서 itemView를 얻는다(data까지 bind된)
        //현재 맨위에 있는 position이 헤더가 필요한 뷰인지 아닌지에 따라서 다른 itemView를 넘겨준다
        val currentHeader = sectionCallback.getHeaderLayoutView(parent, topChildPosition) ?: return

        //data까지 bind된 itemView를 Measure, Layout하는 과정을 거친다
        fixLayoutSize(parent, currentHeader, topChild.measuredHeight)

        //실제로 itemView를 그린다
        currentHeader.draw(c)

    }

    //Measure->뷰그룹과 뷰의 요소들의 크기를 결정, 뷰그룹의 크기가 측정되면 자식뷰들의 크기도 함께 측정
    //Layout->measure 단계에서 측정한 사이즈를 이용해서 각 뷰그룹은 자식뷰의 위치를 결정하는 새로운 Top-down traversal 과정을 진행
    //Draw->실제로 뷰를 그리는 과정

    private fun fixLayoutSize(parent: ViewGroup, view: View, height: Int) {

        //  onMeasure() -> MeasureSpec: EXACTLY 1080, MeasureSpec: AT_MOST 1823
        val widthSpec = View.MeasureSpec.makeMeasureSpec( //부모뷰가 자식에게 전달되는 레이아웃 요구사항 캡슐화.자식에게 전달하기 위한 규격 만들기
            parent.width, //리사이클러뷰의 가로 사이즈
            View.MeasureSpec.EXACTLY //자식뷰의 크기와 관계없이 부모뷰의 제약크기에 걸린다 지식뷰가 mateparent,고정dp일시
        )

        val heightSpec = View.MeasureSpec.makeMeasureSpec(
            parent.height, //리사이클러뷰의 높이
            View.MeasureSpec.AT_MOST //wrap으로 두엇기에
        )

        val childWidth: Int = ViewGroup.getChildMeasureSpec(
            widthSpec, //부모부로부터 전달받는 규격
           0, //부모 뷰로부터 자식뷰 사이 패딩
            view.layoutParams.width //View 객체에서 측정 및 배치 방법을 상위 요소에 알리는 데 사용
        )
        val childHeight: Int = ViewGroup.getChildMeasureSpec(
            heightSpec, //부모부로부터 전달받는 규격
            0,  //부모 뷰로부터 자식뷰 사이 패딩
            view.layoutParams.height //View 객체에서 측정 및 배치 방법을 상위 요소에 알리는 데 사용
        )
        view.measure(childWidth, childHeight) //헤더 그리려고 view의 measure은 호출한 단계 자식뷰들에게 제공할 수 있는 제약정보를 파라미터로 넘김
        //부모와 상대적인 왼 / 위 / 오 = 1080 / 밑 = 192  ->뷰의 위치를 배치 좌표
        view.layout(0, 0, view.measuredWidth, view.measuredHeight) //뷰의 위치를 배치하기
    }

    interface SectionCallback {
        fun getHeaderLayoutView(list: RecyclerView, position: Int): View? //position주면 View반환하기 위함
    }
}
  • 저는 자식뷰인 헤더뷰 width를 match_parent, height를 wrap_content로 두었기 때문에 각 모드를 EXACTLY, AT_MOST 로 두었습니다 실제로 뷰를 그리는 draw()단계에서 currentHeader.draw(c) 를 호출해 구현했는데 혹시 잘못된 부분이 있다면 댓글 남겨주시면 감사하겠습니다🙇‍♀️

👉 getSectionCallback 구현


    private fun initView() {
   binding.rvHome.addItemDecoration(StickyHeaderItemDecoration(getSectionCallback()))
    }


    private fun getSectionCallback(): StickyHeaderItemDecoration.SectionCallback {
        return object : StickyHeaderItemDecoration.SectionCallback {

            override fun getHeaderLayoutView(list: RecyclerView, position: Int): View? {
                return recyclerView2Adapter.getHeaderView(list, position)
            }
        }
    }
  • SectionCallback 을 구현한 익명객체를 StickyHeaderItemDecoration 의 인자로 넘겨줌과 동시에 ItemDecoration으로 지정했습니다

👉 getHeaderView 구현

   lateinit  var binding:FragmentTestBinding
    fun getHeaderView(list: RecyclerView, position: Int): View? {
      binding= FragmentTestBinding.inflate(LayoutInflater.from(list.context), list, false)
        return if(currentList[position].type== HEADER || currentList[position].type== BOTTOM){

            fragmentManager.beginTransaction().replace(R.id.test_fcv,FilterChipFragment()).commit()
            binding.mtHomeTitle.visibility=View.GONE
            binding.rvHome.visibility=View.GONE
            binding.root

        } else {
            fragmentManager.beginTransaction().replace(R.id.test_fcv,BlankFragment()).commit()
            null
        }
    }
  • Adapter에서 getHeaderView 를 정의해줍니다. 저는 viewType이 HEADER, BOTTOM일때 FilterChipFragment를 호스팅한 view를 넘겨주고 viewType이 Header라면 BlankFragment를 호스팅한 view를 넘겨주도록 구현했습니다

  • 50개의 itemView를 그리기 위해 ViewHolder를 8번 생성하고 이후부터는 onViewRecycled가 호출되며 ViewHolder가 재활용됨을 확인할 수 있었습니다! 그리고 원하던 stickyScrollView를 구현할 수 있게 되었습니다😊

👉 아쉬운점

  • 현재 헤더뷰가 FragmentContainerView이기 때문에(FilterChipFragment를 호스팅해야함) viewType에 따라서 FilterChipFragment, BlankFragment를 replace하는 작업이 반복적으로 실행되고 있습니다

  • 해당 문제를 해결방식은 캔버스 위에 직접 View를 그려줘야 하는 작업이 불가피합니다. 즉 stickyscrollview 구현을 위해선 반드시 ItemDecoration을 커스텀하는 작업이 필요하고 이는 View가 그려지는 과정 등 CustomView에 대한 이해가 반드시 필요합니다.

  • 저는 이번 기회를 통해 CustomView를 처음 사용해보면서 View가 그려지는 원리와 CustomView위에 Fragment를 호스팅하며 마주친 여러 오류들 덕분에 FragmentManager에 대한 이해가 깊어졌습니다. 특히!! RecyclerView를 '잘' 사용하는 방법이 무엇인지 고민해본 계기가 되기도 했습니다.

  • 혹시 위 같은 방법보다 나은 방법이 있거나 궁금하신 점이 있으시다면 댓글 남겨주시면 감사하겠습니다!

profile
'왜?'라는 물음을 해결하며 마지막 개념까지 공부합니다✍
post-custom-banner

2개의 댓글

comment-user-thumbnail
2022년 9월 22일

잘 봤습니다!

1개의 답글