androidandroid-toolbarbadgenavigation-drawerandroid-navigationview

Add badge counter to hamburger navigation menu icon in Android


My question is the same as this question (which is not a duplicate of this question).

The only answer to that question does not work for me as, rather than changing the default hamburger icon to the left of the activity's title, it just adds an additional hamburger icon to the right of my activity's title.

So how do I actually get this:

Android hamburger icon with badge counter

I've been poking around at it all day, but have got nowhere.

I see that Toolbar has a setNavigationIcon(Drawable drawable) method. Ideally, I would like to use a layout (that contains the hamburger icon and the badge view) instead of a Drawable, but I'm not sure if/how this is achievable - or if there is a better way?

NB - This isn't a question about how to create the badge view. I have already created that and have implemented it on the nav menu items themselves. So I am now just needing to add a similar badge view to the default hamburger icon.


Solution

  • ActionBarDrawerToggle offers the setDrawerArrowDrawable() method as a means to customize the toggle icon. DrawerArrowDrawable is the class that provides that default icon, and it can be extended to alter as needed.

    The following is a basic example of the standard design with red backing and white text, by default. If you're looking for more bells and whistles, I put together this small project in order to demonstrate a few other features that are pretty simple to add given the available functionalities, and the extra niceties that Kotlin brings.

    import android.content.Context
    import android.graphics.Canvas
    import android.graphics.Color
    import android.graphics.Paint
    import android.graphics.Path
    import android.graphics.PointF
    import android.graphics.Rect
    import android.graphics.Typeface
    import android.graphics.drawable.Drawable
    import androidx.annotation.ColorInt
    import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
    import kotlin.properties.Delegates
    
    class BadgedDrawerArrowDrawable(context: Context) : DrawerArrowDrawable(context) {
    
        var isBadgeEnabled: Boolean by invalidating(false)
    
        @get:ColorInt
        @setparam:ColorInt
        var badgeColor: Int by invalidating(Color.RED)
    
        var badgeText: String? by invalidating(null) { text ->
            if (text.isNullOrBlank()) return@invalidating
            badgePaint.textSize = badgeDiameter * textSizeFactor(text)
            badgePaint.getTextBounds(text, 0, text.length, textBounds)
        }
    
        @get:ColorInt
        @setparam:ColorInt
        var badgeTextColor: Int by invalidating(Color.WHITE)
    
        private val badgeCenter = PointF()
    
        private val clipPath = Path()
    
        // This is figured at init so that we don't have to hang on to the Context.
        private val margin = MARGIN_DP * context.resources.displayMetrics.density
    
        override fun onBoundsChange(bounds: Rect) {
            // (cx, cy) here is the top-right corner of the hamburger.
            val cx = bounds.centerX() + barLength / 2F
            // The badge is the same height as the hamburger, so y-offset = -radius.
            val cy = bounds.centerY() - badgeRadius
    
            badgeCenter.set(cx, cy)
    
            clipPath.rewind()
            clipPath.addCircle(cx, cy, badgeRadius + margin, Path.Direction.CW)
        }
    
        // NB: the super class exposes a (synthetic) `paint` property.
        private val badgePaint =
            Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.DEFAULT_BOLD }
    
        private val textBounds = Rect()
    
        override fun draw(canvas: Canvas) {
            if (isBadgeEnabled) {
                val count = canvas.save()
                canvas.clipOutPath(clipPath)
                super.draw(canvas)
                canvas.restoreToCount(count)
    
                val paint = badgePaint
                val center = badgeCenter
    
                paint.color = badgeColor
                canvas.drawCircle(center.x, center.y, badgeRadius, paint)
    
                val text = badgeText.takeIf { !it.isNullOrBlank() } ?: return
    
                val textX = center.x - textBounds.exactCenterX()
                val textY = center.y - textBounds.exactCenterY()
    
                paint.color = badgeTextColor
                canvas.drawText(text, textX, textY, paint)
            } else {
                super.draw(canvas)
            }
        }
    
        // The same height as the hamburger.
        private val badgeDiameter get() = 3F * barThickness + 2 * gapSize
    
        private val badgeRadius get() = badgeDiameter / 2F
    }
    
    private fun <T> Drawable.invalidating(
        initial: T,
        onChange: ((new: T) -> Unit)? = null
    ) = Delegates.observable(initial) { _, old, new ->
        if (old == new) return@observable
        onChange?.invoke(new)
        invalidateSelf()
    }
    
    // These are simply values that looked OK for numbers of length 1..3.
    private fun textSizeFactor(text: String) = when (text.length) {
        1 -> 0.75F; 2 -> 0.6F; else -> 0.5F
    }
    
    private const val MARGIN_DP = 2
    

    If your minSdk is less than 26, you'll need to wrap the clipOutPath() call with a version check if-else, and use clipPath(clipPath, Region.Op.DIFFERENCE) instead for the older ones.

    As OP had noted in the comments below, the Context used to instantiate BadgedDrawerArrowDrawable should be obtained with ActionBar#getThemedContext() or Toolbar#getContext() to ensure that the correct theme values are used. For example:

    class ExampleActivity : AppCompatActivity() {
    
        private lateinit var toggle: ActionBarDrawerToggle
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            val ui = ActivityExampleBinding.inflate(layoutInflater)
            setContentView(ui.root)
    
            supportActionBar?.setDisplayHomeAsUpEnabled(true)
    
            toggle = ActionBarDrawerToggle(
                this,
                ui.drawerLayout,
                R.string.opened,
                R.string.closed
            )
    
            val context = supportActionBar?.themedContext ?: this
            toggle.drawerArrowDrawable =
                BadgedDrawerArrowDrawable(context).apply {
                    isBadgeEnabled = true
                    badgeText = "99+"
                }
    
            ui.drawerLayout.addDrawerListener(toggle)
        }
    
        override fun onPostResume() {
            super.onPostResume()
            toggle.syncState()
        }
    
        override fun onOptionsItemSelected(item: MenuItem): Boolean {
            if (toggle.onOptionsItemSelected(item)) return true
            return super.onOptionsItemSelected(item)
        }
    }
    

    Do note that the badge is disabled by default. Initializing the drawable like is shown above will give you something like:

    Default examples in both light and dark themes.