zagori / BottomNavBar

A custom BottomNavigationView with a FloatingActionButton
Apache License 2.0
44 stars 9 forks source link

The FAB is not centered correctly #4

Open Gelembjuk opened 1 year ago

Gelembjuk commented 1 year ago

Hi, The lib is nice. But i had no success with using it. For some reason the center button is not aligned correctly

Screenshot 2023-02-10 at 20 42 56 Screenshot 2023-02-10 at 20 43 07

For now i tested only with one of my emulators.

My code is

` <com.zagori.bottomnavbar.BottomNavBar android:id="@+id/bottom_nav_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom"

    app:bn_background_color="@color/colorCommentBackground"
    app:bn_item_color="@color/colorPrimaryDark"
    app:bn_menu="@menu/main_bottom_nav_menu"

    app:bn_curve_vertical_offset="0dp"
    app:bn_curve_margin="6dp"
    app:bn_curve_rounded_corner_radius="8dp"

    app:bn_fab_size="normal"
    app:bn_fab_menu_index="2"
    app:bn_fab_background_color="@color/colorPrimary"
    app:bn_fab_icon_color="@android:color/white"/>`

How can i make it to work?

cliangtime commented 11 months ago

you can use it

import android.content.Context
import android.content.res.Resources.NotFoundException
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.ViewGroup
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import androidx.annotation.ColorInt
import androidx.annotation.IdRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ViewCompat
import androidx.core.view.size
import com.google.android.material.bottomnavigation.BottomNavigationItemView
import com.google.android.material.bottomnavigation.BottomNavigationMenuView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
import com.mymro.rail.pda.R
import com.mymro.rail.pda.databinding.BottomNavBarBinding

class BottomNavBar(context: Context, attrs: AttributeSet?) : CoordinatorLayout(context, attrs) {

    private val binding by lazy { BottomNavBarBinding.inflate(LayoutInflater.from(getContext())) }

    private var onBottomNavigationListener: OnBottomNavigationListener? = null

    /**
     * Sets callback for bottom nav menu item selection.
     * @param onBottomNavigationListener bottomNavigation bar callback
     */
    fun setBottomNavigationListener(onBottomNavigationListener: OnBottomNavigationListener?) {
        this.onBottomNavigationListener = onBottomNavigationListener
    }

    /**
     * Interface definition for a callback to be invoked when user select a menu item on the bottomNav bar
     */
    interface OnBottomNavigationListener {
        /**
         * Fires when user select BottomNav menu item
         *
         * @param menuItem menuItem selected by user
         */
        fun onNavigationItemSelected(menuItem: MenuItem?): Boolean
    }

    companion object {
        private val TAG = BottomNavBar::class.java.simpleName
    }

