Skip to content

Instantly share code, notes, and snippets.

@kevinvanmierlo
Last active November 8, 2023 18:09
Show Gist options
  • Save kevinvanmierlo/4bd011479c66eed598852ffeacdc0156 to your computer and use it in GitHub Desktop.
Save kevinvanmierlo/4bd011479c66eed598852ffeacdc0156 to your computer and use it in GitHub Desktop.
Mentions in Jetpack Compose TextField (@john Doe)
/*
MIT License
Copyright (c) 2022 Kevin van Mierlo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
@Composable
fun TestMentions() {
val mentionTextFieldValueState = remember { mutableStateOf(TextFieldValue("")) }
val mentionTextFieldValue by mentionTextFieldValueState
val mentionHandler = remember { MentionHandler<MentionImpl>(mentionTextFieldValueState) }
Column(
modifier = Modifier.fillMaxSize()
) {
TestMentionsOptionsList(modifier = Modifier.fillMaxWidth().weight(1f), mentionHandler = mentionHandler)
TestMentionsTextField(mentionHandler = mentionHandler, mentionTextFieldValue)
}
}
@Composable
private fun TestMentionsOptionsList(modifier: Modifier = Modifier, mentionHandler: MentionHandler<MentionImpl>) {
val shouldShowMentionOptions by remember { derivedStateOf { mentionHandler.shouldShowMentionOptions && mentionHandler.mentionOptions.isNotEmpty() } }
if(shouldShowMentionOptions) {
LazyColumn(
modifier = modifier
) {
items(mentionHandler.mentionOptions.size) { index ->
val item = mentionHandler.mentionOptions[index]
ListItemCell(
title = item.title,
onClick = {
mentionHandler.clickedMentionOption(item)
}
)
}
}
}
}
@Composable
private fun TestMentionsTextField(mentionHandler: MentionHandler<MentionImpl>, textFieldValue: TextFieldValue) {
mentionHandler.HandleProgrammaticallyTextChange()
TextField(
modifier = Modifier.fillMaxWidth(),
value = textFieldValue,
onValueChange = {
mentionHandler.handleTextFieldValueChanged(newTextFieldValue = it)
},
visualTransformation = mentionHandler.mentionTransformation
)
}
/*
MIT License
Copyright (c) 2022 Kevin van Mierlo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextDecoration
import kotlin.math.max
import kotlin.math.min
class MentionHandler<MD : MentionOption>(var textFieldValueState: MutableState<TextFieldValue>) {
var shouldShowMentionOptions by mutableStateOf(false)
val mentionOptions = mutableStateListOf<MD>()
val mentions = mutableListOf<Mention<MD>>()
var textFieldValue by textFieldValueState
var lastTextFieldValueChange: TextFieldValue? = null
var onMentionFilter: (query: String) -> Unit = {}
// This is used so the mentions will be styled
var mentionTransformation = object : VisualTransformation {
var mentionColor = Color.Blue
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
text = getMentionAnnotatedString(text),
offsetMapping = OffsetMapping.Identity
)
}
fun getMentionAnnotatedString(text: AnnotatedString): AnnotatedString {
return buildAnnotatedString {
append(text)
mentions.forEach {
val styleStartIndex = min(text.length, max(0, it.selection.first))
addStyle(
style = SpanStyle(
color = mentionColor,
textDecoration = TextDecoration.Underline,
),
start = styleStartIndex,
end = max(styleStartIndex, min(text.length, it.selection.last + 1))
)
}
}
}
}
private fun getTextBeforeSelection(maxChars: Int = Int.MAX_VALUE) = textFieldValueState.value.getTextBeforeSelection(maxChars)
// Check if user is typing and possibly mentioning someone (ignores mentions already added)
private fun getMentionInProgressRange(textBeforeSelection: AnnotatedString = getTextBeforeSelection()): IntRange? {
val mentionTriggerIndex = textBeforeSelection.lastIndexOf('@')
if (mentionTriggerIndex < 0) {
return null
}
// Stop trying after typing 20 characters
if ((textBeforeSelection.length - mentionTriggerIndex) > 20) {
return null
}
if (mentions.firstOrNull { it.selection.first == mentionTriggerIndex } == null) {
return mentionTriggerIndex until textBeforeSelection.length
}
return null
}
// Go through the text and find where the texts differentiate
private fun getDiffRange(indexOfDiffStart: Int, oldText: String, newText: String): Pair<IntRange, IntRange> {
val newLastIndex = max(0, newText.length)
val newStartIndex = min(indexOfDiffStart, newLastIndex)
val oldLastIndex = max(0, oldText.length)
val oldStartIndex = min(indexOfDiffStart, oldLastIndex)
var loopIndex = oldStartIndex
var oldTextIndex = -1
while(loopIndex <= oldLastIndex) {
// From where texts differentiates, loop through old text to find at what index the texts will be the same again
oldTextIndex = newText.indexOf(oldText.substring(loopIndex, oldLastIndex))
if(oldTextIndex >= 0) {
break
}
loopIndex++
}
if(oldTextIndex >= 0) {
return Pair(first = oldStartIndex .. loopIndex, second = newStartIndex .. max(0, oldTextIndex))
}
return Pair(first = oldStartIndex .. oldLastIndex, second = newStartIndex .. newLastIndex)
}
/**
* @param ignoreMention This is for if a new mention has been added, so it won't automatically be deleted again
*/
fun handleTextFieldValueChanged(
oldTextFieldValue: TextFieldValue = textFieldValue,
newTextFieldValue: TextFieldValue,
ignoreMention: Mention<MD>? = null
) {
lastTextFieldValueChange = newTextFieldValue
if (oldTextFieldValue.text.contentEquals(newTextFieldValue.text)) {
// Content stayed the same, probably cursor change
} else {
val indexOfDiff = oldTextFieldValue.text.indexOfDifference(newTextFieldValue.text)
if (indexOfDiff >= 0) {
val (oldDiffRange, newDiffRange) = getDiffRange(indexOfDiff, oldTextFieldValue.text, newTextFieldValue.text)
// If it's not the ignore mention and we have edited within the mention range, remove mention
mentions.removeIf { it !== ignoreMention && oldDiffRange.first <= it.selection.last && oldDiffRange.last > it.selection.first }
// Go through mentions and check if an edit happened before the mention. If so, move the range so it is correct again
mentions.forEach { mention ->
if (newDiffRange.first <= mention.selection.first) {
val diff = newDiffRange.length() - oldDiffRange.length()
mention.selection = (mention.selection.first + diff)..(mention.selection.last + diff)
}
}
}
}
textFieldValue = newTextFieldValue
// Check if we are working on a mention, if so, show mentions if possible and call filter callback
val textBeforeSelection = getTextBeforeSelection()
getMentionInProgressRange(textBeforeSelection)?.let { mentionInProgressRange ->
val mentionText = textBeforeSelection.substring(startIndex = mentionInProgressRange.first + 1)
shouldShowMentionOptions = true
onMentionFilter(mentionText)
} ?: kotlin.run {
shouldShowMentionOptions = false
}
}
fun clickedMentionOption(mentionOption: MD) {
getMentionInProgressRange()?.let { lastMentionRange ->
val replacementString = "@${mentionOption.mentionTitle}"
// Also include a space for easy typing
val newText = textFieldValue.text.replaceRange(lastMentionRange, "${replacementString} ")
val newMention = Mention(
option = mentionOption,
selection = lastMentionRange.first until (lastMentionRange.first + replacementString.length)
)
mentions.add(newMention)
// Handle textfield changed so other mentions will be adjusted to this position
handleTextFieldValueChanged(
newTextFieldValue = textFieldValue.copy(text = newText, selection = TextRange(newMention.selection.last + 2 /* After mention and a space */)),
ignoreMention = newMention
)
// Clear mention options because we added the mention
mentionOptions.clear()
}
}
// This is for if we programmatically set the text, we won't get a callback, this will make sure we do so we can handle it
@Composable
fun HandleProgrammaticallyTextChange() {
if(lastTextFieldValueChange?.text?.contentEquals(textFieldValue.text) == false) {
handleTextFieldValueChanged(
oldTextFieldValue = lastTextFieldValueChange!!,
newTextFieldValue = textFieldValue
)
}
}
}
// Find first difference between two strings
private fun String.indexOfDifference(otherString: String): Int {
if (this.contentEquals(otherString)) {
return -1
}
for (i in 0 until min(this.length, otherString.length)) {
if (this[i] != otherString[i]) {
return i
}
}
if (this.length != otherString.length) {
return min(this.length, otherString.length)
}
return -1
}
private fun IntRange.length(): Int {
return last - first
}
/*
MIT License
Copyright (c) 2022 Kevin van Mierlo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
class MentionImpl : MentionOption() {
var title = ""
override val mentionTitle: String
get() = title
}
@iscomad
Copy link

iscomad commented Nov 30, 2022

Kudos for the implementation 👍

@kevinvanmierlo
Copy link
Author

Thank you!

@morganbovi
Copy link

Saved my life today, thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment