Search code examples
androidbackground-imageshapesbottomnavigationview

Custom shape of bottom navigation view (Android)?


How to make the bottom navigation view to a specific shape?

I'd like to have a bottom navigation view of this shape:

Shape of my bottom nav view

I have tried setting it as background of my bottom nav view as:

<com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/navigationBottomView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="@drawable/bg_nav_bar"
        app:itemHorizontalTranslationEnabled="true"
        app:itemIconTint="@drawable/bottom_bar_selector"
        app:itemTextColor="@drawable/bottom_bar_selector"
        app:labelVisibilityMode="labeled"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:menu="@menu/nav_menu"/>

But it doesn't seem to work.

Any help will be appreciated. Thanks!


Solution

  • The BottomNavigationView by default has a background of MaterialShapeDrawable so you can change its shape using the ShapeAppearanceModel by defining a custom TopEdge EdgeTreatment to draw the half-circle above the BottomNavigationView. To be able to draw something above the BottomNavigationView you need to have a parent which has the below attributes:

    android:clipChildren="false"
    android:clipToPadding="false"
    android:paddingTop="35dp"
    

    An Xml sample will be like the below:

    <?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"
        android:background="@android:color/black">
    
        <RelativeLayout
            android:id="@+id/bottomNavigationViewParentRL"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:clipChildren="false"
            android:clipToPadding="false"
            android:paddingTop="35dp"
            android:background="@android:color/transparent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent">
    
            <com.google.android.material.bottomnavigation.BottomNavigationView
                android:id="@+id/bottomNavigationView"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:backgroundTint="@color/white"
                app:elevation="2dp"
                app:labelVisibilityMode="labeled"
                app:itemIconSize="25dp"
                app:itemIconTint="@color/item_icon_tint_selector"
                app:itemTextColor="@color/item_text_color_selector"
                app:menu="@menu/bottom_nav_menu" />
    
        </RelativeLayout>
    
        <fragment
            android:id="@+id/nav_host_fragment_activity_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toTopOf="@id/bottomNavigationViewParentRL"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/mobile_navigation" />
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    

    Then draw the shape like the below:

    val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottomNavigationView)
    val materialShapeDrawable = bottomNavigationView.getBackground() as MaterialShapeDrawable
    materialShapeDrawable.shapeAppearanceModel = materialShapeDrawable.shapeAppearanceModel
        .toBuilder()
        .setTopEdge(CutoutCircleEdgeTreatment(resources, 70.toFloat(), 10.toFloat()))
        .build()
    

    where CutoutCircleEdgeTreatment is a subclass of EdgeTreatment to draw the half-circle at the top which is similar code like the build-in BottomAppBarTopEdgeTreatment class which draws a semi-circular cutout from the top edge to bottom:

    class CutoutCircleEdgeTreatment(res: Resources, circleDiameterDp: Float, circleLeftRightOffsetDp: Float) : EdgeTreatment() {
    
        private val fabDiameter: Float
        private val offset: Float
    
        init {
            fabDiameter = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, circleDiameterDp, res.getDisplayMetrics())
            offset = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, circleLeftRightOffsetDp, res.getDisplayMetrics())
        }
    
        override fun getEdgePath(length: Float, center: Float, interpolation: Float, shapePath: ShapePath) {
            if (fabDiameter == 0f) {
                // There is no cutout to draw.
                shapePath.lineTo(length, 0f)
                return
            }
            val fabMargin = 0f
            val cradleDiameter = fabMargin * 2 + fabDiameter
            val cradleRadius = cradleDiameter / 2f
            val roundedCornerRadius = 0f
            val roundedCornerOffset = interpolation * roundedCornerRadius
            val horizontalOffset = 0f
            val middle = center + horizontalOffset
    
            // The center offset of the cutout tweens between the vertical offset when attached, and the
            // cradleRadius as it becomes detached.
            val cradleVerticalOffset = 0f
            val verticalOffset =
                interpolation * cradleVerticalOffset + (1 - interpolation) * cradleRadius
            val verticalOffsetRatio = verticalOffset / cradleRadius
            if (verticalOffsetRatio >= 1.0f) {
                // Vertical offset is so high that there's no curve to draw in the edge, i.e., the fab is
                // actually above the edge so just draw a straight line.
                shapePath.lineTo(length, 0f)
                return  // Early exit.
            }
    
            // Calculate the path of the cutout by calculating the location of two adjacent circles. One
            // circle is for the rounded corner. If the rounded corner circle radius is 0 the corner will
            // not be rounded. The other circle is the cutout.
    
            // Calculate the X distance between the center of the two adjacent circles using pythagorean
            // theorem.
            val fabCornerSize = -1f
            val cornerSize = fabCornerSize * interpolation
            val arcOffset = 0f
            val distanceBetweenCenters = cradleRadius + roundedCornerOffset
            val distanceBetweenCentersSquared = distanceBetweenCenters * distanceBetweenCenters
            val distanceY = verticalOffset + roundedCornerOffset
            val distanceX =
                Math.sqrt((distanceBetweenCentersSquared - distanceY * distanceY).toDouble())
                    .toFloat()
    
            // Calculate the x position of the rounded corner circles.
            val leftRoundedCornerCircleX = middle - distanceX
            val rightRoundedCornerCircleX = middle + distanceX
    
            // Calculate the arc between the center of the two circles.
            val cornerRadiusArcLength =
                Math.toDegrees(Math.atan((distanceX / distanceY).toDouble())).toFloat()
            val cutoutArcOffset = ARC_QUARTER - cornerRadiusArcLength + arcOffset
    
            // Draw the starting line up to the left rounded corner.
            shapePath.lineTo( /* x= */leftRoundedCornerCircleX, 0f)
    
            // Draw the arc for the left rounded corner circle. The bounding box is the area around the
            // circle's center which is at `(leftRoundedCornerCircleX, roundedCornerOffset)`.
            shapePath.addArc( /* left= */
                leftRoundedCornerCircleX - roundedCornerOffset, 0f,  /* right= */
                leftRoundedCornerCircleX + roundedCornerOffset,  /* bottom= */
                roundedCornerOffset * 2,  /* startAngle= */
                ANGLE_UP.toFloat(),  /* sweepAngle= */
                cornerRadiusArcLength
            )
    
            // Draw the cutout circle.
            shapePath.addArc( /* left= */
                middle - (cradleRadius + offset),  /* top= */
                -cradleRadius - verticalOffset,  /* right= */
                middle + (cradleRadius + offset),  /* bottom= */
                cradleRadius - verticalOffset,  /* startAngle= */
                ANGLE_LEFT - cutoutArcOffset,  /* sweepAngle= */
                cutoutArcOffset * 2 + ARC_HALF
            )
    
            // Draw an arc for the right rounded corner circle. The bounding box is the area around the
            // circle's center which is at `(rightRoundedCornerCircleX, roundedCornerOffset)`.
            shapePath.addArc( /* left= */
                rightRoundedCornerCircleX - roundedCornerOffset, 0f,  /* right= */
                rightRoundedCornerCircleX + roundedCornerOffset,  /* bottom= */
                roundedCornerOffset * 2,  /* startAngle= */
                ANGLE_UP - cornerRadiusArcLength,  /* sweepAngle= */
                cornerRadiusArcLength
            )
    
            // Draw the ending line after the right rounded corner.
            shapePath.lineTo( /* x= */length, 0f)
        }
    
        companion object {
            private const val ARC_QUARTER = 90
            private const val ARC_HALF = 180
            private const val ANGLE_UP = 270
            private const val ANGLE_LEFT = 180
        }
    }
    

    From the above CutoutCircleEdgeTreatment constructor you can pass the circleDiameterDp which is the circle diameter in dp value (in the above example is set to 70dp so the parent RelativeLayout it should have paddingTop equal to the radius of the Circle which is 70/2 = 35dp) and the circleLeftRightOffsetDp is used to draw the circle with a left/right offset in dp value. Of Course you can modify further the code based on your needs.

    Result:

    navigation_bar

    To overlap the semi circle with the fragment hosted

    To make the semi circle overlap with the fragment hosted you have to change the order of fragment:nav_host_fragment_activity_main with the RelativeLayout bottomNavigationViewParentRL like in the below sample:

    <?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"
        android:background="@android:color/black">
    
        <fragment
            android:id="@+id/nav_host_fragment_activity_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toTopOf="@id/bottomNavigationViewParentRL"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/mobile_navigation" />
    
        <RelativeLayout
            android:id="@+id/bottomNavigationViewParentRL"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:clipChildren="false"
            android:clipToPadding="false"
            android:paddingTop="35dp"
            android:background="@android:color/transparent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent">
    
            <com.google.android.material.bottomnavigation.BottomNavigationView
                android:id="@+id/bottomNavigationView"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:backgroundTint="@color/white"
                app:elevation="2dp"
                app:labelVisibilityMode="labeled"
                app:itemIconSize="25dp"
                app:itemIconTint="@color/item_icon_tint_selector"
                app:itemTextColor="@color/item_text_color_selector"
                app:menu="@menu/bottom_nav_menu" />
    
        </RelativeLayout>
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    

    And also give in each of your fragments some bottom margin with the same height of the navigation bar to start at the point of semi circle like in the below sample:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/transparent"
        tools:context=".ui.dashboard.DashboardFragment">
    
        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@android:color/holo_green_dark"
            android:layout_marginBottom="55dp">
    
            <TextView
                android:id="@+id/text_dashboard"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:layout_marginTop="8dp"
                android:layout_marginEnd="8dp"
                android:layout_marginBottom="25dp"
                android:textAlignment="center"
                android:textColor="@color/black"
                android:text="This is dashboard Fragment"
                android:textSize="20sp"
                android:layout_alignParentBottom="true"/>
    
        </RelativeLayout>
    
    </RelativeLayout>
    

    Result:

    overlap_navigation_bar

    Another variation of CutoutCircleEdgeTreatment

    class CutoutCircleEdgeTreatment(res: Resources, circleDiameterDp: Float, circleLeftRightOffsetDp: Float) : EdgeTreatment() {
    
        private val fabDiameter: Float
        private val offset: Float
    
        init {
            fabDiameter = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, circleDiameterDp, res.getDisplayMetrics())
            offset = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, circleLeftRightOffsetDp, res.getDisplayMetrics())
        }
    
        override fun getEdgePath(length: Float, center: Float, interpolation: Float, shapePath: ShapePath) {
            if (fabDiameter == 0f) {
                // There is no cutout to draw.
                shapePath.lineTo(length, 0f)
                return
            }
            val fabMargin = 0f
            val cradleDiameter = fabMargin * 2 + fabDiameter
            val cradleRadius = cradleDiameter / 2f
            val roundedCornerRadius = 0f
            val roundedCornerOffset = interpolation * roundedCornerRadius
            val horizontalOffset = 0f
            val middle = center + horizontalOffset
    
            // The center offset of the cutout tweens between the vertical offset when attached, and the
            // cradleRadius as it becomes detached.
            val cradleVerticalOffset = 0f
            val verticalOffset =
                interpolation * cradleVerticalOffset + (1 - interpolation) * cradleRadius
            val verticalOffsetRatio = verticalOffset / cradleRadius
            if (verticalOffsetRatio >= 1.0f) {
                // Vertical offset is so high that there's no curve to draw in the edge, i.e., the fab is
                // actually above the edge so just draw a straight line.
                shapePath.lineTo(length, 0f)
                return  // Early exit.
            }
    
            // Calculate the path of the cutout by calculating the location of two adjacent circles. One
            // circle is for the rounded corner. If the rounded corner circle radius is 0 the corner will
            // not be rounded. The other circle is the cutout.
    
            // Calculate the X distance between the center of the two adjacent circles using pythagorean
            // theorem.
            val fabCornerSize = -1f
            val cornerSize = fabCornerSize * interpolation
            val arcOffset = 0f
            val distanceBetweenCenters = cradleRadius + roundedCornerOffset
            val distanceBetweenCentersSquared = distanceBetweenCenters * distanceBetweenCenters
            val distanceY = verticalOffset + roundedCornerOffset
            val distanceX =
                Math.sqrt((distanceBetweenCentersSquared - distanceY * distanceY).toDouble())
                    .toFloat()
    
            // Calculate the x position of the rounded corner circles.
            val leftRoundedCornerCircleX = middle - distanceX
            val rightRoundedCornerCircleX = middle + distanceX
    
            // Calculate the arc between the center of the two circles.
            val cornerRadiusArcLength =
                Math.toDegrees(Math.atan((distanceX / distanceY).toDouble())).toFloat()
            val cutoutArcOffset = ARC_QUARTER - cornerRadiusArcLength + arcOffset
    
            // Draw the cutout circle.
            shapePath.addArc( /* left= */
                middle - (cradleRadius + offset),  /* top= */
                -cradleRadius - verticalOffset,  /* right= */
                middle + (cradleRadius + offset),  /* bottom= */
                (cradleRadius - verticalOffset) * 2,  /* startAngle= */
                ANGLE_LEFT + 20.0f,  /* sweepAngle= */
                ARC_HALF - 40.0f
            )
        }
    
        companion object {
            private const val ARC_QUARTER = 90
            private const val ARC_HALF = 180
            private const val ANGLE_UP = 270
            private const val ANGLE_LEFT = 180
        }
    }
    

    Usage:

    val materialShapeDrawable = bottomNavigationView.getBackground() as MaterialShapeDrawable
    materialShapeDrawable.shapeAppearanceModel = materialShapeDrawable.shapeAppearanceModel
        .toBuilder()
        .setTopEdge(CutoutCircleEdgeTreatment(resources, 70.toFloat(), 20.toFloat()))
        .build()
    

    Result:

    navigation_bar_change_v2