feat: enhance emoji picker with swipe functionality and optimize category synchronization

This commit is contained in:
k1ngsterr1
2026-02-06 02:43:35 +05:00
parent 3a810d6d61
commit 0bd8cb39ab
2 changed files with 108 additions and 49 deletions

View File

@@ -10,9 +10,13 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
@@ -74,8 +78,9 @@ fun OptimizedEmojiPicker(
private class StableCallback(val onClick: (String) -> Unit)
/**
* Контент emoji picker'а - ОПТИМИЗИРОВАННЫЙ
* Контент emoji picker'а - ОПТИМИЗИРОВАННЫЙ + СВАЙПЫ
*/
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
private fun EmojiPickerContent(
isDarkTheme: Boolean,
@@ -84,10 +89,14 @@ private fun EmojiPickerContent(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
// 🔥 PagerState для свайпов между категориями
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { EMOJI_CATEGORIES.size }
)
// 🔥 Wrap callback в stable class для избежания recomposition
val stableCallback = remember(onEmojiSelected) { StableCallback(onEmojiSelected) }
@@ -98,24 +107,15 @@ private fun EmojiPickerContent(
}
}
// 🚀 derivedStateOf для минимизации recomposition
val displayedEmojis by remember(selectedCategory) {
derivedStateOf {
if (OptimizedEmojiCache.isLoaded) {
OptimizedEmojiCache.getEmojisForCategory(selectedCategory.key)
} else {
emptyList()
// 🔥 Callback для клика на категорию - плавная анимация к нужной странице
val onCategoryClicked: (Int) -> Unit = remember(scope) {
{ index ->
scope.launch {
pagerState.animateScrollToPage(index)
}
}
}
// 🚀 Scroll to top при смене категории - БЕЗ анимации для скорости
LaunchedEffect(selectedCategory) {
if (displayedEmojis.isNotEmpty()) {
gridState.scrollToItem(0) // 🔥 scrollToItem вместо animateScrollToItem
}
}
// 🎨 Цвета темы - computed один раз
val panelBackground = remember(isDarkTheme) {
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
@@ -133,11 +133,11 @@ private fun EmojiPickerContent(
.height(keyboardHeight)
.background(panelBackground)
) {
// ============ КАТЕГОРИИ ============
// ============ КАТЕГОРИИ (синхронизированы с pager) ============
CategoryBar(
categories = EMOJI_CATEGORIES,
selectedCategory = selectedCategory,
onCategorySelected = { selectedCategory = it },
pagerState = pagerState,
onCategorySelected = onCategoryClicked,
isDarkTheme = isDarkTheme,
backgroundColor = categoryBarBackground
)
@@ -150,30 +150,52 @@ private fun EmojiPickerContent(
color = dividerColor
)
// ============ СЕТКА ЭМОДЗИ ============
// ============ СВАЙПАБЕЛЬНЫЕ СТРАНИЦЫ ЭМОДЗИ ============
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondBoundsPageCount = 1, // Предзагрузка соседних страниц для плавности
key = { EMOJI_CATEGORIES[it].key }
) { pageIndex ->
val category = EMOJI_CATEGORIES[pageIndex]
// 🔥 Эмодзи для текущей страницы
val emojis = remember(category.key, OptimizedEmojiCache.isLoaded) {
if (OptimizedEmojiCache.isLoaded) {
OptimizedEmojiCache.getEmojisForCategory(category.key)
} else {
emptyList()
}
}
// 🔥 Каждая страница имеет свой gridState для сохранения позиции скролла
val pageGridState = rememberLazyGridState()
UltraOptimizedEmojiGrid(
isLoaded = OptimizedEmojiCache.isLoaded,
emojis = displayedEmojis,
gridState = gridState,
emojis = emojis,
gridState = pageGridState,
onEmojiSelected = stableCallback,
isDarkTheme = isDarkTheme
)
}
}
}
/**
* Горизонтальная полоса категорий - ОПТИМИЗИРОВАННАЯ
* Горизонтальная полоса категорий - синхронизирована с PagerState
*/
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
private fun CategoryBar(
categories: List<EmojiCategory>,
selectedCategory: EmojiCategory,
onCategorySelected: (EmojiCategory) -> Unit,
pagerState: PagerState,
onCategorySelected: (Int) -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color
) {
// 🔥 Запоминаем interactionSource один раз для всего LazyRow
val interactionSource = remember { MutableInteractionSource() }
// 🔥 derivedStateOf для минимизации recomposition при свайпе
val currentPage by remember { derivedStateOf { pagerState.currentPage } }
LazyRow(
modifier = Modifier
@@ -188,11 +210,12 @@ private fun CategoryBar(
items = categories,
key = { it.key }
) { category ->
val index = categories.indexOf(category)
// 🔥 Минимальная CategoryButton без анимаций
SimpleCategoryButton(
category = category,
isSelected = selectedCategory == category,
onClick = { onCategorySelected(category) },
isSelected = currentPage == index,
onClick = { onCategorySelected(index) },
isDarkTheme = isDarkTheme
)
}

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.components.metaball
import android.graphics.Path
import android.graphics.RectF
import android.util.Log
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
@@ -18,6 +19,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -69,6 +71,12 @@ object ProfileMetaballConstants {
val AVATAR_SIZE_EXPANDED = 120.dp
val AVATAR_SIZE_MIN = 24.dp
// Standard ActionBar height (like Telegram's actionBar.getHeight())
val ACTION_BAR_HEIGHT = 56.dp
// Offset from actionBar bottom to avatar center (Telegram: -dp(21))
val AVATAR_Y_OFFSET = 21.dp
// Telegram thresholds (in dp)
val THRESHOLD_DRAWING = 40.dp // isDrawing = vr <= 40dp
val THRESHOLD_NEAR = 32.dp // isNear = vr <= 32dp
@@ -229,10 +237,15 @@ private fun computeAvatarState(
// ═══════════════════════════════════════════════════════════════
// CENTER Y - Telegram: avatarY = lerp(endY, startY, diff)
// startY = statusBar + actionBarHeight + avatarRadius - dp(21)
// endY = notch center (or -dp(29) if no notch)
// startY = statusBarHeight + actionBarHeight - dp(21) + some offset
//
// Telegram формула: startY = statusBarHeight + actionBarHeight - dp(21) + avatarRadius
// Это помещает центр аватара на dp(21) НИЖЕ нижней границы actionBar
// ═══════════════════════════════════════════════════════════════
val startY = statusBarHeightPx + (headerHeightPx - statusBarHeightPx) / 2f + 20f // Normal center
val actionBarHeightPx = dp40 + dp18 // ~58dp, близко к стандартным 56dp
val avatarYOffset = dp22 // ~21dp offset ниже actionBar
val startY = statusBarHeightPx + actionBarHeightPx - avatarYOffset + (avatarSizeExpandedPx / 2f)
val endY = notchCenterY // Target = notch center
val centerY: Float = when {
@@ -340,6 +353,9 @@ fun ProfileMetaballOverlay(
val screenWidth = configuration.screenWidthDp.dp
val density = LocalDensity.current
// Debug: log screen dimensions once
val TAG = "ProfileMetaball"
// Convert to pixels for path calculations
val screenWidthPx = with(density) { screenWidth.toPx() }
val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
@@ -350,6 +366,12 @@ fun ProfileMetaballOverlay(
// Get REAL camera/notch info from system (like Telegram does)
val notchInfo = remember { NotchInfoUtils.getInfo(context) }
// Debug log notch info once
LaunchedEffect(notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
Log.d(TAG, "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}")
Log.d(TAG, "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
}
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
val notchRadiusPx = remember(notchInfo) {
if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) {
@@ -366,9 +388,16 @@ fun ProfileMetaballOverlay(
}
}
// Notch center position
// Notch center position - ONLY use if notch is centered (like front camera)
// If notch is off-center (corner notch), use screen center instead
val notchCenterX = remember(notchInfo, screenWidthPx) {
notchInfo?.bounds?.centerX() ?: (screenWidthPx / 2f)
if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) {
// Centered notch (like Dynamic Island or punch-hole camera)
notchInfo.bounds.centerX()
} else {
// No notch or off-center notch - always use screen center
screenWidthPx / 2f
}
}
val notchCenterY = remember(notchInfo) {
@@ -424,6 +453,10 @@ fun ProfileMetaballOverlay(
val c1 = remember { Point() } // Notch center
val c2 = remember { Point() } // Avatar center
// 🔥 Reusable RectF like Telegram's AndroidUtilities.rectTmp
// Avoid creating new RectF objects every frame
val rectTmp = remember { RectF() }
// Calculate "v" parameter - thickness of connector based on distance
// Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0)
val distance = avatarState.centerY - notchCenterY
@@ -444,10 +477,12 @@ fun ProfileMetaballOverlay(
// Show connector only when avatar is small enough (like Telegram isDrawing)
// AND not when expanding (no metaball effect when expanded)
// AND only when hasAvatar is true (no drop animation for placeholder)
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
// AND only when device has real centered notch (Dynamic Island, punch-hole camera)
val hasRealNotch = notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0
val showConnector = hasRealNotch && hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f
// Don't show black metaball shapes when expanded or when no avatar
val showMetaballLayer = hasAvatar && expansionProgress == 0f
// Don't show black metaball shapes when expanded or when no avatar or no real notch
val showMetaballLayer = hasRealNotch && hasAvatar && expansionProgress == 0f
Box(modifier = modifier
.fillMaxSize()
@@ -518,15 +553,16 @@ fun ProfileMetaballOverlay(
val r = avatarState.radius
val cornerR = avatarState.cornerRadius
// 🔥 Use rectTmp like Telegram's AndroidUtilities.rectTmp
// If cornerRadius is close to radius, draw circle; otherwise rounded rect
if (cornerR >= r * 0.95f) {
// Draw circle
nativeCanvas.drawCircle(cx, cy, r, paint)
} else {
// Draw rounded rect (like Telegram when isDrawing)
android.graphics.RectF(cx - r, cy - r, cx + r, cy + r).let { rect ->
nativeCanvas.drawRoundRect(rect, cornerR, cornerR, paint)
}
// Reuse rectTmp to avoid allocations
rectTmp.set(cx - r, cy - r, cx + r, cy + r)
nativeCanvas.drawRoundRect(rectTmp, cornerR, cornerR, paint)
}
}