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.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
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.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
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.res.painterResource
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.sp
import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.api.REVOLT_FILES
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.generic.IconPlaceholder
import chat.revolt.components.generic.RemoteImage
import chat.revolt.internals.Category
import chat.revolt.internals.EmojiMetadata
import chat.revolt.internals.EmojiImpl
import chat.revolt.internals.EmojiPickerItem
import chat.revolt.internals.FitzpatrickSkinTone
import chat.revolt.internals.UnicodeEmojiSection
import kotlinx.coroutines.launch
@ -58,13 +80,18 @@ fun EmojiPicker(
onEmojiSelected: (String) -> Unit,
) {
val view = LocalView.current
val metadata = remember { EmojiMetadata() }
val pickerList = remember(metadata) { metadata.flatPickerList() }
val servers = remember(metadata) { metadata.serversWithEmotes() }
val categorySpans = remember(pickerList) { metadata.categorySpans(pickerList) }
val focusManager = LocalFocusManager.current
val emojiImpl = remember { EmojiImpl() }
val pickerList = remember(emojiImpl) { emojiImpl.flatPickerList() }
val servers = remember(emojiImpl) { emojiImpl.serversWithEmotes() }
val categorySpans = remember(pickerList) { emojiImpl.categorySpans(pickerList) }
val gridState = rememberLazyGridState()
val categoryRowScrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val spanCount = 9 // https://github.com/googlefonts/emoji-metadata/#readme
// The current category is the one that the user is currently looking at.
@ -98,10 +125,154 @@ fun EmojiPicker(
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(
modifier = Modifier
.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(
modifier = Modifier
.horizontalScroll(categoryRowScrollState)
@ -221,14 +392,25 @@ fun EmojiPicker(
.clip(CircleShape)
.clickable {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
onEmojiSelected(item.emoji)
onEmojiSelected(
emojiImpl.applyFitzpatrickSkinTone(
item,
currentSkinTone
)
)
}
.aspectRatio(1f)
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
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>,
)
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) {
Smileys("Smileys and emotions", R.string.emoji_category_smileys),
People("People", R.string.emoji_category_people),
@ -43,11 +52,16 @@ sealed class Category {
sealed class 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()
}
class EmojiMetadata {
class EmojiImpl {
private var metadata: List<EmojiGroup>
private fun initMetadata(context: Context): List<EmojiGroup> {
@ -94,7 +108,13 @@ class EmojiMetadata {
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
list.addAll(group.emoji.map { emoji ->
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
}
/**
* 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 {
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_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_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>