androidkotlinandroid-jetpack-composeinput-field

Chips in InputField like Gmail in Jetpack Compose


I was wondering if it is possible in Jetpack Compose to get a Gmail like behavior, anyone did something like this before and want to share their solution?

I just want to give the user the opportunity to add tags to their content before uploading, I don't need those pop-up suggestions like in the gif below.

Just simple chips in a InputField.


Solution

  • Here is how I did it. Basically I just used flow row along with a text field

    KoohaHashTagEditor(
        textFieldValue = hashTagTextValue,
        onValueChanged = {
            hashTagError = null
            val values = FormUtil.splitPerSpaceOrNewLine(it.text)
    
            if (values.size >= 2) {
                if (!FormUtil.isFilled(values[0])) {
                    hashTagError = "At least 2 characters per tag."
                } else if (!FormUtil.checkTagMinimumCharacter(values[0])) {
                    hashTagError = "At least 2 characters per tag."
                } else if (!FormUtil.checkTagMaximumCharacter(values[0])) {
                    hashTagError = "Up to 50 characters per tag."
                }
    
                if (hashTagError == null) {
                    addHashTag(values[0])
                    hashTagTextValue = hashTagTextValue.copy(text = "")
                }
            } else {
                hashTagTextValue = it
            }
        },
        focusRequester = hashTagFocusRequester,
        focusedFlow = hashTagFocusedFlow.value,
        textFieldInteraction = hashTagInteraction,
        label = null,
        placeholder = "To add a tag, hit the enter or space bar on your keypad after each tag.",
        rowInteraction = rowInteraction,
        errorMessage = hashTagError,
        listOfChips = uiState.hashtags,
        modifier = Modifier.onKeyEvent {
            if (it.key.keyCode == Key.Backspace.keyCode) {
                removeLastTag()
            }
            false
        },
        onChipClick = { chipIndex ->
            removeTagOnIndex(chipIndex)
        }
    )
    
    @OptIn(ExperimentalComposeUiApi::class)
    @Composable
    fun KoohaHashTagEditor(
        modifier: Modifier = Modifier,
        textFieldValue: TextFieldValue,
        onValueChanged: (TextFieldValue) -> Unit,
        focusRequester: FocusRequester,
        focusedFlow: Boolean,
        textFieldInteraction: MutableInteractionSource,
        label: String?,
        placeholder: String,
        readOnly: Boolean = false,
        message: String? = null,
        errorMessage: String? = null,
        keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(
            keyboardType = KeyboardType.Text,
            imeAction = ImeAction.Default
        ),
        rowInteraction: MutableInteractionSource,
        listOfChips: List<String> = emptyList(),
        onChipClick: (Int) -> Unit
    ) {
        val isLight = MaterialTheme.colors.isLight
    
        val focusManager = LocalFocusManager.current
        val keyboardManager = LocalSoftwareKeyboardController.current
    
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .padding(
                    vertical = 10.dp,
                    horizontal = 20.dp
                )
                .clickable(
                    indication = null,
                    interactionSource = rowInteraction,
                    onClick = {
                        focusRequester.requestFocus()
                        keyboardManager?.show()
                    }
                )
        ) {
    
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            ) {
    
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .wrapContentHeight(),
                    verticalArrangement = Arrangement.Center
                ) {
    
                    if (label != null) {
                        Text(
                            text = "$label:",
                            style = MaterialTheme.typography.body1.copy(
                                fontWeight = FontWeight.Bold,
                                color = if (focusedFlow) MaterialTheme.colors.secondary else if (isLight) Color.Gray else Color.White
                            )
                        )
                    }
    
                    TextFieldContent(
                        textFieldValue = textFieldValue,
                        placeholder = placeholder,
                        onValueChanged = onValueChanged,
                        focusRequester = focusRequester,
                        textFieldInteraction = textFieldInteraction,
                        readOnly = readOnly,
                        keyboardOptions = keyboardOptions,
                        focusManager = focusManager,
                        listOfChips = listOfChips,
                        modifier = modifier,
                        emphasizePlaceHolder = false,
                        onChipClick = onChipClick
                    )
                }
    
                ErrorSection(
                    message = message,
                    errorMessage = errorMessage
                )
            }
        }
    }
    
    @OptIn(ExperimentalMaterialApi::class)
    @Composable
    fun TextFieldContent(
        textFieldValue: TextFieldValue,
        placeholder: String,
        onValueChanged: (TextFieldValue) -> Unit,
        focusRequester: FocusRequester,
        textFieldInteraction: MutableInteractionSource,
        readOnly: Boolean,
        keyboardOptions: KeyboardOptions,
        focusManager: FocusManager,
        listOfChips: List<String>,
        emphasizePlaceHolder: Boolean = false,
        modifier: Modifier,
        onChipClick: (Int) -> Unit
    ) {
        Box {
            val isFocused = textFieldInteraction.collectIsFocusedAsState()
    
            if (textFieldValue.text.isEmpty() && listOfChips.isEmpty()) {
                Text(
                    text = placeholder,
                    color = if (emphasizePlaceHolder && !isFocused.value) {
                        MaterialTheme.colors.onSurface
                    } else {
                        if (MaterialTheme.colors.isLight) {
                            LocalCustomColors.current.muted
                        } else {
                            Color.Gray
                        }
                    },
                    modifier = Modifier.align(alignment = Alignment.CenterStart)
                )
            }
    
            FlowRow(
                modifier = Modifier
                    .wrapContentHeight()
                    .fillMaxWidth(),
                mainAxisSpacing = 5.dp
            ) {
    
                repeat(times = listOfChips.size) { index ->
                    Chip(
                        onClick = { onChipClick(index) },
                        modifier = Modifier.wrapContentWidth(),
                        trailingIcon = {
                            Box(
                                modifier = Modifier
                                    .clip(CircleShape)
                                    .background(MaterialTheme.colors.primary)
                                    .padding(3.dp)
                            ) {
                                Icon(
                                    painter = rememberVectorPainter(image = Icons.Default.Close),
                                    contentDescription = null,
                                    modifier = Modifier.size(12.dp),
                                    tint = if (MaterialTheme.colors.isLight) {
                                        Color.White
                                    } else {
                                        Color.Black
                                    }
                                )
                            }
                        },
                        colors = ChipDefaults
                            .chipColors(
                                backgroundColor = MaterialTheme.colors.secondary,
                                contentColor = MaterialTheme.colors.onSecondary
                            )
                    ) {
                        Text(text = listOfChips[index])
                    }
                }
    
                BasicTextField(
                    value = textFieldValue,
                    onValueChange = onValueChanged,
                    modifier = modifier
                        .focusRequester(focusRequester)
                        .width(IntrinsicSize.Min),
                    singleLine = false,
                    textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface),
                    decorationBox = { innerTextField ->
                        Row(
                            modifier = Modifier
                                .wrapContentWidth()
                                .defaultMinSize(minHeight = 48.dp),
                            verticalAlignment = Alignment.CenterVertically,
                            horizontalArrangement = Arrangement.Start
                        ) {
                            Box(
                                modifier = Modifier.wrapContentWidth(),
                                contentAlignment = Alignment.CenterStart
                            ) {
                                Row(
                                    modifier = Modifier
                                        .defaultMinSize(minWidth = 4.dp)
                                        .wrapContentWidth(),
                                ) {
                                    innerTextField()
                                }
                            }
                        }
                    },
                    interactionSource = textFieldInteraction,
                    cursorBrush = SolidColor(MaterialTheme.colors.secondary),
                    readOnly = readOnly,
                    keyboardOptions = keyboardOptions,
                    keyboardActions = KeyboardActions(
                        onDone = {
                            focusManager.clearFocus()
                        }
                    )
                )
            }
        }
    }
    
    @OptIn(ExperimentalMaterialApi::class)
    @Composable
    fun ErrorSection(
        message: String?,
        errorMessage: String?
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
        ) {
    
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight(),
                horizontalAlignment = Alignment.End
            ) {
    
                if (message != null) {
                    val color = if (MaterialTheme.colors.isLight) {
                        Color.Gray
                    } else {
                        Color.White
                    }
                    Text(
                        text = message,
                        fontStyle = FontStyle.Italic,
                        style = MaterialTheme.typography.body1.copy(color = color)
                    )
                }
                if (errorMessage != null) {
                    Chip(
                        onClick = {
                        },
                        colors = ChipDefaults
                            .chipColors(
                                backgroundColor = Color.Red,
                                contentColor = Color.White
                            ),
                        leadingIcon = {
                            Icon(
                                painter = rememberVectorPainter(image = Icons.Default.Info),
                                contentDescription = null
                            )
                        }
                    ) {
                        Text(
                            text = errorMessage,
                            style = MaterialTheme.typography.body1.copy(fontSize = 12.sp),
                            modifier = Modifier.padding(2.dp)
                        )
                    }
                }
            }
        }
    }