From c27b1790d3a1d86dec0c97a828e5f2e16743db26 Mon Sep 17 00:00:00 2001 From: DenserMeerkat Date: Thu, 13 Aug 2026 01:28:12 +0530 Subject: [PATCH 1/4] feat: add emoji-compose module --- .gitignore | 3 +- build.gradle.kts | 2 + emoji-compose/api/current.txt | 10 + emoji-compose/build.gradle.kts | 68 +++++++ .../emoji/compose/EmojiImage.android.kt | 75 +++++++ .../compose/EmojiPickerProviders.android.kt | 86 ++++++++ .../emoji/compose/EmojiCategoryTabBar.kt | 114 +++++++++++ .../com/vanniktech/emoji/compose/EmojiGrid.kt | 190 ++++++++++++++++++ .../vanniktech/emoji/compose/EmojiImage.kt | 33 +++ .../vanniktech/emoji/compose/EmojiPicker.kt | 162 +++++++++++++++ .../emoji/compose/EmojiPickerDefaults.kt | 76 +++++++ .../emoji/compose/EmojiPickerProviders.kt | 46 +++++ .../emoji/compose/EmojiPickerVariantPopup.kt | 181 +++++++++++++++++ .../kotlin/com/vanniktech/emoji/Emojis.kt | 30 +++ gradle/libs.versions.toml | 4 + settings.gradle.kts | 1 + 16 files changed, 1080 insertions(+), 1 deletion(-) create mode 100644 emoji-compose/api/current.txt create mode 100644 emoji-compose/build.gradle.kts create mode 100644 emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt create mode 100644 emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt create mode 100644 emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt create mode 100644 emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt create mode 100644 emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.kt create mode 100644 emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt create mode 100644 emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerDefaults.kt create mode 100644 emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt create mode 100644 emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt diff --git a/.gitignore b/.gitignore index 0817bcc72..f3a5ceeb5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,9 @@ bin/ gen/ -# Gradle files +# Gradle & Kotlin build files .gradle/ +.kotlin/ build/ # Local configuration file (sdk path, etc) diff --git a/build.gradle.kts b/build.gradle.kts index 52a33904b..a3d9e15cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,8 @@ buildscript { dependencies { classpath(libs.plugin.android.cache.fix) classpath(libs.plugin.androidgradleplugin) + classpath(libs.plugin.compose) + classpath(libs.plugin.compose.multiplatform) classpath(libs.plugin.dokka) classpath(libs.plugin.kotlin) classpath(libs.plugin.licensee) diff --git a/emoji-compose/api/current.txt b/emoji-compose/api/current.txt new file mode 100644 index 000000000..85da2c704 --- /dev/null +++ b/emoji-compose/api/current.txt @@ -0,0 +1,10 @@ +// Signature format: 4.0 +package com.vanniktech.emoji.compose { + + public final class EmojiPickerKt { + } + + public final class EmojiPickerVariantPopupKt { + } + +} diff --git a/emoji-compose/build.gradle.kts b/emoji-compose/build.gradle.kts new file mode 100644 index 000000000..eb86f32e4 --- /dev/null +++ b/emoji-compose/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + id("org.jetbrains.dokka") + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") + id("com.android.library") + id("org.jetbrains.kotlin.plugin.parcelize") + id("me.tylerbwong.gradle.metalava") + id("com.vanniktech.maven.publish") + id("app.cash.licensee") +} + +licensee { + allow("Apache-2.0") +} + +metalava { + filename.set("api/current.txt") +} + +kotlin { + applyDefaultHierarchyTemplate() + + androidTarget { + publishLibraryVariants("release") + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":emoji")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + } + } + + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } + + val androidMain by getting { + dependencies { + } + } + + val androidUnitTest by getting { + dependencies { + implementation(libs.kotlin.test.junit) + } + } + } +} + +android { + namespace = "com.vanniktech.emoji.compose" + + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + resourcePrefix = "emoji_" +} diff --git a/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt new file mode 100644 index 000000000..af5c7ea87 --- /dev/null +++ b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.EmojiAndroidProvider +import com.vanniktech.emoji.EmojiProvider +import com.vanniktech.emoji.preferredUnicode +import kotlin.math.roundToInt + +/** + * Android actual implementation of [EmojiImage]. + * Renders the provider drawable via Canvas, falling back to unicode text font. + */ +@Composable actual fun EmojiImage( + emoji: Emoji, + provider: EmojiProvider?, + modifier: Modifier, +) { + val context = LocalContext.current + val drawable = remember(emoji, provider, context) { + if (provider is EmojiAndroidProvider) { + try { + provider.getDrawable(emoji, context) + } catch (_: Exception) { null } + } else null + } + + if (drawable != null) { + Canvas(modifier = modifier) { + drawIntoCanvas { canvas -> + drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt()) + drawable.draw(canvas.nativeCanvas) + } + } + } else { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + val fontSize = (minOf(maxWidth, maxHeight).value * 0.75f).sp + Text( + text = emoji.preferredUnicode(), + fontSize = fontSize, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt new file mode 100644 index 000000000..1c97fa023 --- /dev/null +++ b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vanniktech.emoji.EmojiAndroidProvider +import com.vanniktech.emoji.EmojiCategory +import com.vanniktech.emoji.EmojiManager +import com.vanniktech.emoji.EmojiProvider +import com.vanniktech.emoji.recent.RecentEmoji +import com.vanniktech.emoji.recent.RecentEmojiManager +import com.vanniktech.emoji.variant.VariantEmoji +import com.vanniktech.emoji.variant.VariantEmojiManager + +/** + * Returns the default [RecentEmojiManager] for Android using [LocalContext]. + */ +@Composable actual fun rememberDefaultRecentEmoji(): RecentEmoji { + val context = LocalContext.current + return remember(context) { RecentEmojiManager(context) } +} + +/** + * Returns the default [VariantEmojiManager] for Android using [LocalContext]. + */ +@Composable actual fun rememberDefaultVariantEmoji(): VariantEmoji { + val context = LocalContext.current + return remember(context) { VariantEmojiManager(context) } +} + +/** + * Renders the category tab icon using Android vector resources or a text emoji fallback. + */ +@Composable actual fun CategoryTabIcon( + category: EmojiCategory?, + isRecent: Boolean, + isSelected: Boolean, + colors: EmojiPickerColors, + fallbackEmoji: String, + provider: EmojiProvider?, +) { + val tint = if (isSelected) colors.tabSelectedColor else colors.tabUnselectedColor + val iconRes = remember(category, isRecent, provider) { + if (isRecent) { + com.vanniktech.emoji.R.drawable.emoji_recent + } else if (category != null && provider is EmojiAndroidProvider) { + try { provider.getIcon(category) } catch (_: Exception) { null } + } else null + } + + if (iconRes != null && iconRes != 0) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = tint, + modifier = Modifier.size(22.dp), + ) + } else if (fallbackEmoji.isNotEmpty()) { + Text( + text = fallbackEmoji, + fontSize = 18.sp, + ) + } +} diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt new file mode 100644 index 000000000..35158d06f --- /dev/null +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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.dp +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.EmojiCategory +import com.vanniktech.emoji.EmojiProvider +import com.vanniktech.emoji.preferredUnicode + +/** + * Tab bar displaying category icons (and optional recents tab icon) for [EmojiPicker]. + */ +@Composable +fun EmojiCategoryTabBar( + categories: Array, + selectedTabIndex: Int, + onTabSelected: (Int) -> Unit, + modifier: Modifier = Modifier, + hasRecentsTab: Boolean = false, + recentEmojis: List = emptyList(), + colors: EmojiPickerColors = EmojiPickerDefaults.colors(), + emojiProvider: EmojiProvider? = null, + onCategoryIcon: ((EmojiCategory) -> String)? = null, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasRecentsTab) { + val isSelected = selectedTabIndex == 0 + val recentFallbackEmoji = recentEmojis.firstOrNull()?.preferredUnicode() ?: "" + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isSelected) colors.tabSelectedColor.copy(alpha = 0.12f) else Color.Transparent, + ) + .clickable { onTabSelected(0) }, + contentAlignment = Alignment.Center, + ) { + CategoryTabIcon( + category = null, + isRecent = true, + isSelected = isSelected, + colors = colors, + fallbackEmoji = recentFallbackEmoji, + provider = emojiProvider, + ) + } + } + + categories.forEachIndexed { index, category -> + val tabIndex = if (hasRecentsTab) index + 1 else index + val isSelected = selectedTabIndex == tabIndex + val iconEmoji = onCategoryIcon?.invoke(category) ?: category.emojis.firstOrNull()?.preferredUnicode() ?: "" + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isSelected) colors.tabSelectedColor.copy(alpha = 0.12f) else Color.Transparent, + ) + .clickable { onTabSelected(tabIndex) }, + contentAlignment = Alignment.Center, + ) { + CategoryTabIcon( + category = category, + isRecent = false, + isSelected = isSelected, + colors = colors, + fallbackEmoji = iconEmoji, + provider = emojiProvider, + ) + } + } + } + + HorizontalDivider( + color = colors.dividerColor, + thickness = 1.dp, + ) + } +} diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt new file mode 100644 index 000000000..e63355374 --- /dev/null +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.dp +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.EmojiProvider +import com.vanniktech.emoji.filterMeaningfulVariants + +/** + * Modular building block displaying a grid of emojis. + * + * @param emojis List of [Emoji] objects to display in the grid. + * @param columns Number of columns in the grid. + * @param onEmojiClick Callback invoked when an emoji cell is tapped. + * @param onEmojiLongClick Callback invoked when an emoji cell is long-pressed. + * @param modifier Modifier to be applied to the grid layout. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EmojiGrid( + emojis: List, + columns: Int, + onEmojiClick: (Emoji) -> Unit, + onEmojiLongClick: (Emoji) -> Unit, + modifier: Modifier = Modifier, + provider: EmojiProvider? = null, +) { + EmojiGrid( + emojis = emojis, + columns = columns, + onEmojiClick = onEmojiClick, + onEmojiLongClick = { emoji, _ -> onEmojiLongClick(emoji) }, + modifier = modifier, + provider = provider, + ) +} + +/** + * Modular building block displaying a grid of emojis with bounds awareness, row capping, and theming. + * + * @param emojis List of [Emoji] objects to display in the grid. + * @param columns Number of columns in the grid. + * @param onEmojiClick Callback invoked when an emoji cell is tapped. + * @param onEmojiLongClick Callback invoked with the emoji and window bounds when long-pressed. + * @param modifier Modifier to be applied to the grid layout. + * @param emojiGridRows Optional row capping factor for fixed-height grids. + * @param colors Color scheme configuration for the grid and variant indicators. + * @param showVariants Whether to display variant indicators on emojis that have variants. + * @param provider Optional [EmojiProvider] supplying custom image graphics for emojis. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EmojiGrid( + emojis: List, + columns: Int, + onEmojiClick: (Emoji) -> Unit, + onEmojiLongClick: (Emoji, Rect) -> Unit, + modifier: Modifier = Modifier, + emojiGridRows: Float? = null, + colors: EmojiPickerColors = EmojiPickerDefaults.colors(), + showVariants: Boolean = true, + provider: EmojiProvider? = null, +) { + val gridModifier = if (emojiGridRows != null) { + modifier.height((emojiGridRows * 44 + 12).dp) + } else { + modifier.fillMaxSize() + } + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + contentPadding = PaddingValues(0.dp), + modifier = gridModifier, + ) { + items( + items = emojis, + key = { it.unicode }, + contentType = { "emoji_cell" }, + ) { emoji -> + EmojiCell( + emoji = emoji, + onEmojiClick = onEmojiClick, + onEmojiLongClick = onEmojiLongClick, + colors = colors, + showVariants = showVariants, + provider = provider, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun EmojiCell( + emoji: Emoji, + onEmojiClick: (Emoji) -> Unit, + onEmojiLongClick: (Emoji, Rect) -> Unit, + modifier: Modifier = Modifier, + colors: EmojiPickerColors = EmojiPickerDefaults.colors(), + showVariants: Boolean = true, + provider: EmojiProvider? = null, +) { + val hasVariants = remember(emoji, showVariants) { + showVariants && filterMeaningfulVariants(emoji).isNotEmpty() + } + var cellBounds by remember { mutableStateOf(null) } + + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1f) + .onGloballyPositioned { coordinates -> + cellBounds = coordinates.boundsInWindow() + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(10.dp)) + .combinedClickable( + onClick = { onEmojiClick(emoji) }, + onLongClick = { onEmojiLongClick(emoji, cellBounds ?: Rect.Zero) }, + ), + contentAlignment = Alignment.Center, + ) { + EmojiImage( + emoji = emoji, + provider = provider, + modifier = Modifier.fillMaxSize(0.80f), + ) + } + + if (hasVariants) { + val indicatorColor = colors.variantIndicatorColor + Canvas( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 2.dp, end = 2.dp) + .size(5.dp), + ) { + val trianglePath = Path().apply { + moveTo(size.width, 0f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + drawPath( + path = trianglePath, + color = indicatorColor, + ) + } + } + } +} diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.kt new file mode 100644 index 000000000..e14bffd34 --- /dev/null +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.EmojiProvider + +/** + * Composable equivalent of View-based EmojiImageView. + * Renders the emoji image using provider drawables or a fallback text representation. + */ +@Composable +expect fun EmojiImage( + emoji: Emoji, + provider: EmojiProvider?, + modifier: Modifier = Modifier, +) diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt new file mode 100644 index 000000000..5e67e2f08 --- /dev/null +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.dp +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.EmojiCategory +import com.vanniktech.emoji.EmojiProvider +import com.vanniktech.emoji.filterMeaningfulVariants +import com.vanniktech.emoji.recent.RecentEmoji +import com.vanniktech.emoji.variant.VariantEmoji + +/** + * A customizable Jetpack Compose Emoji Picker component displaying category tabs, recents, + * and emoji grids with skin-tone variant support. + * + * @param emojiProvider The [EmojiProvider] supplying categories and emojis (e.g. GoogleEmojiProvider()). + * @param onEmojiPicked Callback invoked when an emoji is clicked or selected from the variant picker. + * @param modifier Modifier to be applied to the layout container. + * @param recentEmoji Provider for recents persistence; defaults to platform implementation. + * @param variantEmoji Provider for skin-tone variant persistence; defaults to platform implementation. + * @param autoAddRecentEmoji Whether clicking an emoji should automatically add it to [recentEmoji]. Set to `false` for confirmation-based selection dialogs. + * @param columns Number of columns in the emoji grid. + * @param emojiGridRows Optional row capping factor for fixed-height grids. + * @param colors Color scheme configuration for the picker. + * @param onCategoryIcon Optional custom callback mapping an [EmojiCategory] to a fallback Unicode string. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EmojiPicker( + onEmojiPicked: (Emoji) -> Unit, + modifier: Modifier = Modifier, + emojiProvider: EmojiProvider? = null, + recentEmoji: RecentEmoji = rememberDefaultRecentEmoji(), + variantEmoji: VariantEmoji = rememberDefaultVariantEmoji(), + autoAddRecentEmoji: Boolean = true, + columns: Int = 7, + emojiGridRows: Float? = null, + colors: EmojiPickerColors = EmojiPickerDefaults.colors(), + onCategoryIcon: ((EmojiCategory) -> String)? = null, +) { + var selectedTabIndex by remember { mutableIntStateOf(0) } + var emojiForVariantPicker by remember { mutableStateOf?>(null) } + + val categories = remember(emojiProvider) { + emojiProvider?.categories ?: emptyArray() + } + + val recentEmojis = remember(recentEmoji) { + recentEmoji.getRecentEmojis().toList() + } + + val hasRecentsTab = recentEmojis.isNotEmpty() + val totalTabs = (if (hasRecentsTab) 1 else 0) + categories.size + + Column( + modifier = modifier + .fillMaxSize() + .background(colors.containerColor), + ) { + if (totalTabs > 0) { + EmojiCategoryTabBar( + categories = categories, + selectedTabIndex = selectedTabIndex, + onTabSelected = { selectedTabIndex = it }, + hasRecentsTab = hasRecentsTab, + recentEmojis = recentEmojis, + colors = colors, + emojiProvider = emojiProvider, + onCategoryIcon = onCategoryIcon, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + val isRecentTab = hasRecentsTab && selectedTabIndex == 0 + + val activeEmojis = remember(selectedTabIndex, hasRecentsTab, categories, recentEmojis, variantEmoji, emojiProvider) { + val baseList = if (isRecentTab) { + val providerEmojisMap = categories.flatMap { it.emojis }.flatMap { listOf(it) + it.variants }.associateBy { it.unicode } + recentEmojis.map { raw -> providerEmojisMap[raw.unicode] ?: raw } + } else { + val categoryIndex = if (hasRecentsTab) selectedTabIndex - 1 else selectedTabIndex + categories.getOrNull(categoryIndex)?.emojis?.toList().orEmpty() + } + + if (isRecentTab) { + baseList + } else { + baseList.map { variantEmoji.getVariant(it) } + } + } + + EmojiGrid( + emojis = activeEmojis, + columns = columns, + onEmojiClick = { emoji -> + if (autoAddRecentEmoji) { + recentEmoji.addEmoji(emoji) + recentEmoji.persist() + } + onEmojiPicked(emoji) + }, + onEmojiLongClick = { emoji, bounds -> + if (!isRecentTab && filterMeaningfulVariants(emoji).isNotEmpty()) { + emojiForVariantPicker = emoji to bounds + } + }, + modifier = Modifier.weight(1f), + emojiGridRows = emojiGridRows, + colors = colors, + showVariants = !isRecentTab, + provider = emojiProvider, + ) + } + + emojiForVariantPicker?.let { (baseEmoji, bounds) -> + EmojiPickerVariantPopup( + baseEmoji = baseEmoji, + onEmojiPicked = { variant -> + if (autoAddRecentEmoji) { + recentEmoji.addEmoji(variant) + recentEmoji.persist() + } + variantEmoji.addVariant(variant) + variantEmoji.persist() + onEmojiPicked(variant) + }, + onDismissRequest = { emojiForVariantPicker = null }, + targetBounds = bounds, + colors = colors, + provider = emojiProvider, + ) + } +} diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerDefaults.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerDefaults.kt new file mode 100644 index 000000000..6ea5e56dc --- /dev/null +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerDefaults.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +/** + * Immutable color configuration for [EmojiPicker] and [EmojiGrid]. + * + * @param containerColor Background color for the picker container. + * @param tabSelectedColor Highlight color applied to the selected category tab icon. + * @param tabUnselectedColor Color applied to unselected category tab icons. + * @param dividerColor Color applied to the horizontal divider line below category tabs. + * @param variantIndicatorColor Color for the triangle indicator drawn on emojis with skin-tone variants. + * @param variantPopupContainerColor Background surface color of the floating [EmojiPickerVariantPopup] popup. + */ +@Immutable +data class EmojiPickerColors( + val containerColor: Color, + val tabSelectedColor: Color, + val tabUnselectedColor: Color, + val dividerColor: Color, + val variantIndicatorColor: Color, + val variantPopupContainerColor: Color, +) + +/** + * Default color factory for [EmojiPicker]. + */ +object EmojiPickerDefaults { + /** + * Creates an [EmojiPickerColors] instance using Material Theme defaults. + * + * @param containerColor Background color for the picker container. + * @param tabSelectedColor Color applied to selected category tab indicator. + * @param tabUnselectedColor Color applied to unselected category tabs. + * @param dividerColor Color applied to horizontal divider below tab bar. + * @param variantIndicatorColor Color for the triangle variant indicator. + * @param variantPopupContainerColor Background color of the floating variant popup dialog. + */ + @Composable + fun colors( + containerColor: Color = MaterialTheme.colorScheme.background, + tabSelectedColor: Color = MaterialTheme.colorScheme.primary, + tabUnselectedColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + dividerColor: Color = MaterialTheme.colorScheme.outlineVariant, + variantIndicatorColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + variantPopupContainerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + ): EmojiPickerColors { + return EmojiPickerColors( + containerColor = containerColor, + tabSelectedColor = tabSelectedColor, + tabUnselectedColor = tabUnselectedColor, + dividerColor = dividerColor, + variantIndicatorColor = variantIndicatorColor, + variantPopupContainerColor = variantPopupContainerColor, + ) + } +} diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt new file mode 100644 index 000000000..05ae65ba6 --- /dev/null +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.runtime.Composable +import com.vanniktech.emoji.EmojiCategory +import com.vanniktech.emoji.EmojiProvider +import com.vanniktech.emoji.recent.RecentEmoji +import com.vanniktech.emoji.variant.VariantEmoji + +/** + * Returns the default [RecentEmoji] implementation for the current platform target. + */ +@Composable expect fun rememberDefaultRecentEmoji(): RecentEmoji + +/** + * Returns the default [VariantEmoji] implementation for the current platform target. + */ +@Composable expect fun rememberDefaultVariantEmoji(): VariantEmoji + +/** + * Renders the category tab icon for the given category or recent tab. + */ +@Composable +expect fun CategoryTabIcon( + category: EmojiCategory?, + isRecent: Boolean, + isSelected: Boolean, + colors: EmojiPickerColors, + fallbackEmoji: String, + provider: EmojiProvider? = null, +) diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt new file mode 100644 index 000000000..c61025e7c --- /dev/null +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2016 - Niklas Baudy, Ruben Gees, Mario Đanić and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.vanniktech.emoji.compose + +import androidx.compose.foundation.clickable +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.fillMaxSize +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.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.EmojiProvider +import com.vanniktech.emoji.filterMeaningfulVariants + +private class EmojiPickerVariantPopupPositionProvider( + private val targetBounds: Rect, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val targetCenterX = (targetBounds.left + targetBounds.width / 2f).toInt() + var x = targetCenterX - popupContentSize.width / 2 + x = x.coerceIn(16, (windowSize.width - popupContentSize.width - 16).coerceAtLeast(16)) + + val spaceAbove = targetBounds.top + val popupHeight = popupContentSize.height.toFloat() + + val y = if (spaceAbove >= popupHeight + 16f) { + (targetBounds.top - popupHeight - 12f).toInt() + } else { + (targetBounds.bottom + 12f).toInt() + } + + return IntOffset(x, y) + } +} + +/** + * Floating popup for selecting variants of an emoji. + * + * @param baseEmoji The base [Emoji] whose variants will be displayed. + * @param onEmojiPicked Callback invoked when a specific emoji variant is chosen. + * @param onDismissRequest Callback invoked when the user dismisses the variant popup. + * @param modifier Modifier to be applied to the popup surface layout. + * @param targetBounds The window bounds of the anchor cell, used to position the popup dynamically. + * @param colors Color scheme configuration for the variant popup. + * @param provider Optional [EmojiProvider] supplying custom image graphics for variants. + */ +@Composable +fun EmojiPickerVariantPopup( + baseEmoji: Emoji, + onEmojiPicked: (Emoji) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + targetBounds: Rect? = null, + colors: EmojiPickerColors = EmojiPickerDefaults.colors(), + provider: EmojiProvider? = null, +) { + val allVariants = remember(baseEmoji) { + val rootBase = baseEmoji.base + val meaningful = filterMeaningfulVariants(rootBase) + listOf(rootBase) + meaningful + } + + val popupPositionProvider = remember(targetBounds) { + targetBounds?.let { EmojiPickerVariantPopupPositionProvider(it) } + } + + val popupProperties = remember { + PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true, + clippingEnabled = false, + ) + } + + val variantRows = remember(allVariants) { + if (allVariants.size > 6) { + allVariants.chunked(6) + } else { + listOf(allVariants) + } + } + + val shape = if (variantRows.size > 1) RoundedCornerShape(24.dp) else CircleShape + + val content = @Composable { + Surface( + shape = shape, + color = colors.variantPopupContainerColor, + shadowElevation = 8.dp, + tonalElevation = 6.dp, + modifier = modifier, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + variantRows.forEach { rowVariants -> + Row( + horizontalArrangement = Arrangement.spacedBy(0.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + rowVariants.forEach { variant -> + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable { + onEmojiPicked(variant) + onDismissRequest() + }, + contentAlignment = Alignment.Center, + ) { + EmojiImage( + emoji = variant, + provider = provider, + modifier = Modifier.fillMaxSize(0.80f), + ) + } + } + } + } + } + } + } + + if (popupPositionProvider != null) { + Popup( + popupPositionProvider = popupPositionProvider, + onDismissRequest = onDismissRequest, + properties = popupProperties, + content = content, + ) + } else { + Popup( + alignment = Alignment.Center, + onDismissRequest = onDismissRequest, + properties = popupProperties, + content = content, + ) + } +} diff --git a/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt b/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt index 5e0a8eb97..c8fb78baa 100644 --- a/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt +++ b/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt @@ -18,6 +18,7 @@ package com.vanniktech.emoji +import com.vanniktech.emoji.variant.VariantEmoji import kotlin.jvm.JvmName private val SPACE_REMOVAL = Regex("[\\s]") @@ -59,3 +60,32 @@ fun CharSequence.emojiInformation(): EmojiInformation { emojiRanges = emojiRanges, ) } + +/** + * Returns the preferred Unicode representation of this emoji, preferring Variation Selector 16 + * (\uFE0F) to ensure full-color graphical presentation. + */ +fun Emoji.preferredUnicode(): String { + if (unicode.contains(VARIANT_SELECTOR_16)) { + return unicode + } + val vs16Variant = variants.firstOrNull { it.unicode.contains(VARIANT_SELECTOR_16) } + ?: base.variants.firstOrNull { it.unicode.contains(VARIANT_SELECTOR_16) } + return vs16Variant?.unicode ?: unicode +} + +/** + * Filters and returns the list of skin tone variants for the given [emoji]. + * Excludes non-variant selector entries (`isVariantSelector16`) and base duplicates. + */ +fun filterMeaningfulVariants(emoji: Emoji, variantEmoji: VariantEmoji? = null): List { + val rootBase = emoji.base + if (rootBase.isVariantSelector16()) { + return emptyList() + } + val managedVariants = variantEmoji?.getVariants(rootBase).orEmpty() + val candidates = managedVariants.ifEmpty { rootBase.variants } + return candidates.filterNot { variant -> + variant.isVariantSelector16() || variant.unicode == rootBase.unicode + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 664dd3acf..4312997a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,8 @@ leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", ve material = { module = "com.google.android.material:material", version = "1.13.0" } plugin-android-cache-fix = { module = "org.gradle.android.cache-fix:org.gradle.android.cache-fix.gradle.plugin", version = "3.0.3" } plugin-androidgradleplugin = { module = "com.android.tools.build:gradle", version.ref = "androidgradleplugin" } +plugin-compose = { module = "org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin", version.ref = "kotlin" } +plugin-compose-multiplatform = { module = "org.jetbrains.compose:compose-gradle-plugin", version = "1.7.3" } plugin-dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "2.1.0" } plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } plugin-licensee = { module = "app.cash.licensee:licensee-gradle-plugin", version = "1.14.1" } @@ -37,4 +39,6 @@ ui = { module = "com.vanniktech:ui", version = "0.10.0" } [plugins] codequalitytools = { id = "com.vanniktech.code.quality.tools", version = "0.25.0" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version = "1.7.3" } dependencygraphgenerator = { id = "com.vanniktech.dependency.graph.generator", version = "0.8.0" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f1006c5b..730494de7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,7 @@ pluginManagement { include(":app") include(":emoji") include(":emoji-androidx-emoji2") +include(":emoji-compose") include(":emoji-facebook") include(":emoji-google") include(":emoji-google-compat") From ffb22c1f97d00f2b7c30319bca0f93fed99176a9 Mon Sep 17 00:00:00 2001 From: DenserMeerkat Date: Sat, 22 Aug 2026 17:41:23 +0530 Subject: [PATCH 2/4] refactor: reuse core variant helpers --- .../emoji/compose/EmojiImage.android.kt | 3 +- .../emoji/compose/EmojiCategoryTabBar.kt | 5 ++-- .../com/vanniktech/emoji/compose/EmojiGrid.kt | 18 ++++++++---- .../vanniktech/emoji/compose/EmojiPicker.kt | 20 +++++-------- .../emoji/compose/EmojiPickerVariantPopup.kt | 9 +++--- .../kotlin/com/vanniktech/emoji/Emojis.kt | 29 ------------------- 6 files changed, 28 insertions(+), 56 deletions(-) diff --git a/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt index af5c7ea87..1aa5fba9d 100644 --- a/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt +++ b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiImage.android.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.unit.sp import com.vanniktech.emoji.Emoji import com.vanniktech.emoji.EmojiAndroidProvider import com.vanniktech.emoji.EmojiProvider -import com.vanniktech.emoji.preferredUnicode import kotlin.math.roundToInt /** @@ -66,7 +65,7 @@ import kotlin.math.roundToInt ) { val fontSize = (minOf(maxWidth, maxHeight).value * 0.75f).sp Text( - text = emoji.preferredUnicode(), + text = emoji.unicode, fontSize = fontSize, textAlign = TextAlign.Center, ) diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt index 35158d06f..c17a5396a 100644 --- a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiCategoryTabBar.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.unit.dp import com.vanniktech.emoji.Emoji import com.vanniktech.emoji.EmojiCategory import com.vanniktech.emoji.EmojiProvider -import com.vanniktech.emoji.preferredUnicode /** * Tab bar displaying category icons (and optional recents tab icon) for [EmojiPicker]. @@ -58,7 +57,7 @@ fun EmojiCategoryTabBar( ) { if (hasRecentsTab) { val isSelected = selectedTabIndex == 0 - val recentFallbackEmoji = recentEmojis.firstOrNull()?.preferredUnicode() ?: "" + val recentFallbackEmoji = recentEmojis.firstOrNull()?.unicode ?: "" Box( modifier = Modifier .size(36.dp) @@ -83,7 +82,7 @@ fun EmojiCategoryTabBar( categories.forEachIndexed { index, category -> val tabIndex = if (hasRecentsTab) index + 1 else index val isSelected = selectedTabIndex == tabIndex - val iconEmoji = onCategoryIcon?.invoke(category) ?: category.emojis.firstOrNull()?.preferredUnicode() ?: "" + val iconEmoji = onCategoryIcon?.invoke(category) ?: category.emojis.firstOrNull()?.unicode ?: "" Box( modifier = Modifier .size(36.dp) diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt index e63355374..d77f0185f 100644 --- a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiGrid.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import com.vanniktech.emoji.Emoji import com.vanniktech.emoji.EmojiProvider -import com.vanniktech.emoji.filterMeaningfulVariants +import com.vanniktech.emoji.variant.VariantEmoji /** * Modular building block displaying a grid of emojis. @@ -59,6 +59,7 @@ fun EmojiGrid( onEmojiLongClick: (Emoji) -> Unit, modifier: Modifier = Modifier, provider: EmojiProvider? = null, + variantEmoji: VariantEmoji? = null, ) { EmojiGrid( emojis = emojis, @@ -67,6 +68,7 @@ fun EmojiGrid( onEmojiLongClick = { emoji, _ -> onEmojiLongClick(emoji) }, modifier = modifier, provider = provider, + variantEmoji = variantEmoji, ) } @@ -95,6 +97,7 @@ fun EmojiGrid( colors: EmojiPickerColors = EmojiPickerDefaults.colors(), showVariants: Boolean = true, provider: EmojiProvider? = null, + variantEmoji: VariantEmoji? = null, ) { val gridModifier = if (emojiGridRows != null) { modifier.height((emojiGridRows * 44 + 12).dp) @@ -119,6 +122,7 @@ fun EmojiGrid( colors = colors, showVariants = showVariants, provider = provider, + variantEmoji = variantEmoji, ) } } @@ -134,9 +138,11 @@ private fun EmojiCell( colors: EmojiPickerColors = EmojiPickerDefaults.colors(), showVariants: Boolean = true, provider: EmojiProvider? = null, + variantEmoji: VariantEmoji? = null, ) { - val hasVariants = remember(emoji, showVariants) { - showVariants && filterMeaningfulVariants(emoji).isNotEmpty() + val hasVariants = remember(emoji, variantEmoji) { + val variants = variantEmoji?.getVariants(emoji.base) ?: emoji.base.variants + variants.isNotEmpty() } var cellBounds by remember { mutableStateOf(null) } @@ -155,7 +161,9 @@ private fun EmojiCell( .clip(RoundedCornerShape(10.dp)) .combinedClickable( onClick = { onEmojiClick(emoji) }, - onLongClick = { onEmojiLongClick(emoji, cellBounds ?: Rect.Zero) }, + onLongClick = if (hasVariants) { + { onEmojiLongClick(emoji, cellBounds ?: Rect.Zero) } + } else null, ), contentAlignment = Alignment.Center, ) { @@ -166,7 +174,7 @@ private fun EmojiCell( ) } - if (hasVariants) { + if (showVariants && hasVariants) { val indicatorColor = colors.variantIndicatorColor Canvas( modifier = Modifier diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt index 5e67e2f08..c2a2b034c 100644 --- a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPicker.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.unit.dp import com.vanniktech.emoji.Emoji import com.vanniktech.emoji.EmojiCategory import com.vanniktech.emoji.EmojiProvider -import com.vanniktech.emoji.filterMeaningfulVariants import com.vanniktech.emoji.recent.RecentEmoji import com.vanniktech.emoji.variant.VariantEmoji @@ -102,19 +101,12 @@ fun EmojiPicker( val isRecentTab = hasRecentsTab && selectedTabIndex == 0 - val activeEmojis = remember(selectedTabIndex, hasRecentsTab, categories, recentEmojis, variantEmoji, emojiProvider) { - val baseList = if (isRecentTab) { - val providerEmojisMap = categories.flatMap { it.emojis }.flatMap { listOf(it) + it.variants }.associateBy { it.unicode } - recentEmojis.map { raw -> providerEmojisMap[raw.unicode] ?: raw } - } else { - val categoryIndex = if (hasRecentsTab) selectedTabIndex - 1 else selectedTabIndex - categories.getOrNull(categoryIndex)?.emojis?.toList().orEmpty() - } - + val activeEmojis = remember(selectedTabIndex, hasRecentsTab, categories, recentEmojis, variantEmoji) { if (isRecentTab) { - baseList + recentEmojis } else { - baseList.map { variantEmoji.getVariant(it) } + val categoryIndex = if (hasRecentsTab) selectedTabIndex - 1 else selectedTabIndex + categories.getOrNull(categoryIndex)?.emojis?.map { variantEmoji.getVariant(it) }.orEmpty() } } @@ -129,7 +121,7 @@ fun EmojiPicker( onEmojiPicked(emoji) }, onEmojiLongClick = { emoji, bounds -> - if (!isRecentTab && filterMeaningfulVariants(emoji).isNotEmpty()) { + if (!isRecentTab && variantEmoji.getVariants(emoji.base).isNotEmpty()) { emojiForVariantPicker = emoji to bounds } }, @@ -138,6 +130,7 @@ fun EmojiPicker( colors = colors, showVariants = !isRecentTab, provider = emojiProvider, + variantEmoji = variantEmoji, ) } @@ -157,6 +150,7 @@ fun EmojiPicker( targetBounds = bounds, colors = colors, provider = emojiProvider, + variantEmoji = variantEmoji, ) } } diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt index c61025e7c..61fa5f639 100644 --- a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerVariantPopup.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import com.vanniktech.emoji.Emoji import com.vanniktech.emoji.EmojiProvider -import com.vanniktech.emoji.filterMeaningfulVariants +import com.vanniktech.emoji.variant.VariantEmoji private class EmojiPickerVariantPopupPositionProvider( private val targetBounds: Rect, @@ -91,11 +91,12 @@ fun EmojiPickerVariantPopup( targetBounds: Rect? = null, colors: EmojiPickerColors = EmojiPickerDefaults.colors(), provider: EmojiProvider? = null, + variantEmoji: VariantEmoji? = null, ) { - val allVariants = remember(baseEmoji) { + val allVariants = remember(baseEmoji, variantEmoji) { val rootBase = baseEmoji.base - val meaningful = filterMeaningfulVariants(rootBase) - listOf(rootBase) + meaningful + val meaningfulVariants = variantEmoji?.getVariants(rootBase) ?: rootBase.variants + listOf(rootBase) + meaningfulVariants } val popupPositionProvider = remember(targetBounds) { diff --git a/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt b/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt index c8fb78baa..1a50362c1 100644 --- a/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt +++ b/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt @@ -60,32 +60,3 @@ fun CharSequence.emojiInformation(): EmojiInformation { emojiRanges = emojiRanges, ) } - -/** - * Returns the preferred Unicode representation of this emoji, preferring Variation Selector 16 - * (\uFE0F) to ensure full-color graphical presentation. - */ -fun Emoji.preferredUnicode(): String { - if (unicode.contains(VARIANT_SELECTOR_16)) { - return unicode - } - val vs16Variant = variants.firstOrNull { it.unicode.contains(VARIANT_SELECTOR_16) } - ?: base.variants.firstOrNull { it.unicode.contains(VARIANT_SELECTOR_16) } - return vs16Variant?.unicode ?: unicode -} - -/** - * Filters and returns the list of skin tone variants for the given [emoji]. - * Excludes non-variant selector entries (`isVariantSelector16`) and base duplicates. - */ -fun filterMeaningfulVariants(emoji: Emoji, variantEmoji: VariantEmoji? = null): List { - val rootBase = emoji.base - if (rootBase.isVariantSelector16()) { - return emptyList() - } - val managedVariants = variantEmoji?.getVariants(rootBase).orEmpty() - val candidates = managedVariants.ifEmpty { rootBase.variants } - return candidates.filterNot { variant -> - variant.isVariantSelector16() || variant.unicode == rootBase.unicode - } -} From da0e7df7de55fad379288548b004c5b0efa3461c Mon Sep 17 00:00:00 2001 From: DenserMeerkat Date: Sat, 22 Aug 2026 19:10:30 +0530 Subject: [PATCH 3/4] refactor: category tab icon - added compose resources. --- emoji-compose/build.gradle.kts | 1 + .../compose/EmojiPickerProviders.android.kt | 44 --------------- .../drawable/directions_car_24px.xml | 9 +++ .../drawable/emoji_food_beverage_24px.xml | 9 +++ .../drawable/emoji_nature_24px.xml | 9 +++ .../drawable/emoji_objects_24px.xml | 9 +++ .../drawable/emoji_symbols_24px.xml | 9 +++ .../composeResources/drawable/flag_24px.xml | 9 +++ .../drawable/schedule_24px.xml | 9 +++ .../sentiment_very_satisfied_24px.xml | 9 +++ .../drawable/sports_soccer_24px.xml | 9 +++ .../emoji/compose/EmojiPickerProviders.kt | 56 ++++++++++++++++++- 12 files changed, 135 insertions(+), 47 deletions(-) create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/directions_car_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/emoji_food_beverage_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/emoji_nature_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/emoji_objects_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/emoji_symbols_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/flag_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/schedule_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/sentiment_very_satisfied_24px.xml create mode 100644 emoji-compose/src/commonMain/composeResources/drawable/sports_soccer_24px.xml diff --git a/emoji-compose/build.gradle.kts b/emoji-compose/build.gradle.kts index eb86f32e4..591875d8f 100644 --- a/emoji-compose/build.gradle.kts +++ b/emoji-compose/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) + implementation(compose.components.resources) } } diff --git a/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt index 1c97fa023..a78d0e523 100644 --- a/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt +++ b/emoji-compose/src/androidMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.android.kt @@ -15,20 +15,9 @@ */ package com.vanniktech.emoji.compose -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vanniktech.emoji.EmojiAndroidProvider -import com.vanniktech.emoji.EmojiCategory -import com.vanniktech.emoji.EmojiManager -import com.vanniktech.emoji.EmojiProvider import com.vanniktech.emoji.recent.RecentEmoji import com.vanniktech.emoji.recent.RecentEmojiManager import com.vanniktech.emoji.variant.VariantEmoji @@ -50,37 +39,4 @@ import com.vanniktech.emoji.variant.VariantEmojiManager return remember(context) { VariantEmojiManager(context) } } -/** - * Renders the category tab icon using Android vector resources or a text emoji fallback. - */ -@Composable actual fun CategoryTabIcon( - category: EmojiCategory?, - isRecent: Boolean, - isSelected: Boolean, - colors: EmojiPickerColors, - fallbackEmoji: String, - provider: EmojiProvider?, -) { - val tint = if (isSelected) colors.tabSelectedColor else colors.tabUnselectedColor - val iconRes = remember(category, isRecent, provider) { - if (isRecent) { - com.vanniktech.emoji.R.drawable.emoji_recent - } else if (category != null && provider is EmojiAndroidProvider) { - try { provider.getIcon(category) } catch (_: Exception) { null } - } else null - } - if (iconRes != null && iconRes != 0) { - Icon( - painter = painterResource(iconRes), - contentDescription = null, - tint = tint, - modifier = Modifier.size(22.dp), - ) - } else if (fallbackEmoji.isNotEmpty()) { - Text( - text = fallbackEmoji, - fontSize = 18.sp, - ) - } -} diff --git a/emoji-compose/src/commonMain/composeResources/drawable/directions_car_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/directions_car_24px.xml new file mode 100644 index 000000000..d0a9d47e7 --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/directions_car_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/emoji_food_beverage_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/emoji_food_beverage_24px.xml new file mode 100644 index 000000000..dff33de2e --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/emoji_food_beverage_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/emoji_nature_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/emoji_nature_24px.xml new file mode 100644 index 000000000..1631ebc42 --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/emoji_nature_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/emoji_objects_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/emoji_objects_24px.xml new file mode 100644 index 000000000..226df8b40 --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/emoji_objects_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/emoji_symbols_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/emoji_symbols_24px.xml new file mode 100644 index 000000000..15cdc158b --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/emoji_symbols_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/flag_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/flag_24px.xml new file mode 100644 index 000000000..075b45203 --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/flag_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/schedule_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/schedule_24px.xml new file mode 100644 index 000000000..41aeb1e9b --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/schedule_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/sentiment_very_satisfied_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/sentiment_very_satisfied_24px.xml new file mode 100644 index 000000000..97b36df80 --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/sentiment_very_satisfied_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/composeResources/drawable/sports_soccer_24px.xml b/emoji-compose/src/commonMain/composeResources/drawable/sports_soccer_24px.xml new file mode 100644 index 000000000..52507d06c --- /dev/null +++ b/emoji-compose/src/commonMain/composeResources/drawable/sports_soccer_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt index 05ae65ba6..530680fa4 100644 --- a/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt +++ b/emoji-compose/src/commonMain/kotlin/com/vanniktech/emoji/compose/EmojiPickerProviders.kt @@ -22,6 +22,25 @@ import com.vanniktech.emoji.EmojiProvider import com.vanniktech.emoji.recent.RecentEmoji import com.vanniktech.emoji.variant.VariantEmoji +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import emoji.emoji_compose.generated.resources.Res +import emoji.emoji_compose.generated.resources.directions_car_24px +import emoji.emoji_compose.generated.resources.emoji_food_beverage_24px +import emoji.emoji_compose.generated.resources.emoji_nature_24px +import emoji.emoji_compose.generated.resources.emoji_objects_24px +import emoji.emoji_compose.generated.resources.emoji_symbols_24px +import emoji.emoji_compose.generated.resources.flag_24px +import emoji.emoji_compose.generated.resources.schedule_24px +import emoji.emoji_compose.generated.resources.sentiment_very_satisfied_24px +import emoji.emoji_compose.generated.resources.sports_soccer_24px +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource + /** * Returns the default [RecentEmoji] implementation for the current platform target. */ @@ -33,14 +52,45 @@ import com.vanniktech.emoji.variant.VariantEmoji @Composable expect fun rememberDefaultVariantEmoji(): VariantEmoji /** - * Renders the category tab icon for the given category or recent tab. + * Renders the category tab icon for the given category or recent tab using multiplatform vector resources. */ @Composable -expect fun CategoryTabIcon( +fun CategoryTabIcon( category: EmojiCategory?, isRecent: Boolean, isSelected: Boolean, colors: EmojiPickerColors, fallbackEmoji: String, provider: EmojiProvider? = null, -) +) { + val tint = if (isSelected) colors.tabSelectedColor else colors.tabUnselectedColor + val res: DrawableResource? = when { + isRecent -> Res.drawable.schedule_24px + category == null -> null + else -> when (category::class.simpleName) { + "SmileysAndPeopleCategory" -> Res.drawable.sentiment_very_satisfied_24px + "AnimalsAndNatureCategory" -> Res.drawable.emoji_nature_24px + "FoodAndDrinkCategory" -> Res.drawable.emoji_food_beverage_24px + "ActivitiesCategory" -> Res.drawable.sports_soccer_24px + "TravelAndPlacesCategory" -> Res.drawable.directions_car_24px + "ObjectsCategory" -> Res.drawable.emoji_objects_24px + "SymbolsCategory" -> Res.drawable.emoji_symbols_24px + "FlagsCategory" -> Res.drawable.flag_24px + else -> null + } + } + + if (res != null) { + Icon( + painter = painterResource(res), + contentDescription = null, + tint = tint, + modifier = Modifier.size(22.dp), + ) + } else if (fallbackEmoji.isNotEmpty()) { + Text( + text = fallbackEmoji, + fontSize = 18.sp, + ) + } +} From 74962b9f0c731b6cd0706c2fba9d5604042a70f0 Mon Sep 17 00:00:00 2001 From: DenserMeerkat Date: Sat, 22 Aug 2026 19:50:03 +0530 Subject: [PATCH 4/4] refactor: cleanup unused import --- emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt b/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt index 1a50362c1..5e0a8eb97 100644 --- a/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt +++ b/emoji/src/commonMain/kotlin/com/vanniktech/emoji/Emojis.kt @@ -18,7 +18,6 @@ package com.vanniktech.emoji -import com.vanniktech.emoji.variant.VariantEmoji import kotlin.jvm.JvmName private val SPACE_REMOVAL = Regex("[\\s]")