commit fa5bbfd5adef3cc4f78198c79dc526286387553f
parent bd28c9a0c61bdfa19426e87af91f83ab2ab97837
Author: Mugurell <Mugurell@users.noreply.github.com>
Date: Wed, 3 Dec 2025 10:32:36 +0000
Bug 1999177 - Support cursor scroll in the composable toolbar r=android-reviewers,moyin
By using the BasicTextField version with TextFieldState instead of the one
with TextFieldValue.
Differential Revision: https://phabricator.services.mozilla.com/D274742
Diffstat:
2 files changed, 251 insertions(+), 158 deletions(-)
diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt
@@ -17,19 +17,29 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.InputTransformation
+import androidx.compose.foundation.text.input.OutputTransformation
+import androidx.compose.foundation.text.input.TextFieldBuffer
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -52,23 +62,18 @@ import androidx.compose.ui.platform.PlatformTextInputMethodRequest
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.toClipEntry
-import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.OffsetMapping
-import androidx.compose.ui.text.input.TextFieldValue
-import androidx.compose.ui.text.input.TransformedText
-import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import mozilla.components.compose.base.theme.AcornTheme
import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.ADDRESSBAR_SEARCH_BOX
@@ -106,50 +111,27 @@ internal fun InlineAutocompleteTextField(
onUrlEdit: (BrowserToolbarQuery) -> Unit = {},
onUrlCommitted: (String) -> Unit = {},
) {
- var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
- LaunchedEffect(query) {
- if (query != textFieldValue.text) {
- textFieldValue = TextFieldValue(
- text = query,
- selection = when (showQueryAsPreselected) {
- true -> TextRange(0, query.length)
- false -> TextRange(query.length)
- },
- )
- }
- }
+ val textFieldState = rememberTextFieldState(
+ initialText = query,
+ initialSelection = when {
+ showQueryAsPreselected -> TextRange(0, query.length)
+ else -> TextRange(query.length)
+ },
+ )
+ var useSuggestion by remember { mutableStateOf(true) }
+ // Properties referenced in long lived lambdas
+ val currentSuggestion by rememberUpdatedState(suggestion)
+ val currentUseSuggestion by rememberUpdatedState(useSuggestion)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
- LaunchedEffect(Unit) {
- focusRequester.requestFocus()
- }
- var useSuggestion by remember { mutableStateOf(true) }
val suggestionTextColor = MaterialTheme.colorScheme.onSurface
val highlightBackgroundColor = Color(TEXT_HIGHLIGHT_COLOR.toColorInt())
- val suggestionVisualTransformation = remember(useSuggestion, suggestion, textFieldValue) {
- when (textFieldValue.text.isEmpty() || !useSuggestion) {
- true -> VisualTransformation.None
- false -> AutocompleteVisualTransformation(
- userInput = textFieldValue,
- suggestion = suggestion,
- textColor = suggestionTextColor,
- textBackground = highlightBackgroundColor,
- )
- }
- }
-
- val localView = LocalView.current
- LaunchedEffect(suggestion) {
- suggestion?.text?.let {
- @Suppress("DEPRECATION")
- localView.announceForAccessibility(it)
- }
- }
var suggestionBounds by remember { mutableStateOf<Rect?>(null) }
val deviceLayoutDirection = LocalLayoutDirection.current
+ val scrollState = rememberScrollState()
val context = LocalContext.current
val defaultTextToolbar = LocalTextToolbar.current
@@ -158,6 +140,32 @@ internal fun InlineAutocompleteTextField(
val pasteInterceptorToolbar = remember(defaultTextToolbar, clipboard) {
PasteSanitizerTextToolbar(context, defaultTextToolbar, clipboard, coroutineScope)
}
+ DisposableEffect(Unit) {
+ onDispose { pasteInterceptorToolbar.hide() }
+ }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ LaunchedEffect(query) {
+ if (query != textFieldState.text.toString()) {
+ textFieldState.edit {
+ replace(0, length, query)
+ selection = TextRange(query.length)
+ }
+ }
+ }
+
+ val localView = LocalView.current
+ LaunchedEffect(suggestion) {
+ if (useSuggestion) {
+ suggestion?.text?.let {
+ @Suppress("DEPRECATION")
+ localView.announceForAccessibility(it)
+ }
+ }
+ }
// Always want the text to be entered left to right.
CompositionLocalProvider(
@@ -178,38 +186,7 @@ internal fun InlineAutocompleteTextField(
},
) {
BasicTextField(
- value = textFieldValue,
- onValueChange = { newValue ->
- // Remove suggestion if cursor placement changed
- val onlySelectionChanged = textFieldValue.text == newValue.text &&
- textFieldValue.composition == newValue.composition &&
- textFieldValue.annotatedString == newValue.annotatedString
- if (onlySelectionChanged) {
- useSuggestion = false
- textFieldValue = newValue
- return@BasicTextField
- }
-
- // Remove suggestion if user pressed backspace and
- // only delete query characters for the next backspace after the suggestion was removed.
- val originalText = textFieldValue.text
- val newText = newValue.text
- val isBackspaceHidingSuggestion = originalText.length == newText.length + 1 &&
- originalText.startsWith(newText) &&
- (useSuggestion && suggestion?.text?.startsWith(originalText) == true)
- if (isBackspaceHidingSuggestion) {
- useSuggestion = false
- } else {
- useSuggestion = true
- onUrlEdit(
- BrowserToolbarQuery(
- previous = originalText,
- current = newText,
- ),
- )
- textFieldValue = newValue
- }
- },
+ state = textFieldState,
modifier = modifier
.testTag(ADDRESSBAR_SEARCH_BOX)
.fillMaxWidth()
@@ -227,80 +204,66 @@ internal fun InlineAutocompleteTextField(
LayoutDirection.Rtl -> TextAlign.End
},
),
+ lineLimits = TextFieldLineLimits.SingleLine,
+ scrollState = scrollState,
keyboardOptions = KeyboardOptions(
showKeyboardOnFocus = true,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
autoCorrectEnabled = !usePrivateModeQueries,
),
- keyboardActions = KeyboardActions(
- onGo = {
- keyboardController?.hide()
- val currentSuggestion = suggestion?.text
- onUrlCommitted(
- when (useSuggestion && currentSuggestion?.startsWith(textFieldValue.text) == true) {
- true -> currentSuggestion
- else -> textFieldValue.text
- },
- )
- },
- ),
- singleLine = true,
- visualTransformation = suggestionVisualTransformation,
+ onKeyboardAction = {
+ keyboardController?.hide()
+ val currentText = textFieldState.text.toString()
+ val finalUrl = if (useSuggestion && suggestion?.text?.startsWith(currentText) == true) {
+ suggestion.text
+ } else {
+ currentText
+ }
+ onUrlCommitted(finalUrl)
+ },
+ inputTransformation = remember(onUrlEdit) {
+ AutocompleteInputTransformation(
+ suggestion = { currentSuggestion },
+ shouldUseSuggestion = { currentUseSuggestion },
+ onSuggestionVisibilityChangeRequest = { useSuggestion = it },
+ onUrlEdit = onUrlEdit,
+ )
+ },
+ outputTransformation = remember(suggestionTextColor) {
+ AutocompleteOutputTransformation(
+ suggestion = { currentSuggestion },
+ shouldUseSuggestion = { currentUseSuggestion },
+ textColor = suggestionTextColor,
+ textBackground = highlightBackgroundColor,
+ )
+ },
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
onTextLayout = { layoutResult ->
- val currentInput = textFieldValue.text
+ val currentInput = textFieldState.text
suggestionBounds = when (currentInput.isEmpty()) {
true -> null
false -> try {
- layoutResult.getBoundingBox(currentInput.length - 1)
+ layoutResult()?.getBoundingBox(currentInput.length - 1)
} catch (_: IllegalArgumentException) {
null
}
}
},
- cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
- decorationBox = { innerTextField ->
- Box(
- modifier = Modifier
- .fillMaxWidth()
- // Commit the suggestion when users tap on the outside of the typed in text.
- .pointerInput(suggestion, suggestionBounds) {
- awaitEachGesture {
- val downEvent = awaitFirstDown(requireUnconsumed = false)
- val bounds = suggestionBounds
- val suggestion = suggestion?.text
- if (bounds != null && suggestion != null &&
- bounds.right < downEvent.position.x
- ) {
- onUrlEdit(
- BrowserToolbarQuery(
- previous = textFieldValue.text,
- current = suggestion,
- ),
- )
- textFieldValue = TextFieldValue(
- text = suggestion,
- selection = TextRange(suggestion.length),
- )
- }
- }
- },
- contentAlignment = when (deviceLayoutDirection) {
- LayoutDirection.Ltr -> Alignment.CenterStart
- LayoutDirection.Rtl -> Alignment.CenterEnd
+ decorator = { innerTextField ->
+ AutocompleteDecorator(
+ hint = hint,
+ suggestion = when {
+ useSuggestion -> currentSuggestion
+ else -> null
},
- ) {
- if (textFieldValue.text.isEmpty()) {
- Text(
- text = hint,
- style = TextStyle(
- fontSize = TEXT_SIZE.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- ),
- )
- }
- innerTextField()
- }
+ onSuggestionVisibilityChangeRequest = { useSuggestion = it },
+ suggestionBounds = suggestionBounds,
+ textFieldState = textFieldState,
+ onUrlEdit = onUrlEdit,
+ deviceLayoutDirection = deviceLayoutDirection,
+ innerTextField = innerTextField,
+ )
},
)
}
@@ -319,44 +282,146 @@ data class BrowserToolbarQuery(
)
/**
+ * Helper for removing the suggestion or delete from the user query when backspace is pressed.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+private class AutocompleteInputTransformation(
+ private val suggestion: () -> AutocompleteResult?,
+ private val shouldUseSuggestion: () -> Boolean,
+ private val onSuggestionVisibilityChangeRequest: (Boolean) -> Unit,
+ private val onUrlEdit: (BrowserToolbarQuery) -> Unit,
+) : InputTransformation {
+ override fun TextFieldBuffer.transformInput() {
+ val originalText = originalText.toString()
+ val newText = asCharSequence().toString()
+ val suggestion = suggestion()?.text
+
+ val isBackspace = originalText.length > newText.length && originalText.startsWith(newText)
+ val isSuggestionVisible = shouldUseSuggestion() &&
+ suggestion?.startsWith(originalText) == true && suggestion.length > originalText.length
+ val isCursorAtQueryEnd = originalSelection.collapsed && originalSelection.end == originalText.length
+
+ if (isBackspace) {
+ onSuggestionVisibilityChangeRequest(false)
+
+ val isBackspaceHidingSuggestion = isCursorAtQueryEnd && isSuggestionVisible
+ if (isBackspaceHidingSuggestion) {
+ // Avoid deleting text, just hide the suggestion.
+ revertAllChanges()
+ } else {
+ // Actually delete text and hide the suggestion.
+ onUrlEdit(BrowserToolbarQuery(previous = originalText, current = newText))
+ }
+ } else {
+ if (originalText != newText) {
+ onSuggestionVisibilityChangeRequest(true)
+ onUrlEdit(BrowserToolbarQuery(previous = originalText, current = newText))
+ }
+ }
+ }
+}
+
+/**
* Helper for showing the autocomplete suggestion inline with user's input.
*/
-private class AutocompleteVisualTransformation(
- private val userInput: TextFieldValue,
- private val suggestion: AutocompleteResult?,
+@OptIn(ExperimentalFoundationApi::class)
+private class AutocompleteOutputTransformation(
+ private val suggestion: () -> AutocompleteResult?,
+ private val shouldUseSuggestion: () -> Boolean,
private val textColor: Color,
private val textBackground: Color,
-) : VisualTransformation {
-
- override fun filter(text: AnnotatedString): TransformedText {
- if (suggestion?.text.isNullOrEmpty() || !suggestion.text.startsWith(userInput.text)) {
- return TransformedText(AnnotatedString(userInput.text), OffsetMapping.Identity)
- }
+) : OutputTransformation {
+ override fun TextFieldBuffer.transformOutput() {
+ val userInput = asCharSequence()
+ val suggestion = suggestion()
+ if (!shouldUseSuggestion() ||
+ suggestion?.text?.isEmpty() == true ||
+ suggestion?.text?.startsWith(userInput) == false
+ ) { return }
- val transformed = buildAnnotatedString {
- append(userInput.text)
- append(
- AnnotatedString(
- suggestion.text.removePrefix(userInput.text),
- spanStyle = SpanStyle(
- color = textColor,
- background = textBackground,
- ),
+ val suffix = suggestion?.text?.removePrefix(userInput) ?: return
+ if (suffix.isNotEmpty()) {
+ val originalLength = length
+ append(suffix)
+ addStyle(
+ SpanStyle(
+ color = textColor,
+ background = textBackground,
),
+ originalLength,
+ length,
)
}
+ }
+}
- val offsetMapping = object : OffsetMapping {
- override fun originalToTransformed(offset: Int): Int {
- return offset
+/**
+ * Helper for handling the text shown to the user:
+ * - show the current query or hint if query is empty.
+ * - dismisses the suggestion if cursor is placed in query.
+ * - commits the suggestion if cursor is placed in the suggestion or after it.
+ */
+@Composable
+@Suppress("LongParameterList")
+private fun AutocompleteDecorator(
+ hint: String,
+ suggestion: AutocompleteResult?,
+ onSuggestionVisibilityChangeRequest: (Boolean) -> Unit,
+ suggestionBounds: Rect?,
+ textFieldState: TextFieldState,
+ onUrlEdit: (BrowserToolbarQuery) -> Unit,
+ deviceLayoutDirection: LayoutDirection,
+ innerTextField: @Composable () -> Unit,
+) {
+ // Stop using the suggestion if cursor is moved manually away from the end.
+ LaunchedEffect(textFieldState) {
+ snapshotFlow { textFieldState.selection }
+ .collectLatest {
+ if (it.end != textFieldState.text.length) {
+ onSuggestionVisibilityChangeRequest(false)
+ }
}
+ }
- override fun transformedToOriginal(offset: Int): Int {
- return offset.coerceIn(0, userInput.text.length)
- }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ // Commit the suggestion when users tap on the outside of the typed in text.
+ .pointerInput(suggestion, suggestionBounds) {
+ awaitEachGesture {
+ val downEvent = awaitFirstDown(requireUnconsumed = false)
+ val suggestionText = suggestion?.text
+ if (suggestionBounds != null && suggestionText != null &&
+ suggestionBounds.right < downEvent.position.x
+ ) {
+ onUrlEdit(
+ BrowserToolbarQuery(
+ previous = textFieldState.text.toString(),
+ current = suggestionText,
+ ),
+ )
+ textFieldState.edit {
+ replace(0, length, suggestionText)
+ selection = TextRange(suggestionText.length)
+ }
+ }
+ }
+ },
+ contentAlignment = when (deviceLayoutDirection) {
+ LayoutDirection.Ltr -> Alignment.CenterStart
+ LayoutDirection.Rtl -> Alignment.CenterEnd
+ },
+ ) {
+ if (textFieldState.text.isEmpty()) {
+ Text(
+ text = hint,
+ style = LocalTextStyle.current.merge(
+ fontSize = TEXT_SIZE.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ )
}
-
- return TransformedText(transformed, offsetMapping)
+ innerTextField()
}
}
diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt
@@ -111,6 +111,34 @@ class InlineAutocompleteTextFieldTest {
}
@Test
+ fun `GIVEN an autocomplete suggestion is shown WHEN tapping outside the query, THEN commit the autocomplete suggestion`() {
+ val onUrlEdit: (BrowserToolbarQuery) -> Unit = mock()
+ val suggestion = AutocompleteResult(
+ input = "w",
+ text = "wikipedia.org",
+ url = "https://wikipedia.org",
+ source = "test",
+ totalItems = 1,
+ )
+
+ composeTestRule.setContent {
+ InlineAutocompleteTextField(
+ query = "w",
+ hint = "",
+ suggestion = suggestion,
+ showQueryAsPreselected = false,
+ usePrivateModeQueries = false,
+ onUrlEdit = onUrlEdit,
+ )
+ }
+
+ // Tapping on the very right is to the outside of the current query.
+ composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).performTouchInput { click(position = centerRight) }
+
+ verify(onUrlEdit).invoke(BrowserToolbarQuery(previous = "w", current = "wikipedia.org"))
+ }
+
+ @Test
fun `GIVEN a query and suggestion are shown WHEN backspace is first pressed THEN only clear the suggestion`() {
val onUrlEdit: (BrowserToolbarQuery) -> Unit = mock()
val suggestion = AutocompleteResult(