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