authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-19 12:46:03-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-19 09:46:03-07:00
logb44c1ce5d25ee0d5a5be82e380618a886c19ea96
tree6be2ea4890b87173f64e976d8bc2c5fa92be269c
parent9accc567670da5096e9a694c1adb0d35adb51d9d
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep the Android composer placeholder on one line (#12605)


2 files changed, 94 insertions(+), 1 deletions(-)

apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt+35-1
...@@ -12,6 +12,7 @@ import android.text.Editable...@@ -12,6 +12,7 @@ import android.text.Editable
12import android.text.InputType12import android.text.InputType
13import android.text.InputFilter13import android.text.InputFilter
14import android.text.Spanned14import android.text.Spanned
15import android.text.TextUtils
15import android.text.TextWatcher16import android.text.TextWatcher
16import android.text.style.ReplacementSpan17import android.text.style.ReplacementSpan
17import android.util.TypedValue18import android.util.TypedValue
...@@ -254,7 +255,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -254,7 +255,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
254 }255 }
255256
256 fun setPlaceholder(placeholder: String) {257 fun setPlaceholder(placeholder: String) {
257 editor.hint = placeholder258 editor.placeholder = placeholder
258 }259 }
259260
260 fun setClipboardFragment(fragment: String) {261 fun setClipboardFragment(fragment: String) {
...@@ -267,12 +268,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(...@@ -267,12 +268,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
267 } else {268 } else {
268 Typeface.DEFAULT269 Typeface.DEFAULT
269 }270 }
271 editor.applyPlaceholder()
270 }272 }
271273
272 fun setFontSize(fontSize: Float) {274 fun setFontSize(fontSize: Float) {
273 editor.textSize = fontSize275 editor.textSize = fontSize
274 applyLineHeight()276 applyLineHeight()
275 applyTokenSpans()277 applyTokenSpans()
278 editor.applyPlaceholder()
276 }279 }
277280
278 fun setLineHeight(lineHeight: Float) {281 fun setLineHeight(lineHeight: Float) {
...@@ -587,6 +590,37 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) {...@@ -587,6 +590,37 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) {
587 var maxInputChars = Int.MAX_VALUE590 var maxInputChars = Int.MAX_VALUE
588 var clipboardFragment = ""591 var clipboardFragment = ""
589592
593 /**
594 * Placeholder shown while the draft is empty. An editable TextView never ellipsizes its hint,
595 * so a long placeholder wraps once a wide system font or a large font scale (Samsung defaults)
596 * runs out of width, and the resting composer grows to two lines. The hint is instead cut to
597 * one line with an ellipsis for whatever width the editor is measured at.
598 */
599 var placeholder = ""
600 set(value) {
601 field = value
602 applyPlaceholder()
603 }
604
605 fun applyPlaceholder(availableWidth: Int = width - compoundPaddingLeft - compoundPaddingRight) {
606 val next =
607 if (availableWidth > 0) {
608 TextUtils.ellipsize(placeholder, paint, availableWidth.toFloat(), TextUtils.TruncateAt.END)
609 } else {
610 placeholder
611 }
612 if (hint?.toString() != next.toString()) hint = next
613 }
614
615 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
616 if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) {
617 applyPlaceholder(
618 MeasureSpec.getSize(widthMeasureSpec) - compoundPaddingLeft - compoundPaddingRight
619 )
620 }
621 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
622 }
623
590 private fun deleteChip(backwards: Boolean): Boolean {624 private fun deleteChip(backwards: Boolean): Boolean {
591 val content = text625 val content = text
592 val start = minOf(selectionStart, selectionEnd)626 val start = minOf(selectionStart, selectionEnd)
apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPlaceholderTest.kt created+59
...@@ -0,0 +1,59 @@
1package expo.modules.t3composereditor
2
3import android.view.View
4import android.view.ViewGroup
5import org.junit.Assert.assertEquals
6import org.junit.Assert.assertTrue
7import org.junit.Test
8import org.junit.runner.RunWith
9import org.robolectric.RobolectricTestRunner
10import org.robolectric.RuntimeEnvironment
11import org.robolectric.annotation.Config
12import org.robolectric.annotation.GraphicsMode
13
14@RunWith(RobolectricTestRunner::class)
15@Config(sdk = [36], manifest = Config.NONE)
16@GraphicsMode(GraphicsMode.Mode.NATIVE)
17class ComposerPlaceholderTest {
18 private val placeholder = "Ask the repo agent, or run a command…"
19 private val editor = SelectionAwareEditText(RuntimeEnvironment.getApplication()).apply {
20 layoutParams = ViewGroup.LayoutParams(
21 ViewGroup.LayoutParams.MATCH_PARENT,
22 ViewGroup.LayoutParams.MATCH_PARENT,
23 )
24 textSize = 16f
25 placeholder = this@ComposerPlaceholderTest.placeholder
26 }
27
28 private fun measureAt(width: Int) {
29 editor.measure(
30 View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
31 View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
32 )
33 editor.layout(0, 0, width, editor.measuredHeight)
34 }
35
36 @Test
37 fun narrowEditorCutsThePlaceholderToOneLine() {
38 val fullWidth = editor.paint.measureText(placeholder)
39 val width = (fullWidth / 2).toInt()
40 measureAt(width)
41
42 val hint = editor.hint.toString()
43 assertTrue(hint, hint.endsWith("…") && hint.length < placeholder.length)
44 assertTrue(hint, editor.paint.measureText(hint) <= width)
45 }
46
47 @Test
48 fun wideEditorKeepsTheWholePlaceholder() {
49 measureAt(editor.paint.measureText(placeholder).toInt() + 20)
50 assertEquals(placeholder, editor.hint.toString())
51 }
52
53 @Test
54 fun placeholderRecoversWhenTheEditorWidens() {
55 measureAt((editor.paint.measureText(placeholder) / 2).toInt())
56 measureAt(editor.paint.measureText(placeholder).toInt() + 20)
57 assertEquals(placeholder, editor.hint.toString())
58 }
59}