fix: adjust padding and status bar colors in ImageEditorScreen for improved UI experience

This commit is contained in:
k1ngsterr1
2026-02-03 01:56:08 +05:00
parent 5c5c5e45ee
commit 7d90a9d744
3 changed files with 267 additions and 154 deletions

View File

@@ -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 =

View File

@@ -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 {

View File

@@ -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
) { ) {