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 configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp 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) // 🎬 Animatable для плавной высоты - КЛЮЧЕВОЙ ЭЛЕМЕНТ
val bottomOffset = 56.dp 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( val animatedOffset by animateFloatAsState(
targetValue = if (animationStarted) 0f else 1f, targetValue = if (shouldShow && !isClosing) 0f else 1f,
animationSpec = spring( animationSpec = if (isClosing) {
dampingRatio = Spring.DampingRatioMediumBouncy, tween(200, easing = FastOutSlowInEasing)
stiffness = Spring.StiffnessMedium } else {
), tween(280, easing = FastOutSlowInEasing)
},
finishedListener = {
if (isClosing) {
isClosing = false
shouldShow = false
isExpanded = false
// Сбрасываем высоту
animationScope.launch {
sheetHeightPx.snapTo(collapsedHeightPx)
}
onDismiss()
}
},
label = "sheet_slide" 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 val showSheet = isVisible && editingItem == null && pendingPhotoUri == null && previewPhotoUri == null
// Запускаем анимацию когда showSheet становится true // Запускаем анимацию когда showSheet меняется
LaunchedEffect(showSheet) { LaunchedEffect(showSheet) {
if (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 { } else {
animationStarted = false onDispose { }
} }
} }
// Используем Popup для показа поверх клавиатуры // Используем Popup для показа поверх клавиатуры
if (showSheet) { if (shouldShow) {
// BackHandler для закрытия по back // BackHandler для закрытия по back
BackHandler { onDismiss() } BackHandler {
if (isExpanded) {
animationScope.launch {
isExpanded = false
sheetHeightPx.animateTo(
collapsedHeightPx,
animationSpec = tween(250, easing = FastOutSlowInEasing)
)
}
} else {
animatedClose()
}
}
Popup( Popup(
alignment = Alignment.BottomCenter, alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран
onDismissRequest = onDismiss, onDismissRequest = animatedClose,
properties = PopupProperties( properties = PopupProperties(
focusable = false, // НЕ забираем фокус - клавиатура остаётся! focusable = false,
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true dismissOnClickOutside = false
) )
) { ) {
// Полноэкранный контейнер с затемнением // Полноэкранный контейнер с мягким затемнением
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f * animatedAlpha)) .background(Color.Black.copy(alpha = scrimAlpha))
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { onDismiss() }, ) { animatedClose() },
contentAlignment = Alignment.BottomCenter contentAlignment = Alignment.BottomCenter
) { ) {
// Sheet content - с анимированным offset // Sheet content
val sheetSlideOffset = with(density) { (sheetHeight.toPx() * animatedOffset).toInt() } val currentHeightDp = with(density) { sheetHeightPx.value.toDp() }
val slideOffset = (sheetHeightPx.value * animatedOffset).toInt()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(sheetHeight) .height(currentHeightDp)
.padding(bottom = bottomOffset) // Отступ от низа чтобы быть над input bar .offset { IntOffset(0, slideOffset) }
.offset { IntOffset(0, (sheetSlideOffset + dragOffsetY).roundToInt()) }
.graphicsLayer { .graphicsLayer {
// Небольшой scale эффект при появлении // Scale эффект только при появлении/закрытии
val scale = 0.95f + 0.05f * (1f - animatedOffset) val scale = 0.95f + 0.05f * (1f - animatedOffset)
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
alpha = 0.9f + 0.1f * (1f - animatedOffset)
} }
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.background(backgroundColor) .background(backgroundColor)
@@ -270,14 +385,16 @@ fun MediaPickerBottomSheet(
.pointerInput(Unit) { .pointerInput(Unit) {
detectVerticalDragGestures( detectVerticalDragGestures(
onDragEnd = { onDragEnd = {
if (dragOffsetY > 100) { // Snap к ближайшему состоянию
onDismiss() snapToNearestState()
}
dragOffsetY = 0f
}, },
onVerticalDrag = { _, dragAmount -> onVerticalDrag = { change, dragAmount ->
if (dragAmount > 0) { // Only drag down change.consume()
dragOffsetY += dragAmount // 🔥 КЛЮЧЕВОЕ: Меняем высоту в реальном времени!
val newHeight = (sheetHeightPx.value - dragAmount)
.coerceIn(minHeightPx, expandedHeightPx)
animationScope.launch {
sheetHeightPx.snapTo(newHeight)
} }
} }
) )
@@ -302,11 +419,11 @@ fun MediaPickerBottomSheet(
// Header with action buttons // Header with action buttons
MediaPickerHeader( MediaPickerHeader(
selectedCount = selectedItems.size, selectedCount = selectedItems.size,
onDismiss = onDismiss, onDismiss = animatedClose,
onSend = { onSend = {
val selected = mediaItems.filter { it.id in selectedItems } val selected = mediaItems.filter { it.id in selectedItems }
onMediaSelected(selected) onMediaSelected(selected)
onDismiss() animatedClose()
}, },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
textColor = textColor textColor = textColor
@@ -316,15 +433,15 @@ fun MediaPickerBottomSheet(
QuickActionsRow( QuickActionsRow(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onCameraClick = { onCameraClick = {
onDismiss() animatedClose()
onOpenCamera() onOpenCamera()
}, },
onFileClick = { onFileClick = {
onDismiss() animatedClose()
onOpenFilePicker() onOpenFilePicker()
}, },
onAvatarClick = { onAvatarClick = {
onDismiss() animatedClose()
onAvatarClick() onAvatarClick()
} }
) )
@@ -398,7 +515,7 @@ fun MediaPickerBottomSheet(
mediaItems = mediaItems, mediaItems = mediaItems,
selectedItems = selectedItems, selectedItems = selectedItems,
onCameraClick = { onCameraClick = {
onDismiss() animatedClose()
onOpenCamera() onOpenCamera()
}, },
onItemClick = { item, position -> onItemClick = { item, position ->
@@ -427,9 +544,6 @@ fun MediaPickerBottomSheet(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
// Bottom safe area
Spacer(modifier = Modifier.navigationBarsPadding())
} }
} }
} }