DEV Community

Krzysztof Balana
Krzysztof Balana

Posted on

3

Creating responsive “Buttons Bar” widget in Android (part 1)

Today’s web developers have plenty tools to build modern, fast and (most of all) responsive web pages. But when it comes to Android, things are not so bright. We’re tightly bounded to what our native “system” has to offer. And there is not so much of responsivness there ;)

The Problem

Recently I’ve had to do some custom ui, which will act accordingly to different language translations and changing numbers of elements inside. Believe me, one which looks good in english, can get pretty extended in other languages.

Below, you can find an example ui visualisation:

Everything looks great! We have slick icons, great labels, tremendeous white spaces…but what if someone would like to use longer names, of more than three buttons? And what about smaller phones screens?

We could use LinearLayout’s weight approach, with some TextView’s autosizing and try to divide elements accordingly, but without knowing theirs initial width, we could end like this:

Some will say: “ Success. Everything is distributed evenly. It’s readable. We can end it right know.”

But not us. We want greater control and to have everything unified. Fonts, spaces, icons… you name it. We want this:

Much better! Each element has same font size and takes as much space as he wants and can get from its parent.
The aim is to apply strategies to containers view children, untill they reach desirable state, whether by having smaller font, smaller paddings or even icons hidden! Every strategy should apply to each children, so we could get an consistent and more pleasant look.

The Solution

I assume you know how to create new project, so I will skip this unrelevant part ;)
What I want to do, is to prepare a compound view (ButtonBarViewHolder), which will act as a container for particular view items (ButtonBarItem). So, my ButtonBarViewHolder could implement it’s own way of dealing with measuring and placing its children. I think, to achieve best results I shouldn’t just limit myself to applying only one method for optimising child items, but I will talk about it later (how it could be used as a responsive bottom bar widget).

Let’s start with preparing some building blocks.

class ButtonBarItem @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
@BindView(R.id.border)
lateinit var border: View
@BindView(R.id.button_bar_item_icon)
lateinit var icon: ImageView
@BindView(R.id.button_bar_item_title)
lateinit var title: TextView
private val mainView: View
init {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
mainView = inflater.inflate(R.layout.view_button_bar_item, this)
ButterKnife.bind(mainView, this)
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ButtonBar, 0, 0)
try {
title.text = typedArray.getText(R.styleable.ButtonBar_buttonText)
val iconDrawable =
ContextCompat.getDrawable(context, typedArray.getResourceId(R.styleable.ButtonBar_buttonIcon, 0))
icon.setImageDrawable(iconDrawable)
} finally {
typedArray.recycle()
}
}
}
view raw ButtonBarItem hosted with ❤ by GitHub

attr.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ButtonBar">
<attr name="buttonText" format="string"/>
<attr name="buttonIcon" format="reference" />
</declare-styleable>
</resources>
view raw attrs.xml hosted with ❤ by GitHub

ButtonBartemsHolder.kt

class ButtonBarItemsHolder @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
): LinearLayout(context, attrs, defStyleAttr) {
}

We will use it more in just a moment.

view_button_bar_item.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/button_bar_item_wrapper"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:clickable="true"
android:focusable="true"
android:orientation="horizontal"
android:background="@android:color/white"
tools:parentTag="android.widget.LinearLayout">
<View
android:id="@+id/border"
android:background="@color/colorPrimary"
android:layout_width="1dp"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/button_bar_item_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:minWidth="20dp"
android:minHeight="20dp"
android:layout_gravity="center_vertical"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:clickable="false"/>
<TextView
android:id="@+id/button_bar_item_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
android:singleLine="true"
android:layout_weight="2"
android:textColor="@color/colorPrimary"
android:textStyle="bold"
tools:text="Text" />
</LinearLayout>

activity_main.xml

<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/lightBackground"
android:padding="24dp"
tools:context=".MainActivity">
<com.krzysztofbalana.buttonsbar.ui.ButtonBarItemsHolder
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.krzysztofbalana.buttonsbar.ui.ButtonBarItem
app:buttonText="Profile"
app:buttonIcon="@drawable/ic_profile_icon"
android:layout_height="wrap_content"
android:layout_width="wrap_content"/>
<com.krzysztofbalana.buttonsbar.ui.ButtonBarItem
app:buttonText="Search Results"
app:buttonIcon="@drawable/ic_list_icon"
android:layout_height="wrap_content"
android:layout_width="wrap_content"/>
<com.krzysztofbalana.buttonsbar.ui.ButtonBarItem
app:buttonText="Data Charts"
app:buttonIcon="@drawable/ic_chart_icon"
android:layout_height="wrap_content"
android:layout_width="wrap_content"/>
</com.krzysztofbalana.buttonsbar.ui.ButtonBarItemsHolder>
</androidx.constraintlayout.widget.ConstraintLayout>
view raw gistfile1.txt hosted with ❤ by GitHub

Ok! Having all what is needed to present problem, we can now start and measure desired width and after that, children’s width’s in order to determine if they cumulative size exceeds our desired width (or not).

In order to achieve a proper and measured childrens, we will need to use a recursion during viewholder’s onMeasure() pass. It makes widget computational heavier, due too more than two measuring passes (normally from 4 to 5, and sometimes more). Be careful! Using this approach in many places on one screen can lead to frame drops or stackoverflow.

