Floating action button with badge counter
Or how to practice with measuring and drawing
Couple of weeks ago I’ve entered the new job in a new company. One of the first really big tasks was to make a chat feature. So part of that huge task was a creation of FAB with a badge on it, which would contain number of unread messages. Similar badges we see all the time. For example:
We already have floating button in Android. But it is just an ordinary ImageButton with some styles above.
There are plenty of libraries with super-charged, fully equipped fabs. At first glance: this and this. But non of them contains a badge feature, which was strange for me. And yet another confirmation of my previous talk about libraries and that they will not resolve all your problems.
Okay, I found one, but I would not recommend to use it is all situations. We will get to that little bit later.
We are too mature to make a ViewGroup 😉
“Phh” — you will said. Easy to solve that task. Just create an FrameLayout
with two children. One is google’s FAB, and other is just a TextView with round background. Easy peasy.
Here is a sample snippet how we can achieve this. Only two views in the layout and voila! Fab with badge is ready!
.
<?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:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:fabSize="normal"
app:backgroundTint="@color/colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:layout_width="18sp"
android:layout_height="18sp"
android:background="@drawable/bg_round"
android:elevation="6dp"
android:gravity="center"
android:text="9"
android:textColor="#fff"
android:textSize="10sp"
app:layout_constraintTop_toTopOf="@id/fab"
app:layout_constraintEnd_toEndOf="@id/fab"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Not so easy, guys. There are couple of problems, sneaking to you and waiting your commit to production build, to appear, when release will happen:
- Layouting. You need to adjust all measurements and layouting for “pretty” result. But it looks nice for now. When you need to have small fab button, you will find that badge is not looks nice more and you will need adjust layout again. Moreover, if you have, suppose big fab button, you will need to use
bias
attribute to adjust badge position, so it would look like on a top right arc of fab’s circle. It is hard and not so nice. Especially on huge amount of phone, with enormous screen size and text size settings. - Animations. You need to also remember, that fab has two methods
show
andhide
to provoke scale up and scale down animations. We need to make animations of hiding and showing badge as well. And it will be not just scale-up, scale-down stuff. Also, we will need to properly change the x and y position of counting badge. But most important, that we don’t know anything about hiding and showing FAB animation. Neither its time, nor its alpha changing. We can peep into the source code, but this solution will be not stable, as with other release animation can change, and we will need to support that and keep that in mind always. - Optimization. It will be not so optimal to provide every time 2 views instead of just one. It looks like, when you have a text with different typefaces and colors, you would use separate text view for each text style instead of one textview with different spans for different parts of text. It is easier maybe, but not so rational and pragmatic in terms or resource consumption. I wrote about good way to work with spannables and how you can do that nicely with Kotlin. Check it out here.
Uniting those two views into one layout and separating it into custom layout not help either. We will be able to customize positioning and animation of hiding and showing. But we will still have not so optimal solution. Moreover we will not be able to customize our fab through xml attributes, because now it inside some custom layout.
Make it right then!
We would be able to solve aforementioned problems, creating custom layout, but for me it is more easy to create a custom view, which will be inheritor of FloatingActionButton
.
The idea is very simple. We give a FloatingActionButton
and draw a circle with a text above it. Circle will depend on text size. Let’s name it CountableFloatingActionButton
since we will work only with counter badge. If you will need to create badge with custom text or drawings, you can use my solution as a basis and guide for your implementation.
Let’s start with defining two paints: one for text and one for badge background:
private val textPaint = TextPaint(ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.LEFT
}
private val tintPaint = Paint(ANTI_ALIAS_FLAG)
Okay this is simple. Now let’s define base metrics, the user will be able to setup and adjust:
var counterTextColor: Int
get() = textPaint.color
set(value) {
val was = textPaint.color
if (was != value) {
textPaint.color = value
invalidate()
}
}
var counterTint: Int
get() = tintPaint.color
set(value) {
val was = tintPaint.color
if (was != value) {
tintPaint.color = value
invalidate()
}
}
var counterTextSize: Float
get() = textPaint.textSize
set(value) {
val was = textPaint.textSize
if (was != value) {
textPaint.textSize = value
invalidate()
requestLayout()
}
}
var counterTypeface: Typeface?
get() = textPaint.typeface
set(value) {
val was = textPaint.typeface
if (was != value) {
textPaint.typeface = value
invalidate()
requestLayout()
}
}
var counterTextPadding: Float = 0f
set(value) {
if (field != value) {
field = value
invalidate()
requestLayout()
}
}
Each property has not any backing field (only last one has, because it is not just a handy setup for paint, but independent parameter). That’s why we not use was = field
construction. Also, we make additional check whether field was change or not, to not invalidating all just because.
Now we need to define two primary fields for our button: for counting and for max border of that counting:
var maxCount: Int = 9
set(@IntRange(from = 1) value) {
if (field != value) {
field = value
countMaxStr = "$value+"
requestLayout()
}
}
var count: Int = 0
set(@IntRange(from = 0) value) {
if (field != value) {
field = value
countStr = countStr(value)
textPaint.getTextBounds(countStr, counterTextBounds)
invalidate()
}
}private fun countStr(count: Int) = if (count > maxCount) "$maxCount+" else count.toString()
The point here is that we want to add maximum count. And if our count
will be higher, then we need to show only maxcount with symbol +
. For example, if max count will be 15, and count is 600, then we will just show 15+.
What interesting, when we update our count
value then we will just invoke invalidate
function to force redraw layout, whereas after updating an maxCount
we call only requestLayout
. That happens because maxCount
will be used to compute the badge circle size. Since every number from 0
to maxCount+
range will have less symbols than in maxCount
plus plus symbol (okay, if fact this does not mean that, suppose, width of 99999 will be less than width of 111111, because it totally depends on style and typeface, but we will ignore that for now, otherwise you will need have array of widths for all ten digits, which will be updated each time, when typeface will be changed, to have more proper results).
counterStr
and counterMaxStr
is just a secondary optional fields for slight optimizations. We will need those strings, and we just don’t want to create them each time during measuring/drawing processes.
Also, let’s define other auxiliary fields:
private var fabBounds: Rect = Rect()
private var counterBounds: RectF = RectF()
private var counterTextBounds: Rect = Rect()
private var counterMaxTextBounds: Rect = Rect()
private var counterPossibleCenter: PointF = PointF()
First one is a bounds of fab’s circle, second one is a bounds of badge circle, third one is a bounds of text inside that circle. Next field is a bounds of maximum text, that can be inside that circle. It will help to define the actual borders of circle. And the last one is a circle possible center.
In fact we need to provide ability to setup, where user wants to place badge. But in our project it is only needed to be on top right corner, so I ignored that setting.
So we want to make that badge appear on top right area with center on fab’s arc. But also, I want to not change size of fab view, so if badge size will be big enough and more, I just want to show if in a top right corner of view, even if its center will be not on fab’s arc.
Drawing
Okay, so now we need to describe 2 main processes of creating custom views: measuring and drawing. Let’s start with easier one. Suppose we measured our view and inside aforementioned fields we have proper info. Then our onDraw
method will be pretty simple:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (count > 0) {
canvas.drawCircle(counterBounds, tintPaint)
val textX = counterBounds.centerX() - counterTextBounds.width() / 2f - counterTextBounds.left
val textY = counterBounds.centerY() + counterTextBounds.height() / 2f - counterTextBounds.bottom
canvas.drawText(countStr, textX, textY, textPaint)
}
}fun Canvas.drawCircle(bounds: RectF, paint: Paint) =
drawCircle(bounds.centerX(), bounds.centerY(), min(bounds.width(), bounds.height()) / 2, paint)
Measuring
Okay then. Now we will talk about measuring:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
calculateCounterBounds(counterBounds)
}
So, what happens inside?
- We need to calculate badge’s possible center. We assume that idealy it will be placed on 45° in top right corner:
private fun calculateCounterCenter(radius: Float, outPoint: PointF) =
calculateCounterCenter(radius, (PI / 4).toFloat(), outPoint)
private fun calculateCounterCenter(radius: Float, angle: Float, outPoint: PointF) {
outPoint.x = radius * cos(angle)
outPoint.y = radius * sin(angle)
}
To receive the absolute possible center not relative, we need to transform outPoint
coordinates (inBounds
parameter is actually will be fabBounds
value):
private fun calculateCounterCenter(inBounds: Rect, outPoint: PointF) {
val radius = min(inBounds.width(), inBounds.height()) / 2f
calculateCounterCenter(radius, outPoint)
outPoint.x = inBounds.centerX() + outPoint.x
outPoint.y = inBounds.centerY() - outPoint.y
}
2. Calculate badge diameter:
textPaint.getTextBounds(countMaxStr, counterMaxTextBounds)
val counterDiameter = max(counterMaxTextBounds.width(), counterMaxTextBounds.height()) + 2 * counterTextPadding//where
fun Paint.getTextBounds(text: String, bounds: Rect) = getTextBounds(text, 0, text.length, bounds)
3. Adjust badge’s center if it’s diameter is too big:
val counterRight = min(counterPossibleCenter.x + counterDiameter / 2, fabBounds.right.toFloat())val counterTop = max(counterPossibleCenter.y - counterDiameter / 2, fabBounds.top.toFloat())
4. All together:
private fun calculateCounterBounds(outRect: RectF) {
getMeasuredContentRect(fabBounds)
calculateCounterCenter(fabBounds, counterPossibleCenter)
textPaint.getTextBounds(countMaxStr, counterMaxTextBounds)
val counterDiameter = max(counterMaxTextBounds.width(), counterMaxTextBounds.height()) + 2 * counterTextPadding
val counterRight = min(counterPossibleCenter.x + counterDiameter / 2, fabBounds.right.toFloat())
val counterTop = max(counterPossibleCenter.y - counterDiameter / 2, fabBounds.top.toFloat())
outRect.set(counterRight - counterDiameter, counterTop, counterRight, counterTop + counterDiameter)
}
Now that’s it. We done with a first version. You actually can look a whole file here. I made a gist for that.
Support rtl
direction
I’ve told at beginning of article that I not recommend to use CounterFab library. And the reason is absence of rtl support.
Of course, we can add it as pull request to that library, but for example, this feature is required for you project and fast as possible. What you gonna do then?
It is not hard to add this support to our solution.
The idea is very simple. We just need to mirror badge relative to the center of FAB. To do that, we take the left and right badge’s bounds offsets regarding fab’s right bound and apply those offsets to left bound:
//in a calculateCounterBounds fun right after formulating outRect
if (isRtl()) {
val newLeft = fabBounds.left + (fabBounds.right - outRect.right)
val newRight = fabBounds.left + (fabBounds.right - outRect.left)
outRect.left = newLeft
outRect.right = newRight
}//where
private fun isRtl() = layoutDirection == View.LAYOUT_DIRECTION_RTL
Basically that all, because all other things are symmetrical. Whew! We are lucky :)
Fab’s Badge and Material Design
Some of you can argue that we don’t need to put badges on a FAB, since Material Design doesn’t recommend that and moreover consider it as anti-pattern. Fare enough.
In the dawn of material design, in the end of 2014, I was really exited when saw, what Google prepared for new generation of Android phones. I though “This is it, a full guide for UI and UX”, “we don’t need designers anymore” and “I will follow those rules adamantly”. But the truth is that this is just a recommendations and they change during the time.
Consider aforementioned FAB. Earlier it was just a round button with some simple action. But now it can be diamond shaped, for instance. Time has come and things has changed. Don’t follow the time, but lead the changes and create a handy UX for users.
Afterwords
Thank you all for reading that article. I hope we will create great apps, guys ;)