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.
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> |
ButtonBartemsHolder.kt
We will use it more in just a moment.
view_button_bar_item.xml
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> |
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 } |
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) | |
} |
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.
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.
Top comments (0)