    init {
//        inflate(getContext(), R.layout.bottom_nav_bar, this)
        addView(
            binding.root,
            LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
        )
        // initialize the bottom nav and fab views
//        bottomNavigationView = findViewById(R.id.nav_view)
//        fab = findViewById(R.id.fab)
        val styleAttrs = context.theme.obtainStyledAttributes(attrs, R.styleable.BottomNavBar, 0, 0)
        val curveMargin = styleAttrs.getDimension(R.styleable.BottomNavBar_bn_curve_margin, 0f)
        val roundedCornerRadius =
            styleAttrs.getDimension(R.styleable.BottomNavBar_bn_curve_rounded_corner_radius, 0f)
        val cradleVerticalOffset =
            styleAttrs.getDimension(R.styleable.BottomNavBar_bn_curve_vertical_offset, 0f)
        @ColorInt val backgroundColor = styleAttrs.getColor(
            R.styleable.BottomNavBar_bn_background_color,
            binding.navView.solidColor
        ) // binding.navView.getItemBackground()
        @IdRes val menuResID = styleAttrs.getResourceId(R.styleable.BottomNavBar_bn_menu, -1)
        val fabMenuIndex = styleAttrs.getInt(R.styleable.BottomNavBar_bn_fab_menu_index, -1)
        val menuItemColorState =
            styleAttrs.getColorStateList(R.styleable.BottomNavBar_bn_item_color)
        val fabBackgroundColor =
            styleAttrs.getColorStateList(R.styleable.BottomNavBar_bn_fab_background_color)
        val fabIconColor = styleAttrs.getColorStateList(R.styleable.BottomNavBar_bn_fab_icon_color)
        val fabDiameter: Float
        val fabSize: Int
        val bnFabSize = styleAttrs.getString(R.styleable.BottomNavBar_bn_fab_size)
        if (bnFabSize == null || bnFabSize.equals("normal", ignoreCase = true)) {
            // set fab param to default fab parameters
            fabDiameter = resources.getDimension(R.dimen.fab_size_normal)
            fabSize = FloatingActionButton.SIZE_NORMAL
        } else if (bnFabSize.equals("mini", ignoreCase = true)) {
            fabDiameter = resources.getDimension(R.dimen.fab_size_mini)
            fabSize = FloatingActionButton.SIZE_MINI
        } else {
            Log.e(TAG, "bnFabSize should be either 'mini' or 'normal'")
            throw IllegalArgumentException("bnFabSize '$bnFabSize' does not match any of the fab size values")
        }

        // check if no menu has been added to BottomNav view
        if (menuResID == -1) {
            Log.e(TAG, "menuResID should not be null.")
            throw NotFoundException("Menu must be added to BottomNav.")
        }

        // add the menu to the bottom-nav
        binding.navView.inflateMenu(menuResID)
        binding.navView.itemIconTintList = menuItemColorState
        binding.navView.itemTextColor = menuItemColorState

        // set listener on the bottomNavigationView
        binding.navView.setOnNavigationItemSelectedListener { item ->
            onBottomNavigationListener != null && onBottomNavigationListener!!.onNavigationItemSelected(
                item
            )
        }

        // set listener on the fab
        binding.fab.setOnClickListener {
            binding.navView.selectedItemId =
                binding.navView.menu.getItem(fabMenuIndex).itemId
        }

        // check if fab is not added
        if (fabMenuIndex == -1) {
            binding.fab.visibility = GONE
            Log.w(TAG, "No fab is added")
        }

        // check if fab position does not match any menu item position
        if (fabMenuIndex >= binding.navView.menu.size()) {
            binding.fab.visibility = GONE
            Log.e(
                TAG,
                "fabMenuIndex is out of bound. fabMenuIndex only accepts values from 0 to " + (binding.navView.menu.size() - 1)
            )
            throw IllegalArgumentException("fabMenuIndex does not match any menu item position")
        }

        // show fab
        binding.fab.visibility = VISIBLE

        // set fav size
        binding.fab.size = fabSize

        // set the fab background tint
        binding.fab.backgroundTintList = fabBackgroundColor

        // set the fab icon tint
        binding.fab.imageTintList = fabIconColor

        // get the icon from menu item and set it to the fab
        binding.fab.setImageDrawable(binding.navView.menu.getItem(fabMenuIndex).icon)

        // remove the icon from the item in the bottom-nav
        binding.navView.menu.getItem(fabMenuIndex).icon = null

        // get an instance of the 1st menu item, as we need it to
        val bottomNavigationMenuView = binding.navView.getChildAt(0) as BottomNavigationMenuView
        val bottomNavigationItemView =
            bottomNavigationMenuView.getChildAt(1) as BottomNavigationItemView
        binding.navView.viewTreeObserver.addOnGlobalLayoutListener(object :
            OnGlobalLayoutListener {
            override fun onGlobalLayout() {

                // kill the global layout listener so it does not keep calling the layout
                bottomNavigationItemView.viewTreeObserver.removeOnGlobalLayoutListener(this)

                // calculate the distance between the fab center and the bottom_nav's left edge
                val leftEdge =
                    (width - bottomNavigationItemView.measuredWidth * binding.navView.menu.size) / 2
                val fabMarginLeft =
                    leftEdge + fabMenuIndex * bottomNavigationItemView.measuredWidth + bottomNavigationItemView.measuredWidth / 2 - binding.fab.measuredWidth / 2

                //get the fab layout params
                val mp = binding.fab.layoutParams as MarginLayoutParams

                // set the fab margins
                mp.setMargins(fabMarginLeft, 0, 0, 0)

                // apply changes and initial the fab view
                binding.fab.requestLayout()

                val topEdgeTreatment = TopEdgeTreatment(
                    leftEdge,
                    bottomNavigationItemView.measuredWidth,
                    fabMenuIndex,
                    curveMargin,
                    roundedCornerRadius,
                    cradleVerticalOffset
                )
                topEdgeTreatment.setFabDiameter(fabDiameter)
                val materialShapeDrawable = MaterialShapeDrawable()
                val shapeAppearanceModel = materialShapeDrawable.shapeAppearanceModel
                    .toBuilder().setTopEdge(topEdgeTreatment).build()
                materialShapeDrawable.setTint(backgroundColor)
                materialShapeDrawable.shapeAppearanceModel = shapeAppearanceModel
                ViewCompat.setBackground(binding.navView, materialShapeDrawable)
            }
        })
    }
}

TopEdgeTreatment

