To get this working you need to extend the BottomNavigationView
to be able to have multiple listeners. The original implementation only allows for one but you need another to be able to tell the indicator when to move.
class CustomBottomNavigationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : BottomNavigationView(context, attrs, defStyleAttr), OnNavigationItemSelectedListener {
private val onNavigationItemSelectedListeners =
mutableListOf<OnNavigationItemSelectedListener>()
init {
super.setOnNavigationItemSelectedListener(this)
itemIconTintList = null
}
override fun setOnNavigationItemSelectedListener(listener: OnNavigationItemSelectedListener?) {
if (listener != null) addOnNavigationItemSelectedListener(listener)
}
fun addOnNavigationItemSelectedListener(listener: OnNavigationItemSelectedListener) {
onNavigationItemSelectedListeners.add(listener)
}
fun addOnNavigationItemSelectedListener(listener: (Int) -> Unit) {
addOnNavigationItemSelectedListener(OnNavigationItemSelectedListener {
for (i in 0 until menu.size()) if (menu.getItem(i) == it) listener(i)
false
})
}
override fun onNavigationItemSelected(item: MenuItem) = onNavigationItemSelectedListeners
.map { it.onNavigationItemSelected(item) }
.fold(false) { acc, it -> acc || it }
}
Then create an indicator item:
class BottomNavigationViewIndicator @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
val size = 8f
private val targetId: Int
private var target: BottomNavigationMenuView? = null
private var rect = Rect()
// val drawPaint = Paint()
private val backgroundDrawable: Drawable
private var index = 0
private var animator: AnimatorSet? = null
init {
if (attrs == null) {
targetId = NO_ID
backgroundDrawable = ColorDrawable(Color.TRANSPARENT)
} else {
with(context.obtainStyledAttributes(attrs, BottomNavigationViewIndicator)) {
targetId =
getResourceId(BottomNavigationViewIndicator_targetBottomNavigation, NO_ID)
val clippableId =
getResourceId(BottomNavigationViewIndicator_clippableBackground, NO_ID)
backgroundDrawable = if (clippableId != NO_ID) {
androidx.appcompat.content.res.AppCompatResources.getDrawable(
context,
clippableId
) ?: ColorDrawable(Color.TRANSPARENT)
} else {
ColorDrawable(
getColor(
BottomNavigationViewIndicator_clippableBackground,
Color.TRANSPARENT
)
)
}
recycle()
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (targetId == NO_ID) return attachedError("invalid target id $targetId, did you set the app:targetBottomNavigation attribute?")
val parentView =
parent as? View ?: return attachedError("Impossible to find the view using $parent")
val child = parentView.findViewById<View?>(targetId)
if (child !is CustomBottomNavigationView) return attachedError("Invalid view $child, the app:targetBottomNavigation has to be n ListenableBottomNavigationView")
for (i in 0 until child.childCount) {
val subView = child.getChildAt(i)
if (subView is BottomNavigationMenuView) target = subView
}
elevation = child.elevation
child.addOnNavigationItemSelectedListener { updateRectByIndex(it, true) }
post { updateRectByIndex(index, false) }
}
private fun attachedError(message: String) {
Log.e("BNVIndicator", message)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
target = null
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.clipRect(rect)
// canvas.drawCircle(size,size,size, drawPaint)
backgroundDrawable.draw(canvas)
}
private fun updateRectByIndex(index: Int, animated: Boolean) {
this.index = index
target?.apply {
if (childCount < 1 || index >= childCount) return
val reference = getChildAt(index)
val start = reference.left + left
val end = reference.right + left
backgroundDrawable.setBounds(left, top, right, bottom)
val newRect = Rect(start, 0, end, height)
if (animated) startUpdateRectAnimation(newRect) else updateRect(newRect)
}
}
private fun startUpdateRectAnimation(rect: Rect) {
animator?.cancel()
animator = AnimatorSet().also {
it.playTogether(
ofInt(this, "rectLeft", this.rect.left, rect.left),
ofInt(this, "rectRight", this.rect.right, rect.right),
ofInt(this, "rectTop", this.rect.top, rect.top),
ofInt(this, "rectBottom", this.rect.bottom, rect.bottom)
)
it.interpolator = FastOutSlowInInterpolator()
it.duration = resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
it.start()
}
}
private fun updateRect(rect: Rect) {
this.rect = rect
postInvalidate()
}
@Keep
fun setRectLeft(left: Int) = updateRect(rect.apply { this.left = left })
@Keep
fun setRectRight(right: Int) = updateRect(rect.apply { this.right = right })
@Keep
fun setRectTop(top: Int) = updateRect(rect.apply { this.top = top })
@Keep
fun setRectBottom(bottom: Int) = updateRect(rect.apply { this.bottom = bottom })
}
Put some attributes into attrs.xml
:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="BottomNavigationViewIndicator">
<attr name="targetBottomNavigation" format="reference"/>
<attr name="clippableBackground" format="reference|color"/>
</declare-styleable>
</resources>
Create a background drawable that'll be clipped as the indicator moves:
my_orange_background.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="24dp"
android:height="24dp"
android:viewportWidth="360"
android:viewportHeight="4">
<path
android:fillType="nonZero"
android:pathData="M0,0h359.899v3.933H0z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="360"
android:endY="2"
android:startX="0"
android:startY="2"
android:type="linear">
<item
android:color="@color/orange"
android:offset="0" />
<item
android:color="@color/orange"
android:offset="0.55" />
</gradient>
</aapt:attr>
</path>
</vector>
Create your activity/ fragment layout:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewpager"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintBottom_toTopOf="@+id/bottom_nav"
app:layout_constraintTop_toTopOf="parent" />
<com.example.app.view.CustomBottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:layout_marginEnd="0dp"
android:background="?attr/colorSurface"
app:labelVisibilityMode="unlabeled"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/menu_bottom_nav" />
<com.example.app.view.BottomNavigationViewIndicator
android:layout_width="0dp"
android:layout_height="4dp"
app:clippableBackground="@drawable/my_orange_background"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@+id/bottom_nav"
app:targetBottomNavigation="@+id/bottom_nav" />
</androidx.constraintlayout.widget.ConstraintLayout>
Sources: Medium article GitHub project