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를 사용할 수 있겠다고 생각했습니다.
view들을 type별로 나눠 그린 후 현재 포지션에 해당하는 view가 헤더가 필요한 뷰인지 아닌지를 판단해서 만약 헤더가 필요한 뷰라면 현재 뷰 위에 header를 그리는 방식을 생각했습니다. 즉 실제로 헤더뷰가 최상단에 달라붙는게 아니라 현재 뷰 위에 헤더뷰를 중첩해서 그리는 눈속임 방법을 이용하는 것입니다!
일단 RecyclerView위에 새로운 view를 그리는 것이기 때문에 itemDecoration을 커스텀해보기로 했습니다 막연히 itemDecoration의 onDraw()로 itemView를 RecyclerView의 캔버스 위에 그리면 가능할 것 같았지만 canvas, view에 대한 깊은 지식이 없었서 막막했습니다. 비슷하게 구현한 소스를 검색해본 결과 해당 프로젝트에서 itemDecoration의 onDraw()메소드를 사용해 header역할의 view를 itemView 위에 그리는 방식으로 적용한 걸 발견했습니다!! 해당 소스를 참고해 문제를 해결해보기로 했습니다.
가장먼저 NestedScrollView의 하위 클래스로 구현된 라이브러리를 사용하지 않고 해당 화면을 여러 ViewType을 가진 RecyclerView로 구현해야합니다.
고정영역인 Appbar 아래부터 상단에 고정되어야할 view인 Header를 기준으로 ViewType을 나눴습니다. Top(헤더뷰의 상단뷰), Header(화면 상단에 고정될 헤더뷰), Bottom(헤더뷰의 하단뷰) 으로 view를 만들었습니다
중첩된 RecyclerView가 viewHolder를 생성, 재활용하는 부분을 로그로 남겨보니 원하던대로 viewHolder가 재활용되어 onCreateViewHolder가 호출되는 횟수가 감소되었습니다. itemView가 그려지는 속도도 매우 빨라졌습니다
하지만 저는 Header부분이 위에 달라붙는 StickyScrollView를 구현하고 싶었기 때문에 ItemDecoration을 커스텀하는 단계가 남아있습니다!!
ItemDecoration이 해줘야 하는 일은 아래와 같습니다
현재 RecyclerView에서 맨 위에 보이는 view를 얻는다
얻은 view의 position을 얻는다
position에 해당하는 view가 헤더가 필요한 뷰인지 아닌지를 판단한다
헤더가 필요한 뷰라면 헤더뷰를 그린다
필요하지 않은 뷰라면 그리지 않는다
interface SectionCallback {
fun getHeaderLayoutView(list: RecyclerView, position: Int): View? //position주면 View반환하기 위함
}
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반환하기 위함
}
}
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)
}
}
}
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
}
}
현재 헤더뷰가 FragmentContainerView이기 때문에(FilterChipFragment를 호스팅해야함) viewType에 따라서 FilterChipFragment, BlankFragment를 replace하는 작업이 반복적으로 실행되고 있습니다
해당 문제를 해결방식은 캔버스 위에 직접 View를 그려줘야 하는 작업이 불가피합니다. 즉 stickyscrollview 구현을 위해선 반드시 ItemDecoration을 커스텀하는 작업이 필요하고 이는 View가 그려지는 과정 등 CustomView에 대한 이해가 반드시 필요합니다.
저는 이번 기회를 통해 CustomView를 처음 사용해보면서 View가 그려지는 원리와 CustomView위에 Fragment를 호스팅하며 마주친 여러 오류들 덕분에 FragmentManager에 대한 이해가 깊어졌습니다. 특히!! RecyclerView를 '잘' 사용하는 방법이 무엇인지 고민해본 계기가 되기도 했습니다.
혹시 위 같은 방법보다 나은 방법이 있거나 궁금하신 점이 있으시다면 댓글 남겨주시면 감사하겠습니다!
잘 봤습니다!