Синхронизированы анимации чата и Requests с Telegram, добавлен shimmer и исправлено копирование текста

This commit is contained in:
2026-03-18 20:09:33 +05:00
parent 74d5db3f05
commit 5d8984ab91
4 changed files with 260 additions and 61 deletions

View File

@@ -49,6 +49,7 @@ import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.components.SwipeBackContainer
import com.rosetta.messenger.ui.components.SwipeBackEnterAnimation
import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.settings.BackupScreen import com.rosetta.messenger.ui.settings.BackupScreen
@@ -1029,6 +1030,7 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1,
swipeEnabled = !isChatSwipeLocked, swipeEnabled = !isChatSwipeLocked,
enterAnimation = SwipeBackEnterAnimation.SlideFromRight,
propagateBackgroundProgress = false propagateBackgroundProgress = false
) { ) {
selectedUser?.let { currentChatUser -> selectedUser?.let { currentChatUser ->

View File

@@ -17,8 +17,13 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.SizeTransform import androidx.compose.animation.SizeTransform
@@ -53,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -119,6 +125,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private data class IncomingRunAvatarAccumulator( private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String, val senderPublicKey: String,
val senderDisplayName: String, val senderDisplayName: String,
@@ -515,11 +523,15 @@ fun ChatDetailScreen(
val chatsListViewModel: ChatsListViewModel = viewModel() val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState() val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) } val groupRepository = remember { GroupRepository.getInstance(context) }
val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
}
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet()) mutableStateOf<Set<String>>(emptySet())
} }
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) { var groupMembersCount by remember(groupMembersCacheKey) {
mutableStateOf<Int?>(null) mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
} }
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList()) mutableStateOf<List<MentionCandidate>>(emptyList())
@@ -540,14 +552,25 @@ fun ChatDetailScreen(
return@LaunchedEffect return@LaunchedEffect
} }
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
groupMembersCount = cachedMembersCount
val members = withContext(Dispatchers.IO) { val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty() groupRepository.requestGroupMembers(user.publicKey)
}
if (members == null) {
groupAdminKeys = emptySet()
mentionCandidates = emptyList()
return@LaunchedEffect
} }
val normalizedMembers = val normalizedMembers =
members.map { it.trim() } members.map { it.trim() }
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
groupMembersCount = normalizedMembers.size groupMembersCount = normalizedMembers.size
if (normalizedMembers.isNotEmpty()) {
groupMembersCountCache[groupMembersCacheKey] = normalizedMembers.size
}
val adminKey = normalizedMembers.firstOrNull().orEmpty() val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys = groupAdminKeys =
@@ -1006,9 +1029,13 @@ fun ChatDetailScreen(
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) || val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) || user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount isSystemAccount
val groupMembersSubtitleCount = groupMembersCount ?: 0 val groupMembersSubtitleCount = groupMembersCount
val groupMembersSubtitle = val groupMembersSubtitle =
if (groupMembersSubtitleCount == null) {
""
} else {
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}" "$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
}
val chatSubtitle = val chatSubtitle =
when { when {
isSavedMessages -> "Notes" isSavedMessages -> "Notes"
@@ -1249,18 +1276,9 @@ fun ChatDetailScreen(
extractCopyableMessageText( extractCopyableMessageText(
msg msg
) )
if (messageText.isBlank()) { messageText
null .takeIf {
} else { it.isNotBlank()
val time =
SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
} }
} }
.joinToString( .joinToString(
@@ -1582,6 +1600,10 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme =
isDarkTheme isDarkTheme
) )
} else if (isGroupChat &&
groupMembersCount == null
) {
GroupMembersSubtitleSkeleton()
} else { } else {
Text( Text(
text = text =
@@ -3597,6 +3619,53 @@ fun ChatDetailScreen(
} // Закрытие outer Box } // Закрытие outer Box
} }
@Composable
private fun GroupMembersSubtitleSkeleton() {
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")
val shimmerProgress by
transition.animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec =
infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "groupMembersSkeletonShimmer"
)
BoxWithConstraints(
modifier =
Modifier.padding(top = 2.dp)
.width(76.dp)
.height(12.dp)
) {
val density = LocalDensity.current
val widthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
val gradientWidthPx = with(density) { 44.dp.toPx() }
val shimmerX = shimmerProgress * widthPx
val shimmerBrush =
Brush.linearGradient(
colorStops =
arrayOf(
0f to Color.White.copy(alpha = 0.22f),
0.5f to Color.White.copy(alpha = 0.56f),
1f to Color.White.copy(alpha = 0.22f)
),
start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f)
)
Box(
modifier =
Modifier.fillMaxSize()
.clip(RoundedCornerShape(6.dp))
.background(shimmerBrush)
)
}
}
@Composable @Composable
private fun TiledChatWallpaper( private fun TiledChatWallpaper(
wallpaperResId: Int, wallpaperResId: Int,

View File

@@ -2171,19 +2171,83 @@ fun ChatsListScreen(
} }
} }
if (requestsCount > 0) { item(key = "requests_section") {
item( val isRequestsSectionVisible =
key = requestsCount > 0 &&
"requests_section" isRequestsVisible
) {
AnimatedVisibility( AnimatedVisibility(
visible = isRequestsVisible, visible =
enter = expandVertically( isRequestsSectionVisible,
animationSpec = tween(250, easing = FastOutSlowInEasing) enter =
) + fadeIn(animationSpec = tween(200)), slideInVertically(
exit = shrinkVertically( initialOffsetY = {
animationSpec = tween(250, easing = FastOutSlowInEasing) fullHeight ->
) + fadeOut(animationSpec = tween(200)) -fullHeight /
3
},
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
expandVertically(
expandFrom =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
fadeIn(
animationSpec =
tween(
durationMillis =
180
),
initialAlpha =
0.7f
),
exit =
slideOutVertically(
targetOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
shrinkVertically(
shrinkTowards =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
fadeOut(
animationSpec =
tween(
durationMillis =
140
)
)
) { ) {
Column { Column {
RequestsSection( RequestsSection(
@@ -2206,7 +2270,6 @@ fun ChatsListScreen(
} }
} }
} }
}
items( items(
items = currentDialogs, items = currentDialogs,

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.components package com.rosetta.messenger.ui.components
import android.content.Context import android.content.Context
import android.view.animation.DecelerateInterpolator
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -19,15 +20,21 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive
import kotlin.coroutines.coroutineContext
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) // Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
private val TelegramOpenDecelerateEasing =
Easing { input -> DecelerateInterpolator(1.5f).getInterpolation(input) }
// Swipe-back thresholds tuned for ultra-light navigation. // Swipe-back thresholds tuned for ultra-light navigation.
private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width
private const val FLING_VELOCITY_THRESHOLD = 35f // px/s private const val FLING_VELOCITY_THRESHOLD = 35f // px/s
private const val ANIMATION_DURATION_ENTER = 300 private const val ANIMATION_DURATION_ENTER = 300
private const val ANIMATION_DURATION_ENTER_TELEGRAM_CHAT = 150
private const val ANIMATION_DURATION_EXIT = 200 private const val ANIMATION_DURATION_EXIT = 200
private const val EDGE_ZONE_DP = 320 private const val EDGE_ZONE_DP = 320
private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f
@@ -36,6 +43,42 @@ private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f
private const val BACKGROUND_MIN_SCALE = 0.97f private const val BACKGROUND_MIN_SCALE = 0.97f
private const val BACKGROUND_PARALLAX_DP = 4 private const val BACKGROUND_PARALLAX_DP = 4
enum class SwipeBackEnterAnimation {
Fade,
SlideFromRight
}
private suspend fun runTelegramChatEnterAnimation(
alphaAnimatable: Animatable<Float, AnimationVector1D>,
enterOffsetAnimatable: Animatable<Float, AnimationVector1D>,
startOffsetPx: Float
) {
var progress = 0f
var lastFrameTimeMs = withFrameNanos { it } / 1_000_000L
while (coroutineContext.isActive && progress < 1f) {
val nowMs = withFrameNanos { it } / 1_000_000L
var dtMs = nowMs - lastFrameTimeMs
if (dtMs > 40L && progress == 0f) {
dtMs = 0L
} else if (dtMs > 18L) {
dtMs = 18L
}
lastFrameTimeMs = nowMs
progress =
(progress + (dtMs / ANIMATION_DURATION_ENTER_TELEGRAM_CHAT.toFloat()))
.coerceIn(0f, 1f)
val interpolated = TelegramOpenDecelerateEasing.transform(progress).coerceIn(0f, 1f)
alphaAnimatable.snapTo(interpolated)
enterOffsetAnimatable.snapTo(startOffsetPx * (1f - interpolated))
}
alphaAnimatable.snapTo(1f)
enterOffsetAnimatable.snapTo(0f)
}
/** /**
* Telegram-style swipe back container (optimized) * Telegram-style swipe back container (optimized)
* *
@@ -89,6 +132,7 @@ fun SwipeBackContainer(
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true, propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false, deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -103,10 +147,12 @@ fun SwipeBackContainer(
val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() } val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() }
val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION) val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION)
val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() } val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() }
val chatEnterOffsetPx = with(density) { 48.dp.toPx() }
val containerId = remember { System.identityHashCode(Any()).toLong() } val containerId = remember { System.identityHashCode(Any()).toLong() }
// Animation state for swipe (used only for swipe animations, not during drag) // Animation state for swipe (used only for swipe animations, not during drag)
val offsetAnimatable = remember { Animatable(0f) } val offsetAnimatable = remember { Animatable(0f) }
val enterOffsetAnimatable = remember { Animatable(0f) }
// Alpha animation for fade-in entry // Alpha animation for fade-in entry
val alphaAnimatable = remember { Animatable(0f) } val alphaAnimatable = remember { Animatable(0f) }
@@ -129,8 +175,15 @@ fun SwipeBackContainer(
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
// Current offset: use drag offset during drag, animatable otherwise // Current offset: use drag offset during drag, animatable otherwise + optional enter slide
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
val enterOffset =
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.value
} else {
0f
}
val currentOffset = baseOffset + enterOffset
// Current alpha: use animatable during fade animations, otherwise 1 // Current alpha: use animatable during fade animations, otherwise 1
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
@@ -186,7 +239,17 @@ fun SwipeBackContainer(
isAnimatingIn = true isAnimatingIn = true
try { try {
offsetAnimatable.snapTo(0f) // No slide for entry offsetAnimatable.snapTo(0f) // No slide for entry
dragOffset = 0f
alphaAnimatable.snapTo(0f) alphaAnimatable.snapTo(0f)
if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.snapTo(chatEnterOffsetPx)
runTelegramChatEnterAnimation(
alphaAnimatable = alphaAnimatable,
enterOffsetAnimatable = enterOffsetAnimatable,
startOffsetPx = chatEnterOffsetPx
)
} else {
enterOffsetAnimatable.snapTo(0f)
alphaAnimatable.animateTo( alphaAnimatable.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = animationSpec =
@@ -195,8 +258,10 @@ fun SwipeBackContainer(
easing = FastOutSlowInEasing easing = FastOutSlowInEasing
) )
) )
}
} finally { } finally {
isAnimatingIn = false isAnimatingIn = false
enterOffsetAnimatable.snapTo(0f)
} }
} else if (!isVisible && shouldShow) { } else if (!isVisible && shouldShow) {
// Animate out: fade-out (when triggered by button, not swipe) // Animate out: fade-out (when triggered by button, not swipe)
@@ -231,7 +296,7 @@ fun SwipeBackContainer(
} }
) { ) {
// Scrim (dimming layer behind the screen) - only when swiping // Scrim (dimming layer behind the screen) - only when swiping
if (currentOffset > 0f) { if (!isAnimatingIn && currentOffset > 0f) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha))) Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
} }