androidkotlincanvasandroid-jetpack-composebottom-navigation-bar

How to create a Custom BottomNavigationAppBar in jetpack compose?


I am trying to create a custom Bottomnavigationbar (see image). I have created a normal bottomappbar but I have encountered 2 problems:

  1. I can't seem to get the bell curve the correct symmetry, as seen in the video. It also does "cut" out the parts on left and right side for some reason.
  2. How do I create the animation for it to change logo/icon when swapping between Icons?

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)
    )
}

Solution

    1. The glitchy corners in your animation happen when one of the rounded corners overlaps with an edge of the circle cutout. I solved it by reducing the size of the rounded corners when such overlap occurs or removing it completely when the cutout's edge goes off-screen. See BarShape for the code.
    2. That depends what kind of animation you need. If it is a simple fade or scale than you can use AnimatedContent like i did. If you want a complex shape morphing than there is AnimatedVectorDrawable.

    screen record

    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,
    )