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:
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.
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: