diff --git a/README.md b/README.md index 22fad83..d4f464d 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,12 @@ implementation "io.github.dokar3:chiptextfield:latest_version" ```kotlin var value by remember { mutableStateOf("Initial text") } -val state = rememberChipTextFieldState( - value = value, - onValueChange = { value = it }, -) +val state = rememberChipTextFieldState() ChipTextField( state = state, - onSubmit = { textFieldValue -> Chip(textFieldValue.text) }, + value = value, + onValueChange = { value = it }, + onSubmit = { text -> Chip(text) }, ) ``` @@ -30,7 +29,7 @@ Simplified version if do not care about the text field value: val state = rememberChipTextFieldState() ChipTextField( state = state, - onSubmit = { textFieldValue -> Chip(textFieldValue.text) }, + onSubmit = ::Chip, ) ``` @@ -42,7 +41,7 @@ ChipTextField( val state = rememberChipTextFieldState() OutlinedChipTextField( state = state, - onSubmit = { Chip(it.text) }, + onSubmit = ::Chip, ) ``` @@ -53,10 +52,10 @@ OutlinedChipTextField( ```kotlin val state = rememberChipTextFieldState() ChipTextField( - state = state, - onSubmit = { Chip(it.text) }, + state = state, + onSubmit = ::Chip, colors = TextFieldDefaults.textFieldColors( - backgroundColor = Color.Transparent + backgroundColor = Color.Transparent, ), contentPadding = PaddingValues(bottom = 8.dp), ) @@ -72,7 +71,7 @@ class CheckableChip(text: String, isChecked: Boolean = false) : Chip(text) { } val state = rememberChipTextFieldState( - chips = listOf(CheckableChip(""), ...), + chips = listOf(CheckableChip(""), /*...*/), ) BasicChipTextField( state = state, @@ -84,7 +83,7 @@ BasicChipTextField( ) @Composable -fun CheckIcon(chip: CheckableChip, modifier: Modifier = Modifier) { ... } +fun CheckIcon(chip: CheckableChip, modifier: Modifier = Modifier) { /*...*/ } ``` ![](/images/screenshot_checkable.jpg) @@ -102,7 +101,7 @@ ChipTextField( ) @Composable -fun Avatar(chip: AvatarChip, modifier: Modifier = Modifier) { ... } +fun Avatar(chip: AvatarChip, modifier: Modifier = Modifier) { /*...*/ } ``` ![](/images/screenshot_avatar.png) diff --git a/build.gradle b/build.gradle index 0a3fffc..b2c646c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext { - compose_version = '1.3.0-beta02' + compose_version = '1.3.0-beta03' compose_compiler_version = '1.3.1' kotlin_version = '1.7.10' accompanist_version = '0.26.4-beta' diff --git a/gradle.properties b/gradle.properties index 8b6bdf9..cb50100 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ kotlin.code.style=official GROUP=io.github.dokar3 POM_ARTIFACT_ID=chiptextfield -VERSION_NAME=0.4.0-beta +VERSION_NAME=0.4.0-rc POM_NAME=ChipTextField POM_DESCRIPTION=Editable chip layout in Jetpack Compose. diff --git a/library/src/main/java/com/dokar/chiptextfield/BasicChipTextField.kt b/library/src/main/java/com/dokar/chiptextfield/BasicChipTextField.kt index 53e7ae5..b230adb 100644 --- a/library/src/main/java/com/dokar/chiptextfield/BasicChipTextField.kt +++ b/library/src/main/java/com/dokar/chiptextfield/BasicChipTextField.kt @@ -3,8 +3,8 @@ package com.dokar.chiptextfield import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.FocusInteraction import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -20,6 +20,7 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,6 +39,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.TextRange @@ -47,7 +49,8 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.dokar.chiptextfield.util.filterNewLine +import com.dokar.chiptextfield.util.StableHolder +import com.dokar.chiptextfield.util.filterNewLines import com.google.accompanist.flowlayout.FlowCrossAxisAlignment import com.google.accompanist.flowlayout.FlowRow import kotlinx.coroutines.flow.distinctUntilChanged @@ -57,8 +60,182 @@ import kotlinx.coroutines.flow.filter * A text field can display chips, press enter to create a new chip. * * @param state Use [rememberChipTextFieldState] to create new state. + * @param onSubmit Called after pressing enter key, used to create new chips. * @param modifier Modifier for chip text field. + * @param enabled Enabled state, if false, user will not able to edit and select. + * @param readOnly If true, edit will be disabled, but user can still select text. + * @param readOnlyChips If true, chips are no more editable, but the text field can still be edited + * if [readOnly] is not true. + * @param isError Error state, it is used to change cursor color. + * @param keyboardOptions See [BasicTextField] for the details. + * @param textStyle Text style, also apply to text in chips. + * @param chipStyle Chip style, include shape, text color, background color, etc. See [ChipStyle]. + * @param chipVerticalSpacing Vertical spacing between chips. + * @param chipHorizontalSpacing Horizontal spacing between chips. + * @param chipLeadingIcon Leading chip icon, nothing will be displayed by default. + * @param chipTrailingIcon Trailing chip icon, by default, a [CloseButton] will be displayed. + * @param onChipClick Chip click action. + * @param onChipLongClick Chip long click action. + * @param colors Text colors. [TextFieldDefaults.textFieldColors] is default colors. + * @param decorationBox The decoration box to wrap around text field. + * + * @see BasicTextField + */ +@Composable +fun BasicChipTextField( + state: ChipTextFieldState, + onSubmit: (value: String) -> T?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + readOnlyChips: Boolean = readOnly, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + textStyle: TextStyle = LocalTextStyle.current, + chipStyle: ChipStyle = ChipTextFieldDefaults.chipStyle(), + chipVerticalSpacing: Dp = 4.dp, + chipHorizontalSpacing: Dp = 4.dp, + chipLeadingIcon: @Composable (chip: T) -> Unit = {}, + chipTrailingIcon: @Composable (chip: T) -> Unit = { CloseButton(state, it) }, + onChipClick: ((chip: T) -> Unit)? = null, + onChipLongClick: ((chip: T) -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + colors: TextFieldColors = TextFieldDefaults.textFieldColors(), + decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit = + @Composable { innerTextField -> innerTextField() }, +) { + var value by remember { mutableStateOf(TextFieldValue()) } + val onValueChange: (TextFieldValue) -> Unit = { value = it } + BasicChipTextField( + state = state, + onSubmit = { onSubmit(it.text) }, + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + readOnlyChips = readOnlyChips, + isError = isError, + keyboardOptions = keyboardOptions, + textStyle = textStyle, + chipStyle = chipStyle, + chipVerticalSpacing = chipVerticalSpacing, + chipHorizontalSpacing = chipHorizontalSpacing, + chipLeadingIcon = chipLeadingIcon, + chipTrailingIcon = chipTrailingIcon, + onChipClick = onChipClick, + onChipLongClick = onChipLongClick, + interactionSource = interactionSource, + colors = colors, + decorationBox = decorationBox, + ) +} + +/** + * A text field can display chips, press enter to create a new chip. + * + * @param state Use [rememberChipTextFieldState] to create new state. + * @param value The value of text field. + * @param onValueChange Called when the value in ChipTextField has changed. * @param onSubmit Called after pressing enter key, used to create new chips. + * @param modifier Modifier for chip text field. + * @param enabled Enabled state, if false, user will not able to edit and select. + * @param readOnly If true, edit will be disabled, but user can still select text. + * @param readOnlyChips If true, chips are no more editable, but the text field can still be edited + * if [readOnly] is not true. + * @param isError Error state, it is used to change cursor color. + * @param keyboardOptions See [BasicTextField] for the details. + * @param textStyle Text style, also apply to text in chips. + * @param chipStyle Chip style, include shape, text color, background color, etc. See [ChipStyle]. + * @param chipVerticalSpacing Vertical spacing between chips. + * @param chipHorizontalSpacing Horizontal spacing between chips. + * @param chipLeadingIcon Leading chip icon, nothing will be displayed by default. + * @param chipTrailingIcon Trailing chip icon, by default, a [CloseButton] will be displayed. + * @param onChipClick Chip click action. + * @param onChipLongClick Chip long click action. + * @param colors Text colors. [TextFieldDefaults.textFieldColors] is default colors. + * @param decorationBox The decoration box to wrap around text field. + * + * @see BasicTextField + */ +@Composable +fun BasicChipTextField( + state: ChipTextFieldState, + value: String, + onValueChange: (String) -> Unit, + onSubmit: (value: String) -> T?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + readOnlyChips: Boolean = readOnly, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + textStyle: TextStyle = LocalTextStyle.current, + chipStyle: ChipStyle = ChipTextFieldDefaults.chipStyle(), + chipVerticalSpacing: Dp = 4.dp, + chipHorizontalSpacing: Dp = 4.dp, + chipLeadingIcon: @Composable (chip: T) -> Unit = {}, + chipTrailingIcon: @Composable (chip: T) -> Unit = { CloseButton(state, it) }, + onChipClick: ((chip: T) -> Unit)? = null, + onChipLongClick: ((chip: T) -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + colors: TextFieldColors = TextFieldDefaults.textFieldColors(), + decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit = + @Composable { innerTextField -> innerTextField() }, +) { + // Copied from androidx.compose.foundation.text.BasicTextField.kt + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + val textFieldValue = textFieldValueState.copy(text = value) + SideEffect { + if (textFieldValue.selection != textFieldValueState.selection || + textFieldValue.composition != textFieldValueState.composition) { + textFieldValueState = textFieldValue + } + } + var lastTextValue by remember(value) { mutableStateOf(value) } + val mappedOnValueChange: (TextFieldValue) -> Unit = { newTextFieldValueState -> + textFieldValueState = newTextFieldValueState + + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text + + if (stringChangedSinceLastInvocation) { + onValueChange(newTextFieldValueState.text) + } + } + BasicChipTextField( + state = state, + onSubmit = { onSubmit(it.text) }, + value = textFieldValue, + onValueChange = mappedOnValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + readOnlyChips = readOnlyChips, + isError = isError, + keyboardOptions = keyboardOptions, + textStyle = textStyle, + chipStyle = chipStyle, + chipVerticalSpacing = chipVerticalSpacing, + chipHorizontalSpacing = chipHorizontalSpacing, + chipLeadingIcon = chipLeadingIcon, + chipTrailingIcon = chipTrailingIcon, + onChipClick = onChipClick, + onChipLongClick = onChipLongClick, + interactionSource = interactionSource, + colors = colors, + decorationBox = decorationBox, + ) +} + +/** + * A text field can display chips, press enter to create a new chip. + * + * @param state Use [rememberChipTextFieldState] to create new state. + * @param value The value of text field. + * @param onValueChange Called when the value in ChipTextField has changed. + * @param onSubmit Called after pressing enter key, used to create new chips. + * @param modifier Modifier for chip text field. * @param enabled Enabled state, if false, user will not able to edit and select. * @param readOnly If true, edit will be disabled, but user can still select text. * @param readOnlyChips If true, chips are no more editable, but the text field can still be edited @@ -85,6 +262,8 @@ import kotlinx.coroutines.flow.filter @Composable fun BasicChipTextField( state: ChipTextFieldState, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, onSubmit: (value: TextFieldValue) -> T?, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -105,7 +284,7 @@ fun BasicChipTextField( decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit = @Composable { innerTextField -> innerTextField() }, ) { - val textFieldFocusRequester = remember { FocusRequester() } + val textFieldFocusRequester = remember { StableHolder(FocusRequester()) } val editable = enabled && !readOnly @@ -114,7 +293,7 @@ fun BasicChipTextField( LaunchedEffect(state) { snapshotFlow { state.chips } .filter { it.isEmpty() } - .collect { textFieldFocusRequester.requestFocus() } + .collect { textFieldFocusRequester.value.requestFocus() } } LaunchedEffect(state, state.disposed) { @@ -133,41 +312,41 @@ fun BasicChipTextField( decorationBox { FlowRow( modifier = modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - keyboardController?.show() - textFieldFocusRequester.requestFocus() - state.focusedChip = null - // Move cursor to the end - val selection = state.value.text.length - state.value = state.value.copy(selection = TextRange(selection)) - }, - enabled = editable, - ), + .pointerInput(value) { + detectTapGestures( + onTap = { + if (!editable) return@detectTapGestures + keyboardController?.show() + textFieldFocusRequester.value.requestFocus() + state.focusedChip = null + // Move cursor to the end + val selection = value.text.length + onValueChange(value.copy(selection = TextRange(selection))) + }, + ) + }, mainAxisSpacing = chipHorizontalSpacing, crossAxisSpacing = chipVerticalSpacing, crossAxisAlignment = FlowCrossAxisAlignment.Center ) { - val focuses = remember { mutableSetOf() } + val focuses = remember { StableHolder(mutableSetOf()) } Chips( state = state, enabled = enabled, readOnly = readOnly || readOnlyChips, onRemoveRequest = { state.removeChip(it) }, onFocused = { - if (!focuses.contains(it)) { - focuses.add(it) + if (!focuses.value.contains(it)) { + focuses.value.add(it) interactionSource.tryEmit(it) } }, onFreeFocus = { - focuses.remove(it) + focuses.value.remove(it) interactionSource.tryEmit(FocusInteraction.Unfocus(it)) }, onLoseFocus = { - textFieldFocusRequester.requestFocus() + textFieldFocusRequester.value.requestFocus() state.focusedChip = null }, onChipClick = onChipClick, @@ -181,6 +360,8 @@ fun BasicChipTextField( Input( state = state, onSubmit = onSubmit, + value = value, + onValueChange = onValueChange, enabled = enabled, readOnly = readOnly, isError = isError, @@ -312,18 +493,19 @@ private fun Chips( private fun Input( state: ChipTextFieldState, onSubmit: (value: TextFieldValue) -> T?, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, enabled: Boolean, readOnly: Boolean, isError: Boolean, textStyle: TextStyle, colors: TextFieldColors, keyboardOptions: KeyboardOptions, - focusRequester: FocusRequester, + focusRequester: StableHolder, interactionSource: MutableInteractionSource, onFocusChange: (isFocused: Boolean) -> Unit, modifier: Modifier = Modifier, ) { - val value = state.value if (value.text.isEmpty() && (!enabled || readOnly)) { return } @@ -332,28 +514,20 @@ private fun Input( } fun tryAddNewChip(value: TextFieldValue): Boolean { - val newChip = onSubmit(value) - return if (newChip != null) { - state.addChip(newChip) - true - } else { - false - } + return onSubmit(value)?.also { state.addChip(it) } != null } BasicTextField( value = value, - onValueChange = filterNewLine { newValue, hasNewLine -> - if (hasNewLine && newValue.text.isNotEmpty()) { - if (tryAddNewChip(newValue)) { - state.value = TextFieldValue() - return@filterNewLine - } + onValueChange = filterNewLines { newValue, hasNewLine -> + if (hasNewLine && newValue.text.isNotEmpty() && tryAddNewChip(newValue)) { + onValueChange(TextFieldValue()) + } else { + onValueChange(newValue) } - state.onValueChange(newValue) }, modifier = modifier - .focusRequester(focusRequester) + .focusRequester(focusRequester.value) .onFocusChanged { onFocusChange(it.isFocused) } .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown && it.key == Key.Backspace) { @@ -371,8 +545,8 @@ private fun Input( keyboardOptions = keyboardOptions.copy(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { - if (value.text.isNotEmpty()) { - tryAddNewChip(value) + if (value.text.isNotEmpty() && tryAddNewChip(value)) { + onValueChange(TextFieldValue()) } } ), @@ -479,7 +653,7 @@ private fun ChipItem( var canRemoveChip by remember { mutableStateOf(false) } BasicTextField( value = chip.textFieldValue, - onValueChange = filterNewLine { value, hasNewLine -> + onValueChange = filterNewLines { value, hasNewLine -> chip.textFieldValue = value if (hasNewLine) { onFocusNextRequest() diff --git a/library/src/main/java/com/dokar/chiptextfield/ChipTextField.kt b/library/src/main/java/com/dokar/chiptextfield/ChipTextField.kt index fb912bc..7a4fc95 100644 --- a/library/src/main/java/com/dokar/chiptextfield/ChipTextField.kt +++ b/library/src/main/java/com/dokar/chiptextfield/ChipTextField.kt @@ -13,7 +13,11 @@ import androidx.compose.material.TextFieldColors import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TextFieldDefaults.indicatorLine import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.TextStyle @@ -32,6 +36,170 @@ import androidx.compose.ui.unit.dp @Composable fun ChipTextField( state: ChipTextFieldState, + onSubmit: (value: String) -> T?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + readOnlyChips: Boolean = readOnly, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions(), + textStyle: TextStyle = LocalTextStyle.current, + chipStyle: ChipStyle = ChipTextFieldDefaults.chipStyle(), + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + chipVerticalSpacing: Dp = 4.dp, + chipHorizontalSpacing: Dp = 4.dp, + chipLeadingIcon: @Composable (chip: T) -> Unit = {}, + chipTrailingIcon: @Composable (chip: T) -> Unit = { CloseButton(state, it) }, + onChipClick: ((chip: T) -> Unit)? = null, + onChipLongClick: ((chip: T) -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.TextFieldShape, + colors: TextFieldColors = TextFieldDefaults.textFieldColors(), + contentPadding: PaddingValues = + if (label == null) { + TextFieldDefaults.textFieldWithoutLabelPadding() + } else { + TextFieldDefaults.textFieldWithLabelPadding() + } +) { + var value by remember { mutableStateOf(TextFieldValue()) } + val onValueChange: (TextFieldValue) -> Unit = { value = it } + ChipTextField( + state = state, + onSubmit = { onSubmit(it.text) }, + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + readOnlyChips = readOnlyChips, + isError = isError, + keyboardOptions = keyboardOptions, + textStyle = textStyle, + chipStyle = chipStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + chipVerticalSpacing = chipVerticalSpacing, + chipHorizontalSpacing = chipHorizontalSpacing, + chipLeadingIcon = chipLeadingIcon, + chipTrailingIcon = chipTrailingIcon, + onChipClick = onChipClick, + onChipLongClick = onChipLongClick, + interactionSource = interactionSource, + shape = shape, + colors = colors, + contentPadding = contentPadding, + ) +} + +/** + * Chip text field with Material Design filled style. + * + * @see [BasicChipTextField] + * @see [TextField] + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ChipTextField( + state: ChipTextFieldState, + value: String, + onValueChange: (String) -> Unit, + onSubmit: (value: String) -> T?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + readOnlyChips: Boolean = readOnly, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions(), + textStyle: TextStyle = LocalTextStyle.current, + chipStyle: ChipStyle = ChipTextFieldDefaults.chipStyle(), + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + chipVerticalSpacing: Dp = 4.dp, + chipHorizontalSpacing: Dp = 4.dp, + chipLeadingIcon: @Composable (chip: T) -> Unit = {}, + chipTrailingIcon: @Composable (chip: T) -> Unit = { CloseButton(state, it) }, + onChipClick: ((chip: T) -> Unit)? = null, + onChipLongClick: ((chip: T) -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.TextFieldShape, + colors: TextFieldColors = TextFieldDefaults.textFieldColors(), + contentPadding: PaddingValues = + if (label == null) { + TextFieldDefaults.textFieldWithoutLabelPadding() + } else { + TextFieldDefaults.textFieldWithLabelPadding() + } +) { + // Copied from androidx.compose.foundation.text.BasicTextField.kt + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + val textFieldValue = textFieldValueState.copy(text = value) + SideEffect { + if (textFieldValue.selection != textFieldValueState.selection || + textFieldValue.composition != textFieldValueState.composition) { + textFieldValueState = textFieldValue + } + } + var lastTextValue by remember(value) { mutableStateOf(value) } + val mappedOnValueChange: (TextFieldValue) -> Unit = { newTextFieldValueState -> + textFieldValueState = newTextFieldValueState + + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text + + if (stringChangedSinceLastInvocation) { + onValueChange(newTextFieldValueState.text) + } + } + ChipTextField( + state = state, + onSubmit = { onSubmit(it.text) }, + value = textFieldValue, + onValueChange = mappedOnValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + readOnlyChips = readOnlyChips, + isError = isError, + keyboardOptions = keyboardOptions, + textStyle = textStyle, + chipStyle = chipStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + chipVerticalSpacing = chipVerticalSpacing, + chipHorizontalSpacing = chipHorizontalSpacing, + chipLeadingIcon = chipLeadingIcon, + chipTrailingIcon = chipTrailingIcon, + onChipClick = onChipClick, + onChipLongClick = onChipLongClick, + interactionSource = interactionSource, + shape = shape, + colors = colors, + contentPadding = contentPadding, + ) +} + +/** + * Chip text field with Material Design filled style. + * + * @see [BasicChipTextField] + * @see [TextField] + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ChipTextField( + state: ChipTextFieldState, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, onSubmit: (value: TextFieldValue) -> T?, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -69,6 +237,8 @@ fun ChipTextField( BasicChipTextField( state = state, onSubmit = onSubmit, + value = value, + onValueChange = onValueChange, modifier = Modifier.fillMaxWidth(), enabled = enabled, readOnly = readOnly, @@ -87,7 +257,7 @@ fun ChipTextField( colors = colors, decorationBox = { innerTextField -> TextFieldDefaults.TextFieldDecorationBox( - value = if (state.chips.isEmpty() && state.value.text.isEmpty()) "" else " ", + value = if (state.chips.isEmpty() && value.text.isEmpty()) "" else " ", innerTextField = innerTextField, enabled = !readOnly, singleLine = false, diff --git a/library/src/main/java/com/dokar/chiptextfield/ChipTextFieldState.kt b/library/src/main/java/com/dokar/chiptextfield/ChipTextFieldState.kt index daebd7f..3e78178 100644 --- a/library/src/main/java/com/dokar/chiptextfield/ChipTextFieldState.kt +++ b/library/src/main/java/com/dokar/chiptextfield/ChipTextFieldState.kt @@ -6,114 +6,30 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue - - -/** - * Return a new remembered [ChipTextFieldState] - * - * @param chips Default chips - */ -@Composable -fun rememberChipTextFieldState( - chips: List = emptyList() -): ChipTextFieldState { - var value by remember { mutableStateOf("") } - return rememberChipTextFieldState( - value = value, - onValueChange = { value = it }, - chips = chips, - ) -} /** * Return a new remembered [ChipTextFieldState] * - * @param value The value of text field. - * @param onValueChange Called when the value in ChipTextField has changed. * @param chips Default chips */ @Composable fun rememberChipTextFieldState( - value: String, - onValueChange: (String) -> Unit, chips: List = emptyList() ): ChipTextFieldState { - // Copied from BasicTextField.kt - var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } - val textFieldValue = textFieldValueState.copy(text = value) - var lastTextValue by remember(value) { mutableStateOf(value) } - val mappedOnValueChange: (TextFieldValue) -> Unit = { newTextFieldValueState -> - textFieldValueState = newTextFieldValueState - - val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text - lastTextValue = newTextFieldValueState.text - - if (stringChangedSinceLastInvocation) { - onValueChange(newTextFieldValueState.text) - } - } - - return remember { - ChipTextFieldState(textFieldValue, mappedOnValueChange, chips) - }.apply { - this.onValueChange = mappedOnValueChange - this.value = textFieldValue - this.defaultChips = chips - } -} - -/** - * Return a new remembered [ChipTextFieldState] - * - * @param value The value of text field. - * @param onValueChange Called when the value in ChipTextField has changed. - * @param chips Default chips - */ -@Composable -fun rememberChipTextFieldState( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - chips: List = emptyList() -): ChipTextFieldState { - return remember { - ChipTextFieldState(value, onValueChange, chips) - }.apply { - this.onValueChange = { - if (it != value) { - onValueChange(it) - } - } - this.value = value - this.defaultChips = chips - } + return remember { ChipTextFieldState(chips = chips) } } /** * State class for [BasicChipTextField] * - * @param value The value of text field. - * @param onValueChange The callback to update value. * @param chips Default chips */ @Stable class ChipTextFieldState( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, chips: List = emptyList() ) { internal var disposed = false - private var _value by mutableStateOf(value) - internal var value: TextFieldValue - get() = _value - set(newValue) { - _value = newValue - onValueChange(newValue) - } - - internal var onValueChange by mutableStateOf(onValueChange) - internal var defaultChips: List = chips internal var focusedChip: T? by mutableStateOf(null) diff --git a/library/src/main/java/com/dokar/chiptextfield/OutlinedChipTextField.kt b/library/src/main/java/com/dokar/chiptextfield/OutlinedChipTextField.kt index e891765..c74147c 100644 --- a/library/src/main/java/com/dokar/chiptextfield/OutlinedChipTextField.kt +++ b/library/src/main/java/com/dokar/chiptextfield/OutlinedChipTextField.kt @@ -13,7 +13,11 @@ import androidx.compose.material.OutlinedTextField import androidx.compose.material.TextFieldColors import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.TextStyle @@ -23,6 +27,152 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.dokar.chiptextfield.util.runIf +/** + * Chip text field with Material Design outlined style. + * + * @see [BasicChipTextField] + * @see [OutlinedTextField] + */ +@Composable +fun OutlinedChipTextField( + state: ChipTextFieldState, + onSubmit: (value: String) -> T?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + readOnlyChips: Boolean = readOnly, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions(), + textStyle: TextStyle = LocalTextStyle.current, + chipStyle: ChipStyle = ChipTextFieldDefaults.chipStyle(), + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + chipVerticalSpacing: Dp = 4.dp, + chipHorizontalSpacing: Dp = 4.dp, + chipLeadingIcon: @Composable (chip: T) -> Unit = {}, + chipTrailingIcon: @Composable (chip: T) -> Unit = { CloseButton(state, it) }, + onChipClick: ((chip: T) -> Unit)? = null, + onChipLongClick: ((chip: T) -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = MaterialTheme.shapes.small, + colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors(), +) { + var value by remember { mutableStateOf(TextFieldValue()) } + val onValueChange: (TextFieldValue) -> Unit = { value = it } + OutlinedChipTextField( + state = state, + onSubmit = { onSubmit(it.text) }, + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + readOnlyChips = readOnlyChips, + isError = isError, + keyboardOptions = keyboardOptions, + textStyle = textStyle, + chipStyle = chipStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + chipVerticalSpacing = chipVerticalSpacing, + chipHorizontalSpacing = chipHorizontalSpacing, + chipLeadingIcon = chipLeadingIcon, + chipTrailingIcon = chipTrailingIcon, + onChipClick = onChipClick, + onChipLongClick = onChipLongClick, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + +/** + * Chip text field with Material Design outlined style. + * + * @see [BasicChipTextField] + * @see [OutlinedTextField] + */ +@Composable +fun OutlinedChipTextField( + state: ChipTextFieldState, + value: String, + onValueChange: (String) -> Unit, + onSubmit: (value: String) -> T?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + readOnlyChips: Boolean = readOnly, + isError: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions(), + textStyle: TextStyle = LocalTextStyle.current, + chipStyle: ChipStyle = ChipTextFieldDefaults.chipStyle(), + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + chipVerticalSpacing: Dp = 4.dp, + chipHorizontalSpacing: Dp = 4.dp, + chipLeadingIcon: @Composable (chip: T) -> Unit = {}, + chipTrailingIcon: @Composable (chip: T) -> Unit = { CloseButton(state, it) }, + onChipClick: ((chip: T) -> Unit)? = null, + onChipLongClick: ((chip: T) -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = MaterialTheme.shapes.small, + colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors(), +) { + // Copied from androidx.compose.foundation.text.BasicTextField.kt + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + val textFieldValue = textFieldValueState.copy(text = value) + SideEffect { + if (textFieldValue.selection != textFieldValueState.selection || + textFieldValue.composition != textFieldValueState.composition) { + textFieldValueState = textFieldValue + } + } + var lastTextValue by remember(value) { mutableStateOf(value) } + val mappedOnValueChange: (TextFieldValue) -> Unit = { newTextFieldValueState -> + textFieldValueState = newTextFieldValueState + + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text + + if (stringChangedSinceLastInvocation) { + onValueChange(newTextFieldValueState.text) + } + } + OutlinedChipTextField( + state = state, + onSubmit = { onSubmit(it.text) }, + value = textFieldValue, + onValueChange = mappedOnValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + readOnlyChips = readOnlyChips, + isError = isError, + keyboardOptions = keyboardOptions, + textStyle = textStyle, + chipStyle = chipStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + chipVerticalSpacing = chipVerticalSpacing, + chipHorizontalSpacing = chipHorizontalSpacing, + chipLeadingIcon = chipLeadingIcon, + chipTrailingIcon = chipTrailingIcon, + onChipClick = onChipClick, + onChipLongClick = onChipLongClick, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + /** * Chip text field with Material Design outlined style. * @@ -33,6 +183,8 @@ import com.dokar.chiptextfield.util.runIf @Composable fun OutlinedChipTextField( state: ChipTextFieldState, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, onSubmit: (value: TextFieldValue) -> T?, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -64,6 +216,8 @@ fun OutlinedChipTextField( BasicChipTextField( state = state, onSubmit = onSubmit, + value = value, + onValueChange = onValueChange, modifier = Modifier.fillMaxWidth(), enabled = enabled, readOnly = readOnly, @@ -82,7 +236,7 @@ fun OutlinedChipTextField( colors = colors, decorationBox = { innerTextField -> TextFieldDefaults.OutlinedTextFieldDecorationBox( - value = if (state.chips.isEmpty() && state.value.text.isEmpty()) "" else " ", + value = if (state.chips.isEmpty() && value.text.isEmpty()) "" else " ", innerTextField = innerTextField, enabled = !readOnly, singleLine = false, diff --git a/library/src/main/java/com/dokar/chiptextfield/util/FilterNewLineOnValueChange.kt b/library/src/main/java/com/dokar/chiptextfield/util/FilterNewLinesOnValueChange.kt similarity index 77% rename from library/src/main/java/com/dokar/chiptextfield/util/FilterNewLineOnValueChange.kt rename to library/src/main/java/com/dokar/chiptextfield/util/FilterNewLinesOnValueChange.kt index efdfbca..120f8d3 100644 --- a/library/src/main/java/com/dokar/chiptextfield/util/FilterNewLineOnValueChange.kt +++ b/library/src/main/java/com/dokar/chiptextfield/util/FilterNewLinesOnValueChange.kt @@ -5,14 +5,14 @@ import androidx.compose.ui.text.input.TextFieldValue /** * Remove all `\n` in [TextFieldValue] */ -internal fun filterNewLine( +internal fun filterNewLines( block: (value: TextFieldValue, hasNewLine: Boolean) -> Unit ): (TextFieldValue) -> Unit = { val text = it.text val hasNewLine = text.hasNewLine() val value = if (hasNewLine) { TextFieldValue( - text = text.removeNewLine(), + text = text.removeNewLines(), selection = it.selection, composition = it.composition, ) @@ -26,13 +26,11 @@ private fun String.hasNewLine(): Boolean { return indexOf('\n') != -1 } -private fun String.removeNewLine(): String { +private fun String.removeNewLines(): String { val index = indexOf('\n') return if (index != -1) { - replace(NEW_LINE_REGEX, "") + replace("\n", "") } else { this } -} - -private val NEW_LINE_REGEX = Regex("\\n") +} \ No newline at end of file diff --git a/library/src/main/java/com/dokar/chiptextfield/util/StableHolder.kt b/library/src/main/java/com/dokar/chiptextfield/util/StableHolder.kt new file mode 100644 index 0000000..38f69b6 --- /dev/null +++ b/library/src/main/java/com/dokar/chiptextfield/util/StableHolder.kt @@ -0,0 +1,6 @@ +package com.dokar.chiptextfield.util + +import androidx.compose.runtime.Stable + +@Stable +internal data class StableHolder(val value: T) \ No newline at end of file diff --git a/sample/src/main/java/com/dokar/chiptextfield/sample/AvatarChips.kt b/sample/src/main/java/com/dokar/chiptextfield/sample/AvatarChips.kt index 4e16f0f..b52fefc 100644 --- a/sample/src/main/java/com/dokar/chiptextfield/sample/AvatarChips.kt +++ b/sample/src/main/java/com/dokar/chiptextfield/sample/AvatarChips.kt @@ -37,7 +37,7 @@ internal fun AvatarChips( ChipTextField( state = state, - onSubmit = { AvatarChip(it.text, SampleChips.randomAvatarUrl()) }, + onSubmit = { AvatarChip(it, SampleChips.randomAvatarUrl()) }, modifier = Modifier .fillMaxWidth() .padding(8.dp), diff --git a/sample/src/main/java/com/dokar/chiptextfield/sample/TextChips.kt b/sample/src/main/java/com/dokar/chiptextfield/sample/TextChips.kt index 51bf274..806105a 100644 --- a/sample/src/main/java/com/dokar/chiptextfield/sample/TextChips.kt +++ b/sample/src/main/java/com/dokar/chiptextfield/sample/TextChips.kt @@ -45,13 +45,13 @@ internal fun TextChips(chipFieldStyle: ChipFieldStyle) { private fun Underline(chipFieldStyle: ChipFieldStyle) { var value by remember { mutableStateOf("Android") } val state = rememberChipTextFieldState( - value = value, - onValueChange = { value = it }, chips = remember { SampleChips.getTextChips() }, ) ChipTextField( state = state, - onSubmit = { Chip(it.text) }, + value = value, + onValueChange = { value = it }, + onSubmit = ::Chip, modifier = Modifier.padding(8.dp), chipStyle = ChipTextFieldDefaults.chipStyle( focusedTextColor = chipFieldStyle.textColor, @@ -74,7 +74,7 @@ private fun MaterialOutlined(chipFieldStyle: ChipFieldStyle) { ) OutlinedChipTextField( state = state, - onSubmit = { Chip(it.text) }, + onSubmit = ::Chip, modifier = Modifier.padding(8.dp), chipStyle = ChipTextFieldDefaults.chipStyle( focusedTextColor = chipFieldStyle.textColor, @@ -98,7 +98,7 @@ private fun MaterialFilled(chipFieldStyle: ChipFieldStyle) { ChipTextField( state = state, modifier = Modifier.padding(8.dp), - onSubmit = { Chip(it.text) }, + onSubmit = ::Chip, chipStyle = ChipTextFieldDefaults.chipStyle( focusedTextColor = chipFieldStyle.textColor, focusedBorderColor = chipFieldStyle.borderColor, diff --git a/sample/src/main/java/com/dokar/chiptextfield/sample/ThemeColorSelector.kt b/sample/src/main/java/com/dokar/chiptextfield/sample/ThemeColorSelector.kt index f2ad04f..19f25f2 100644 --- a/sample/src/main/java/com/dokar/chiptextfield/sample/ThemeColorSelector.kt +++ b/sample/src/main/java/com/dokar/chiptextfield/sample/ThemeColorSelector.kt @@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +@Immutable internal data class ChipFieldStyle( val textColor: Color, val borderColor: Color,