fix: improve MediaPickerBottomSheet animations and expand/collapse functionality
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user