fix: adjust padding and status bar colors in ImageEditorScreen for improved UI experience
This commit is contained in:
@@ -84,6 +84,8 @@ import android.provider.MediaStore
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -128,6 +130,9 @@ fun ChatDetailScreen(
|
|||||||
val coordinator = remember { KeyboardTransitionCoordinator() }
|
val coordinator = remember { KeyboardTransitionCoordinator() }
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
|
||||||
|
// 🎨 Window reference для управления статус баром
|
||||||
|
val window = remember { (view.context as? Activity)?.window }
|
||||||
|
|
||||||
// 🔥 Focus state for input
|
// 🔥 Focus state for input
|
||||||
val inputFocusRequester = remember { FocusRequester() }
|
val inputFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
@@ -189,6 +194,24 @@ fun ChatDetailScreen(
|
|||||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||||
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
||||||
|
|
||||||
|
// 🎨 Управление статус баром - чёрный при просмотре фото
|
||||||
|
DisposableEffect(isDarkTheme, showImageViewer) {
|
||||||
|
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) }
|
||||||
|
|
||||||
|
if (showImageViewer) {
|
||||||
|
// 📸 При просмотре фото - чёрный статус бар
|
||||||
|
window?.statusBarColor = 0xFF000000.toInt()
|
||||||
|
insetsController?.isAppearanceLightStatusBars = false
|
||||||
|
} else {
|
||||||
|
// Обычный режим - цвет хедера
|
||||||
|
val headerColor = if (isDarkTheme) 0xFF212121.toInt() else 0xFFFFFFFF.toInt()
|
||||||
|
window?.statusBarColor = headerColor
|
||||||
|
insetsController?.isAppearanceLightStatusBars = !isDarkTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
// 📷 Camera: URI для сохранения фото
|
// 📷 Camera: URI для сохранения фото
|
||||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
@@ -532,6 +555,8 @@ fun ChatDetailScreen(
|
|||||||
else Color.White
|
else Color.White
|
||||||
} else headerBackground
|
} else headerBackground
|
||||||
)
|
)
|
||||||
|
// 🎨 statusBarsPadding ПОСЛЕ background = хедер начинается под статус баром
|
||||||
|
.statusBarsPadding()
|
||||||
) {
|
) {
|
||||||
// Контент хедера с Crossfade для плавной смены - ускоренная
|
// Контент хедера с Crossfade для плавной смены - ускоренная
|
||||||
// анимация
|
// анимация
|
||||||
@@ -545,7 +570,6 @@ fun ChatDetailScreen(
|
|||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
|
||||||
.height(56.dp)
|
.height(56.dp)
|
||||||
.padding(
|
.padding(
|
||||||
horizontal =
|
horizontal =
|
||||||
@@ -723,7 +747,6 @@ fun ChatDetailScreen(
|
|||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
|
||||||
.height(56.dp)
|
.height(56.dp)
|
||||||
.padding(
|
.padding(
|
||||||
horizontal =
|
horizontal =
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import ja.burhanrashid52.photoeditor.PhotoEditorView
|
|||||||
import ja.burhanrashid52.photoeditor.SaveSettings
|
import ja.burhanrashid52.photoeditor.SaveSettings
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -163,14 +164,23 @@ fun ImageEditorScreen(
|
|||||||
val originalStatusBarColor = window?.statusBarColor ?: 0
|
val originalStatusBarColor = window?.statusBarColor ?: 0
|
||||||
val originalNavigationBarColor = window?.navigationBarColor ?: 0
|
val originalNavigationBarColor = window?.navigationBarColor ?: 0
|
||||||
|
|
||||||
// Устанавливаем черный цвет
|
// 🔥 Сохраняем оригинальное состояние иконок статус бара
|
||||||
|
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) }
|
||||||
|
val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false
|
||||||
|
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false
|
||||||
|
|
||||||
|
// Устанавливаем черный цвет и светлые иконки
|
||||||
window?.statusBarColor = android.graphics.Color.BLACK
|
window?.statusBarColor = android.graphics.Color.BLACK
|
||||||
window?.navigationBarColor = android.graphics.Color.BLACK
|
window?.navigationBarColor = android.graphics.Color.BLACK
|
||||||
|
insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
|
||||||
|
insetsController?.isAppearanceLightNavigationBars = false
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
// Восстанавливаем оригинальные цвета
|
// Восстанавливаем оригинальные цвета и состояние иконок
|
||||||
window?.statusBarColor = originalStatusBarColor
|
window?.statusBarColor = originalStatusBarColor
|
||||||
window?.navigationBarColor = originalNavigationBarColor
|
window?.navigationBarColor = originalNavigationBarColor
|
||||||
|
insetsController?.isAppearanceLightStatusBars = originalLightStatusBars
|
||||||
|
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,10 +596,10 @@ fun ImageEditorScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
// 🔥 imePadding СНАЧАЛА - поднимает над клавиатурой
|
// 🔥 Отступ для toolbar ТОЛЬКО когда клавиатура/emoji закрыты
|
||||||
.then(if (shouldUseImePadding) Modifier.imePadding() else Modifier)
|
|
||||||
// 🔥 Затем добавляем отступ для toolbar когда клавиатура закрыта
|
|
||||||
.padding(bottom = bottomPaddingForCaption)
|
.padding(bottom = bottomPaddingForCaption)
|
||||||
|
// 🔥 imePadding поднимает над клавиатурой (применяется только когда emoji не показан)
|
||||||
|
.then(if (shouldUseImePadding) Modifier.imePadding() else Modifier)
|
||||||
) {
|
) {
|
||||||
TelegramCaptionBar(
|
TelegramCaptionBar(
|
||||||
caption = caption,
|
caption = caption,
|
||||||
@@ -1414,6 +1424,33 @@ fun MultiImageEditorScreen(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val view = LocalView.current
|
||||||
|
|
||||||
|
// 🎨 Черный статус бар и навигационный бар для редактора
|
||||||
|
val window = remember { (view.context as? Activity)?.window }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val originalStatusBarColor = window?.statusBarColor ?: 0
|
||||||
|
val originalNavigationBarColor = window?.navigationBarColor ?: 0
|
||||||
|
|
||||||
|
// 🔥 Сохраняем оригинальное состояние иконок статус бара
|
||||||
|
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) }
|
||||||
|
val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false
|
||||||
|
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false
|
||||||
|
|
||||||
|
// Устанавливаем черный цвет и светлые иконки
|
||||||
|
window?.statusBarColor = android.graphics.Color.BLACK
|
||||||
|
window?.navigationBarColor = android.graphics.Color.BLACK
|
||||||
|
insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
|
||||||
|
insetsController?.isAppearanceLightNavigationBars = false
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
// Восстанавливаем оригинальные цвета и состояние иконок
|
||||||
|
window?.statusBarColor = originalStatusBarColor
|
||||||
|
window?.navigationBarColor = originalNavigationBarColor
|
||||||
|
insetsController?.isAppearanceLightStatusBars = originalLightStatusBars
|
||||||
|
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val imagesWithCaptions = remember {
|
val imagesWithCaptions = remember {
|
||||||
mutableStateListOf<ImageWithCaption>().apply {
|
mutableStateListOf<ImageWithCaption>().apply {
|
||||||
|
|||||||
@@ -155,23 +155,22 @@ fun ImageViewerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close with animation
|
// Close with animation - красиво возвращаемся к пузырьку
|
||||||
fun closeWithAnimation() {
|
fun closeWithAnimation() {
|
||||||
if (isClosing) return
|
if (isClosing) return
|
||||||
isClosing = true
|
isClosing = true
|
||||||
|
|
||||||
if (sourceBounds != null) {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (sourceBounds != null) {
|
||||||
|
// Плавно возвращаемся к исходному положению пузырька
|
||||||
animationProgress.animateTo(
|
animationProgress.animateTo(
|
||||||
targetValue = 0f,
|
targetValue = 0f,
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
durationMillis = 200,
|
durationMillis = 280,
|
||||||
easing = TelegramEasing
|
easing = FastOutSlowInEasing
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onDismiss()
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,17 +191,61 @@ fun ImageViewerScreen(
|
|||||||
// UI visibility state
|
// UI visibility state
|
||||||
var showControls by remember { mutableStateOf(true) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
// Drag to dismiss
|
// ═══════════════════════════════════════════════════════════
|
||||||
var offsetY by remember { mutableStateOf(0f) }
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 🎬 ПРОСТОЙ FADE-OUT при свайпе
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
val dismissThreshold = 200f
|
var dragVelocity by remember { mutableFloatStateOf(0f) }
|
||||||
|
val dismissThreshold = 100f
|
||||||
|
val velocityThreshold = 500f
|
||||||
|
|
||||||
// Animated background alpha based on animation progress and drag
|
// Анимированные значения
|
||||||
val baseAlpha = animationProgress.value
|
val animatedOffsetY = remember { Animatable(0f) }
|
||||||
val dragAlpha = if (isDragging) {
|
val dismissAlpha = remember { Animatable(1f) }
|
||||||
(1f - (offsetY.absoluteValue / 500f)).coerceIn(0.3f, 1f)
|
|
||||||
} else 1f
|
// Синхронизируем drag с анимацией
|
||||||
val backgroundAlpha = baseAlpha * dragAlpha
|
LaunchedEffect(isDragging, dragOffsetY) {
|
||||||
|
if (isDragging) {
|
||||||
|
animatedOffsetY.snapTo(dragOffsetY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎬 Простой fade-out dismiss
|
||||||
|
fun smoothDismiss() {
|
||||||
|
if (isClosing) return
|
||||||
|
isClosing = true
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
// Плавно исчезаем - более длинная анимация
|
||||||
|
dismissAlpha.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 350,
|
||||||
|
easing = LinearEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Плавный возврат на место
|
||||||
|
fun snapBack() {
|
||||||
|
scope.launch {
|
||||||
|
animatedOffsetY.animateTo(
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 250,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpha на основе drag прогресса
|
||||||
|
val dismissProgress = (animatedOffsetY.value.absoluteValue / 300f).coerceIn(0f, 1f)
|
||||||
|
val backgroundAlpha = animationProgress.value * dismissAlpha.value * (1f - dismissProgress * 0.5f)
|
||||||
|
|
||||||
// Current image info
|
// Current image info
|
||||||
val currentImage = images.getOrNull(pagerState.currentPage)
|
val currentImage = images.getOrNull(pagerState.currentPage)
|
||||||
@@ -212,37 +255,6 @@ fun ImageViewerScreen(
|
|||||||
closeWithAnimation()
|
closeWithAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// 🎬 CALCULATE ANIMATED TRANSFORMS (Telegram style)
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
val progress = animationProgress.value
|
|
||||||
|
|
||||||
// Calculate interpolated values for shared element transition
|
|
||||||
val animatedScale: Float
|
|
||||||
val animatedTranslationX: Float
|
|
||||||
val animatedTranslationY: Float
|
|
||||||
val animatedCornerRadius: Float
|
|
||||||
|
|
||||||
if (sourceBounds != null && screenSize.width > 0 && screenSize.height > 0) {
|
|
||||||
// Source state (thumbnail in chat)
|
|
||||||
val sourceScale = sourceBounds.width / screenSize.width.toFloat()
|
|
||||||
val sourceCenterX = sourceBounds.left + sourceBounds.width / 2f
|
|
||||||
val sourceCenterY = sourceBounds.top + sourceBounds.height / 2f
|
|
||||||
val screenCenterX = screenSize.width / 2f
|
|
||||||
val screenCenterY = screenSize.height / 2f
|
|
||||||
|
|
||||||
// Interpolate between source and fullscreen
|
|
||||||
animatedScale = sourceScale + (1f - sourceScale) * progress
|
|
||||||
animatedTranslationX = (sourceCenterX - screenCenterX) * (1f - progress)
|
|
||||||
animatedTranslationY = (sourceCenterY - screenCenterY) * (1f - progress)
|
|
||||||
animatedCornerRadius = sourceBounds.cornerRadius * (1f - progress)
|
|
||||||
} else {
|
|
||||||
animatedScale = 1f
|
|
||||||
animatedTranslationX = 0f
|
|
||||||
animatedTranslationY = 0f
|
|
||||||
animatedCornerRadius = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -250,63 +262,86 @@ fun ImageViewerScreen(
|
|||||||
.background(Color.Black.copy(alpha = backgroundAlpha))
|
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||||
) {
|
) {
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📸 HORIZONTAL PAGER with shared element animation
|
// 📸 HORIZONTAL PAGER с Telegram-style эффектом наслаивания
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
// Apply Telegram-style shared element transform
|
alpha = dismissAlpha.value
|
||||||
scaleX = animatedScale
|
translationY = animatedOffsetY.value
|
||||||
scaleY = animatedScale
|
|
||||||
translationX = animatedTranslationX
|
|
||||||
translationY = animatedTranslationY + offsetY
|
|
||||||
// Clip with animated corner radius
|
|
||||||
clip = animatedCornerRadius > 0f
|
|
||||||
shape = if (animatedCornerRadius > 0f) {
|
|
||||||
RoundedCornerShape(animatedCornerRadius.dp)
|
|
||||||
} else {
|
|
||||||
RectangleShape
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
pageSpacing = 30.dp, // Telegram: dp(30) между фото
|
||||||
key = { images[it].attachmentId }
|
key = { images[it].attachmentId }
|
||||||
) { page ->
|
) { page ->
|
||||||
val image = images[page]
|
val image = images[page]
|
||||||
|
|
||||||
|
// 🎬 Telegram-style наслаивание: правая страница уменьшается и затемняется
|
||||||
|
val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
|
||||||
|
val isRightPage = pageOffset < 0
|
||||||
|
|
||||||
|
// Scale эффект только для правой страницы (1.0 → 0.7)
|
||||||
|
val scaleFactor = if (isRightPage) {
|
||||||
|
val progress = (-pageOffset).coerceIn(0f, 1f)
|
||||||
|
1f - (progress * 0.3f)
|
||||||
|
} else 1f
|
||||||
|
|
||||||
|
// Alpha для правой страницы
|
||||||
|
val pageAlpha = if (isRightPage) {
|
||||||
|
1f - ((-pageOffset).coerceIn(0f, 1f) * 0.3f)
|
||||||
|
} else 1f
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scaleFactor
|
||||||
|
scaleY = scaleFactor
|
||||||
|
alpha = pageAlpha
|
||||||
|
}
|
||||||
|
) {
|
||||||
ZoomableImage(
|
ZoomableImage(
|
||||||
image = image,
|
image = image,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
onTap = { showControls = !showControls },
|
onTap = { showControls = !showControls },
|
||||||
onVerticalDrag = { dragAmount ->
|
onVerticalDrag = { dragAmount, velocity ->
|
||||||
offsetY += dragAmount
|
dragOffsetY += dragAmount
|
||||||
|
dragVelocity = velocity
|
||||||
isDragging = true
|
isDragging = true
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
isDragging = false
|
isDragging = false
|
||||||
if (offsetY.absoluteValue > dismissThreshold) {
|
val shouldDismiss = dragOffsetY.absoluteValue > dismissThreshold ||
|
||||||
closeWithAnimation()
|
dragVelocity.absoluteValue > velocityThreshold
|
||||||
|
if (shouldDismiss) {
|
||||||
|
smoothDismiss()
|
||||||
} else {
|
} else {
|
||||||
offsetY = 0f
|
dragOffsetY = 0f
|
||||||
|
dragVelocity = 0f
|
||||||
|
snapBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} // Закрытие Box wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 🎛️ TOP BAR (показываем только когда анимация завершена)
|
// 🎛️ TOP BAR - Telegram-style (200ms, 24dp slide)
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showControls && animationState == 1 && !isClosing,
|
visible = showControls && animationState == 1 && !isClosing,
|
||||||
enter = fadeIn(tween(150)) + slideInVertically { -it },
|
enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) +
|
||||||
exit = fadeOut(tween(100)) + slideOutVertically { -it },
|
slideInVertically(tween(200, easing = FastOutSlowInEasing)) { -96 }, // ~24dp
|
||||||
|
exit = fadeOut(tween(200, easing = FastOutSlowInEasing)) +
|
||||||
|
slideOutVertically(tween(200, easing = FastOutSlowInEasing)) { -96 },
|
||||||
modifier = Modifier.align(Alignment.TopCenter)
|
modifier = Modifier.align(Alignment.TopCenter)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(
|
.background(
|
||||||
Color.Black.copy(alpha = 0.6f)
|
Color.Black.copy(alpha = 0.5f)
|
||||||
)
|
)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||||
@@ -348,13 +383,15 @@ fun ImageViewerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📍 PAGE INDICATOR (если больше 1 фото, показываем когда анимация завершена)
|
// 📍 PAGE INDICATOR - Telegram-style (200ms, 24dp slide снизу)
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
if (images.size > 1) {
|
if (images.size > 1) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showControls && animationState == 1 && !isClosing,
|
visible = showControls && animationState == 1 && !isClosing,
|
||||||
enter = fadeIn(tween(150)),
|
enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) +
|
||||||
exit = fadeOut(tween(100)),
|
slideInVertically(tween(200, easing = FastOutSlowInEasing)) { 96 }, // +24dp снизу
|
||||||
|
exit = fadeOut(tween(200, easing = FastOutSlowInEasing)) +
|
||||||
|
slideOutVertically(tween(200, easing = FastOutSlowInEasing)) { 96 },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = 32.dp)
|
.padding(bottom = 32.dp)
|
||||||
@@ -406,14 +443,14 @@ fun ImageViewerScreen(
|
|||||||
* - Double tap to zoom 2.5x / reset
|
* - Double tap to zoom 2.5x / reset
|
||||||
* - Pinch to zoom 1x-5x
|
* - Pinch to zoom 1x-5x
|
||||||
* - Pan when zoomed
|
* - Pan when zoomed
|
||||||
* - Vertical drag to dismiss when not zoomed
|
* - Vertical drag to dismiss when not zoomed (with velocity tracking)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ZoomableImage(
|
private fun ZoomableImage(
|
||||||
image: ViewableImage,
|
image: ViewableImage,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
onTap: () -> Unit,
|
onTap: () -> Unit,
|
||||||
onVerticalDrag: (Float) -> Unit = {},
|
onVerticalDrag: (Float, Float) -> Unit = { _, _ -> }, // dragAmount, velocity
|
||||||
onDragEnd: () -> Unit = {}
|
onDragEnd: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -564,15 +601,20 @@ private fun ZoomableImage(
|
|||||||
}
|
}
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
var isVerticalDragging = false
|
var isVerticalDragging = false
|
||||||
|
var lastDragTime = 0L
|
||||||
|
var lastDragY = 0f
|
||||||
|
var currentVelocity = 0f
|
||||||
|
val touchSlopValue = 20f // Минимальное смещение для определения направления
|
||||||
|
|
||||||
forEachGesture {
|
awaitEachGesture {
|
||||||
awaitPointerEventScope {
|
|
||||||
// Wait for first down
|
// Wait for first down
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
lastDragTime = System.currentTimeMillis()
|
||||||
|
lastDragY = down.position.y
|
||||||
|
currentVelocity = 0f
|
||||||
|
|
||||||
var zoom = 1f
|
var zoom = 1f
|
||||||
var pastTouchSlop = false
|
var pastTouchSlop = false
|
||||||
val touchSlop = viewConfiguration.touchSlop
|
|
||||||
var lockedToDismiss = false
|
var lockedToDismiss = false
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -586,8 +628,8 @@ private fun ZoomableImage(
|
|||||||
if (!pastTouchSlop) {
|
if (!pastTouchSlop) {
|
||||||
zoom *= zoomChange
|
zoom *= zoomChange
|
||||||
val centroidSize = event.calculateCentroidSize(useCurrent = false)
|
val centroidSize = event.calculateCentroidSize(useCurrent = false)
|
||||||
val touchMoved = abs(panChange.x) > touchSlop || abs(panChange.y) > touchSlop
|
val touchMoved = abs(panChange.x) > touchSlopValue || abs(panChange.y) > touchSlopValue
|
||||||
val zoomMotion = abs(1 - zoom) * centroidSize > touchSlop
|
val zoomMotion = abs(1 - zoom) * centroidSize > touchSlopValue
|
||||||
|
|
||||||
if (touchMoved || zoomMotion) {
|
if (touchMoved || zoomMotion) {
|
||||||
pastTouchSlop = true
|
pastTouchSlop = true
|
||||||
@@ -603,8 +645,20 @@ private fun ZoomableImage(
|
|||||||
|
|
||||||
if (pastTouchSlop) {
|
if (pastTouchSlop) {
|
||||||
if (lockedToDismiss) {
|
if (lockedToDismiss) {
|
||||||
// Vertical drag for dismiss
|
// Calculate velocity for smooth dismiss
|
||||||
onVerticalDrag(panChange.y)
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val currentY = event.changes.firstOrNull()?.position?.y ?: lastDragY
|
||||||
|
val timeDelta = (currentTime - lastDragTime).coerceAtLeast(1L)
|
||||||
|
val positionDelta = currentY - lastDragY
|
||||||
|
|
||||||
|
// Velocity in px/second
|
||||||
|
currentVelocity = (positionDelta / timeDelta) * 1000f
|
||||||
|
|
||||||
|
lastDragTime = currentTime
|
||||||
|
lastDragY = currentY
|
||||||
|
|
||||||
|
// Vertical drag for dismiss with velocity
|
||||||
|
onVerticalDrag(panChange.y, currentVelocity)
|
||||||
event.changes.forEach { it.consume() }
|
event.changes.forEach { it.consume() }
|
||||||
} else {
|
} else {
|
||||||
// Zoom and pan
|
// Zoom and pan
|
||||||
@@ -643,7 +697,6 @@ private fun ZoomableImage(
|
|||||||
offsetY = 0f
|
offsetY = 0f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user