Created
February 1, 2025 02:09
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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