ButtonBarItemsHolder:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = MeasureSpec.getSize(widthMeasureSpec)
val (totalWidth, totalHeight) = recalculateChildren(desiredWith, widthMeasureSpec, heightMeasureSpec)
//add satisfied (after recalculations) width and height
}
private fun recalculateChildren(desiredWith: Int, widthMeasureSpec: Int, heightMeasureSpec: Int): Pair<Int, Int> {
val (totalWidth, totalHeight) = measureChildrensActualDimensions(widthMeasureSpec, heightMeasureSpec)
//check here if childrens cumulative width is below desired parent width, if no apply some strategy to change their font size, paddings etc and recalculate again, when requirements not met
return Pair(totalWidth, totalHeight)
}
private fun measureChildrensActualDimensions(widthMeasureSpec: Int, heightMeasureSpec: Int): Pair<Int, Int> {
var totalWidth = 0
var totalHeight = 0
viewChildren<ButtonBarItem>().forEach {
measureChild(it, widthMeasureSpec, heightMeasureSpec)
totalWidth += it.measuredWidth
if (it.measuredHeight > totalHeight) {
totalHeight = it.measuredHeight
}
}
return Pair(totalWidth, totalHeight)
}
}
//naive way of listing viewgroups children (dont' use it in production)
fun <T> ViewGroup.viewChildren(): List<T> = (0 until this.childCount).map { this.getChildAt(it) as T }
view raw gistfile1.txt hosted with ❤ by GitHub

As you can see, we’ve measured deisred (parent) width and having that knoweledge asked its children to measure themself . Next we will match measured childrens againts parent and try to use a strategy, which will execute some kind of “tweaks” to childrens.

For this we’ll use an interface:

abstract class Strategy<V> {
abstract fun execute(view: V, onApplied: () -> Unit, onDepleted: () -> Unit)
}
view raw Strategy.kt hosted with ❤ by GitHub

and concrete implemetation:

class FontScalingStrategy(private val scaleDensity: Float, private val minFontSize: Float) : Strategy<ButtonBarItem>() {
private val decimalFormatter: DecimalFormat
get() {
val decimalFormat = DecimalFormat("#")
decimalFormat.roundingMode = RoundingMode.CEILING
return decimalFormat
}
companion object {
const val FONT_SCALE_STEP = 1f
}
override fun execute(view: ButtonBarItem, onApplied: () -> Unit, onDepleted: () -> Unit) {
require(scaleDensity != 0F, { "Scale density cannot be equal to 0" })
val currentFontSize: Float = view.title.textSize / scaleDensity
val currentRoundedFontSize = decimalFormatter.format(currentFontSize).toFloat()
if (currentRoundedFontSize == minFontSize) {
onDepleted()
}
if (currentRoundedFontSize > minFontSize) {
view.title.setTextSize(TypedValue.COMPLEX_UNIT_SP, currentFontSize - FONT_SCALE_STEP)
onApplied()
}
}
}

This strategy will try to downsize fonts of given view and call “onDepleted” when reach end (which in this case is minimum font sie of 10 points).

We could this strategy it in ButtonBarItemsHolder directly, but (in my opinion) it will be better to delegate it to other class. We could use some kind of strategies executor, which will execute all the strategies we will provide to it.

class StrategyExecutor(private var strategies: List<Strategy<ButtonBarItem>>) {
private var counter: Int = 0
private constructor(builder: Builder) : this(builder.strategies)
fun executeStrategyOn(items: List<ButtonBarItem>, onStrategyExecuted: () -> Unit) {
resetCounter()
val strategy = strategies.firstOrNull()
items.forEachIndexed { index, view ->
strategy?.execute(view,
{ counter++ },
{ }
)
}
if (counter == items.size) {
onStrategyExecuted.invoke()
}
}
private fun resetCounter() {
counter = 0
}
class Builder {
var strategies: MutableList<Strategy<ButtonBarItem>> = mutableListOf()
fun addStrategy(strategy: Strategy<ButtonBarItem>): Builder {
this.strategies.add(strategy)
return this
}
fun build(): StrategyExecutor {
return StrategyExecutor(this)
}
}
}

Having working executor, we can now add it to our ButtonBarItemsHolder and launch inside recalculateChildren() methods body.

[...]
var strategyExecutor: StrategyExecutor = StrategyExecutor.Builder()
.addStrategy(FontScalingStrategy(resources.displayMetrics.scaledDensity, 10F))
.build()
[...]
private fun recalculateChildren(desiredWith: Int, widthMeasureSpec: Int, heightMeasureSpec: Int): Pair<Int, Int> {
val (totalWidth, totalHeight) = measureChildrensActualDimensions(widthMeasureSpec, heightMeasureSpec)
if (desiredWith < totalWidth) {
strategyExecutor.executeStrategyOn(viewChildren()) {
recalculateChildren(desiredWith, widthMeasureSpec, heightMeasureSpec)
}
}
Log.i("ButtonBarItemsHolder", "Desired width: $desiredWith, childrensWidth: $totalWidth")
return Pair(totalWidth, totalHeight)
}

Uff! Finally made it trough part 1 of our mini series! Now our “widget” is a little more responsive than it was before. In further steps we will try to add more strategies (like changing paddins, hiding icons etc) and position.

You can always check working example on my github if you like.

https://github.com/krzysztofbalana/buttons-bar-navigation

Sentry mobile image

App store rankings love fast apps - mobile vitals can help you get there

Slow startup times, UI hangs, and frozen frames frustrate users—but they’re also fixable. Mobile Vitals help you measure and understand these performance issues so you can optimize your app’s speed and responsiveness. Learn how to use them to reduce friction and improve user experience.

Read full post →

Top comments (0)

Sentry mobile image

App store rankings love fast apps - mobile vitals can help you get there

Slow startup times, UI hangs, and frozen frames frustrate users—but they’re also fixable. Mobile Vitals help you measure and understand these performance issues so you can optimize your app’s speed and responsiveness. Learn how to use them to reduce friction and improve user experience.

Read full post →

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay