Hanbit the Developer
[Kotlin] Custom Bottom Navigation With Animation 본문
서론
먼저, 구현하게 될 내용은 다음과 같습니다.
위 영상을 보면, 아이템이 선택되면 파란색 배경이 다이다믹하게 움직입니다. 이번 글은 해당 Indicator를 중심으로 설명할 예정입니다 :)
(com.google.android.material.bottomnavigation와 androidx.navigation를 기준으로 구현했습니다.)
구현
IndicatorBottomNavigationView.kt
사실 이 글의 내용의 전부입니다. 개요는 다음과 같습니다.
- BottomNavigationView를 상속하는 IndicatorBottomNavigationView 클래스 생성
- startIndicatorAnimation() - indicator: RectF, paint: Paint를 이용한 애니메이션
- startIndicatorAnimation() 함수 배치
- cancelAnimator()를 구현하여 예외 처리
BottomNavigationView를 상속하는 IndicatorBottomNavigationView 클래스 생성
class IndicatorBottomNavigationView: BottomNavigationView {
private var animator: ValueAnimator? = null
private val indicator = RectF()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.primary)
}
private val View.centerX get() = left + width / 2f
private val View.centerY get() = height / 2f
constructor(context: Context): super(context, null)
constructor(context: Context, attrs: AttributeSet?): super(context, attrs, com.google.android.material.R.attr.bottomNavigationStyle)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr, com.google.android.material.R.style.Widget_Design_BottomNavigationView)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int): super(context, attrs, defStyleAttr, defStyleRes)
}
여기서 [indicator: RectF]는 네모 박스를 나타내게 될 left, top, right, bottom 영역을 정의하게 되며, [paint: Paint]는 영역을 채우게 될 색상을 지정합니다.
startIndicatorAnimation() - indicator: RectF, paint: Paint를 이용한 애니메이션
private const val MAX_SCALE = 1.5f
private const val ANIMATION_DURATION = 300L
private const val INDICATOR_WIDTH = 170f
private const val INDICATOR_HEIGHT = 120f
먼저 상단에 애니메이션에 쓰일 상수들을 정의합니다.
(MAX_SCALE: 글의 도입부에 첨부한 영상에서 애니메이션이 진행될 때, 인디케이터 박스가 커졌다가 다시 줄어드는 것을 알 수 있습니다. 이 값이 1.5f이면, 기본 크기의 1.5배까지 커졌다가 다시 줄어든다는 의미입니다.)
fun startIndicatorAnimation(itemId: Int, animated: Boolean = true) {
if (!isLaidOut) return
val itemView = findViewById<View>(itemId) ?: return
val startCenterX = indicator.centerX()
val endCenterX = itemView.centerX
val startScale = indicator.width() / INDICATOR_WIDTH
if (startCenterX == endCenterX) return // 애니메이션 불필요
// nextX: startCenterX -> 선택된 아이템의 centerX
// indicatorWidth: width -> width * MAX_SCALE -> width
animator = ValueAnimator.ofFloat(startScale, MAX_SCALE, 1f).apply {
addUpdateListener {
val nextX = linearInterpolation(it.animatedFraction, startCenterX, endCenterX)
val indicatorWidth = INDICATOR_WIDTH * (it.animatedValue as Float)
val top = centerY - INDICATOR_HEIGHT / 2f
val bottom = centerY + INDICATOR_HEIGHT / 2f
val left = nextX - indicatorWidth / 2f
val right = nextX + indicatorWidth / 2f
indicator.set(left, top, right, bottom)
invalidate()
}
duration = if(animated) ANIMATION_DURATION else 0L
start()
}
}
// t == 0.3 -> return 0.7a + 0.3b
private fun linearInterpolation(t: Float, a: Float, b: Float) = (1 - t) * a + t * b
ValueAnimator.ofFloat() 부분이 핵심입니다.
먼저 구현할 애니메이션의 핵심은 두 파트입니다.
1. 인디케이터가 커졌다가 다시 작아진다.(scale: 1.0f -> 1.5f -> 1.0f)
2. 인디케이터가 이동한다.(x 좌표: 현재 좌표 -> 선택 메뉴에 대한 좌표)
이를 위해 먼저, animatedValue가 startScale(1.0f가 아닌 이유는, 이전 애니메이션이 끝나지 않았을 때 시작되는 경우를 처리하기 위함입니다.) -> MAX_SCALE -> 1.0으로 변하게 되는 ValueAnimator를 구성합니다. 이렇게 되면 인디케이터의 width를 INDICATOR_WIDTH*animatedValue를 통해 구할 수 있게 됩니다.
다음으로 ValueAnimator 내부에서 실시간으로 변하는 x 좌표를 얻어야 합니다. 우선 animatedFraction을 통해 애니메이션이 현재 얼마나 진행되었는지를 0~1 범위 내에서 얻을 수 있는데, 이것을 이용하면 됩니다. 여기서 animatedFraction이 0일 때 x 좌표는 startCenterX, 0.5일 때는 startCenterX*0.5 + endCenter*0.5, 1일 때는 endCenter이며, 이를 일반화하기 위한 함수인 linearInterpolation()를 작성하여 적용합니다. 예를 들어, animatedFraction이 0.3이고, startCenterX가 10, endCenterX가 20일 경우에는 13을 반환하게 됩니다.
마지막으로 [indicator: Rect]의 범위를 설정하고 indicator에 삽입해주면 됩니다.
이렇게 indicator, paint 값이 함수에 의해 변하게 되며, 이 값을 기준으로 캔버스에 그려야 합니다.
override fun dispatchDraw(canvas: Canvas) {
if(isLaidOut) {
val cornerRadius = indicator.height() / 2f
canvas.drawRoundRect(indicator, cornerRadius, cornerRadius, paint)
}
// indicator를 먼저 그린 뒤에 나머지 뷰를 그리게 됩니다.
super.dispatchDraw(canvas)
}
IndicatorBottomNavigationView 내부에 위 코드를 작성합니다. 이때 super.dispatchDraw(canvas) 함수가 먼저 나오면 인디케이터 박스가 menu item보다 위에 그려지므로 위 코드와 같은 순서를 지켜야 합니다.
*dispatchDraw() vs onDraw()
In general, if you're customizing the drawing of a single view, you should override the
onDraw() method. If you're customizing the drawing of a view group, you should override the
dispatchDraw() method.
ChatGPT에 따르면 dispatchDraw()는 ViewGroup이 자식 뷰를 그리는 함수(자식 뷰의 onDraw()를 호출), onDraw()는 View가 뷰를 그리는 함수입니다. BottomNavigationView -> NavigationBarView -> FrameLayout -> ViewGroup 순서로 클래스가 확장되고 있기 때문에 dispatchDraw()에서 뷰를 그리게 되므로 indicator를 해당 함수에서 그려야 합니다.
startIndicatorAnimation() 함수 배치
override fun onAttachedToWindow() {
super.onAttachedToWindow()
doOnPreDraw {
// 최초에는 애니메이션을 제거한 채로 이동시킵니다.
startIndicatorAnimation(selectedItemId, animated = false)
}
}
IndicatorBottomNavigationView 내부에 위 코드를 작성합니다.
navController.addOnDestinationChangedListener { _, _, _ ->
startIndicatorAnimation(selectedItemId)
}
또한 NavController를 얻은 뒤 위 코드를 작성합니다.
cancelAnimator()를 구현하여 예외 처리
private fun cancelAnimator(shouldAnimatorEnd: Boolean) = animator?.let {
if(shouldAnimatorEnd) it.end()
else it.cancel()
it.removeAllUpdateListeners()
animator = null
}
애니메이션을 완전히 끝내거나 취소하는 분기를 맡게 될 shouldAnimatorEnd를 인자로 받아 처리합니다.
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cancelAnimator(true)
}
IndicatorBottomNavigationView 내부에 위와 같이 두어 애니메이션이 완전히 끝나게 배치합니다.
fun startIndicatorAnimation(itemId: Int, animated: Boolean = true) {
if (!isLaidOut) return
cancelAnimator(false)
// ...
또한 위에서 작성한 startIndicatorAnimator() 함수 초반에 배치하여, 기존 애니메이션을 '취소'하고 그 지점에서 다시 애니메이션을 시작하게끔 합니다.
전체 코드
private const val MAX_SCALE = 1.5f
private const val ANIMATION_DURATION = 300L
private const val INDICATOR_WIDTH = 170f
private const val INDICATOR_HEIGHT = 120f
class IndicatorBottomNavigationView: BottomNavigationView {
private var animator: ValueAnimator? = null
private val indicator = RectF()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.primary_blue)
}
private val View.centerX get() = left + width / 2f
private val View.centerY get() = height / 2f
constructor(context: Context): super(context, null)
constructor(context: Context, attrs: AttributeSet?): super(context, attrs, com.google.android.material.R.attr.bottomNavigationStyle)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int): super(context, attrs, defStyleAttr, com.google.android.material.R.style.Widget_Design_BottomNavigationView)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int): super(context, attrs, defStyleAttr, defStyleRes)
override fun onAttachedToWindow() {
super.onAttachedToWindow()
doOnPreDraw {
// 최초에는 애니메이션을 제거한 채로 이동시킵니다.
startIndicatorAnimation(selectedItemId, animated = false)
}
}
override fun dispatchDraw(canvas: Canvas) {
if(isLaidOut) {
val cornerRadius = indicator.height() / 2f
canvas.drawRoundRect(indicator, cornerRadius, cornerRadius, paint)
}
// indicator를 먼저 그린 뒤에 나머지 뷰를 그리게 됩니다.
super.dispatchDraw(canvas)
}
fun startIndicatorAnimation(itemId: Int, animated: Boolean = true) {
if (!isLaidOut) return
cancelAnimator(false)
val itemView = findViewById<View>(itemId) ?: return
val startCenterX = indicator.centerX()
val endCenterX = itemView.centerX
val startScale = indicator.width() / INDICATOR_WIDTH
if (startCenterX == endCenterX) return // 애니메이션 불필요
// nextX: startCenterX -> 선택된 아이템의 centerX
// indicatorWidth: width -> width * MAX_SCALE -> width
animator = ValueAnimator.ofFloat(startScale, MAX_SCALE, 1f).apply {
addUpdateListener {
val nextX = linearInterpolation(it.animatedFraction, startCenterX, endCenterX)
val indicatorWidth = INDICATOR_WIDTH * (it.animatedValue as Float)
val top = centerY - INDICATOR_HEIGHT / 2f
val bottom = centerY + INDICATOR_HEIGHT / 2f
val left = nextX - indicatorWidth / 2f
val right = nextX + indicatorWidth / 2f
indicator.set(left, top, right, bottom)
invalidate()
}
duration = if(animated) ANIMATION_DURATION else 0L
start()
}
}
private fun cancelAnimator(shouldAnimatorEnd: Boolean) = animator?.let {
if(shouldAnimatorEnd) it.end()
else it.cancel()
it.removeAllUpdateListeners()
animator = null
}
// t == 0.3 -> return 0.7a + 0.3b
private fun linearInterpolation(t: Float, a: Float, b: Float) = (1 - t) * a + t * b
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cancelAnimator(true)
}
navController.addOnDestinationChangedListener { _, _, _ ->
startIndicatorAnimation(selectedItemId)
}
References
https://developer.android.com/reference/android/graphics/Canvas
https://developer.android.com/reference/android/graphics/RectF
https://developer.android.com/reference/android/graphics/Paint
https://developer.android.com/reference/android/animation/ValueAnimator
'Android' 카테고리의 다른 글
[Kotlin] ViewPager2에 custom indicator 적용하기(without TabLayout) (0) | 2022.08.06 |
---|---|
[Trouble shooting] TabLayout customView에 setTypeface를 주면 indicator의 값이 잠시 0으로 리셋되는 문제 (0) | 2022.08.02 |
[Kotlin] Android Custom Gallery Image Picker 만들기 (0) | 2022.07.23 |
[Kotlin] BottomNavigationView with multiple navigation (0) | 2022.07.19 |
Android AAR Library 추가 방법 (0) | 2022.07.07 |