Skip to content

Instantly share code, notes, and snippets.

@kevinvanmierlo
Last active May 11, 2025 10:09
Show Gist options
  • Select an option

  • Save kevinvanmierlo/4bd011479c66eed598852ffeacdc0156 to your computer and use it in GitHub Desktop.

Select an option

Save kevinvanmierlo/4bd011479c66eed598852ffeacdc0156 to your computer and use it in GitHub Desktop.

Revisions

  1. kevinvanmierlo renamed this gist Sep 6, 2022. 1 changed file with 0 additions and 0 deletions.
  2. kevinvanmierlo revised this gist Sep 5, 2022. 3 changed files with 6 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions ComposeTextFieldExample.kt
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,4 @@
    /*
    MIT License
    Copyright (c) 2022 Kevin van Mierlo
    @@ -19,6 +20,7 @@ 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() {
    2 changes: 2 additions & 0 deletions MentionHandler.kt
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,4 @@
    /*
    MIT License
    Copyright (c) 2022 Kevin van Mierlo
    @@ -19,6 +20,7 @@ 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
    2 changes: 2 additions & 0 deletions MentionImpl.kt
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,4 @@
    /*
    MIT License
    Copyright (c) 2022 Kevin van Mierlo
    @@ -19,6 +20,7 @@ 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 = ""
  3. kevinvanmierlo created this gist Sep 5, 2022.
    69 changes: 69 additions & 0 deletions ComposeTextFieldExample.kt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,69 @@
    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
    )
    }
    206 changes: 206 additions & 0 deletions MentionHandler.kt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,206 @@
    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
    }
    28 changes: 28 additions & 0 deletions MentionImpl.kt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,28 @@
    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
    }