Search code examples
androidandroid-layoutandroid-linearlayout

Why isn't LinearLayout distributing shrinkage in proportion to weights?


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:

  • first child is a subcolumn consisting of 3 radio buttons labeled "A", "B", "C"
  • second child is a subcolumn consisting of 2 radio buttons labeled "0", "1"

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.

[screenshot]

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

Solution

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

    WHAT IT DID

    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:

    • First child column's 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.
    • Second child column's 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:

    • First child's onMeasure() gets called with heightSpec="EXACTLY 166". It distributes that height 166 fairly to its three children, giving them heights 56,55,55.
    • Second child's 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.

    WHAT WENT WRONG

    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.

    WHAT IT SHOULD HAVE DONE INSTEAD

    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:

    • First child column's onMeasure() gets called with heightSpec = UNSPECIFIED. First child column replies that it would like to have height 336=3*112 (3 radio buttons).
    • Second child column's 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:

    • First child's onMeasure() gets called with heightSpec = EXACTLY 180. It distributes that height 180 fairly to its three children, giving them heights 60,60,60.
    • Second child's 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.