I was under the impression that the value returned by View.getWidth() includes padding in it. I don't think I'm hallucinating things - a common line of code that Android developers write is something like myView.run { width - (paddingLeft + paddingRight) }, whose goal is to get the width of a View without the padding (the "base width", so to speak).
This is something I personally have done at my previous jobs, and is also corroborated by numerous other examples online (ref: SourceGraph code search).
However, after trying to use this pattern today, I was shocked to see that the value returned View.getWidth() does not include padding in it. To illustrate, I ran two experiments:
Experiment 1: programmatically added padding
activity_main.xml:
<HorizontalScrollView
android:id="@+id/container"
android:background="#ff00c0cb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:clipToPadding="false"
android:clipChildren="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<LinearLayout
android:id="@+id/inner"
android:background="#ffffc0cb"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</HorizontalScrollView>
MainActivity.kt:
myList.forEach { s ->
val squareItemView = SquareItemView(this)
// irrelevant boilerplate omitted
innerContents.addView(squareItemView)
innerContents.post {
val baseWidth = innerContents.run { width - (paddingLeft + paddingRight) }
Log.i(
"TAG",
"innerContents.measuredWidth=${innerContents.measuredWidth}, " +
"innerContents.width=${innerContents.width}, " +
"baseWidth=${baseWidth}, " +
"innerContents.paddingLeft=${innerContents.paddingLeft}"
)
Log.i("TAG", "will add 10px px on either side")
innerContents.apply { setPadding(paddingLeft + 10, paddingTop, paddingRight + 10, paddingBottom) }
}
}
Here is the log output:
23:42:49.923 I innerContents.measuredWidth=693, innerContents.width=693, baseWidth=693, innerContents.paddingLeft=0
23:42:49.923 I will add 10px px on either side
23:42:49.924 I innerContents.measuredWidth=693, innerContents.width=693, baseWidth=673, innerContents.paddingLeft=10
23:42:49.924 I will add 10px px on either side
23:42:49.924 I innerContents.measuredWidth=693, innerContents.width=693, baseWidth=653, innerContents.paddingLeft=20
23:42:49.924 I will add 10px px on either side
In this demo, notice that each time we add 10px of padding to left and right:
- the plain getMeasuredWidth and getWidth numbers are identical and remain constant
- the "base width" calculation decreases by the amount of the padding
Experiment 2: hard-coded padding (less variables to think about)
activity_main.xml:
<TextView
android:id="@+id/texty"
android:layout_width="300dp"
android:layout_height="300dp"
android:padding="10dp" (note that I tested including and omitting this attribute)
android:layout_gravity="top"
android:text="Texty (300x300)"
android:background="#ff00f0fe" />
MainActivity.kt:
findViewById<View>(R.id.texty).let {
it.doOnNextLayout {
Log.i("TAG", "texty.height=${it.height}, texty.paddingTop=${it.paddingTop}")
Log.i("TAG", "texty.width=${it.width}, texty.paddingLeft=${it.paddingLeft}")
}
}
Log output when the padding is omitted:
16:49:57.414 I texty.height=788, texty.paddingTop=0
16:49:57.414 I texty.width=788, texty.paddingLeft=0
Log output when the padding is set to 10dp:
16:50:39.012 I texty.height=788, texty.paddingTop=26
16:50:39.012 I texty.width=788, texty.paddingLeft=26
This result is at odds with what we expect to see. Like I said, I don't remember this being the case a few months (weeks?) ago, but apparently it is so. Did Google change this behaviour on the library level or even the OS level? What's going on? :(
1 Answer 1
The answer is yes, View.getHeight/getWidth includes padding.
The reason you are seeing the behaviour you're seeing is:
Experiment 1: Race condition
When you call setPadding(), the paddingLeft/paddingRight attributes immediately get updated, but getMeasuredWidth() does NOT get updated until after View.measure() is called, and getWidth() does NOT get updated until after View.layout() is called. The fact that you used View.post() doesn't really matter; the method just guarantees that the provided callback executes on the UI thread, it does not guarantee that measure-layout-draw has occurred before executing the callback.
It seems to me that what you are really trying to do is ensure that measure-layout-draw occurs after you set padding the nth time, and before you set padding the n+1th time - i.e. the order of operations is an alternating pattern of setPadding, measure-layout-draw, setPadding, measure-layout-draw, setPadding, ....
You have two options:
Option 1: manually call measure and layout
This is the simplest way that requires the least amount of work. Depending on the specific way your Views and their parent ViewGroups are set up, you might be able to get away with refactoring your code to use getMeasuredWidth() instead, in which case you only need to call View.measure().
Option 2: block on the natural timing of measure-layout-draw before executing the callbacks in sequence
Mind you, this requires you to manually keep track of concurrency. The following code is sloppy, most likely leaks memory, etc. - this is a proof-of-concept, bare minimum concurrency management you need to listen to the natural timing of the measure-layout-draw cycle. (If anyone here is better at RxJava than I am, then please feel free to improve this!)
private var innerPtr: View? = null
private var innerLayoutChangeListener: View.OnLayoutChangeListener? = null
private val layoutChangesObservable = PublishSubject.create<Int>()
private var layoutCounter = 0
private val semaphore = Semaphore(1, /* fair= */ true)
private val orders = PublishSubject.create<() -> Unit>()
private var orderCounter = 0
private var omnilistener: Disposable? = null
init {
omnilistener = orders.observeOn(Schedulers.io()).subscribe { callback ->
Log.d(TAG, "received order")
semaphore.acquire()
layoutChangesObservable.take(1)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { nthLayoutChange ->
Log.d(TAG, "nth layout change: $nthLayoutChange")
try {
callback.invoke()
} finally {
semaphore.release()
}
Log.d(TAG, "processed order ${orderCounter++}")
}
}
}
fun tweakPadding(innerContents: View) {
if (innerContents != innerPtr) {
Log.d("TAG", "new button holder ptr")
innerPtr?.removeOnLayoutChangeListener(innerLayoutChangeListener)
innerLayoutChangeListener = View.OnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ ->
Log.d("TAG", "layout changed")
layoutChangesObservable.onNext(layoutCounter++)
}
innerContents.addOnLayoutChangeListener(innerLayoutChangeListener)
innerPtr = innerContents
}
// It is heavily implied that this triggers a layout change. I don't know what will happen if the following block does not trigger a layout change.
orders.onNext {
val baseWidth = innerContents.run { width - (paddingLeft + paddingRight) }
Log.i(
"TAG",
"innerContents.measuredWidth=${innerContents.measuredWidth}, " +
"innerContents.width=${innerContents.width}, " +
"baseWidth=${baseWidth}, " +
"innerContents.paddingLeft=${innerContents.paddingLeft}"
)
Log.i("TAG", "will add 10px px on either side")
innerContents.apply { setPadding(paddingLeft + 10, paddingTop, paddingRight + 10, paddingBottom) }
}
}
While Option 2 is technically the most correct solution, if you had to ask me, I'd probably just go with Option 1 due to the far-superior readability and maintainability, even if it means you have to refactor your view hierarchy.
Experiment 2: Width is constant
This experiment was flawed and probably done in a sleep-deprived stupor. The question you ask really only makes sense when the width is wrap_content.
Comments
Explore related questions
See similar questions with these tags.
SquareItemView? Is there a possibility that it's somehow overriding the method with a custom implementation?