이번 포스팅에는 위와 같은 애니메이션 효과를 구현할 것이다. 위의 구글 캘린더 앱을 보면 왼쪽의 날짜와 요일이 좌측 상단에 고정되어 표시되고 있다. 같은 요일의 일정들이 있으면 좌측의 날짜는 고정이 되고 다음 요일로 지나가게 되면 위로 밀려 올라가는 방식이다.
프로젝트에서 위와 같은 애니메이션을 적용해야 했을 때 Sticky Header RecyclerView가 떠올랐다. 보통의 Sticky Header RecyclerView는 위의 사진과 같이 RecyclerView에서 위의 헤더를 고정시킬 때 많이 사용한다. 위와 같은 방식을 조금 응용하면 쉽게 구현할 수 있을 듯 했다.
Sticky Header RecyclerView는 RecyclerView에 ItemDecoration을 추가하는 방식으로 사용한다. RecyclerView의 어댑터와 뷰홀더를 통해 애니메이션 효과를 내는 것이 아니라 ItemDecoration의 onDrawOver() 함수로 RecyclerView의 위에 그리게 된다.
StickHeaderItemDecoration.kt
class StickyHeaderItemDecoration(private val sectionCallback: SectionCallback) : ItemDecoration() {
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val topChild = parent.getChildAt(0) ?: return
val topChildPosition = parent.getChildAdapterPosition(topChild)
if (topChildPosition == RecyclerView.NO_POSITION) {
return
}
/* 헤더 */
val currentHeader: View = sectionCallback.getHeaderLayoutView(parent, topChildPosition) ?: return
/* View의 레이아웃 설정 */
fixLayoutSize(parent, currentHeader, topChild.measuredHeight)
val contactPoint = currentHeader.bottom
val childInContact: View = getChildInContact(parent, contactPoint) ?: return
val childAdapterPosition = parent.getChildAdapterPosition(childInContact)
if (childAdapterPosition == -1)
return
when {
sectionCallback.isHeader(childAdapterPosition) ->
moveHeader(c, currentHeader, childInContact)
else ->
drawHeader(c, currentHeader)
}
}
private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? {
var childInContact: View? = null
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
if (child.bottom > contactPoint) {
if (child.top <= contactPoint) {
childInContact = child
break
}
}
}
return childInContact
}
private fun moveHeader(c: Canvas, currentHeader: View, nextHeader: View) {
c.save()
c.translate(0f, nextHeader.top - currentHeader.height.toFloat())
currentHeader.draw(c)
c.restore()
}
private fun drawHeader(c: Canvas, header: View) {
c.save()
c.translate(0f, 0f)
header.draw(c)
c.restore()
}
/**
* Measures the header view to make sure its size is greater than 0 and will be drawn
* https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations
*/
private fun fixLayoutSize(parent: ViewGroup, view: View, height: Int) {
val widthSpec = View.MeasureSpec.makeMeasureSpec(
parent.width,
View.MeasureSpec.EXACTLY
)
val heightSpec = View.MeasureSpec.makeMeasureSpec(
parent.height,
View.MeasureSpec.EXACTLY
)
val childWidth: Int = ViewGroup.getChildMeasureSpec(
widthSpec,
parent.paddingLeft + parent.paddingRight,
view.layoutParams.width
)
val childHeight: Int = ViewGroup.getChildMeasureSpec(
heightSpec,
parent.paddingTop + parent.paddingBottom,
height
)
view.measure(childWidth, childHeight)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
interface SectionCallback {
fun isHeader(position: Int): Boolean
fun getHeaderLayoutView(list: RecyclerView, position: Int): View?
}
}
애니메이션 효과를 주기 위해 ItemDecoration을 상속 받아 구현해준다. 앞서 설명한대로 onDrawOver() 함수를 통해 RecyclerView 위에 새로운 뷰를 그려준다.
뷰를 그려주기 위해선 Header가 고정되어 있는지, 움직여야 하는지를 판단할 함수가 필요하다. 또한 어떤 뷰를 그려줄지를 반환하는 함수도 필요하다. 이 두개의 함수는 SectionCallback 인터페이스를 통해 외부에서 주입 받도록 한다.
MainActivity.kt
class MainActivity : AppCompatActivity() {
private lateinit var adapter: StickyHeaderAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
adapter = StickyHeaderAdapter(events)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
recyclerView.addItemDecoration(StickyHeaderItemDecoration(getSectionCallback()))
}
private fun getSectionCallback(): StickyHeaderItemDecoration.SectionCallback {
return object : StickyHeaderItemDecoration.SectionCallback {
override fun isHeader(position: Int): Boolean {
return adapter.isHeader(position)
}
override fun getHeaderLayoutView(list: RecyclerView, position: Int): View? {
return adapter.getHeaderView(list, position)
}
}
}
/* sample */
private val events: List<Event>
get() {
val result = mutableListOf<Event>()
for (i in 0 until 100) {
result.add(Event(i))
}
return result
}
}
StickyHeaderAdapter.kt
class StickyHeaderAdapter(
private val items: List<Event>
) : RecyclerView.Adapter<StickyHeaderAdapter.StickyHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = StickyHeaderViewHolder(
Item1Binding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: StickyHeaderViewHolder, position: Int) {
holder.bind(items[position])
}
inner class StickyHeaderViewHolder(
private val binding: Item1Binding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(event: Event) {
binding.title.text = event.value.toString()
binding.date.text = "${event.date}월"
binding.date.visibility = if (event.isHeader) View.VISIBLE else View.GONE
}
}
fun isHeader(position: Int) = items[position].isHeader
fun getHeaderView(list: RecyclerView, position: Int): View? {
val item = items[position]
val binding = Item2Binding.inflate(LayoutInflater.from(list.context), list, false)
binding.date.text = "${item.date}월"
return binding.root
}
}
getHeaderView() 함수를 통해 RecyclerView 위에 그려줄 뷰를 반환해준다.
Event.kt
data class Event(val value: Int) {
val date: Int
get() = value / 10
val isHeader: Boolean
get() = value % 10 == 0
}
위의 gif를 보면 이 방법을 좀 더 명확하게 파악할 수가 있다. '2월'이 Header에 붙고 좀 더 위로 올리면 '2월'이 분신처럼 쪼개져서 위로 더 올라가는 것을 확인할 수 있다. 이 같은 현상은 RecyclerView에 그려진 뷰와 그 위에 새로 그려진 뷰가 겹쳐지기 때문에 나온다.
마지막으로 새로 그려진 뷰의 background 색을 흰색으로 적용하면 뒤에 있는 뷰를 가려주기 때문에 아래와 같은 결과를 얻을 수 있다.
최종 결과
예제 소스
https://github.com/tkdgusl94/blog-source/tree/master/StickyHeader
참고
'Android' 카테고리의 다른 글
[Android] Event Wrapper를 사용한 단일 이벤트 처리 (1) | 2021.06.20 |
---|---|
[Android] Kotlin으로 안드로이드 개발 시 테스트 하는 법 - MockK (0) | 2021.05.26 |
[Android] LeakCanary로 메모리릭 잡기 (0) | 2021.04.17 |
[Android] 문자열 단/복수 처리 - plurals (0) | 2021.03.28 |
[Android] Kotlin에서 LiveData의 null 허용 개선 - NonNullLiveData (2) | 2021.01.24 |