feat(emoji-picker): in-memory skin tone selector

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-19 01:12:40 +02:00
parent 5f82548d1e
commit 922d1da6c2
3 changed files with 245 additions and 10 deletions

View File

@ -2,53 +2,75 @@ package chat.revolt.components.emoji
import android.util.TypedValue import android.util.TypedValue
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll 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.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.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
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState 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.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.KeyboardArrowRight
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
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable 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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.revolt.R import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.api.REVOLT_FILES import chat.revolt.api.REVOLT_FILES
import chat.revolt.callbacks.Action import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.generic.IconPlaceholder import chat.revolt.components.generic.IconPlaceholder
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.internals.Category import chat.revolt.internals.Category
import chat.revolt.internals.EmojiMetadata import chat.revolt.internals.EmojiImpl
import chat.revolt.internals.EmojiPickerItem import chat.revolt.internals.EmojiPickerItem
import chat.revolt.internals.FitzpatrickSkinTone
import chat.revolt.internals.UnicodeEmojiSection import chat.revolt.internals.UnicodeEmojiSection
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -58,13 +80,18 @@ fun EmojiPicker(
onEmojiSelected: (String) -> Unit, onEmojiSelected: (String) -> Unit,
) { ) {
val view = LocalView.current val view = LocalView.current
val metadata = remember { EmojiMetadata() } val focusManager = LocalFocusManager.current
val pickerList = remember(metadata) { metadata.flatPickerList() }
val servers = remember(metadata) { metadata.serversWithEmotes() } val emojiImpl = remember { EmojiImpl() }
val categorySpans = remember(pickerList) { metadata.categorySpans(pickerList) } val pickerList = remember(emojiImpl) { emojiImpl.flatPickerList() }
val servers = remember(emojiImpl) { emojiImpl.serversWithEmotes() }
val categorySpans = remember(pickerList) { emojiImpl.categorySpans(pickerList) }
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val categoryRowScrollState = rememberScrollState() val categoryRowScrollState = rememberScrollState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val spanCount = 9 // https://github.com/googlefonts/emoji-metadata/#readme val spanCount = 9 // https://github.com/googlefonts/emoji-metadata/#readme
// The current category is the one that the user is currently looking at. // The current category is the one that the user is currently looking at.
@ -98,10 +125,154 @@ fun EmojiPicker(
categoryRowScrollState.animateScrollTo(offset * px) categoryRowScrollState.animateScrollTo(offset * px)
} }
var currentSkinTone by remember { mutableStateOf(FitzpatrickSkinTone.None) }
var showSkinToneMenu by remember { mutableStateOf(false) }
val skinToneMenuAreaWeight by animateFloatAsState(
if (showSkinToneMenu) 1f else .15f,
animationSpec = RevoltTweenFloat,
label = "skinToneMenuAreaWeight"
)
val skinToneMenuCloseHintIconOpacity by animateFloatAsState(
if (showSkinToneMenu) 1f else 0f,
animationSpec = RevoltTweenFloat,
label = "skinToneMenuCloseHintIconOpacity"
)
val skinSample = remember(pickerList) {
pickerList
.filterIsInstance<EmojiPickerItem.UnicodeEmoji>()
.first { it.character == "\uD83E\uDEF0" }
}
var searchQuery by remember { mutableStateOf("Search not implemented yet") }
val searchFieldOpacity by animateFloatAsState(
if (showSkinToneMenu) 0f else 1f,
animationSpec = RevoltTweenFloat,
label = "searchFieldOpacity"
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
) { ) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(37.dp)
) {
BasicTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
},
textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
readOnly = showSkinToneMenu,
singleLine = true,
modifier = Modifier
.fillMaxWidth(.9f)
.alpha(searchFieldOpacity)
.align(Alignment.CenterStart)
) { innerTextField ->
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
innerTextField()
}
}
Row(
modifier = Modifier
.height(37.dp)
.fillMaxWidth(skinToneMenuAreaWeight)
.align(Alignment.CenterEnd),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.weight(1f))
AnimatedVisibility(
showSkinToneMenu
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FitzpatrickSkinTone.entries.forEach { skinTone ->
Text(
emojiImpl.applyFitzpatrickSkinTone(
skinSample,
skinTone
),
modifier = Modifier
.padding(4.dp)
.requiredSize(24.dp)
.clip(CircleShape)
.clickable(
onClickLabel = when (skinTone) {
FitzpatrickSkinTone.None -> stringResource(R.string.emoji_picker_skin_tone_none)
FitzpatrickSkinTone.Light -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_1_2)
FitzpatrickSkinTone.MediumLight -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_3)
FitzpatrickSkinTone.Medium -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_4)
FitzpatrickSkinTone.MediumDark -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_5)
FitzpatrickSkinTone.Dark -> stringResource(R.string.emoji_picker_skin_tone_fitzpatrick_6)
}
) {
currentSkinTone = skinTone
showSkinToneMenu = false
focusManager.clearFocus() // this prevents the text field Z-below from gaining focus
}
.aspectRatio(1f),
textAlign = TextAlign.Center,
)
}
}
}
Spacer(Modifier.width(4.dp))
Box(
modifier = Modifier
.padding(end = 8.dp)
.requiredSize(24.dp)
.clip(CircleShape)
.clickable {
showSkinToneMenu = !showSkinToneMenu
}
.aspectRatio(1f)
) {
Text(
emojiImpl.applyFitzpatrickSkinTone(
skinSample,
currentSkinTone
),
modifier = Modifier
.padding(4.dp)
.requiredSize(24.dp)
.clip(CircleShape)
.aspectRatio(1f)
.alpha(1f - skinToneMenuCloseHintIconOpacity),
textAlign = TextAlign.Center
)
Icon(
imageVector = if (LocalLayoutDirection.current == LayoutDirection.Rtl) {
Icons.Default.KeyboardArrowLeft
} else {
Icons.Default.KeyboardArrowRight
},
contentDescription = stringResource(R.string.emoji_picker_close_skin_tone_menu),
tint = LocalContentColor.current,
modifier = Modifier
.alpha(skinToneMenuCloseHintIconOpacity)
)
}
}
}
Spacer(Modifier.height(4.dp))
Row( Row(
modifier = Modifier modifier = Modifier
.horizontalScroll(categoryRowScrollState) .horizontalScroll(categoryRowScrollState)
@ -221,14 +392,25 @@ fun EmojiPicker(
.clip(CircleShape) .clip(CircleShape)
.clickable { .clickable {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onEmojiSelected(item.emoji) onEmojiSelected(
emojiImpl.applyFitzpatrickSkinTone(
item,
currentSkinTone
)
)
} }
.aspectRatio(1f) .aspectRatio(1f)
.weight(1f), .weight(1f),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text(item.emoji, style = LocalTextStyle.current.copy(fontSize = 20.sp)) Text(
emojiImpl.applyFitzpatrickSkinTone(
item,
currentSkinTone
),
style = LocalTextStyle.current.copy(fontSize = 20.sp)
)
} }
} }

