Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/api-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ inventory check is temporarily deferred until that inventory recognizes
that renderer. The MyGo, Web, Apple, Android and shared ABI regressions remain
covered separately.

## Unreleased: Android single-line text placeholders

Android placeholders now follow their control's line policy: `text-field`,
`secure-field`, `input`, `search-field` and `combobox` keep placeholders on one
line and ellipsize overflow. `textarea` placeholders continue to wrap. Previously
a narrow or zero-width field could wrap its placeholder character by character,
inflating a single-line control and its containing row to hundreds of dp even
though the editable text itself was single-line.

The wire format, default control height and `grow` behavior are unchanged. For
example, keep sending `{"op":"set-prop","id":2,"property":"placeholder","value":"Search this book…"}`;
no fixed height or placeholder rewrite is necessary. Applications must still
provide usable width or responsive toolbar alternatives: this correction does
not make a field usable when other row children consume all available width.

## Unreleased: Apple normalizes all floating wire properties

Apple now normalizes integral JSON numbers to its floating retained value for
Expand Down
8 changes: 7 additions & 1 deletion platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1279,7 +1279,13 @@ private fun LuiTextControl(
}
}
val placeholderContent: (@Composable () -> Unit)? =
node.propString("placeholder")?.let { p -> { Text(p) } }
node.propString("placeholder")?.let { p -> {
Text(
p,
maxLines = if (multiline) Int.MAX_VALUE else 1,
overflow = if (multiline) TextOverflow.Clip else TextOverflow.Ellipsis,
)
} }
val transformation = if (secure) {
androidx.compose.ui.text.input.PasswordVisualTransformation()
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package dev.lui

import androidx.compose.foundation.layout.Box
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

class LuiTextControlLayoutTest {
@get:Rule val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5.copy(softButtons = false))

private fun nodes(root: SemanticsNode): List<SemanticsNode> =
listOf(root) + root.children.flatMap(::nodes)

@Test fun narrowPlaceholderRespectsTextControlLinePolicy() {
for (kind in listOf("text-field", "secure-field", "input", "search-field", "combobox", "textarea")) {
val backend = LuiBackend()
val ops = mutableListOf("""{"op":"create-node","id":1,"kind":"column"}""")
for (id in 2..3) {
ops.add("""{"op":"create-node","id":$id,"kind":"$kind"}""")
ops.add("""{"op":"set-prop","id":$id,"property":"width","value":80}""")
ops.add("""{"op":"insert-child","parent":1,"child":$id,"index":${id - 2}}""")
}
ops.add("""{"op":"set-prop","id":3,"property":"placeholder","value":"A long placeholder that cannot fit in one narrow line"}""")
assertTrue(backend.applyBatch("""{"generation":1,"ops":[${ops.joinToString(",")}]}"""))
var heights = emptyList<Int>()
val view = ComposeView(paparazzi.context).apply { setContent {
val native = LocalView.current
LuiTheme {
Box(Modifier.onGloballyPositioned {
val owner = native.javaClass.methods.first { it.name == "getSemanticsOwner" }
.invoke(native) as SemanticsOwner
heights = nodes(owner.unmergedRootSemanticsNode)
.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null }
.map { it.size.height }
}) { backend.Content(1) }
}
} }
try {
paparazzi.snapshot(view, name = "narrow-$kind-placeholder")
assertEquals("both $kind controls were measured", 2, heights.size)
assertTrue("empty $kind has an intrinsic height", heights[0] > 0)
if (kind == "textarea") {
assertTrue("textarea placeholder still wraps: $heights", heights[1] > heights[0])
} else {
assertEquals("single-line $kind placeholder must not inflate height", heights[0], heights[1])
}
} finally {
view.disposeComposition()
}
}
}
}
Loading