Skip to content

Instantly share code, notes, and snippets.

@zach-klippenstein
Created February 1, 2025 02:09
Show Gist options
  • Save zach-klippenstein/e11a44e0f343e87b4e37959ab6c691b7 to your computer and use it in GitHub Desktop.
Save zach-klippenstein/e11a44e0f343e87b4e37959ab6c691b7 to your computer and use it in GitHub Desktop.
A little hack for adding content padding to your decoration boxes in Jetpack Compose's BasicTextField.
import androidx.compose.foundation.background
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier.Node
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutAwareModifierNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateMeasurement
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import com.squareup.ui.market.layout.infinityOr
import kotlin.math.roundToInt
internal class DelegatingNestedScrollConnection : NestedScrollConnection {
var delegate: NestedScrollConnection? = null
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = delegate?.onPreScroll(available, source) ?: Offset.Zero
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = delegate?.onPostScroll(consumed, available, source) ?: Offset.Zero
}
/**
* Modifier that fakes "content padding" by adding padding to the bottom of the modified component
* unless it is has scrollable content below the bottom of the viewport, in which case it grows the
* component instead, to show the content "through" the padding.
*
* This modifier will result in the parent seeing the same size for this node as it would have been
* without the modifier – it only applies the padding "internally". The modified node must report a
* correct intrinsic size for this to work.
*
* This is a workaround for https://issuetracker.google.com/issues/302599627.
*
* This implementation is imperfect. Since pre-BTF2 text fields don't expose their scroll state, we
* don't actually know definitely if the field is scrolled to the bottom. We use nested scrolling to
* get informed when it is via scroll events, but there are two other ways it can change:
* 1. The content in the scrollable changes (e.g. the text content). We can't get a signal for this
* directly.
* 2. The incoming constraints change, causing us to resize the viewport. We detect when we shrink
* to get this signal, and assume that if we're showing external padding while shrinking that we
* should gradually hide it.
*
* @param bottomPaddingPx The number of pixels of empty space to leave at the bottom of the field when
* scrolled all the way to the bottom.
* @param scrollConnection A [DelegatingNestedScrollConnection] that must be applied *to the
* [BasicTextField] directly* by [Modifier.nestedScroll]. It can not be applied inside the decoration
* box, since [BasicTextField] applies does something different than `verticalScroll` and makes the
* entire field scrollable, despite scroll only *affecting* the contents inside the decoration box.
*/
internal fun Modifier.textFieldContentPaddingHack(
bottomPaddingPx: Float,
scrollConnection: DelegatingNestedScrollConnection? = null,
): Modifier = this
.background(Color.Red.copy(alpha = 0.2f))
.then(TextFieldContentPaddingModifier(bottomPaddingPx, scrollConnection))
.background(Color.Green.copy(alpha = 0.2f))
private data class TextFieldContentPaddingModifier(
val bottomPaddingPx: Float,
val scrollConnection: DelegatingNestedScrollConnection?,
) : ModifierNodeElement<TextFieldContentPaddingModifierNode>() {
override fun create(): TextFieldContentPaddingModifierNode =
TextFieldContentPaddingModifierNode(bottomPaddingPx, scrollConnection)
override fun update(node: TextFieldContentPaddingModifierNode) {
node.bottomPaddingPx = bottomPaddingPx
node.scrollConnection = scrollConnection
}
}
private class TextFieldContentPaddingModifierNode(
bottomPaddingPx: Float,
scrollConnection: DelegatingNestedScrollConnection?
) : Node(), LayoutModifierNode, LayoutAwareModifierNode, NestedScrollConnection {
/** We'll explicitly invalidate measurement when inputs change. */
override val shouldAutoInvalidate: Boolean
get() = false
/**
* This is the total amount of padding to put at the bottom. We split it into "internal" and
* "external" padding, where internal is applied by growing the modified node, and external is
* applied by leaving space below the modified node.
*/
var bottomPaddingPx: Float = bottomPaddingPx
set(value) {
if (field != value) {
field = value
clampExternalPadding()
invalidateMeasurement()
}
}
/**
* The part of [bottomPaddingPx] that is applied by leaving room below the modified node. It's
* updated as we get signals that something changed wrt scrolling – both through nested scroll events
* and resize events.
*/
private var externalPaddingPx = 0f
/**
* Used to determine when we shrink, which we assume means we need to reduce [externalPaddingPx].
*/
private var lastMeasuredHeight = 0
var scrollConnection: DelegatingNestedScrollConnection? = scrollConnection
set(value) {
if (field != value) {
field?.delegate = null
field = value
field?.delegate = this
}
}
override fun onAttach() {
scrollConnection?.delegate = this
}
override fun onDetach() {
scrollConnection?.delegate = null
}
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = if (available.y > 0f && externalPaddingPx > 0f) {
val requestedNewValue = externalPaddingPx - available.y
val newValue = requestedNewValue.coerceIn(0f, bottomPaddingPx)
val consumedByUs = externalPaddingPx - newValue
if (externalPaddingPx != newValue) {
externalPaddingPx = newValue
invalidateMeasurement()
}
Offset(0f, consumedByUs)
} else {
Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = if (available.y < 0f && externalPaddingPx < bottomPaddingPx) {
val requestedNewValue = externalPaddingPx - available.y
val newValue = requestedNewValue.coerceIn(0f, bottomPaddingPx)
val consumedByUs = externalPaddingPx - newValue
if (externalPaddingPx != newValue) {
externalPaddingPx = newValue
invalidateMeasurement()
}
Offset(0f, consumedByUs)
} else {
Offset.Zero
}
/**
* This gets called *after* we report our size to the `layout` function in [measure], which means
* that writing [externalPaddingPx] and calling [invalidateMeasurement] is effectively a "backwards
* write", and the new update won't be applied until the next frame. This is not ideal, however it's
* the best we can do since we need to know the actual measured size. And since this whole thing is a
* hack, it's fine.
*/
override fun onRemeasured(size: IntSize) {
val maxHeightDelta = size.height - lastMeasuredHeight
if (maxHeightDelta < 0) {
externalPaddingPx += maxHeightDelta
clampExternalPadding()
invalidateMeasurement()
}
lastMeasuredHeight = size.height
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
// Bottom padding is the fixed amount of padding to simulate.
// We divide it into two parts:
// - "internal" padding is applied by *growing* the child.
// - "external" padding is applied by leaving empty space *below* the child.
// As the scroll container scrolled to the end, we move the dividing line between the two, so we
// gradually replace the bottom of the viewport by empty space, but only when the scrollable is
// already at the end and there wouldn't be anything there anyway. Or put another way, we hide the
// external padding as the container is scrolled up so that the content that would otherwise be
// hidden below the padding is visible.
val bottomPadding = bottomPaddingPx.roundToInt()
clampExternalPadding()
val externalPadding = externalPaddingPx.roundToInt()
val internalPadding = bottomPadding - externalPadding
// To calculate the max height for the child, it's easy. It's the same as our incoming max height,
// but if that's not infinity then we just force the child to be small enough to leave room for
// external padding.
val childMaxHeight = constraints.maxHeight.infinityOr { it - externalPadding }
// To calculate the min constraint for the child, we calculate two separate minimums:
// 1. If the min is unconstrained, then the child would be its intrinsic height by default. So
// so in that case we make it grow by internalPadding to fill the empty space.
// 2. If our incoming min constraint is large, then we might need to grow the child, but we don't
// want it to be the full size of the min constraint since we need to leave room for external
// padding, so we shrink by the externalPadding.
val paddedIntrinsicHeight = measurable.minIntrinsicHeight(constraints.maxWidth) + internalPadding
val paddedIncomingMinHeight = constraints.minHeight - externalPadding
// These are both minimums, so the smallest value that satisfies both is the larger of the two.
val childMinHeight = maxOf(paddedIntrinsicHeight, paddedIncomingMinHeight)
.coerceAtMost(childMaxHeight)
val childConstraints = constraints.copy(minHeight = childMinHeight, maxHeight = childMaxHeight)
val placeable = measurable.measure(childConstraints)
// This value will stay constant as the container is scrolled since placeable.height always
// includes internalPadding, and internalPadding+externalPadding always add up to the same
// value: bottomPadding.
val myHeight = placeable.height + externalPadding
return layout(placeable.width, myHeight) {
placeable.place(0, 0)
}
}
private fun clampExternalPadding() {
externalPaddingPx = externalPaddingPx.coerceIn(0f, bottomPaddingPx)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment