fix: improve MediaPickerBottomSheet animations and expand/collapse functionality

This commit is contained in:
k1ngsterr1
2026-02-02 02:36:57 +05:00
parent e1cc49c12b
commit 5e5c2af494

View File

@@ -184,82 +184,197 @@ fun MediaPickerBottomSheet(
// ═══════════════════════════════════════════════════════════════
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() }
// Высота галереи - большая, почти половина экрана
val sheetHeight = screenHeight * 0.55f
// 🔄 Высоты в пикселях для точного контроля
val collapsedHeightPx = screenHeightPx * 0.45f // Свёрнутое - 45% экрана
val expandedHeightPx = screenHeightPx * 0.88f // Развёрнутое - 88% экрана
val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз
// Отступ снизу чтобы быть НАД клавиатурой (примерно высота input bar)
val bottomOffset = 56.dp
// 🎬 Animatable для плавной высоты - КЛЮЧЕВОЙ ЭЛЕМЕНТ
val sheetHeightPx = remember { Animatable(collapsedHeightPx) }
// 🎬 Анимация появления
var animationStarted by remember { mutableStateOf(false) }
// Текущее состояние (для логики)
var isExpanded by remember { mutableStateOf(false) }
// 🎬 Анимация появления/закрытия
var isClosing by remember { mutableStateOf(false) }
var shouldShow by remember { mutableStateOf(false) }
// Scope для анимаций
val animationScope = rememberCoroutineScope()
// Анимация slide для появления/закрытия
val animatedOffset by animateFloatAsState(
targetValue = if (animationStarted) 0f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
targetValue = if (shouldShow && !isClosing) 0f else 1f,
animationSpec = if (isClosing) {
tween(200, easing = FastOutSlowInEasing)
} else {
tween(280, easing = FastOutSlowInEasing)
},
finishedListener = {
if (isClosing) {
isClosing = false
shouldShow = false
isExpanded = false
// Сбрасываем высоту
animationScope.launch {
sheetHeightPx.snapTo(collapsedHeightPx)
}
onDismiss()
}
},
label = "sheet_slide"
)
val animatedAlpha by animateFloatAsState(
targetValue = if (animationStarted) 1f else 0f,
animationSpec = tween(200),
label = "scrim_alpha"
)
// Drag offset для свайпа вниз
var dragOffsetY by remember { mutableFloatStateOf(0f) }
// 🌑 Плавное затемнение экрана (включая статус бар)
// Мягкий fade-in вместо резкого черного overlay
val scrimAlpha by animateFloatAsState(
targetValue = if (shouldShow && !isClosing) {
// Базовое затемнение + немного больше когда развёрнуто
val expandProgress = (sheetHeightPx.value - collapsedHeightPx) / (expandedHeightPx - collapsedHeightPx)
0.25f + 0.15f * expandProgress.coerceIn(0f, 1f)
} else {
0f
},
animationSpec = tween(
durationMillis = if (isClosing) 200 else 300,
easing = FastOutSlowInEasing
),
label = "scrim_fade"
)
// Показываем галерею
val showSheet = isVisible && editingItem == null && pendingPhotoUri == null && previewPhotoUri == null
// Запускаем анимацию когда showSheet становится true
// Запускаем анимацию когда showSheet меняется
LaunchedEffect(showSheet) {
if (showSheet) {
animationStarted = true
shouldShow = true
isClosing = false
sheetHeightPx.snapTo(collapsedHeightPx)
}
}
// Функция для анимированного закрытия
val animatedClose: () -> Unit = {
if (!isClosing) {
isClosing = true
}
}
// Функция snap к ближайшему состоянию с плавной анимацией
fun snapToNearestState() {
animationScope.launch {
val currentHeight = sheetHeightPx.value
val midPoint = (collapsedHeightPx + expandedHeightPx) / 2
when {
// Слишком низко - закрываем
currentHeight < minHeightPx + 50 -> {
animatedClose()
}
// Ближе к expanded
currentHeight > midPoint -> {
isExpanded = true
sheetHeightPx.animateTo(
expandedHeightPx,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
}
// Ближе к collapsed
else -> {
isExpanded = false
sheetHeightPx.animateTo(
collapsedHeightPx,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
}
}
}
}
// 🎨 Затемнение статус бара когда галерея открыта
val view = LocalView.current
DisposableEffect(shouldShow, scrimAlpha) {
if (shouldShow && !view.isInEditMode) {
val window = (view.context as? android.app.Activity)?.window
val originalStatusBarColor = window?.statusBarColor ?: 0
// Затемняем статус бар
val scrimColor = android.graphics.Color.argb(
(scrimAlpha * 255).toInt().coerceIn(0, 255),
0, 0, 0
)
window?.statusBarColor = scrimColor
onDispose {
// Восстанавливаем оригинальный цвет
window?.statusBarColor = originalStatusBarColor
}
} else {
animationStarted = false
onDispose { }
}
}
// Используем Popup для показа поверх клавиатуры
if (showSheet) {
if (shouldShow) {
// BackHandler для закрытия по back
BackHandler { onDismiss() }
BackHandler {
if (isExpanded) {
animationScope.launch {
isExpanded = false
sheetHeightPx.animateTo(
collapsedHeightPx,
animationSpec = tween(250, easing = FastOutSlowInEasing)
)
}
} else {
animatedClose()
}
}
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = onDismiss,
alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран
onDismissRequest = animatedClose,
properties = PopupProperties(
focusable = false, // НЕ забираем фокус - клавиатура остаётся!
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = true
dismissOnClickOutside = false
)
) {
// Полноэкранный контейнер с затемнением
// Полноэкранный контейнер с мягким затемнением
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f * animatedAlpha))
.background(Color.Black.copy(alpha = scrimAlpha))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onDismiss() },
) { animatedClose() },
contentAlignment = Alignment.BottomCenter
) {
// Sheet content - с анимированным offset
val sheetSlideOffset = with(density) { (sheetHeight.toPx() * animatedOffset).toInt() }
// Sheet content
val currentHeightDp = with(density) { sheetHeightPx.value.toDp() }
val slideOffset = (sheetHeightPx.value * animatedOffset).toInt()
Column(
modifier = Modifier
.fillMaxWidth()
.height(sheetHeight)
.padding(bottom = bottomOffset) // Отступ от низа чтобы быть над input bar
.offset { IntOffset(0, (sheetSlideOffset + dragOffsetY).roundToInt()) }
.height(currentHeightDp)
.offset { IntOffset(0, slideOffset) }
.graphicsLayer {
// Небольшой scale эффект при появлении
// Scale эффект только при появлении/закрытии
val scale = 0.95f + 0.05f * (1f - animatedOffset)
scaleX = scale
scaleY = scale
alpha = 0.9f + 0.1f * (1f - animatedOffset)
}
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.background(backgroundColor)
@@ -270,14 +385,16 @@ fun MediaPickerBottomSheet(
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragOffsetY > 100) {
onDismiss()
}
dragOffsetY = 0f
// Snap к ближайшему состоянию
snapToNearestState()
},
onVerticalDrag = { _, dragAmount ->
if (dragAmount > 0) { // Only drag down
dragOffsetY += dragAmount
onVerticalDrag = { change, dragAmount ->
change.consume()
// 🔥 КЛЮЧЕВОЕ: Меняем высоту в реальном времени!
val newHeight = (sheetHeightPx.value - dragAmount)
.coerceIn(minHeightPx, expandedHeightPx)
animationScope.launch {
sheetHeightPx.snapTo(newHeight)
}
}
)
@@ -302,11 +419,11 @@ fun MediaPickerBottomSheet(
// Header with action buttons
MediaPickerHeader(
selectedCount = selectedItems.size,
onDismiss = onDismiss,
onDismiss = animatedClose,
onSend = {
val selected = mediaItems.filter { it.id in selectedItems }
onMediaSelected(selected)
onDismiss()
animatedClose()
},
isDarkTheme = isDarkTheme,
textColor = textColor
@@ -316,15 +433,15 @@ fun MediaPickerBottomSheet(
QuickActionsRow(
isDarkTheme = isDarkTheme,
onCameraClick = {
onDismiss()
animatedClose()
onOpenCamera()
},
onFileClick = {
onDismiss()
animatedClose()
onOpenFilePicker()
},
onAvatarClick = {
onDismiss()
animatedClose()
onAvatarClick()
}
)
@@ -398,7 +515,7 @@ fun MediaPickerBottomSheet(
mediaItems = mediaItems,
selectedItems = selectedItems,
onCameraClick = {
onDismiss()
animatedClose()
onOpenCamera()
},
onItemClick = { item, position ->
@@ -427,9 +544,6 @@ fun MediaPickerBottomSheet(
modifier = Modifier.weight(1f)
)
}
// Bottom safe area
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}