Add skin tone picker
This commit is contained in:
+151
-12
@@ -8,32 +8,51 @@
|
||||
|
||||
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.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.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.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.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.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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@@ -44,12 +63,22 @@ fun EmojiItem(
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
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) {
|
||||
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 hoveredIndex by remember { mutableStateOf(-1) }
|
||||
var dismissed by remember { mutableStateOf(false) }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val description = a11yReactionAction(
|
||||
emoji = item.unicode,
|
||||
userAlreadyReacted = isSelected,
|
||||
@@ -57,13 +86,88 @@ fun EmojiItem(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.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) {
|
||||
detectTapGestures(
|
||||
onPress = { pressOffset ->
|
||||
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 {
|
||||
contentDescription = description
|
||||
},
|
||||
@@ -73,6 +177,41 @@ fun EmojiItem(
|
||||
text = item.unicode,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
@@ -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,26 @@ fun EmojiPicker(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
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) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
@@ -68,6 +92,11 @@ fun EmojiPicker(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
selectedSkinUnicodes = selectedSkinUnicodes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +135,11 @@ fun EmojiPicker(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
selectedSkinUnicodes = selectedSkinUnicodes,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +151,11 @@ private fun EmojiResults(
|
||||
emojis: ImmutableList<Emoji>,
|
||||
isEmojiSelected: (Emoji) -> Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: ((Emoji) -> Unit)? = null,
|
||||
skinPickerEmoji: Emoji? = null,
|
||||
onDismissSkinPicker: (() -> Unit)? = null,
|
||||
selectedEmojis: ImmutableSet<String> = persistentSetOf(),
|
||||
selectedSkinUnicodes: Set<String> = emptySet(),
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -131,7 +170,12 @@ private fun EmojiResults(
|
||||
item = item,
|
||||
isSelected = isEmojiSelected(item),
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = onLongPress,
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = onDismissSkinPicker,
|
||||
emojiSize = 32.dp.toSp(),
|
||||
selectedSkinUnicodes = selectedSkinUnicodes,
|
||||
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+131
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user