feat: enhance emoji picker with swipe functionality and optimize category synchronization
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
// ============ СЕТКА ЭМОДЗИ ============
|
||||
UltraOptimizedEmojiGrid(
|
||||
isLoaded = OptimizedEmojiCache.isLoaded,
|
||||
emojis = displayedEmojis,
|
||||
gridState = gridState,
|
||||
onEmojiSelected = stableCallback,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
// ============ СВАЙПАБЕЛЬНЫЕ СТРАНИЦЫ ЭМОДЗИ ============
|
||||
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 = 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -506,10 +541,10 @@ fun ProfileMetaballOverlay(
|
||||
screenWidthPx / 2f,
|
||||
statusBarHeightPx / 2f,
|
||||
notchRadiusPx,
|
||||
paint
|
||||
)
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw avatar shape - circle or rounded rect depending on state
|
||||
if (avatarState.showBlob) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +578,7 @@ fun ProfileMetaballOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // END if (showMetaballLayer)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user