mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix the image transition
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+2
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user