package com.yourdomain.app.ui
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.Gravity
import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
/**
* Drop this inside your XML layout.
* Note on Frost/Blur: Pure Android views cannot do dynamic background blur natively
* before Android 12 without killing performance.
* Wrap this entire view in 'com.github.Dimezis:BlurView' in your XML to get that frosted glass.
*/
class AnimatedBottomNav @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val pills = mutableListOf<NavPill>()
private var activeIndex = 1
init {
orientation = HORIZONTAL
gravity = Gravity.CENTER
// The translucent pill container background
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dpToPx(32f)
setColor(Color.parseColor("#992A2A2A")) // 60% opacity dark gray
setStroke(dpToPx(1f).toInt(), Color.parseColor("#1AFFFFFF")) // Subtle rim light
}
setPadding(dpToPx(8f).toInt(), dpToPx(8f).toInt(), dpToPx(8f).toInt(), dpToPx(8f).toInt())
}
fun addTabs(icons: List<Int>, defaultIndex: Int = 1) {
activeIndex = defaultIndex
icons.forEachIndexed { index, iconRes ->
val pill = NavPill(context).apply {
setIcon(iconRes)
setOnClickListener { selectTab(index) }
}
pills.add(pill)
addView(pill)
pill.setActive(index == activeIndex, animate = false)
}
}
private fun selectTab(index: Int) {
if (index == activeIndex) return
pills[activeIndex].setActive(false, animate = true)
activeIndex = index
pills[activeIndex].setActive(true, animate = true)
}
private fun dpToPx(dp: Float): Float = dp * context.resources.displayMetrics.density
}
/**
* The individual pill that handles its own spring animations.
*/
private class NavPill(context: Context) : FrameLayout(context) {
private val iconView = ImageView(context)
// Colors
private val activeBgColor = Color.parseColor("#26FFFFFF") // 15% White
private val inactiveBgColor = Color.TRANSPARENT
private val activeIconColor = Color.WHITE
private val inactiveIconColor = Color.parseColor("#9CA3AF") // Gray
// Sizes
private val activeWidth = dpToPx(80f).toInt()
private val inactiveWidth = dpToPx(48f).toInt()
private val height = dpToPx(48f).toInt()
private val pillBackground = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dpToPx(24f)
setColor(inactiveBgColor)
}
init {
layoutParams = LinearLayout.LayoutParams(inactiveWidth, height).apply {
marginEnd = dpToPx(4f).toInt()
marginStart = dpToPx(4f).toInt()
}
background = pillBackground
iconView.layoutParams = LayoutParams(dpToPx(24f).toInt(), dpToPx(24f).toInt()).apply {
gravity = Gravity.CENTER
}
iconView.imageTintList = ColorStateList.valueOf(inactiveIconColor)
addView(iconView)
}
fun setIcon(resId: Int) {
iconView.setImageResource(resId)
}
fun setActive(isActive: Boolean, animate: Boolean) {
val targetWidth = if (isActive) activeWidth else inactiveWidth
val targetBgColor = if (isActive) activeBgColor else inactiveBgColor
val targetIconColor = if (isActive) activeIconColor else inactiveIconColor
if (!animate) {
layoutParams.width = targetWidth
requestLayout()
pillBackground.setColor(targetBgColor)
iconView.imageTintList = ColorStateList.valueOf(targetIconColor)
return
}
// 1. Spring Animation for Width
ValueAnimator.ofInt(layoutParams.width, targetWidth).apply {
duration = 350
interpolator = OvershootInterpolator(1.2f) // Bouncy!
addUpdateListener { anim ->
layoutParams.width = anim.animatedValue as Int
requestLayout()
}
start()
}
// 2. Crossfade for Background Color
ValueAnimator.ofObject(ArgbEvaluator(), (pillBackground.color?.defaultColor ?: inactiveBgColor), targetBgColor).apply {
duration = 300
addUpdateListener { anim -> pillBackground.setColor(anim.animatedValue as Int) }
start()
}
// 3. Crossfade for Icon Color
ValueAnimator.ofObject(ArgbEvaluator(), iconView.imageTintList?.defaultColor ?: inactiveIconColor, targetIconColor).apply {
duration = 300
addUpdateListener { anim -> iconView.imageTintList = ColorStateList.valueOf(anim.animatedValue as Int) }
start()
}
}
private fun dpToPx(dp: Float): Float = dp * context.resources.displayMetrics.density
}⚠️Content was pasted as plain text and auto-formatted as a code block. Use the Code Block button in the editor for proper formatting.