Last active
November 8, 2023 18:09
-
-
Save kevinvanmierlo/4bd011479c66eed598852ffeacdc0156 to your computer and use it in GitHub Desktop.
Mentions in Jetpack Compose TextField (@john Doe)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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 | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Saved my life today, thank you!