Hanbit the Developer

[Kotlin] Custom Bottom Navigation With Animation 본문

Mobile/Android

[Kotlin] Custom Bottom Navigation With Animation

hanbikan 2022. 8. 1. 18:09

서론

먼저, 구현하게 될 내용은 다음과 같습니다.

위 영상을 보면, 아이템이 선택되면 파란색 배경이 다이다믹하게 움직입니다. 이번 글은 해당 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

 

Canvas  |  Android Developers

android.net.wifi.hotspot2.omadm

developer.android.com

https://developer.android.com/reference/android/graphics/RectF

 

RectF  |  Android Developers

android.net.wifi.hotspot2.omadm

developer.android.com

https://developer.android.com/reference/android/graphics/Paint

 

Paint  |  Android Developers

android.net.wifi.hotspot2.omadm

developer.android.com

https://developer.android.com/reference/android/animation/ValueAnimator