I am trying to create a custom Bottomnavigationbar (see image). I have created a normal bottomappbar but I have encountered 2 problems:
Appreciate any feedback!
My current custom BottomAppBar:
My Goal:
An example in use:
My code:
My Main animated bottomBar - contains "IndentedAnimation" which controls width and height of the bezier curve!
@Composable
fun MyCustomAnimatedBottomNavBar() {
var selectedItem by remember { mutableStateOf(0) }
var prevSelectedIndex by remember { mutableStateOf(0) }
AnimatedNavigationBar(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 60.dp)
.height(85.dp),
selectedIndex = selectedItem,
ballColor = Color.White,
cornerRadius = shapeCornerRadius(25.dp),
ballAnimation = Straight(
spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessVeryLow)
),
indentAnimation = StraightIndent(
indentWidth = 60.dp,
indentHeight = 25.dp,
animationSpec = tween(1000)
)
) {
colorButtons.forEachIndexed { index, it ->
ColorButton(
modifier = Modifier.fillMaxSize(),
prevSelectedIndex = prevSelectedIndex,
selectedIndex = selectedItem,
index = index,
onClick = {
prevSelectedIndex = selectedItem
selectedItem = index
},
icon = it.icon,
contentDescription = stringResource(id = it.description),
animationType = it.animationType,
background = it.animationType.background
)
}
}
}
IndentPatch - Controls the Bezier curve:
class IndentPath(
private val rect: Rect,
) {
private val maxX = 110f
private val maxY = 34f
private fun translate(x: Float, y: Float): PointF {
return PointF(
((x / maxX) * rect.width) + rect.left,
((y / maxY) * rect.height) + rect.top
)
}
fun createPath(): Path {
val start = translate(x = -90f, y = 0f) // Left corner
val middle = translate(x = 45f, y = 90f) // , Y = Depth of curve
val end = translate(x = 180f, y = 0f) // Right corner
val control1 = translate(x = 1f, y = 1f) // X1 and Y1
val control2 = translate(x = 6.62f, y = 85f) // X2 and Y2, Left bottom axis (X)
val control3 = translate(x = 130f, y = 85f)
val control4 = translate(x = 87f, y = 0f)
val path = Path()
path.moveTo(start.x, start.y)
path.cubicTo(control1.x, control1.y, control2.x, control2.y, middle.x, middle.y)
path.cubicTo(control3.x, control3.y, control4.x, control4.y, end.x, end.y)
return path
}
}
AnimatedNavigationbar:
/**
*A composable function that creates an animated navigation bar with a moving ball and indent
* to indicate the selected item.
*
*@param [modifier] Modifier to be applied to the navigation bar
*@param [selectedIndex] The index of the currently selected item
*@param [barColor] The color of the navigation bar
*@param [ballColor] The color of the moving ball
*@param [cornerRadius] The corner radius of the navigation bar
*@param [ballAnimation] The animation to be applied to the moving ball
*@param [indentAnimation] The animation to be applied to the navigation bar to indent selected item
*@param [content] The composable content of the navigation bar
*/
@Composable
fun AnimatedNavigationBar(
modifier: Modifier = Modifier,
selectedIndex: Int,
barColor: Color = Color.White,
ballColor: Color = Color.Black,
cornerRadius: ShapeCornerRadius = shapeCornerRadius(0f),
ballAnimation: BallAnimation = Parabolic(tween(300)),
indentAnimation: IndentAnimation = Height(tween(300)),
content: @Composable () -> Unit,
) {
var itemPositions by remember { mutableStateOf(listOf<Offset>()) }
val measurePolicy = animatedNavBarMeasurePolicy {
itemPositions = it.map { xCord ->
Offset(xCord, 0f)
}
}
val selectedItemOffset by remember(selectedIndex, itemPositions) {
derivedStateOf {
if (itemPositions.isNotEmpty()) itemPositions[selectedIndex] else Offset.Unspecified
}
}
val indentShape = indentAnimation.animateIndentShapeAsState(
shapeCornerRadius = cornerRadius,
targetOffset = selectedItemOffset
)
val ballAnimInfoState = ballAnimation.animateAsState(
targetOffset = selectedItemOffset,
)
Box(
modifier = modifier
) {
Layout(
modifier = Modifier
.graphicsLayer {
clip = true
shape = indentShape.value
}
.background(barColor),
content = content,
measurePolicy = measurePolicy
)
if (ballAnimInfoState.value.offset.isSpecified) {
ColorBall(
ballAnimInfo = ballAnimInfoState.value,
ballColor = ballColor,
sizeDp = ballSize
)
}
}
}
val ballSize = 52.dp
@Composable
private fun ColorBall(
modifier: Modifier = Modifier,
ballColor: Color,
ballAnimInfo: BallAnimInfo,
sizeDp: Dp,
) {
Box(
modifier = modifier
.ballTransform(ballAnimInfo)
.size(sizeDp)
.clip(shape = CircleShape)
.background(ballColor)
)
}
BarShape
for the code.AnimatedContent
like i did. If you want a complex shape morphing than there is AnimatedVectorDrawable
.AnimatedNavigationBar
data class ButtonData(val text: String, val icon: ImageVector)
@Composable
fun AnimatedNavigationBar(
buttons: List<ButtonData>,
barColor: Color,
circleColor: Color,
selectedColor: Color,
unselectedColor: Color,
) {
val circleRadius = 26.dp
var selectedItem by rememberSaveable { mutableIntStateOf(0) }
var barSize by remember { mutableStateOf(IntSize(0, 0)) }
// first item's center offset for Arrangement.SpaceAround
val offsetStep = remember(barSize) {
barSize.width.toFloat() / (buttons.size * 2)
}
val offset = remember(selectedItem, offsetStep) {
offsetStep + selectedItem * 2 * offsetStep
}
val circleRadiusPx = LocalDensity.current.run { circleRadius.toPx().toInt() }
val offsetTransition = updateTransition(offset, "offset transition")
val animation = spring<Float>(dampingRatio = 0.5f, stiffness = Spring.StiffnessVeryLow)
val cutoutOffset by offsetTransition.animateFloat(
transitionSpec = {
if (this.initialState == 0f) {
snap()
} else {
animation
}
},
label = "cutout offset"
) { it }
val circleOffset by offsetTransition.animateIntOffset(
transitionSpec = {
if (this.initialState == 0f) {
snap()
} else {
spring(animation.dampingRatio, animation.stiffness)
}
},
label = "circle offset"
) {
IntOffset(it.toInt() - circleRadiusPx, -circleRadiusPx)
}
val barShape = remember(cutoutOffset) {
BarShape(
offset = cutoutOffset,
circleRadius = circleRadius,
cornerRadius = 25.dp,
)
}
Box {
Circle(
modifier = Modifier
.offset { circleOffset }
// the circle should be above the bar for accessibility reasons
.zIndex(1f),
color = circleColor,
radius = circleRadius,
button = buttons[selectedItem],
iconColor = selectedColor,
)
Row(
modifier = Modifier
.onPlaced { barSize = it.size }
.graphicsLayer {
shape = barShape
clip = true
}
.fillMaxWidth()
.background(barColor),
horizontalArrangement = Arrangement.SpaceAround,
) {
buttons.forEachIndexed { index, button ->
val isSelected = index == selectedItem
NavigationBarItem(
selected = isSelected,
onClick = { selectedItem = index },
icon = {
val iconAlpha by animateFloatAsState(
targetValue = if (isSelected) 0f else 1f,
label = "Navbar item icon"
)
Icon(
imageVector = button.icon,
contentDescription = button.text,
modifier = Modifier.alpha(iconAlpha)
)
},
label = { Text(button.text) },
colors = NavigationBarItemDefaults.colors().copy(
selectedIconColor = selectedColor,
selectedTextColor = selectedColor,
unselectedIconColor = unselectedColor,
unselectedTextColor = unselectedColor,
selectedIndicatorColor = Color.Transparent,
)
)
}
}
}
}
BarShape
private class BarShape(
private val offset: Float,
private val circleRadius: Dp,
private val cornerRadius: Dp,
private val circleGap: Dp = 5.dp,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
return Outline.Generic(getPath(size, density))
}
private fun getPath(size: Size, density: Density): Path {
val cutoutCenterX = offset
val cutoutRadius = density.run { (circleRadius + circleGap).toPx() }
val cornerRadiusPx = density.run { cornerRadius.toPx() }
val cornerDiameter = cornerRadiusPx * 2
return Path().apply {
val cutoutEdgeOffset = cutoutRadius * 1.5f
val cutoutLeftX = cutoutCenterX - cutoutEdgeOffset
val cutoutRightX = cutoutCenterX + cutoutEdgeOffset
// bottom left
moveTo(x = 0F, y = size.height)
// top left
if (cutoutLeftX > 0) {
val realLeftCornerDiameter = if (cutoutLeftX >= cornerRadiusPx) {
// there is a space between rounded corner and cutout
cornerDiameter
} else {
// rounded corner and cutout overlap
cutoutLeftX * 2
}
arcTo(
rect = Rect(
left = 0f,
top = 0f,
right = realLeftCornerDiameter,
bottom = realLeftCornerDiameter
),
startAngleDegrees = 180.0f,
sweepAngleDegrees = 90.0f,
forceMoveTo = false
)
}
lineTo(cutoutLeftX, 0f)
// cutout
cubicTo(
x1 = cutoutCenterX - cutoutRadius,
y1 = 0f,
x2 = cutoutCenterX - cutoutRadius,
y2 = cutoutRadius,
x3 = cutoutCenterX,
y3 = cutoutRadius,
)
cubicTo(
x1 = cutoutCenterX + cutoutRadius,
y1 = cutoutRadius,
x2 = cutoutCenterX + cutoutRadius,
y2 = 0f,
x3 = cutoutRightX,
y3 = 0f,
)
// top right
if (cutoutRightX < size.width) {
val realRightCornerDiameter = if (cutoutRightX <= size.width - cornerRadiusPx) {
cornerDiameter
} else {
(size.width - cutoutRightX) * 2
}
arcTo(
rect = Rect(
left = size.width - realRightCornerDiameter,
top = 0f,
right = size.width,
bottom = realRightCornerDiameter
),
startAngleDegrees = -90.0f,
sweepAngleDegrees = 90.0f,
forceMoveTo = false
)
}
// bottom right
lineTo(x = size.width, y = size.height)
close()
}
}
}
Circle
@Composable
private fun Circle(
modifier: Modifier = Modifier,
color: Color = Color.White,
radius: Dp,
button: ButtonData,
iconColor: Color,
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(radius * 2)
.clip(CircleShape)
.background(color),
) {
AnimatedContent(
targetState = button.icon, label = "Bottom bar circle icon",
) { targetIcon ->
Icon(targetIcon, button.text, tint = iconColor)
}
}
}
Usage
val buttons = listOf(
ButtonData("Home", Icons.Default.Home),
ButtonData("History", Icons.Default.DateRange),
ButtonData("Profile", Icons.Default.Person),
ButtonData("Calendar", Icons.Default.DateRange),
ButtonData("Settings", Icons.Default.Settings),
)
AnimatedNavigationBar(
buttons = buttons,
barColor = Color.White,
circleColor = Color.White,
selectedColor = Color.Blue,
unselectedColor = Color.Gray,
)