View File

@ -24,6 +24,15 @@ data class EmojiGroup(
val emoji: List<Emoji>, val emoji: List<Emoji>,
) )
enum class FitzpatrickSkinTone(val modifierCodepoint: Int?) {
None(null),
Light(0x1F3FB),
MediumLight(0x1F3FC),
Medium(0x1F3FD),
MediumDark(0x1F3FE),
Dark(0x1F3FF),
}
enum class UnicodeEmojiSection(val googleName: String, val nameResource: Int) { enum class UnicodeEmojiSection(val googleName: String, val nameResource: Int) {
Smileys("Smileys and emotions", R.string.emoji_category_smileys), Smileys("Smileys and emotions", R.string.emoji_category_smileys),
People("People", R.string.emoji_category_people), People("People", R.string.emoji_category_people),
@ -43,11 +52,16 @@ sealed class Category {
sealed class EmojiPickerItem { sealed class EmojiPickerItem {
data class Section(val category: Category) : EmojiPickerItem() data class Section(val category: Category) : EmojiPickerItem()
data class UnicodeEmoji(val emoji: String) : EmojiPickerItem() data class UnicodeEmoji(
val character: String,
val hasSkinTones: Boolean,
val alternates: List<List<Long>>,
) : EmojiPickerItem()
data class ServerEmote(val emote: chat.revolt.api.schemas.Emoji) : EmojiPickerItem() data class ServerEmote(val emote: chat.revolt.api.schemas.Emoji) : EmojiPickerItem()
} }
class EmojiMetadata { class EmojiImpl {
private var metadata: List<EmojiGroup> private var metadata: List<EmojiGroup>
private fun initMetadata(context: Context): List<EmojiGroup> { private fun initMetadata(context: Context): List<EmojiGroup> {
@ -94,7 +108,13 @@ class EmojiMetadata {
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category))) list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
list.addAll(group.emoji.map { emoji -> list.addAll(group.emoji.map { emoji ->
EmojiPickerItem.UnicodeEmoji( EmojiPickerItem.UnicodeEmoji(
emoji.base.joinToString("") { String(Character.toChars(it.toInt())) } emoji.base.joinToString("") { String(Character.toChars(it.toInt())) },
emoji.alternates.any { alternate ->
alternate.any { codepoint ->
codepoint in 0x1F3FB..0x1F3FF
}
},
emoji.alternates
) )
}) })
} }
@ -145,6 +165,31 @@ class EmojiMetadata {
return output return output
} }
/**
* All of our unicode emoji are the base variant with no modifiers applied by default.
* This function returns the unicode emoji with the modifier from the specified skin type applied.
*/
fun applyFitzpatrickSkinTone(
item: EmojiPickerItem.UnicodeEmoji,
skinType: FitzpatrickSkinTone
): String {
if (!item.hasSkinTones || skinType == FitzpatrickSkinTone.None) return item.character
// HACK: We simply find the modifier version from metadata that
// contains the skin tone modifier codepoint.
val modifier = item.alternates.maxByOrNull { alternate ->
// HACK HACK: We find the alternate with the most frequency of our skin tone modifier.
// This is because some emoji have multiple skin tone modifier and we are taking the
// easy way here by only allowing a single skin tone change. This is not ideal.
// Users are encouraged to use the system emoji keyboard to get the full range of
// skin tone modifiers.
alternate.count { it == skinType.modifierCodepoint?.toLong() }
}
return modifier?.joinToString("") { String(Character.toChars(it.toInt())) }
?: item.character
}
init { init {
metadata = initMetadata(RevoltApplication.instance.applicationContext) metadata = initMetadata(RevoltApplication.instance.applicationContext)
} }

View File

@ -343,6 +343,14 @@
<string name="file_picker_chip_documents">Attach a file</string> <string name="file_picker_chip_documents">Attach a file</string>
<string name="file_picker_chip_camera">Take a photo</string> <string name="file_picker_chip_camera">Take a photo</string>
<string name="emoji_picker_close_skin_tone_menu">Close skin tone menu</string>
<string name="emoji_picker_skin_tone_none">No skin tone</string>
<string name="emoji_picker_skin_tone_fitzpatrick_1_2">Light skin tone</string>
<string name="emoji_picker_skin_tone_fitzpatrick_3">Medium-light 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_6">Dark skin tone</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>
<string name="spark_sidebar_settings_tutorial_description_2">Then long tap your profile picture to open the settings.</string> <string name="spark_sidebar_settings_tutorial_description_2">Then long tap your profile picture to open the settings.</string>