Add skin tone picker

This commit is contained in:
bxdxnn
2026-05-30 14:14:55 +00:00
parent 8210070ec2
commit a98fd96303
3 changed files with 326 additions and 12 deletions
@@ -8,32 +8,51 @@
package io.element.android.features.messages.impl.timeline.components.customreaction package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
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.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
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 androidx.compose.ui.window.Popup
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePickerContent
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -44,12 +63,22 @@ fun EmojiItem(
onSelectEmoji: (Emoji) -> Unit, onSelectEmoji: (Emoji) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
emojiSize: TextUnit = 20.sp, emojiSize: TextUnit = 20.sp,
onLongPress: ((Emoji) -> Unit)? = null,
skinPickerEmoji: Emoji? = null,
onDismissSkinPicker: (() -> Unit)? = null,
selectedSkinUnicodes: Set<String> = emptySet(),
hasSelectedSkin: Boolean = false,
) { ) {
val backgroundColor = if (isSelected) { val backgroundColor = when {
ElementTheme.colors.bgActionPrimaryRest isSelected -> ElementTheme.colors.bgActionPrimaryRest
} else { hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected
Color.Transparent else -> Color.Transparent
} }
val density = LocalDensity.current
var itemSize by remember { mutableStateOf(IntSize.Zero) }
var hoveredIndex by remember { mutableStateOf(-1) }
var dismissed by remember { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val description = a11yReactionAction( val description = a11yReactionAction(
emoji = item.unicode, emoji = item.unicode,
userAlreadyReacted = isSelected, userAlreadyReacted = isSelected,
@@ -57,13 +86,88 @@ fun EmojiItem(
Box( Box(
modifier = modifier modifier = modifier
.sizeIn(minWidth = 40.dp, minHeight = 40.dp) .sizeIn(minWidth = 40.dp, minHeight = 40.dp)
.onSizeChanged { itemSize = it }
.background(backgroundColor, CircleShape) .background(backgroundColor, CircleShape)
.clickable( .indication(interactionSource, ripple())
enabled = true, .pointerInput(item) {
onClick = { onSelectEmoji(item) }, detectTapGestures(
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp), onPress = { pressOffset ->
interactionSource = remember { MutableInteractionSource() } val press = PressInteraction.Press(pressOffset)
interactionSource.emit(press)
if (tryAwaitRelease()) {
interactionSource.emit(PressInteraction.Release(press))
} else {
interactionSource.emit(PressInteraction.Cancel(press))
}
},
onTap = { onSelectEmoji(item) },
) )
}
.pointerInput(item) {
detectDragGesturesAfterLongPress(
onDragStart = { _ ->
if (item.skins != null && onLongPress != null) {
dismissed = false
onLongPress(item)
hoveredIndex = -1
}
},
onDrag = { change, _ ->
if (item.skins != null && !dismissed) {
change.consume()
val yThresholdPx = with(density) { 100.dp.toPx() }
if (change.position.y > yThresholdPx) {
dismissed = true
hoveredIndex = -1
onDismissSkinPicker?.invoke()
} else {
val skinCount = item.skins!!.size
val slotWidthPx = with(density) { SkinToneSlotSize.toPx() }
val spacingPx = with(density) { SkinToneSlotSpacing.toPx() }
val paddingPx = with(density) { SkinTonePadding.toPx() }
val totalSlots = 1 + skinCount
val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx
val pickerHalfWidthPx = pickerWidthPx / 2f
val centerXPx = itemSize.width / 2f
val pickerLeftPx = centerXPx - pickerHalfWidthPx
val xInPicker = change.position.x - pickerLeftPx
val index = if (xInPicker >= 0f && xInPicker <= pickerWidthPx) {
(xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1)
} else {
-1
}
hoveredIndex = index
}
}
},
onDragEnd = {
if (!dismissed) {
val idx = hoveredIndex
val skinCount = item.skins?.size ?: 0
if (idx in 0..skinCount) {
if (idx == 0) {
onSelectEmoji(item)
} else {
val skin = item.skins?.getOrNull(idx - 1)
if (skin != null) {
onSelectEmoji(item.copy(unicode = skin.unicode))
}
}
} else if (idx < 0) {
onSelectEmoji(item)
}
}
dismissed = false
hoveredIndex = -1
onDismissSkinPicker?.invoke()
},
onDragCancel = {
dismissed = false
hoveredIndex = -1
onDismissSkinPicker?.invoke()
},
)
}
.clearAndSetSemantics { .clearAndSetSemantics {
contentDescription = description contentDescription = description
}, },
@@ -73,6 +177,41 @@ fun EmojiItem(
text = item.unicode, text = item.unicode,
style = LocalTextStyle.current.copy(fontSize = emojiSize), style = LocalTextStyle.current.copy(fontSize = emojiSize),
) )
if (item.skins != null) {
Canvas(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(8.dp),
) {
val path = Path().apply {
moveTo(size.width, size.height)
lineTo(size.width, 0f)
lineTo(0f, size.height)
close()
}
drawPath(path, color = Color.Gray.copy(alpha = 0.5f), style = Fill)
}
}
if (skinPickerEmoji == item) {
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
Popup(
onDismissRequest = { onDismissSkinPicker?.invoke() },
alignment = Alignment.BottomCenter,
offset = IntOffset(0, popupOffsetPx),
) {
SkinTonePickerContent(
emoji = item,
onSelect = { selectedEmoji ->
onSelectEmoji(selectedEmoji)
onDismissSkinPicker?.invoke()
},
hoveredIndex = hoveredIndex,
selectedUnicodes = selectedSkinUnicodes,
)
}
}
} }
} }
@@ -25,7 +25,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -54,6 +58,26 @@ fun EmojiPicker(
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { state.categories.size }) val pagerState = rememberPagerState(pageCount = { state.categories.size })
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
val selectedSkinUnicodes = remember(skinPickerEmoji, selectedEmojis) {
if (skinPickerEmoji != null) {
val emoji = skinPickerEmoji!!
val variants = emoji.skins.orEmpty()
.map { it.unicode }
.filter { it in selectedEmojis }
if (variants.isNotEmpty()) {
variants.toSet()
} else if (emoji.unicode in selectedEmojis) {
setOf(emoji.unicode)
} else {
emptySet()
}
} else {
emptySet()
}
}
Column(modifier) { Column(modifier) {
SearchBar( SearchBar(
modifier = Modifier.padding(bottom = 10.dp), modifier = Modifier.padding(bottom = 10.dp),
@@ -68,6 +92,11 @@ fun EmojiPicker(
emojis = emojis, emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) }, isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji, onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
selectedSkinUnicodes = selectedSkinUnicodes,
) )
} }
@@ -106,6 +135,11 @@ fun EmojiPicker(
emojis = emojis, emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) }, isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji, onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
selectedSkinUnicodes = selectedSkinUnicodes,
) )
} }
} }
@@ -117,6 +151,11 @@ private fun EmojiResults(
emojis: ImmutableList<Emoji>, emojis: ImmutableList<Emoji>,
isEmojiSelected: (Emoji) -> Boolean, isEmojiSelected: (Emoji) -> Boolean,
onSelectEmoji: (Emoji) -> Unit, onSelectEmoji: (Emoji) -> Unit,
onLongPress: ((Emoji) -> Unit)? = null,
skinPickerEmoji: Emoji? = null,
onDismissSkinPicker: (() -> Unit)? = null,
selectedEmojis: ImmutableSet<String> = persistentSetOf(),
selectedSkinUnicodes: Set<String> = emptySet(),
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -131,7 +170,12 @@ private fun EmojiResults(
item = item, item = item,
isSelected = isEmojiSelected(item), isSelected = isEmojiSelected(item),
onSelectEmoji = onSelectEmoji, onSelectEmoji = onSelectEmoji,
onLongPress = onLongPress,
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = onDismissSkinPicker,
emojiSize = 32.dp.toSp(), emojiSize = 32.dp.toSp(),
selectedSkinUnicodes = selectedSkinUnicodes,
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
) )
} }
} }
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojiSkin
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.persistentListOf
val SkinToneSlotSize = 48.dp
val SkinToneSlotSpacing = 2.dp
val SkinTonePadding = 4.dp
@Composable
fun SkinTonePickerContent(
emoji: Emoji,
onSelect: (Emoji) -> Unit,
modifier: Modifier = Modifier,
hoveredIndex: Int = -1,
selectedUnicodes: Set<String> = emptySet(),
) {
val skins = emoji.skins.orEmpty()
val emojiSize = 32.dp.toSp()
Surface(
modifier = modifier,
shape = RoundedCornerShape(12.dp),
color = ElementTheme.colors.bgCanvasDefault,
tonalElevation = 4.dp,
) {
Row(
modifier = Modifier.padding(SkinTonePadding),
horizontalArrangement = Arrangement.spacedBy(SkinToneSlotSpacing),
) {
SkinToneSlot(
unicode = emoji.unicode,
emojiSize = emojiSize,
isHovered = hoveredIndex == 0,
isSelected = emoji.unicode in selectedUnicodes,
onClick = { onSelect(emoji) },
)
skins.forEachIndexed { index, skin ->
SkinToneSlot(
unicode = skin.unicode,
emojiSize = emojiSize,
isHovered = hoveredIndex == index + 1,
isSelected = skin.unicode in selectedUnicodes,
onClick = { onSelect(emoji.copy(unicode = skin.unicode)) },
)
}
}
}
}
@Composable
private fun SkinToneSlot(
unicode: String,
emojiSize: TextUnit,
isHovered: Boolean,
isSelected: Boolean,
onClick: () -> Unit,
) {
val background = when {
isSelected && isHovered -> ElementTheme.colors.bgActionPrimaryPressed
isSelected -> ElementTheme.colors.bgActionPrimaryRest
isHovered -> ElementTheme.colors.bgActionPrimaryHovered
else -> Color.Transparent
}
Box(
modifier = Modifier
.size(SkinToneSlotSize)
.clip(CircleShape)
.background(background, CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
text = unicode,
style = LocalTextStyle.current.copy(fontSize = emojiSize),
)
}
}
@PreviewsDayNight
@Composable
internal fun SkinTonePickerContentPreview() = ElementPreview {
SkinTonePickerContent(
emoji = Emoji(
hexcode = "1F44D",
label = "thumbs up",
tags = null,
shortcodes = persistentListOf("+1", "thumbsup"),
unicode = "👍",
skins = persistentListOf(
EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"),
EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"),
EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"),
EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"),
EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"),
),
),
onSelect = {},
)
}