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,137 +419,134 @@ 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()
},
isDarkTheme = isDarkTheme,
textColor = textColor
)
// Quick action buttons row (Camera, Gallery, File, Avatar, etc.)
QuickActionsRow(
isDarkTheme = isDarkTheme,
onCameraClick = {
onDismiss()
onOpenCamera()
},
onFileClick = {
onDismiss()
onOpenFilePicker()
},
onAvatarClick = {
onDismiss()
onAvatarClick()
}
)
Spacer(modifier = Modifier.height(8.dp))
// Content
if (!hasPermission) {
// Permission request UI
PermissionRequestView(
onMediaSelected(selected)
animatedClose()
},
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onRequestPermission = {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
)
} else {
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
}
permissionLauncher.launch(permissions)
}
textColor = textColor
)
} else if (isLoading) {
// Loading indicator
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = PrimaryBlue,
modifier = Modifier.size(48.dp)
)
}
} else if (mediaItems.isEmpty()) {
// Empty state
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
TablerIcons.Photo,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No photos or videos",
color = secondaryTextColor,
fontSize = 16.sp
)
}
}
} else {
// Media grid
MediaGrid(
mediaItems = mediaItems,
selectedItems = selectedItems,
// Quick action buttons row (Camera, Gallery, File, Avatar, etc.)
QuickActionsRow(
isDarkTheme = isDarkTheme,
onCameraClick = {
onDismiss()
animatedClose()
onOpenCamera()
},
onItemClick = { item, position ->
// Telegram-style: клик на фото сразу открывает редактор с caption
if (!item.isVideo) {
thumbnailPosition = position
editingItem = item
} else {
// Для видео - добавляем/убираем из selection
onFileClick = {
animatedClose()
onOpenFilePicker()
},
onAvatarClick = {
animatedClose()
onAvatarClick()
}
)
Spacer(modifier = Modifier.height(8.dp))
// Content
if (!hasPermission) {
// Permission request UI
PermissionRequestView(
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onRequestPermission = {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.CAMERA
)
} else {
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.CAMERA
)
}
permissionLauncher.launch(permissions)
}
)
} else if (isLoading) {
// Loading indicator
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = PrimaryBlue,
modifier = Modifier.size(48.dp)
)
}
} else if (mediaItems.isEmpty()) {
// Empty state
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
TablerIcons.Photo,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No photos or videos",
color = secondaryTextColor,
fontSize = 16.sp
)
}
}
} else {
// Media grid
MediaGrid(
mediaItems = mediaItems,
selectedItems = selectedItems,
onCameraClick = {
animatedClose()
onOpenCamera()
},
onItemClick = { item, position ->
// Telegram-style: клик на фото сразу открывает редактор с caption
if (!item.isVideo) {
thumbnailPosition = position
editingItem = item
} else {
// Для видео - добавляем/убираем из selection
if (item.id in selectedItems) {
selectedItems = selectedItems - item.id
} else if (selectedItems.size < maxSelection) {
selectedItems = selectedItems + item.id
}
}
},
onItemLongClick = { item ->
// Long press - снять выделение если выбрана
if (item.id in selectedItems) {
selectedItems = selectedItems - item.id
} else if (selectedItems.size < maxSelection) {
selectedItems = selectedItems + item.id
}
}
},
onItemLongClick = { item ->
// Long press - снять выделение если выбрана
if (item.id in selectedItems) {
selectedItems = selectedItems - item.id
} else if (selectedItems.size < maxSelection) {
selectedItems = selectedItems + item.id
}
},
isDarkTheme = isDarkTheme,
modifier = Modifier.weight(1f)
)
},
isDarkTheme = isDarkTheme,
modifier = Modifier.weight(1f)
)
}
}
// Bottom safe area
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
// Image Editor overlay для фото из галереи