My understanding is that LinearLayout is supposed to distribute slack (extra space) and/or shrinkage (negative extra space) according to its children's layout weights. This seems to work as I expect for slack, but not for shrinkage.
The following android Activity demonstrates. It shows 9 columns (vertical LinearLayouts), with varying degrees of crowdedness. Each of the 9 columns has two children:
The weight of each subcolumn is proportional to the number of radio buttons in it (3 or 2), and the weight of each radio button within the subcolumn is 1.
I chose these weights in order to distribute the column's extra space (negative or positive) equally among its grandchildren (the radio buttons), so that all 5 radio buttons within a given column end up the same size.
It seems to work as intended for slack (the red columns, on the right), but not for shrinkage (the blue columns, on the left), as the following screenshot shows (with developer option "Show layout bounds" enabled). The discrepancy is most noticable in the most crowded (leftmost) column, in which the first 3 radio buttons ended up much smaller than their 2 cousins.
Is this LinearLayout's intended behavior? Or is it a bug in LinearLayout? Or a bug in my program?
Here's the program listing (in Kotlin):
// app/src/main/java/com/example/donhatch/linearlayoutweightsquestionactivity/MainActivity.kt
// Simple activity to test LinearLayout's slack/shrinkage distribution behavior.
package com.example.donhatch.linearlayoutweightsquestionactivity
import android.graphics.Color
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.RadioGroup
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val c = applicationContext
val WC = LinearLayout.LayoutParams.WRAP_CONTENT
setContentView(object: LinearLayout(c) {init{
// A row of 9 columns, from crowded to uncrowded.
orientation = HORIZONTAL
val columnHeights = arrayOf(
200, 300, 400, 500, // crowded
WC, // just right (comes out 5*112 = 560)
600, 700, 800, 900 // uncrowded
)
for (columnHeight in columnHeights) {
addView(object: LinearLayout(c) {init{
// A column with two child columns (radio groups).
orientation = VERTICAL
setBackgroundColor(when {
columnHeight==WC -> Color.WHITE
columnHeight<560 -> Color.argb(255,224,224,255) // crowded: blue
else -> Color.argb(255,255,224,224) // uncrowded: red
})
addView(object: RadioGroup(c) {init{
// First child column: three radio buttons.
orientation = VERTICAL
for (i in 0 until 3) {
addView(object: RadioButton(c) {init{
text = 'A'.plus(i) + " " // "A", "B", ...
}}, LayoutParams(WC, WC, 1.0f))
}
}}, LayoutParams(WC, WC, 3.0f)) // weight=3 for 3 children
addView(object: RadioGroup(c) {init{
// Second child column: two radio buttons.
orientation = VERTICAL
for (i in 0 until 2) {
addView(object: RadioButton(c) {init{
text = '0'.plus(i) + " " // "0", "1", ...
}}, LayoutParams(WC, WC, 1.0f))
}
}}, LayoutParams(WC, WC, 2.0f)) // weight=2 for 2 children
}}, LayoutParams(WC, columnHeight))
} // for columnHeight
}}) // setContentView
} // onCreate
} // class MainActivity
I traced the calls to onMeasure in the LinearLayouts and RadioButtons, and I believe I understand what's going on now. Based on that, I believe this is a bug in LinearLayout.
Focusing on the column of height 300 with 3+2 radio buttons in it, here are the calculations it goes through.
Parent column's onMeasure()
gets called with heightSpec = EXACTLY 300
.
It asks its two children how big they'd like to be, constrained to being no more than its own
exactly-known height 300:
onMeasure()
gets called with heightSpec = AT_MOST 300
.
First child column's total preferred height is 336=3*112 (3 radio buttons each of which likes to be 112 pixels high) which is over budget,
so it replies that it would like to have its height be the max allowed, which is 300.onMeasure()
gets called with heightSpec = AT_MOST 300
.
Second child column's total preferred height is 224=2*112 (2 radio buttons) which is within budget,
so it replies that it would like to have height 224.So the total preferred size reported by the children is 300+224 = 524, which is too big by 224 pixels. So it distributes the shrinkage 224 to the two children in proportion to their weights 3:2; that is, the first child gets shrinkage (3/5)*224 = 134.4 (rounded to 134) and the second child gets shrinkage (2/5)*224 = 89.6 (rounded to 90). So the two child columns get heights 300-134=166 and 224-90=134, respectively. The two child columns now get told their heights, for them to distribute to their children:
onMeasure()
gets called with heightSpec="EXACTLY 166"
.
It distributes that height 166 fairly to its three children, giving them heights 56,55,55.onMeasure()
gets called with heightSpec="EXACTLY 134"
.
It distributes that height 134 fairly to its two children, giving them heights 67,67.So the final heights of the 5 radio buttons in the column are 56,55,55, 67,67; i.e. the first three are smaller than the last two. That explains the unfair distribution.
It seems to me that the mistake was specifying AT_MOST 300
when asking the children what heights
they would like to be: MeasureSpec
mode AT_MOST
is neither needed nor appropriate there,
and it results in unfair distribution of the shrinkage. MeasureSpec
mode UNSPECIFIED
would have been a better choice: it would have led to the right answer in the end without any constraint
violations.
Let's go through the calculation again, but using UNSPECIFIED
instead of AT_MOST 300
when asking the children what heights they would like to be.
Parent column's onMeasure()
gets called with heightSpec = EXACTLY 300
.
It asks its two children how big they'd like to be, with no constraints:
onMeasure()
gets called with heightSpec = UNSPECIFIED
.
First child column replies that it would like to have height 336=3*112 (3 radio buttons).onMeasure()
gets called with heightSpec = UNSPECIFIED
Second child column replies that it would like to have height 224=2*112 (2 radio buttons).So the total preferred size of the children is 336+224 = 560, which is too big by 260 pixels. So it distributes the shrinkage 260 to the two children in proportion to their weights 3:2; that is, the first child gets shrinkage (3/5)*260 = 156 and the second child gets shrinkage (2/5)*260 = 104. So the two child columns get heights 336-156=180 and 224-104=120, respectively. The two child columns now get told their heights, and to tell their descendents:
onMeasure()
gets called with heightSpec = EXACTLY 180
.
It distributes that height 180 fairly to its three children, giving them heights 60,60,60.onMeasure()
gets called with heightSpec = EXACTLY 120
It distributes that height 120 fairly to its two children, giving them heights 60,60.So the final heights of the 5 radio buttons in the column are 60,60,60, 60,60, which is the desired behavior.