From 1395ad4eda97c362a2d1a02cb9325df87e2553be Mon Sep 17 00:00:00 2001 From: fankesyooni Date: Tue, 21 Nov 2023 06:23:48 +0800 Subject: [PATCH] feat: add auto complete box in TextField --- .../flexiui/component/TextField.kt | 139 +++++++++++++++++- 1 file changed, 135 insertions(+), 4 deletions(-) diff --git a/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/TextField.kt b/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/TextField.kt index 4de146a..5f9df31 100644 --- a/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/TextField.kt +++ b/flexiui-core/src/commonMain/kotlin/com/highcapable/flexiui/component/TextField.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -64,14 +65,18 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import com.highcapable.flexiui.LocalColors import com.highcapable.flexiui.LocalShapes import com.highcapable.flexiui.LocalSizes @@ -84,12 +89,11 @@ import com.highcapable.flexiui.utils.orElse import com.highcapable.flexiui.utils.solidColor import com.highcapable.flexiui.utils.status -// TODO: auto complete text box (possible a few long time later) - @Immutable data class TextFieldColors( val cursorColor: Color, val selectionColors: TextSelectionColors, + val completionColors: AutoCompleteBoxColors, val decorInactiveTint: Color, val decorActiveTint: Color, val borderInactiveColor: Color, @@ -97,6 +101,12 @@ data class TextFieldColors( val backgroundColor: Color ) +@Immutable +data class AutoCompleteBoxColors( + val highlightContentColor: Color, + val menuColors: DropdownMenuColors +) + @Immutable data class TextFieldStyle( val padding: Dp, @@ -106,18 +116,29 @@ data class TextFieldStyle( val endPadding: Dp, val shape: Shape, val borderInactive: BorderStroke, - val borderActive: BorderStroke + val borderActive: BorderStroke, + val completionStyle: DropdownMenuStyle +) + +@Immutable +data class AutoCompleteOptions( + val checkCase: Boolean = true, + val checkStartSpace: Boolean = true, + val checkEndSpace: Boolean = true, + val threshold: Int = 2 ) @Composable fun TextField( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, + completionValues: List = emptyList(), modifier: Modifier = Modifier, colors: TextFieldColors = TextField.colors, style: TextFieldStyle = TextField.style, enabled: Boolean = true, readOnly: Boolean = false, + completionOptions: AutoCompleteOptions = AutoCompleteOptions(), keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, @@ -210,6 +231,17 @@ fun TextField( ) } } + AutoCompleteTextFieldBox( + value = value, + onValueChange = onValueChange, + completionValues = completionValues, + completionOptions = completionOptions, + completionColors = colors.completionColors, + completionStyle = style.completionStyle, + focusRequester = focusRequester, + dropdownMenuWidth = if (needInflatable) maxWidth else Dp.Unspecified, + textFieldAvailable = enabled && !readOnly && focused + ) } } @@ -217,11 +249,13 @@ fun TextField( fun TextField( value: String, onValueChange: (String) -> Unit, + completionValues: List = emptyList(), modifier: Modifier = Modifier, colors: TextFieldColors = TextField.colors, style: TextFieldStyle = TextField.style, enabled: Boolean = true, readOnly: Boolean = false, + completionOptions: AutoCompleteOptions = AutoCompleteOptions(), keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, @@ -243,11 +277,13 @@ fun TextField( textFieldValue = it onValueChange(it.text) }, + completionValues = completionValues, modifier = modifier, colors = colors, style = style, enabled = enabled, readOnly = readOnly, + completionOptions = completionOptions, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, singleLine = singleLine, @@ -385,11 +421,13 @@ fun PasswordTextField( fun BackspaceTextField( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, + completionValues: List = emptyList(), modifier: Modifier = Modifier, colors: TextFieldColors = TextField.colors, style: TextFieldStyle = TextField.style, enabled: Boolean = true, readOnly: Boolean = false, + completionOptions: AutoCompleteOptions = AutoCompleteOptions(), keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, @@ -406,11 +444,13 @@ fun BackspaceTextField( TextField( value = value, onValueChange = onValueChange, + completionValues = completionValues, modifier = modifier, colors = colors, style = style, enabled = enabled, readOnly = readOnly, + completionOptions = completionOptions, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, singleLine = singleLine, @@ -456,11 +496,13 @@ fun BackspaceTextField( fun BackspaceTextField( value: String, onValueChange: (String) -> Unit, + completionValues: List = emptyList(), modifier: Modifier = Modifier, colors: TextFieldColors = TextField.colors, style: TextFieldStyle = TextField.style, enabled: Boolean = true, readOnly: Boolean = false, + completionOptions: AutoCompleteOptions = AutoCompleteOptions(), keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, @@ -481,11 +523,13 @@ fun BackspaceTextField( textFieldValue = it onValueChange(it.text) }, + completionValues = completionValues, modifier = modifier, colors = colors, style = style, enabled = enabled, readOnly = readOnly, + completionOptions = completionOptions, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, singleLine = singleLine, @@ -501,6 +545,88 @@ fun BackspaceTextField( ) } +@Composable +private fun AutoCompleteTextFieldBox( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + completionValues: List, + completionOptions: AutoCompleteOptions, + completionColors: AutoCompleteBoxColors, + completionStyle: DropdownMenuStyle, + focusRequester: FocusRequester, + dropdownMenuWidth: Dp, + textFieldAvailable: Boolean +) { + if (completionValues.isEmpty()) return + // We need to use some "last" to remember the last using data, + // because we need to mantain the animation state of the dropdown menu. + // This allows the animation to finish playing due to the next composable event. + var lastHandingModified by remember { mutableStateOf(false) } + var lastMatchedValue by remember { mutableStateOf("") } + var lastInputLength by remember { mutableStateOf(0) } + var lastMatchedValues by remember { mutableStateOf(listOf()) } + val inputText = value.text.let { + var currentText = it + when { + !completionOptions.checkStartSpace -> currentText = currentText.trimStart() + !completionOptions.checkEndSpace -> currentText = currentText.trimEnd() + }; currentText + } + val hasInput = inputText.isNotEmpty() + val matchedValues = completionValues.filter { + if (inputText.length >= completionOptions.threshold) + if (completionOptions.checkCase) + it.startsWith(inputText) + else it.lowercase().startsWith(inputText.lowercase()) + else false + }.sortedBy { it.length } + if (matchedValues.isNotEmpty() && !lastHandingModified) { + lastMatchedValues = matchedValues + lastInputLength = inputText.length + } + val matchText = if (completionOptions.checkCase) + lastMatchedValue != inputText + else lastMatchedValue.lowercase() != inputText.lowercase() + val expanded = hasInput && matchedValues.isNotEmpty() && matchText + // As long as it is expanded, reset the lastHandingModified, lastMatchedValue. + if (expanded) { + lastHandingModified = false + lastMatchedValue = "" + } + // Clearly, if the text field is not available, + // the dropdown menu should not be displayed when reavailable. + if (!textFieldAvailable && matchedValues.isNotEmpty()) lastMatchedValue = inputText + DropdownMenu( + expanded = expanded && textFieldAvailable, + onDismissRequest = {}, + modifier = dropdownMenuWidth.orElse()?.let { Modifier.width(it) } ?: Modifier.width(IntrinsicSize.Max), + colors = completionColors.menuColors, + style = completionStyle, + properties = PopupProperties(focusable = false) + ) { + lastMatchedValues.forEach { matchedValue -> + DropdownMenuItem( + onClick = { + val newValue = TextFieldValue(matchedValue, TextRange(matchedValue.length)) + lastHandingModified = true + lastMatchedValue = matchedValue + onValueChange(newValue) + focusRequester.requestFocus() + } + ) { + Text(buildAnnotatedString { + append(matchedValue) + addStyle( + style = SpanStyle(color = completionColors.highlightContentColor, fontWeight = FontWeight.Bold), + start = 0, + end = lastInputLength + ) + }) + } + } + } +} + @Composable private fun InnerDecorationBox( decorTint: Color, @@ -597,6 +723,10 @@ private fun defaultTextFieldColors() = TextFieldColors( handleColor = LocalColors.current.themePrimary, backgroundColor = LocalColors.current.themeSecondary ), + completionColors = AutoCompleteBoxColors( + highlightContentColor = LocalColors.current.themePrimary, + menuColors = DropdownMenu.colors + ), decorInactiveTint = LocalColors.current.themeSecondary, decorActiveTint = LocalColors.current.themePrimary, borderInactiveColor = LocalColors.current.themeSecondary, @@ -617,7 +747,8 @@ private fun defaultTextFieldStyle() = TextFieldStyle( else -> LocalShapes.current.secondary }, borderInactive = defaultTextFieldInactiveBorder(), - borderActive = defaultTextFieldActiveBorder() + borderActive = defaultTextFieldActiveBorder(), + completionStyle = DropdownMenu.style ) @Composable