diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt index c70bb8c..429b1be 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt @@ -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, - 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 ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index e9cf1e3..18fc055 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -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) // ═══════════════════════════════════════════════════════════════