import com.google.android.material.shape.EdgeTreatment
import com.google.android.material.shape.ShapePath
import kotlin.math.atan
import kotlin.math.sqrt

/**
 * This is a Top edge treatment for the bottom navigation bar, (customized from BottomAppBarTopEdgeTreatment.java)
 * which "cradles" a circular {@link FloatingActionButton}.
 *
 * <p>This edge features a downward semi-circular cutout from the edge line. The two corners created
 * by the cutout can optionally be rounded. The circular cutout can also support a vertically offset
 * FloatingActionButton; i.e., the cut-out need not be a perfect semi-circle, but could be an arc of
 * less than 180 degrees that does not start or finish with a vertical path. This vertical offset
 * must be positive.
 */

class TopEdgeTreatment(
    private val leftEdge: Int,
    private val menuItemWidth: Int,
    private val fabMenuIndex: Int,
    private val fabMargin: Float,
    private val roundedCornerRadius: Float,
    private val cradleVerticalOffset: Float
) : EdgeTreatment() {

    private var fabDiameter: Float = 0f
    //private val horizontalOffset: Float? = null

    init {
        if (cradleVerticalOffset < 0.0f) throw IllegalArgumentException("cradleVerticalOffset must be positive.")
    }

    override fun getEdgePath(
        length: Float,
        center: Float,
        interpolation: Float,
        shapePath: ShapePath
    ) {
        super.getEdgePath(length, center, interpolation, shapePath)

        if (fabDiameter == 0f) { // There is no cutout to draw
            shapePath.lineTo(length, 0f)
            return
        }

        val cradleDiameter: Float = fabMargin * 2.0f + fabDiameter
        val cradleRadius = cradleDiameter / 2.0f
        val roundedCornerOffset: Float = interpolation * this.roundedCornerRadius
        val fabPositionX: Float = (leftEdge + fabMenuIndex * menuItemWidth + menuItemWidth / 2).toFloat()
        //val middle: Float = center + horizontalOffset

        val verticalOffset: Float =
            interpolation * cradleVerticalOffset + (1.0f - 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, 0.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.

        // 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 distanceBetweenCenters = cradleRadius + roundedCornerOffset
        val distanceBetweenCentersSquared = distanceBetweenCenters * distanceBetweenCenters
        val distanceY = verticalOffset + roundedCornerOffset
        val distanceX = sqrt((distanceBetweenCentersSquared - distanceY * distanceY).toDouble())
            .toFloat()

        // Calculate the x position of the rounded corner circles.

        // Calculate the x position of the rounded corner circles.
        val leftRoundedCornerCircleX = fabPositionX - distanceX
        val rightRoundedCornerCircleX = fabPositionX + distanceX

        // Calculate the arc between the center of the two circles.

        // Calculate the arc between the center of the two circles.
        val cornerRadiusArcLength =
            Math.toDegrees(atan((distanceX / distanceY).toDouble())).toFloat()
        val cutoutArcOffset = 90.0f - cornerRadiusArcLength

        // Draw the starting line up to the left rounded corner.

        // Draw the starting line up to the left rounded corner.
        shapePath.lineTo(leftRoundedCornerCircleX - roundedCornerOffset, 0.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)`.

        // 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(
            leftRoundedCornerCircleX - roundedCornerOffset,
            0.0f,
            leftRoundedCornerCircleX + roundedCornerOffset,
            roundedCornerOffset * 2.0f,
            270.0f,
            cornerRadiusArcLength
        )

        // Draw the cutout circle.

        // Draw the cutout circle.
        shapePath.addArc(
            fabPositionX - cradleRadius,
            -cradleRadius - verticalOffset,
            fabPositionX + cradleRadius,
            cradleRadius - verticalOffset,
            180.0f - cutoutArcOffset,
            cutoutArcOffset * 2.0f - 180.0f
        )

        // 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)`.

        // 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(
            rightRoundedCornerCircleX - roundedCornerOffset,
            0.0f,
            rightRoundedCornerCircleX + roundedCornerOffset,
            roundedCornerOffset * 2.0f,
            270.0f - cornerRadiusArcLength,
            cornerRadiusArcLength
        )

        // Draw the ending line after the right rounded corner.

        // Draw the ending line after the right rounded corner.
        shapePath.lineTo(length, 0.0f)
    }

    /**
     * Returns current fab diameter in pixels.
     */
    fun getFabDiameter(): Float {
        return fabDiameter
    }

    /**
     * Sets the fab diameter the size of the fab in pixels.
     */
    fun setFabDiameter(fabDiameter: Float) {
        this.fabDiameter = fabDiameter
    }
}