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.LazyRow
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
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.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.lazy.items
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -74,8 +78,9 @@ fun OptimizedEmojiPicker(
|
|||||||
private class StableCallback(val onClick: (String) -> Unit)
|
private class StableCallback(val onClick: (String) -> Unit)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Контент emoji picker'а - ОПТИМИЗИРОВАННЫЙ
|
* Контент emoji picker'а - ОПТИМИЗИРОВАННЫЙ + СВАЙПЫ
|
||||||
*/
|
*/
|
||||||
|
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmojiPickerContent(
|
private fun EmojiPickerContent(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
@@ -84,10 +89,14 @@ private fun EmojiPickerContent(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
|
|
||||||
val gridState = rememberLazyGridState()
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 🔥 PagerState для свайпов между категориями
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = 0,
|
||||||
|
pageCount = { EMOJI_CATEGORIES.size }
|
||||||
|
)
|
||||||
|
|
||||||
// 🔥 Wrap callback в stable class для избежания recomposition
|
// 🔥 Wrap callback в stable class для избежания recomposition
|
||||||
val stableCallback = remember(onEmojiSelected) { StableCallback(onEmojiSelected) }
|
val stableCallback = remember(onEmojiSelected) { StableCallback(onEmojiSelected) }
|
||||||
|
|
||||||
@@ -98,24 +107,15 @@ private fun EmojiPickerContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 derivedStateOf для минимизации recomposition
|
// 🔥 Callback для клика на категорию - плавная анимация к нужной странице
|
||||||
val displayedEmojis by remember(selectedCategory) {
|
val onCategoryClicked: (Int) -> Unit = remember(scope) {
|
||||||
derivedStateOf {
|
{ index ->
|
||||||
if (OptimizedEmojiCache.isLoaded) {
|
scope.launch {
|
||||||
OptimizedEmojiCache.getEmojisForCategory(selectedCategory.key)
|
pagerState.animateScrollToPage(index)
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 Scroll to top при смене категории - БЕЗ анимации для скорости
|
|
||||||
LaunchedEffect(selectedCategory) {
|
|
||||||
if (displayedEmojis.isNotEmpty()) {
|
|
||||||
gridState.scrollToItem(0) // 🔥 scrollToItem вместо animateScrollToItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🎨 Цвета темы - computed один раз
|
// 🎨 Цвета темы - computed один раз
|
||||||
val panelBackground = remember(isDarkTheme) {
|
val panelBackground = remember(isDarkTheme) {
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
||||||
@@ -133,11 +133,11 @@ private fun EmojiPickerContent(
|
|||||||
.height(keyboardHeight)
|
.height(keyboardHeight)
|
||||||
.background(panelBackground)
|
.background(panelBackground)
|
||||||
) {
|
) {
|
||||||
// ============ КАТЕГОРИИ ============
|
// ============ КАТЕГОРИИ (синхронизированы с pager) ============
|
||||||
CategoryBar(
|
CategoryBar(
|
||||||
categories = EMOJI_CATEGORIES,
|
categories = EMOJI_CATEGORIES,
|
||||||
selectedCategory = selectedCategory,
|
pagerState = pagerState,
|
||||||
onCategorySelected = { selectedCategory = it },
|
onCategorySelected = onCategoryClicked,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
backgroundColor = categoryBarBackground
|
backgroundColor = categoryBarBackground
|
||||||
)
|
)
|
||||||
@@ -150,30 +150,52 @@ private fun EmojiPickerContent(
|
|||||||
color = dividerColor
|
color = dividerColor
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============ СЕТКА ЭМОДЗИ ============
|
// ============ СВАЙПАБЕЛЬНЫЕ СТРАНИЦЫ ЭМОДЗИ ============
|
||||||
UltraOptimizedEmojiGrid(
|
HorizontalPager(
|
||||||
isLoaded = OptimizedEmojiCache.isLoaded,
|
state = pagerState,
|
||||||
emojis = displayedEmojis,
|
modifier = Modifier.fillMaxSize(),
|
||||||
gridState = gridState,
|
beyondBoundsPageCount = 1, // Предзагрузка соседних страниц для плавности
|
||||||
onEmojiSelected = stableCallback,
|
key = { EMOJI_CATEGORIES[it].key }
|
||||||
isDarkTheme = isDarkTheme
|
) { 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
|
@Composable
|
||||||
private fun CategoryBar(
|
private fun CategoryBar(
|
||||||
categories: List<EmojiCategory>,
|
categories: List<EmojiCategory>,
|
||||||
selectedCategory: EmojiCategory,
|
pagerState: PagerState,
|
||||||
onCategorySelected: (EmojiCategory) -> Unit,
|
onCategorySelected: (Int) -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
backgroundColor: Color
|
backgroundColor: Color
|
||||||
) {
|
) {
|
||||||
// 🔥 Запоминаем interactionSource один раз для всего LazyRow
|
// 🔥 derivedStateOf для минимизации recomposition при свайпе
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val currentPage by remember { derivedStateOf { pagerState.currentPage } }
|
||||||
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -188,11 +210,12 @@ private fun CategoryBar(
|
|||||||
items = categories,
|
items = categories,
|
||||||
key = { it.key }
|
key = { it.key }
|
||||||
) { category ->
|
) { category ->
|
||||||
|
val index = categories.indexOf(category)
|
||||||
// 🔥 Минимальная CategoryButton без анимаций
|
// 🔥 Минимальная CategoryButton без анимаций
|
||||||
SimpleCategoryButton(
|
SimpleCategoryButton(
|
||||||
category = category,
|
category = category,
|
||||||
isSelected = selectedCategory == category,
|
isSelected = currentPage == index,
|
||||||
onClick = { onCategorySelected(category) },
|
onClick = { onCategorySelected(index) },
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.rosetta.messenger.ui.components.metaball
|
package com.rosetta.messenger.ui.components.metaball
|
||||||
|
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
|
import android.graphics.RectF
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.graphics.RenderEffect
|
import android.graphics.RenderEffect
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
@@ -18,6 +19,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -69,6 +71,12 @@ object ProfileMetaballConstants {
|
|||||||
val AVATAR_SIZE_EXPANDED = 120.dp
|
val AVATAR_SIZE_EXPANDED = 120.dp
|
||||||
val AVATAR_SIZE_MIN = 24.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)
|
// Telegram thresholds (in dp)
|
||||||
val THRESHOLD_DRAWING = 40.dp // isDrawing = vr <= 40dp
|
val THRESHOLD_DRAWING = 40.dp // isDrawing = vr <= 40dp
|
||||||
val THRESHOLD_NEAR = 32.dp // isNear = vr <= 32dp
|
val THRESHOLD_NEAR = 32.dp // isNear = vr <= 32dp
|
||||||
@@ -229,10 +237,15 @@ private fun computeAvatarState(
|
|||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// CENTER Y - Telegram: avatarY = lerp(endY, startY, diff)
|
// CENTER Y - Telegram: avatarY = lerp(endY, startY, diff)
|
||||||
|
// startY = statusBar + actionBarHeight + avatarRadius - dp(21)
|
||||||
// endY = notch center (or -dp(29) if no notch)
|
// 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 endY = notchCenterY // Target = notch center
|
||||||
|
|
||||||
val centerY: Float = when {
|
val centerY: Float = when {
|
||||||
@@ -340,6 +353,9 @@ fun ProfileMetaballOverlay(
|
|||||||
val screenWidth = configuration.screenWidthDp.dp
|
val screenWidth = configuration.screenWidthDp.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
// Debug: log screen dimensions once
|
||||||
|
val TAG = "ProfileMetaball"
|
||||||
|
|
||||||
// Convert to pixels for path calculations
|
// Convert to pixels for path calculations
|
||||||
val screenWidthPx = with(density) { screenWidth.toPx() }
|
val screenWidthPx = with(density) { screenWidth.toPx() }
|
||||||
val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
|
val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
|
||||||
@@ -350,6 +366,12 @@ fun ProfileMetaballOverlay(
|
|||||||
// Get REAL camera/notch info from system (like Telegram does)
|
// Get REAL camera/notch info from system (like Telegram does)
|
||||||
val notchInfo = remember { NotchInfoUtils.getInfo(context) }
|
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)
|
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
||||||
val notchRadiusPx = remember(notchInfo) {
|
val notchRadiusPx = remember(notchInfo) {
|
||||||
if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) {
|
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) {
|
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) {
|
val notchCenterY = remember(notchInfo) {
|
||||||
@@ -424,6 +453,10 @@ fun ProfileMetaballOverlay(
|
|||||||
val c1 = remember { Point() } // Notch center
|
val c1 = remember { Point() } // Notch center
|
||||||
val c2 = remember { Point() } // Avatar 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
|
// Calculate "v" parameter - thickness of connector based on distance
|
||||||
// Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0)
|
// Like Telegram: v = clamp((1f - c / 1.3f) / 2f, 0.8f, 0)
|
||||||
val distance = avatarState.centerY - notchCenterY
|
val distance = avatarState.centerY - notchCenterY
|
||||||
@@ -444,10 +477,12 @@ fun ProfileMetaballOverlay(
|
|||||||
// Show connector only when avatar is small enough (like Telegram isDrawing)
|
// Show connector only when avatar is small enough (like Telegram isDrawing)
|
||||||
// AND not when expanding (no metaball effect when expanded)
|
// AND not when expanding (no metaball effect when expanded)
|
||||||
// AND only when hasAvatar is true (no drop animation for placeholder)
|
// 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
|
// Don't show black metaball shapes when expanded or when no avatar or no real notch
|
||||||
val showMetaballLayer = hasAvatar && expansionProgress == 0f
|
val showMetaballLayer = hasRealNotch && hasAvatar && expansionProgress == 0f
|
||||||
|
|
||||||
Box(modifier = modifier
|
Box(modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -506,10 +541,10 @@ fun ProfileMetaballOverlay(
|
|||||||
screenWidthPx / 2f,
|
screenWidthPx / 2f,
|
||||||
statusBarHeightPx / 2f,
|
statusBarHeightPx / 2f,
|
||||||
notchRadiusPx,
|
notchRadiusPx,
|
||||||
paint
|
paint
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Draw avatar shape - circle or rounded rect depending on state
|
// Draw avatar shape - circle or rounded rect depending on state
|
||||||
if (avatarState.showBlob) {
|
if (avatarState.showBlob) {
|
||||||
@@ -518,15 +553,16 @@ fun ProfileMetaballOverlay(
|
|||||||
val r = avatarState.radius
|
val r = avatarState.radius
|
||||||
val cornerR = avatarState.cornerRadius
|
val cornerR = avatarState.cornerRadius
|
||||||
|
|
||||||
|
// 🔥 Use rectTmp like Telegram's AndroidUtilities.rectTmp
|
||||||
// If cornerRadius is close to radius, draw circle; otherwise rounded rect
|
// If cornerRadius is close to radius, draw circle; otherwise rounded rect
|
||||||
if (cornerR >= r * 0.95f) {
|
if (cornerR >= r * 0.95f) {
|
||||||
// Draw circle
|
// Draw circle
|
||||||
nativeCanvas.drawCircle(cx, cy, r, paint)
|
nativeCanvas.drawCircle(cx, cy, r, paint)
|
||||||
} else {
|
} else {
|
||||||
// Draw rounded rect (like Telegram when isDrawing)
|
// Draw rounded rect (like Telegram when isDrawing)
|
||||||
android.graphics.RectF(cx - r, cy - r, cx + r, cy + r).let { rect ->
|
// Reuse rectTmp to avoid allocations
|
||||||
nativeCanvas.drawRoundRect(rect, cornerR, cornerR, paint)
|
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)
|
} // END if (showMetaballLayer)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user