Merge pull request #6917 from bxdxnn/feat/skin-tone-picker

Add skin tone picker to the emoji picker
This commit is contained in:
Benoit Marty
2026-07-08 15:15:56 +02:00
committed by GitHub
11 changed files with 423 additions and 29 deletions
@@ -8,61 +8,206 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.Canvas
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.PressInteraction
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.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import io.element.android.compound.theme.ElementTheme
import io.element.android.emojibasebindings.Emoji
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.SkinTonePicker
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.features.messages.impl.timeline.components.customreaction.picker.aSkinList
import io.element.android.libraries.designsystem.preview.ElementPreview
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 kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@Composable
fun EmojiItem(
item: Emoji,
isSelected: Boolean,
onSelectEmoji: (Emoji) -> Unit,
onLongPress: (Emoji) -> Unit,
onDismissSkinPicker: () -> Unit,
skinPickerEmoji: Emoji?,
selectedSkinUnicodes: ImmutableSet<String>,
hasSelectedSkin: Boolean,
modifier: Modifier = Modifier,
emojiSize: TextUnit = 20.sp,
) {
val backgroundColor = if (isSelected) {
ElementTheme.colors.bgActionPrimaryRest
} else {
Color.Transparent
val backgroundColor = when {
isSelected -> ElementTheme.colors.bgActionPrimaryRest
hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected
else -> Color.Transparent
}
val density = LocalDensity.current
var itemSize by remember { mutableStateOf(IntSize.Zero) }
var itemOffsetPercent by remember { mutableFloatStateOf(0f) }
var hoveredIndex by remember { mutableIntStateOf(-1) }
var dismissed by remember { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
val description = a11yReactionAction(
emoji = item.unicode,
userAlreadyReacted = isSelected,
)
val hasSkinTones = !item.skins.isNullOrEmpty()
Box(
modifier = modifier
.onPlaced { coordinates ->
val position = coordinates.positionInParent()
val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced
// Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset
// for the selection inside the skin tone picker relative to its size.
itemOffsetPercent = if (parentBounds.width > 0) {
position.x / parentBounds.width
} else {
0f
}
}
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
.onSizeChanged { itemSize = it }
.background(backgroundColor, CircleShape)
.clickable(
enabled = true,
onClick = { onSelectEmoji(item) },
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp),
interactionSource = remember { MutableInteractionSource() }
.indication(interactionSource, ripple())
.pointerInput(item) {
// Always detect long press and tap gestures
detectTapGestures(
onPress = { pressOffset ->
val press = PressInteraction.Press(pressOffset)
interactionSource.emit(press)
if (tryAwaitRelease()) {
interactionSource.emit(PressInteraction.Release(press))
if (hasSkinTones) {
onSelectEmoji(item)
}
} else {
interactionSource.emit(PressInteraction.Cancel(press))
}
},
onTap = { onSelectEmoji(item) },
)
}
.then(
// Only detect drag after long press for those items which have a skin tone picker
if (hasSkinTones) {
Modifier.pointerInput(item) {
val slotWidthPx = with(density) { SkinToneSlotSize.toPx() }
val spacingPx = with(density) { SkinToneSlotSpacing.toPx() }
val paddingPx = with(density) { SkinTonePadding.toPx() }
var startOffset = Offset.Zero
detectDragGesturesAfterLongPress(
onDragStart = { position ->
startOffset = position
dismissed = false
onLongPress(item)
hoveredIndex = -1
},
onDrag = { change, _ ->
if (!dismissed) {
change.consume()
// It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector,
// or 1 item below, which should be far enough to not be triggered by mistake
val isValidDrag = if (change.position.y < startOffset.y) {
startOffset.y - change.position.y <= itemSize.height * 2f
} else {
change.position.y - startOffset.y <= itemSize.height
}
if (!isValidDrag) {
dismissed = true
hoveredIndex = -1
onDismissSkinPicker()
} else {
val skinItemsCount = item.skins!!.size
// Original + variants
val totalSlots = 1 + skinItemsCount
// Calculate the whole size of the skin tone picker
val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx
// Calculate the initial offset inside the picker, given the relative position of the item inside its parent
val initialOffset = pickerWidthPx * itemOffsetPercent
val xInPicker = initialOffset + change.position.x
// If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered
val index = if (xInPicker in 0f..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()
},
onDragCancel = {
dismissed = false
hoveredIndex = -1
},
)
}
} else {
Modifier
}
)
.clearAndSetSemantics {
contentDescription = description
@@ -73,6 +218,42 @@ fun EmojiItem(
text = item.unicode,
style = LocalTextStyle.current.copy(fontSize = emojiSize),
)
if (hasSkinTones) {
val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary
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 = pickerIndicatorColor, style = Fill)
}
}
if (skinPickerEmoji == item) {
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
Popup(
onDismissRequest = { onDismissSkinPicker() },
alignment = Alignment.BottomCenter,
offset = IntOffset(0, popupOffsetPx),
) {
SkinTonePicker(
emoji = item,
onSelect = { selectedEmoji ->
onSelectEmoji(selectedEmoji)
onDismissSkinPicker()
},
hoveredIndex = hoveredIndex,
selectedUnicodes = selectedSkinUnicodes,
)
}
}
}
}
@@ -80,21 +261,61 @@ fun EmojiItem(
@Composable
internal fun EmojiItemPreview() = ElementPreview {
Row(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (isSelected in listOf(true, false)) {
EmojiItem(
item = Emoji(
hexcode = "",
label = "",
tags = null,
shortcodes = persistentListOf(),
unicode = "👍",
skins = null
),
isSelected = isSelected,
onSelectEmoji = {},
)
for (isSelected in listOf(false, true)) {
for (skinList in listOf(persistentListOf(), aSkinList())) {
val hasSelectedSkinValues = if (skinList.isNotEmpty()) listOf(false, true) else listOf(false)
for (hasSelectedSkin in hasSelectedSkinValues) {
EmojiItem(
item = Emoji(
hexcode = "",
label = "",
tags = null,
shortcodes = persistentListOf(),
unicode = "👍",
skins = skinList,
),
isSelected = isSelected,
onSelectEmoji = {},
hasSelectedSkin = hasSelectedSkin,
onLongPress = {},
onDismissSkinPicker = {},
skinPickerEmoji = null,
selectedSkinUnicodes = persistentSetOf(),
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun EmojiItemWithPopupPreview() = ElementPreview {
Row(
modifier = Modifier
.padding(top = 100.dp, bottom = 4.dp, start = 200.dp, end = 200.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val item = Emoji(
hexcode = "",
label = "",
tags = null,
shortcodes = persistentListOf(),
unicode = "👍",
skins = aSkinList(),
)
EmojiItem(
item = item,
isSelected = false,
onSelectEmoji = {},
hasSelectedSkin = false,
onLongPress = {},
onDismissSkinPicker = {},
skinPickerEmoji = item,
selectedSkinUnicodes = persistentSetOf("👍🏾"),
)
}
}
@@ -25,7 +25,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -54,6 +58,8 @@ fun EmojiPicker(
) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { state.categories.size })
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
@@ -68,6 +74,10 @@ fun EmojiPicker(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
)
}
@@ -106,6 +116,10 @@ fun EmojiPicker(
emojis = emojis,
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
onSelectEmoji = onSelectEmoji,
onLongPress = { skinPickerEmoji = it },
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = { skinPickerEmoji = null },
selectedEmojis = selectedEmojis,
)
}
}
@@ -117,6 +131,10 @@ private fun EmojiResults(
emojis: ImmutableList<Emoji>,
isEmojiSelected: (Emoji) -> Boolean,
onSelectEmoji: (Emoji) -> Unit,
onLongPress: (Emoji) -> Unit,
skinPickerEmoji: Emoji?,
onDismissSkinPicker: () -> Unit,
selectedEmojis: ImmutableSet<String>,
) {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
@@ -131,7 +149,12 @@ private fun EmojiResults(
item = item,
isSelected = isEmojiSelected(item),
onSelectEmoji = onSelectEmoji,
onLongPress = onLongPress,
skinPickerEmoji = skinPickerEmoji,
onDismissSkinPicker = onDismissSkinPicker,
emojiSize = 32.dp.toSp(),
selectedSkinUnicodes = selectedEmojis,
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
)
}
}
@@ -0,0 +1,137 @@
/*
* 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.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
val SkinToneSlotSize = 48.dp
val SkinToneSlotSpacing = 2.dp
val SkinTonePadding = 4.dp
@Composable
fun SkinTonePicker(
emoji: Emoji,
hoveredIndex: Int,
selectedUnicodes: ImmutableSet<String>,
onSelect: (Emoji) -> Unit,
modifier: Modifier = Modifier,
) {
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 SkinTonePickerPreview() = ElementPreview {
SkinTonePicker(
emoji = Emoji(
hexcode = "1F44D",
label = "thumbs up",
tags = null,
shortcodes = persistentListOf("+1", "thumbsup"),
unicode = "👍",
skins = aSkinList(),
),
hoveredIndex = -1,
selectedUnicodes = persistentSetOf(),
onSelect = {},
)
}
internal fun aSkinList() = 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", "👍🏿"),
)
@@ -19,7 +19,7 @@ class KonsistParameterNameTest {
Konsist.scopeFromProject()
.functions()
.withParameter { parameter ->
parameter.name.endsWith("Press")
parameter.name.endsWith("Press") && !parameter.name.endsWith("LongPress")
}
.assertEmpty(additionalMessage = "Please rename the parameter, for instance from 'onBackPress' to 'onBackClick'.")
}
@@ -87,6 +87,7 @@ class KonsistPreviewTest {
"BackgroundVerticalGradientDisabledPreview",
"BackgroundVerticalGradientPreview",
"ColorAliasesPreview",
"EmojiItemWithPopupPreview",
"FocusedEventPreview",
"GradientFloatingActionButtonCircleShapePreview",
"HeaderFooterPageScrollablePreview",
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0e294bf94727e3599cc94a80668dba83742f1897c55bf7476bbb4b4fb4116c1
size 18794
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ab6d6139c2e15334980c753f97feedc688285a9704650e47956df502cadb6ef
size 18539
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4746d11c30648f7c517b0880021e5a1dc6ed2819af6c800b081b0d842ac2c384
size 5325
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eede6f990a7a1dbddee1cd9e722b30600160d475ad2ae72faebcb773f52aaa7b
size 20793
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f11b581b1b7b5b0098390067f1f227537a0d30687002e429937797e3cad57392
size 7457
oid sha256:d5d9248a5372da8d93013991d8487946f5a133987f75ae1aeccba9346b05d9ee
size 15280
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c90af9a0afef94b7a960f5fc6b399412b05d6814d5da9ac920ff191f29ed05e3
size 7477
oid sha256:b415e40a4169a1b4d321daba926b3f90871109ecf49db1e1be9ed929580c21c7
size 14963