Fix the image transition

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-26 19:21:59 +03:00
Unverified
parent 6303657e10
commit 97d309050f
8 changed files with 314 additions and 43 deletions
-1
View File
@@ -53,7 +53,6 @@ kotlin {
implementation(libs.constraintlayout) implementation(libs.constraintlayout)
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.compose.materialIconsExtended) implementation(libs.compose.materialIconsExtended)
implementation("androidx.compose.animation:animation:1.8.4")
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -0,0 +1,25 @@
package ru.fromchat.ui
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@Composable
actual fun rememberSystemBarsController(): ((Boolean) -> Unit)? {
val view = LocalView.current
return remember(view) {
val activity = view.context as? Activity ?: return@remember null
val window = activity.window ?: return@remember null
val controller = WindowCompat.getInsetsController(window, view) ?: return@remember null
{ visible: Boolean ->
if (visible) {
controller.show(WindowInsetsCompat.Type.statusBars())
} else {
controller.hide(WindowInsetsCompat.Type.statusBars())
}
}
}
}
@@ -32,6 +32,8 @@ import ru.fromchat.ui.dm.DmContainerScreen
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
import ru.fromchat.ui.LocalSystemBarsVisibility
import ru.fromchat.ui.rememberSystemBarsController
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") } val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@@ -104,7 +106,8 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
} }
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController LocalNavController provides navController,
LocalSystemBarsVisibility provides rememberSystemBarsController()
) { ) {
if (startDestination != null) { if (startDestination != null) {
val animationSpec = tween<IntOffset>(400) val animationSpec = tween<IntOffset>(400)
@@ -0,0 +1,9 @@
package ru.fromchat.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
val LocalSystemBarsVisibility = compositionLocalOf<((Boolean) -> Unit)?> { null }
@Composable
expect fun rememberSystemBarsController(): ((Boolean) -> Unit)?
@@ -7,8 +7,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -16,6 +15,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.systemGestures
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -38,14 +39,18 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@@ -54,8 +59,10 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -63,7 +70,10 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.ui.BackHandler import ru.fromchat.ui.BackHandler
import ru.fromchat.ui.LocalSystemBarsVisibility
import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -131,19 +141,33 @@ fun ImageFullscreenPreview(
var dismissRequested by remember { mutableStateOf(false) } var dismissRequested by remember { mutableStateOf(false) }
val backgroundAlpha = remember { Animatable(1f) } val backgroundAlpha = remember { Animatable(1f) }
var hasPlayedOpenAnimation by remember(thumbnailBounds) { mutableStateOf(thumbnailBounds == null) } var hasPlayedOpenAnimation by remember(thumbnailBounds) { mutableStateOf(thumbnailBounds == null) }
var isOpenAnimationPlaying by remember { mutableStateOf(false) }
var dismissProgress by remember { mutableStateOf(0f) }
val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation
val effectiveBackgroundAlpha = if (isInitialOpenState) 0f else backgroundAlpha.value val isTransitioning = isOpenAnimationPlaying || dismissRequested
val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible && dismissProgress < 0.01f
var effectiveBgAlpha by remember { mutableStateOf(1f) }
BackHandler(enabled = true) { BackHandler(enabled = true) {
dismissRequested = true dismissRequested = true
} }
val systemBarsVisibility = LocalSystemBarsVisibility.current
LaunchedEffect(menusVisible) {
systemBarsVisibility?.invoke(menusVisible)
}
DisposableEffect(Unit) {
onDispose {
systemBarsVisibility?.invoke(true)
}
}
Box(modifier = modifier.fillMaxSize()) {
Box( Box(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = effectiveBackgroundAlpha)) .drawBehind { drawRect(Color.Black, alpha = effectiveBgAlpha) }
) { )
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -158,6 +182,11 @@ fun ImageFullscreenPreview(
when (val model = imageModel) { when (val model = imageModel) {
null -> { null -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
)
LaunchedEffect(dismissRequested) { LaunchedEffect(dismissRequested) {
if (dismissRequested) onDismiss() if (dismissRequested) onDismiss()
} }
@@ -187,18 +216,28 @@ fun ImageFullscreenPreview(
} }
var scale by remember { mutableStateOf(initial.scale) } var scale by remember { mutableStateOf(initial.scale) }
var offset by remember { mutableStateOf(Offset(initial.offsetX, initial.offsetY)) } var offset by remember { mutableStateOf(Offset(initial.offsetX, initial.offsetY)) }
var isDragToDismiss by remember { mutableStateOf(false) }
val scaleAnim = remember { Animatable(initial.scale) } val scaleAnim = remember { Animatable(initial.scale) }
val offsetXAnim = remember { Animatable(initial.offsetX) } val offsetXAnim = remember { Animatable(initial.offsetX) }
val offsetYAnim = remember { Animatable(initial.offsetY) } val offsetYAnim = remember { Animatable(initial.offsetY) }
val cornerRadiusAnim = remember { Animatable(initial.cornerRadius) } val cornerRadiusAnim = remember { Animatable(initial.cornerRadius) }
val state = rememberTransformableState { zoomChange, offsetChange, _ -> val density = LocalDensity.current
scale = (scale * zoomChange).coerceIn(1f, 12f) val bottomInsetPx = WindowInsets.safeDrawing.getBottom(density)
offset += offsetChange val backGestureEdgeWidthPx: Float? = WindowInsets.safeDrawing
} .getLeft(density, androidx.compose.ui.unit.LayoutDirection.Ltr)
.toFloat()
.takeIf { it > 0f }
var isTransformInProgress by remember { mutableStateOf(false) }
var gestureEndJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
var lastStableScale by remember { mutableStateOf(initial.scale) }
var lastStableOffsetX by remember { mutableStateOf(initial.offsetX) }
var lastStableOffsetY by remember { mutableStateOf(initial.offsetY) }
LaunchedEffect(thumbnailBounds) { LaunchedEffect(thumbnailBounds) {
if (thumbnailBounds != null && !hasPlayedOpenAnimation) { if (thumbnailBounds != null && !hasPlayedOpenAnimation) {
hasPlayedOpenAnimation = true hasPlayedOpenAnimation = true
isOpenAnimationPlaying = true
val fullTop = (containerHeight - contentHeightAtScale1) / 2f val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset( val fullCenter = Offset(
@@ -228,19 +267,29 @@ fun ImageFullscreenPreview(
launch { backgroundAlpha.animateTo(1f, tween(250)) } launch { backgroundAlpha.animateTo(1f, tween(250)) }
} }
isOpenAnimationPlaying = false
menusVisible = true menusVisible = true
} }
} }
SideEffect {
if (!isTransformInProgress) {
lastStableScale = scaleAnim.value
lastStableOffsetX = offsetXAnim.value
lastStableOffsetY = offsetYAnim.value
}
}
LaunchedEffect(dismissRequested) { LaunchedEffect(dismissRequested) {
if (!dismissRequested) return@LaunchedEffect if (!dismissRequested) return@LaunchedEffect
val hasTransform = scaleAnim.value != 1f || val hasTransform = lastStableScale != 1f ||
offsetXAnim.value != 0f || lastStableOffsetX != 0f ||
offsetYAnim.value != 0f lastStableOffsetY != 0f
if (thumbnailBounds != null) { if (thumbnailBounds != null) {
menusVisible = false menusVisible = false
val wasOpenAnimating = isOpenAnimationPlaying
isOpenAnimationPlaying = false
val fullTop = (containerHeight - contentHeightAtScale1) / 2f val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset( val fullCenter = Offset(
@@ -253,9 +302,11 @@ fun ImageFullscreenPreview(
val targetScale = thumbWidth / containerWidth val targetScale = thumbWidth / containerWidth
val targetOffset = thumbCenter - fullCenter val targetOffset = thumbCenter - fullCenter
scaleAnim.snapTo(scale) if (!wasOpenAnimating) {
offsetXAnim.snapTo(offset.x) scaleAnim.snapTo(lastStableScale)
offsetYAnim.snapTo(offset.y) offsetXAnim.snapTo(lastStableOffsetX)
offsetYAnim.snapTo(lastStableOffsetY)
}
cornerRadiusAnim.snapTo(0f) cornerRadiusAnim.snapTo(0f)
coroutineScope { coroutineScope {
@@ -268,6 +319,9 @@ fun ImageFullscreenPreview(
} else { } else {
if (hasTransform) { if (hasTransform) {
menusVisible = false menusVisible = false
scaleAnim.snapTo(lastStableScale)
offsetXAnim.snapTo(lastStableOffsetX)
offsetYAnim.snapTo(lastStableOffsetY)
coroutineScope { coroutineScope {
launch { scaleAnim.animateTo(1f, tween(220)) } launch { scaleAnim.animateTo(1f, tween(220)) }
launch { offsetXAnim.animateTo(0f, tween(220)) } launch { offsetXAnim.animateTo(0f, tween(220)) }
@@ -285,26 +339,64 @@ fun ImageFullscreenPreview(
onClosingChange(false) onClosingChange(false)
onDismiss() onDismiss()
} }
LaunchedEffect(scale, offset, state.isTransformInProgress) { LaunchedEffect(scale, offset, isTransformInProgress) {
if (state.isTransformInProgress) { if (isTransformInProgress) {
scaleAnim.snapTo(scale) scaleAnim.snapTo(scale)
offsetXAnim.snapTo(offset.x) offsetXAnim.snapTo(offset.x)
offsetYAnim.snapTo(offset.y) offsetYAnim.snapTo(offset.y)
} }
} }
LaunchedEffect(state.isTransformInProgress) { LaunchedEffect(isTransformInProgress) {
if (!state.isTransformInProgress) { if (!isTransformInProgress) {
if (dismissRequested) {
return@LaunchedEffect
}
if (isDragToDismiss) {
val dragDistance = abs(offset.y)
val rawProgress = (dragDistance / (containerHeight * 0.75f)).coerceIn(0f, 1f)
if (rawProgress > 0.2f) {
// Promote drag-to-dismiss to the regular dismiss flow
isDragToDismiss = false
dismissProgress = 0f
dismissRequested = true
return@LaunchedEffect
} else {
// Cancel dismiss → snap back to center
isDragToDismiss = false
dismissProgress = 0f
scaleAnim.snapTo(scale)
offsetXAnim.snapTo(offset.x)
offsetYAnim.snapTo(offset.y)
coroutineScope {
launch { offsetYAnim.animateTo(0f, tween(220)) }
launch { backgroundAlpha.animateTo(1f, tween(220)) }
}
offset = offset.copy(y = 0f)
return@LaunchedEffect
}
}
val clampedScale = scale.coerceIn(1f, 10f) val clampedScale = scale.coerceIn(1f, 10f)
val scaledW = containerWidth * clampedScale val scaledW = containerWidth * clampedScale
val scaledH = contentHeightAtScale1 * clampedScale val scaledH = contentHeightAtScale1 * clampedScale
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f) val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
val minOffsetX = -maxOffsetX
val maxOffsetY = max(0f, (scaledH - containerHeight) / 2f) val maxOffsetY = max(0f, (scaledH - containerHeight) / 2f)
val minOffsetY = -maxOffsetY val clampedOffset = when {
val clampedOffset = Offset( scale < 1f -> Offset.Zero
offset.x.coerceIn(minOffsetX, maxOffsetX), scale > clampedScale -> {
offset.y.coerceIn(minOffsetY, maxOffsetY) val scaleRatio = clampedScale / scale
Offset(
(offset.x * scaleRatio).coerceIn(-maxOffsetX, maxOffsetX),
(offset.y * scaleRatio).coerceIn(-maxOffsetY, maxOffsetY)
) )
}
else -> Offset(
offset.x.coerceIn(-maxOffsetX, maxOffsetX),
offset.y.coerceIn(-maxOffsetY, maxOffsetY)
)
}
scaleAnim.snapTo(scale) scaleAnim.snapTo(scale)
offsetXAnim.snapTo(offset.x) offsetXAnim.snapTo(offset.x)
offsetYAnim.snapTo(offset.y) offsetYAnim.snapTo(offset.y)
@@ -313,11 +405,19 @@ fun ImageFullscreenPreview(
launch { offsetXAnim.animateTo(clampedOffset.x, tween(300)) } launch { offsetXAnim.animateTo(clampedOffset.x, tween(300)) }
launch { offsetYAnim.animateTo(clampedOffset.y, tween(300)) } launch { offsetYAnim.animateTo(clampedOffset.y, tween(300)) }
} }
scale = scaleAnim.value scale = clampedScale
offset = Offset(offsetXAnim.value, offsetYAnim.value) offset = clampedOffset
} }
} }
val positionBasedProgress = if (isDragToDismiss) {
val dragY = if (isTransformInProgress) offset.y else offsetYAnim.value
val raw = (abs(dragY) / (containerHeight * 0.75f)).coerceIn(0f, 1f)
raw * raw
} else 0f
val effectiveBackgroundAlpha = (if (isInitialOpenState) 0f else backgroundAlpha.value) * (1f - positionBasedProgress)
SideEffect { effectiveBgAlpha = effectiveBackgroundAlpha }
val sharedElementModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) { val sharedElementModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) { with(sharedTransitionScope) {
Modifier.sharedElement( Modifier.sharedElement(
@@ -331,9 +431,134 @@ fun ImageFullscreenPreview(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.transformable(state = state, lockRotationOnZoomPan = true) .then(
.pointerInput(Unit) { if (isTransitioning) Modifier
else Modifier.pointerInput(
containerWidth, containerHeight, contentHeightAtScale1,
bottomInsetPx, scope, backGestureEdgeWidthPx
) {
detectTransformGestures(
panZoomLock = true,
onGesture = { centroid, panChange, zoomChange, _ ->
if (dismissRequested || isTransitioning) {
return@detectTransformGestures
}
gestureEndJob?.cancel()
isTransformInProgress = true
gestureEndJob = scope.launch {
delay(150)
isTransformInProgress = false
gestureEndJob = null
}
val newScale = scale * zoomChange
val centerX = containerWidth / 2f
val centerY = containerHeight / 2f
// If this looks like a system back swipe from the left edge, ignore it
if (zoomChange == 1f && backGestureEdgeWidthPx != null) {
val absDx = abs(panChange.x)
val absDy = abs(panChange.y)
if (absDx > absDy && centroid.x < backGestureEdgeWidthPx) {
return@detectTransformGestures
}
}
if (zoomChange != 1f) {
isDragToDismiss = false
dismissProgress = 0f
scale = newScale
val pivotOffsetX = centroid.x - centerX - offset.x
val pivotOffsetY = centroid.y - centerY - offset.y
offset = Offset(
centroid.x - centerX - pivotOffsetX * zoomChange,
centroid.y - centerY - pivotOffsetY * zoomChange
)
val contentHeightAtNewScale = contentHeightAtScale1 * newScale
val scaledW = containerWidth * newScale
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
val maxOffsetYRaw = max(0f, (contentHeightAtNewScale - containerHeight) / 2f)
val limitedBottom = (containerHeight - bottomInsetPx) * 0.5f
val maxOffsetY = min(maxOffsetYRaw, limitedBottom)
val minOffsetY = -maxOffsetY
val edgeResistanceX = when {
offset.x >= maxOffsetX - 1f && panChange.x > 0 -> 0.3f
offset.x <= -maxOffsetX + 1f && panChange.x < 0 -> 0.3f
else -> 1f
}
val newX = (offset.x + panChange.x * edgeResistanceX).coerceIn(-maxOffsetX, maxOffsetX)
val newY = if (maxOffsetY > 0f) {
(offset.y + panChange.y).coerceIn(minOffsetY, maxOffsetY)
} else {
offset.y
}
offset = Offset(newX, newY)
} else {
val contentHeightAtCurrentScale = contentHeightAtScale1 * scale
val canScrollVertically = contentHeightAtCurrentScale > containerHeight
val absDx = abs(panChange.x)
val absDy = abs(panChange.y)
if (scale > 1f || canScrollVertically) {
isDragToDismiss = false
dismissProgress = 0f
val scaledW = containerWidth * scale
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
val maxOffsetYRaw = max(0f, (contentHeightAtCurrentScale - containerHeight) / 2f)
val limitedBottom = (containerHeight - bottomInsetPx) * 0.5f
val maxOffsetY = min(maxOffsetYRaw, limitedBottom)
val minOffsetY = -maxOffsetY
val edgeResistanceX = when {
offset.x >= maxOffsetX - 1f && panChange.x > 0 -> 0.3f
offset.x <= -maxOffsetX + 1f && panChange.x < 0 -> 0.3f
else -> 1f
}
val newX = (offset.x + panChange.x * edgeResistanceX).coerceIn(-maxOffsetX, maxOffsetX)
val newY = if (maxOffsetY > 0f) {
(offset.y + panChange.y).coerceIn(minOffsetY, maxOffsetY)
} else {
offset.y
}
offset = Offset(newX, newY)
} else {
if (scale >= 1f && (isDragToDismiss || absDy > absDx)) {
if (!isDragToDismiss && absDy > 0f) {
isDragToDismiss = true
menusVisible = false
}
offset = offset.copy(y = offset.y + panChange.y)
val dragDistance = abs(offset.y)
val rawProgress = (dragDistance / (containerHeight * 0.75f)).coerceIn(0f, 1f)
dismissProgress = rawProgress * rawProgress
} else {
val scaledW = containerWidth * scale
val maxOffsetX = max(0f, (scaledW - containerWidth) / 2f)
val edgeResistanceX = when {
offset.x >= maxOffsetX - 1f && panChange.x > 0 -> 0.3f
offset.x <= -maxOffsetX + 1f && panChange.x < 0 -> 0.3f
else -> 1f
}
offset = offset.copy(x = (offset.x + panChange.x * edgeResistanceX).coerceIn(-maxOffsetX, maxOffsetX))
}
}
}
}
)
}
)
.pointerInput(isTransitioning) {
if (!isTransitioning) {
detectTapGestures(onTap = { menusVisible = !menusVisible }) detectTapGestures(onTap = { menusVisible = !menusVisible })
}
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -351,6 +576,8 @@ fun ImageFullscreenPreview(
.graphicsLayer { .graphicsLayer {
scaleX = scaleAnim.value scaleX = scaleAnim.value
scaleY = scaleAnim.value scaleY = scaleAnim.value
translationX = offsetXAnim.value - offsetXAnim.value.roundToInt()
translationY = offsetYAnim.value - offsetYAnim.value.roundToInt()
val scale = maxOf(scaleAnim.value, 0.01f) val scale = maxOf(scaleAnim.value, 0.01f)
shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp) shape = androidx.compose.foundation.shape.RoundedCornerShape((cornerRadiusAnim.value / scale).dp)
clip = true clip = true
@@ -368,9 +595,7 @@ fun ImageFullscreenPreview(
visible = effectiveMenusVisible, visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(), exit = androidx.compose.animation.fadeOut(),
modifier = Modifier modifier = Modifier.align(Alignment.TopStart).fillMaxWidth()
.align(Alignment.TopStart)
.fillMaxWidth()
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -468,9 +693,7 @@ fun ImageFullscreenPreview(
visible = effectiveMenusVisible && message.content.isNotBlank(), visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(), exit = androidx.compose.animation.fadeOut(),
modifier = Modifier modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth()
.align(Alignment.BottomStart)
.fillMaxWidth()
) { ) {
if (message.content.isNotBlank()) { if (message.content.isNotBlank()) {
Box( Box(
@@ -0,0 +1,11 @@
package ru.fromchat.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@Composable
actual fun rememberSystemBarsController(): ((Boolean) -> Unit)? {
return remember {
{ _ -> /* iOS 13+ uses scene-based API; status bar control requires native Swift/ObjC bridge */ }
}
}
-1
View File
@@ -57,7 +57,6 @@ kotlin {
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
// Multiplatform settings for iOS Keychain support
implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.coroutines) implementation(libs.multiplatform.settings.coroutines)
implementation(libs.multiplatform.settings.serialization) implementation(libs.multiplatform.settings.serialization)
@@ -1,3 +1,5 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package com.pr0gramm3r101.utils.files package com.pr0gramm3r101.utils.files
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi