feat(emoji-picker): search

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-19 02:08:56 +02:00
parent 922d1da6c2
commit 49a99104cb
3 changed files with 342 additions and 170 deletions

View File

@ -12,6 +12,7 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
@ -29,8 +31,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
@ -41,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -144,13 +149,51 @@ fun EmojiPicker(
.first { it.character == "\uD83E\uDEF0" } .first { it.character == "\uD83E\uDEF0" }
} }
var searchQuery by remember { mutableStateOf("Search not implemented yet") } var searchQuery by remember { mutableStateOf("") }
val searchFieldOpacity by animateFloatAsState( val searchFieldOpacity by animateFloatAsState(
if (showSkinToneMenu) 0f else 1f, if (showSkinToneMenu) 0f else 1f,
animationSpec = RevoltTweenFloat, animationSpec = RevoltTweenFloat,
label = "searchFieldOpacity" label = "searchFieldOpacity"
) )
val searchResults = remember { mutableStateListOf<EmojiPickerItem>() }
LaunchedEffect(searchQuery) {
searchResults.clear()
if (searchQuery.isBlank()) return@LaunchedEffect
searchResults.addAll(emojiImpl.searchForEmoji(searchQuery))
gridState.scrollToItem(0)
}
val onServerEmoteInfo: (String) -> Unit = {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
scope.launch {
ActionChannel.send(
Action.EmoteInfo(
it
)
)
}
}
val onEmojiClick: (EmojiPickerItem) -> Unit = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
when (it) {
is EmojiPickerItem.UnicodeEmoji -> onEmojiSelected(
emojiImpl.applyFitzpatrickSkinTone(
it,
currentSkinTone
)
)
is EmojiPickerItem.ServerEmote -> onEmojiSelected(":${it.emote.id}:")
else -> {}
}
}
val clearQueryButtonOpacity = animateFloatAsState(
if (searchQuery.isNotEmpty()) 1f else 0f,
animationSpec = RevoltTweenFloat,
label = "clearQueryButtonOpacity"
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -178,9 +221,27 @@ fun EmojiPicker(
modifier = Modifier modifier = Modifier
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp))
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.CenterStart
) { ) {
innerTextField() innerTextField()
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.emoji_picker_clear_search),
modifier = Modifier
.clip(CircleShape)
.padding(4.dp)
.size(24.dp)
.then(
if (searchQuery.isNotEmpty()) Modifier.clickable {
searchQuery = ""
focusManager.clearFocus() // this prevents the text field Z-below from gaining focus
} else Modifier
)
.align(Alignment.CenterEnd)
.alpha(clearQueryButtonOpacity.value)
)
} }
} }
@ -273,6 +334,7 @@ fun EmojiPicker(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
AnimatedVisibility(searchResults.isEmpty()) {
Row( Row(
modifier = Modifier modifier = Modifier
.horizontalScroll(categoryRowScrollState) .horizontalScroll(categoryRowScrollState)
@ -367,6 +429,8 @@ fun EmojiPicker(
} }
} }
} }
}
LazyVerticalGrid( LazyVerticalGrid(
state = gridState, state = gridState,
columns = GridCells.Fixed(spanCount), columns = GridCells.Fixed(spanCount),
@ -374,8 +438,67 @@ fun EmojiPicker(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
if (searchResults.isNotEmpty()) {
item(
key = "searchResultsHeader",
span = {
GridItemSpan(spanCount)
},
) {
Text(
text = stringResource(R.string.emoji_picker_search_results_header),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
}
}
// Search results do not get a key, this is intentional.
items(
searchResults.size,
span = {
val item = searchResults[it]
when (item) {
is EmojiPickerItem.UnicodeEmoji -> GridItemSpan(1)
is EmojiPickerItem.ServerEmote -> GridItemSpan(1)
is EmojiPickerItem.Section -> GridItemSpan(spanCount)
}
}
) { index ->
PickerItem(
item = searchResults[index],
skinToneFactory = { emojiImpl.applyFitzpatrickSkinTone(it, currentSkinTone) },
onClick = onEmojiClick,
onServerEmoteInfo = onServerEmoteInfo,
lesserHeaders = true
)
}
if (searchResults.isNotEmpty()) {
item(
key = "searchResultsFooter",
span = {
GridItemSpan(spanCount)
},
) {
Divider()
}
}
items( items(
pickerList.size, pickerList.size,
key = {
when (val item = pickerList[it]) {
is EmojiPickerItem.UnicodeEmoji -> item.character
is EmojiPickerItem.ServerEmote -> item.emote.id ?: it
is EmojiPickerItem.Section -> when (val category = item.category) {
is Category.UnicodeEmojiCategory -> category.definition.googleName
is Category.ServerEmoteCategory -> category.server.id ?: it
}
}
},
span = { span = {
val item = pickerList[it] val item = pickerList[it]
when (item) { when (item) {
@ -385,19 +508,32 @@ fun EmojiPicker(
} }
} }
) { index -> ) { index ->
when (val item = pickerList[index]) { PickerItem(
item = pickerList[index],
skinToneFactory = { emojiImpl.applyFitzpatrickSkinTone(it, currentSkinTone) },
onClick = onEmojiClick,
onServerEmoteInfo = onServerEmoteInfo
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ColumnScope.PickerItem(
item: EmojiPickerItem,
skinToneFactory: (EmojiPickerItem.UnicodeEmoji) -> String,
onClick: (EmojiPickerItem) -> Unit,
onServerEmoteInfo: (String) -> Unit,
lesserHeaders: Boolean = false,
) {
when (item) {
is EmojiPickerItem.UnicodeEmoji -> { is EmojiPickerItem.UnicodeEmoji -> {
Column( Column(
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.clickable { .clickable {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onEmojiSelected(
emojiImpl.applyFitzpatrickSkinTone(
item,
currentSkinTone
)
)
} }
.aspectRatio(1f) .aspectRatio(1f)
.weight(1f), .weight(1f),
@ -405,10 +541,7 @@ fun EmojiPicker(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
emojiImpl.applyFitzpatrickSkinTone( text = skinToneFactory(item),
item,
currentSkinTone
),
style = LocalTextStyle.current.copy(fontSize = 20.sp) style = LocalTextStyle.current.copy(fontSize = 20.sp)
) )
} }
@ -419,22 +552,8 @@ fun EmojiPicker(
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.combinedClickable( .combinedClickable(
onClick = { onClick = { onClick(item) },
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) onLongClick = { item.emote.id?.let { onServerEmoteInfo(it) } }
onEmojiSelected(":${item.emote.id}:")
},
onLongClick = {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
scope.launch {
item.emote.id?.let {
ActionChannel.send(
Action.EmoteInfo(
it
)
)
}
}
}
) )
.aspectRatio(1f) .aspectRatio(1f)
.weight(1f), .weight(1f),
@ -463,10 +582,14 @@ fun EmojiPicker(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp) .padding(8.dp)
.then(
if (lesserHeaders) {
Modifier.alpha(.7f)
} else {
Modifier
}
)
) )
} }
} }
} }
}
}
}

View File

@ -190,6 +190,53 @@ class EmojiImpl {
?: item.character ?: item.character
} }
/**
* Perform a search on the flat picker list to find all custom and unicode emoji that match the
* query.
*/
fun searchForEmoji(query: String): List<EmojiPickerItem> {
val list = mutableListOf<EmojiPickerItem>()
for (server in serversWithEmotes()) {
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
val matchingEmotes =
emotes.filter { it.name?.contains(query, ignoreCase = true) ?: false }
if (matchingEmotes.isNotEmpty()) {
list.add(EmojiPickerItem.Section(Category.ServerEmoteCategory(server)))
list.addAll(matchingEmotes.map { EmojiPickerItem.ServerEmote(it) })
}
}
for (group in metadata) {
val matchingEmoji = group.emoji.filter {
it.shortcodes.any { code ->
code.contains(
query,
ignoreCase = true
)
}
}
if (matchingEmoji.isNotEmpty()) {
val category =
UnicodeEmojiSection.entries.find { it.googleName == group.group } ?: continue
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
list.addAll(matchingEmoji.map { emoji ->
EmojiPickerItem.UnicodeEmoji(
emoji.base.joinToString("") { String(Character.toChars(it.toInt())) },
emoji.alternates.any { alternate ->
alternate.any { codepoint ->
codepoint in 0x1F3FB..0x1F3FF
}
},
emoji.alternates
)
})
}
}
return list
}
init { init {
metadata = initMetadata(RevoltApplication.instance.applicationContext) metadata = initMetadata(RevoltApplication.instance.applicationContext)
} }

View File

@ -350,6 +350,8 @@
<string name="emoji_picker_skin_tone_fitzpatrick_4">Medium skin tone</string> <string name="emoji_picker_skin_tone_fitzpatrick_4">Medium skin tone</string>
<string name="emoji_picker_skin_tone_fitzpatrick_5">Medium-dark skin tone</string> <string name="emoji_picker_skin_tone_fitzpatrick_5">Medium-dark skin tone</string>
<string name="emoji_picker_skin_tone_fitzpatrick_6">Dark skin tone</string> <string name="emoji_picker_skin_tone_fitzpatrick_6">Dark skin tone</string>
<string name="emoji_picker_search_results_header">Search results</string>
<string name="emoji_picker_clear_search">Clear search</string>
<string name="spark_sidebar_settings_tutorial">The settings are in the sidebar</string> <string name="spark_sidebar_settings_tutorial">The settings are in the sidebar</string>
<string name="spark_sidebar_settings_tutorial_description_1">You can open the sidebar by swiping from the left edge of the screen.</string> <string name="spark_sidebar_settings_tutorial_description_1">You can open the sidebar by swiping from the left edge of the screen.</string>