Search code examples
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

  • The current version of 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 subclassed to alter as needed.

    I've added a few extra bells and whistles to this latest update, and created a repo for it to make access and updates a little easier for all of us.

    I've omitted the imports here to save on length. Android Studio should be able to resolve them all automatically with the dependencies included in any of the current View project templates, but if you have any trouble, the repo has the full class.

    class BadgedDrawerArrowDrawable(context: Context) : DrawerArrowDrawable(context) {
    
        var isBadgeEnabled: Boolean by invalidating(false, invalidateClip = true)
    
        sealed class BadgeSize {
            data object Standard : BadgeSize()
            data object Dot : BadgeSize()
            data class Custom(val size: Float) : BadgeSize()
        }
    
        var badgeSize: BadgeSize by invalidating(BadgeSize.Standard, true)
    
        @get:ColorInt
        @setparam:ColorInt
        var badgeColor: Int by invalidating(Color.RED)
    
        enum class Corner { TopLeft, TopRight, BottomRight, BottomLeft }
    
        var badgeCorner: Corner by invalidating(Corner.TopRight, true)
    
        var badgeOffset: PointF by invalidating(PointF(), true)
    
        var badgeClipMargin: Float by invalidating(0F, true)
    
        var badgeText: String? by invalidating(null)
    
        @get:ColorInt
        @setparam:ColorInt
        var badgeTextColor: Int by invalidating(Color.WHITE)
    
        var badgeTextOffset: PointF by invalidating(PointF())
    
        private fun <T> invalidating(initial: T, invalidateClip: Boolean = false) =
            Delegates.observable(initial) { _, old, new ->
                if (old == new) return@observable
                if (invalidateClip) isClipInvalidated = true
                invalidateSelf()
            }
    
        sealed class Animation(
            internal val endScale: Float? = null,
            internal val endRotation: Float? = null
        ) {
            data object None : Animation()
            data object Grow : Animation(1.5F, null)
            data object Shrink : Animation(0F, null)
            data object FullSpinCW : Animation(null, 360F)
            data object FullSpinCCW : Animation(null, -360F)
            data object HalfSpinCW : Animation(null, 180F)
            data object HalfSpinCCW : Animation(null, -180F)
    
            operator fun plus(other: Animation): Animation =
                CombinedAnimation(this, other)
    
            internal class CombinedAnimation(
                private val first: Animation,
                private val second: Animation
            ) : Animation(
                second.endScale ?: first.endScale,
                second.endRotation ?: first.endRotation
            ) {
                override val ss: String?
                    get() = second.run { endScale?.let { ss } } ?: first.ss
                override val rs: String?
                    get() = second.run { endRotation?.let { rs } } ?: first.rs
    
                override fun toString(): String = buildString {
                    ss?.let { append(it) }
                    if (ss != null && rs != null) append("+")
                    rs?.let { append(it) }
                }
            }
    
            internal open val ss: String? get() = toString()
            internal open val rs: String? get() = toString()
        }
    
        var badgeAnimation: Animation = Animation.None
            set(value) {
                if (field == value) return
                field = value
                calculateAnimation(progress)
            }
    
        var autoMirrorOnReverse: Boolean = false
    
        // This is figured at init so we don't have to hang on to the the Context.
        private val dotDiameter = DOT_DP * context.resources.displayMetrics.density
    
        val badgeDiameter: Float
            get() = when (val mode = badgeSize) {
                BadgeSize.Standard -> 3 * barThickness + 2 * gapSize
                BadgeSize.Dot -> dotDiameter
                is BadgeSize.Custom -> mode.size
            }
    
        override fun onBoundsChange(bounds: Rect) {
            super.onBoundsChange(bounds)
            isClipInvalidated = true
        }
    
        // NB: the super class exposes its own (synthetic) `paint` property.
        private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
            .apply { typeface = Typeface.DEFAULT_BOLD }
    
        override fun setAlpha(alpha: Int) {
            super.setAlpha(alpha)
            paint.alpha = alpha
        }
    
        override fun setColorFilter(colorFilter: ColorFilter?) {
            super.setColorFilter(colorFilter)
            paint.setColorFilter(colorFilter)
        }
    
        override fun draw(canvas: Canvas) {
            val centerX = bounds.centerX() + when (badgeCorner) {
                Corner.TopLeft, Corner.BottomLeft -> -barLength / 2F
                else -> barLength / 2F
            } + badgeOffset.x
            val centerY = bounds.centerY() + when (badgeCorner) {
                Corner.TopLeft, Corner.TopRight -> -(1.5F * barThickness + gapSize)
                else -> 1.5F * barThickness + gapSize
            } + badgeOffset.y
            val radius = badgeDiameter / 2F
    
            // The super class doesn't handle its vertical bounds correctly, so we
            // translate the super draw here, and offset the clip by the same below.
            canvas.withTranslation(0F, bounds.top.toFloat()) {
                when (val clip = calculateClipPath(centerX, centerY, radius)) {
                    null -> super.draw(canvas)
                    else -> {
                        val count = canvas.save()
                        clipOutPath(canvas, clip)
                        super.draw(canvas)
                        canvas.restoreToCount(count)
                    }
                }
            }
            if (isBadgeEnabled) drawBadge(canvas, paint, centerX, centerY, radius)
        }
    
        private var clipPath: Path? = null
    
        private var isClipInvalidated = false
    
        private fun calculateClipPath(
            centerX: Float,
            centerY: Float,
            radius: Float
        ): Path? {
            val path = when {
                isBadgeEnabled && badgeClipMargin > 0F -> {
                    val path = clipPath ?: Path()
                    when {
                        isClipInvalidated -> path.apply {
                            rewind()
                            val y = centerY - bounds.top  // 'cause of bug in super.
                            val scaledRadius = (radius + badgeClipMargin) * scale
                            addCircle(centerX, y, scaledRadius, Path.Direction.CW)
                            isClipInvalidated = false
                        }
                        else -> path
                    }
                }
                else -> null
            }
            return path.also { clipPath = it }
        }
    
        private fun drawBadge(
            canvas: Canvas,
            paint: Paint,
            centerX: Float,
            centerY: Float,
            radius: Float
        ) {
            paint.color = badgeColor
            canvas.drawCircle(centerX, centerY, radius * scale, paint)
    
            if (badgeSize == BadgeSize.Dot) return
            val text = badgeText.takeIf { !it.isNullOrBlank() } ?: return
    
            val count = canvas.save()
            canvas.rotate(rotation, centerX, centerY)
            canvas.scale(scale, scale, centerX, centerY)
    
            val textBounds = tmpRect
            paint.textSize = badgeDiameter * textSizeFactor(text.length)
            paint.getTextBounds(text, 0, text.length, textBounds)
    
            val textX = centerX - textBounds.width() / 2F - 1
            val textY = centerY + textBounds.height() / 2F - 1
            val offsetX = textX + badgeTextOffset.x
            val offsetY = textY + badgeTextOffset.y
            paint.color = badgeTextColor
            canvas.drawText(text, offsetX, offsetY, paint)
    
            canvas.restoreToCount(count)
        }
    
        private var scale = 1F
    
        private var rotation = 0F
    
        private var verticalMirror = false
    
        override fun setProgress(progress: Float) {
            if (autoMirrorOnReverse) when (progress) {
                1F -> setVerticalMirror(true)
                0F -> setVerticalMirror(false)
            }
            super.setProgress(progress)
            calculateAnimation(progress)
        }
    
        override fun setVerticalMirror(verticalMirror: Boolean) {
            super.setVerticalMirror(verticalMirror)
            this.verticalMirror = verticalMirror
        }
    
        private fun calculateAnimation(progress: Float) {
            val newScale = when (val end = badgeAnimation.endScale) {
                null -> 1F
                else -> lerp(1F, end, progress)
            }
            val newRotation = when (val end = badgeAnimation.endRotation) {
                null -> 0F
                else -> lerp(0F, end, progress) * if (verticalMirror) -1 else 1
            }
            if (scale != newScale || rotation != newRotation) invalidateSelf()
            if (scale != newScale) isClipInvalidated = true
            rotation = newRotation
            scale = newScale
        }
    
        companion object {
    
            private const val DOT_DP = 8
    
            // This really only handles lengths of 1, 2, or 3. Tweak as needed.
            private fun textSizeFactor(textLength: Int): Float =
                when (textLength) {
                    1 -> 0.75F
                    2 -> 0.6F
                    else -> 0.5F
                }
    
            private fun lerp(start: Float, end: Float, fraction: Float): Float =
                (1F - fraction) * start + fraction * end
        }
    
        private val tmpRect = Rect()
    }
    
    private fun clipOutPath(canvas: Canvas, path: Path) {
        if (Build.VERSION.SDK_INT >= 26) {
            CanvasVerificationHelper.clipOutPath(canvas, path)
        } else {
            @Suppress("DEPRECATION")
            canvas.clipPath(path, Region.Op.DIFFERENCE)
        }
    }
    
    @RequiresApi(26)
    private object CanvasVerificationHelper {
        @DoNotInline
        fun clipOutPath(canvas: Canvas, path: Path) {
            canvas.clipOutPath(path)
        }
    }
    

    The extra features are outlined on the repo, so I'm not going to repeat them here. They're all rather self-explanatory.

    As the OP noted 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 = "1"
                }
    
            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. Enabling it with default values for the rest will give you something like this:

    Default examples in both light and dark themes.